linkshell-cli 0.2.125 → 0.2.126

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, unlinkSync, mkdirSync, existsSync, openSync, readSync, closeSync } from "node:fs";
5
+ import { writeFileSync, readFileSync, readdirSync, statSync, mkdirSync, existsSync, openSync, readSync, closeSync } from "node:fs";
6
6
  import { tmpdir } from "node:os";
7
7
  import { join, basename, resolve } from "node:path";
8
8
  import {
@@ -29,7 +29,7 @@ export interface BridgeSessionOptions {
29
29
  gatewayUrl: string;
30
30
  gatewayHttpUrl: string;
31
31
  pairingGateway?: string;
32
- sessionId?: string;
32
+ hostDeviceId?: string;
33
33
  cols: number;
34
34
  rows: number;
35
35
  clientName: string;
@@ -49,166 +49,14 @@ const RECONNECT_BASE_DELAY = 1_000;
49
49
  const RECONNECT_MAX_DELAY = 30_000;
50
50
  const RECONNECT_MAX_ATTEMPTS = 20;
51
51
  const DEFAULT_TERMINAL_ID = "default";
52
- const HOOK_BODY_LIMIT = 256 * 1024;
53
- const PERMISSION_REQUEST_TIMEOUT_MS = Number(
54
- process.env.LINKSHELL_PERMISSION_TIMEOUT_MS ?? 5 * 60_000,
55
- );
56
- const LINKSHELL_PERMISSION_GUARD_MARKER = "LINKSHELL_PERMISSION_GUARD";
57
52
 
58
53
  interface TerminalInstance {
59
54
  id: string;
60
55
  pty: pty.IPty;
61
56
  cwd: string;
62
- projectName: string;
63
- provider: string;
64
57
  scrollback: ScrollbackBuffer;
65
58
  outputSeq: number;
66
- statusSeq: number;
67
59
  status: "running" | "exited";
68
- hookServer?: http.Server;
69
- hookPort?: number;
70
- hookMarker: string;
71
- hookConfigPaths: string[];
72
- }
73
-
74
- interface PendingPermission {
75
- terminalId: string;
76
- timeout: ReturnType<typeof setTimeout>;
77
- permissionSuggestions: unknown[];
78
- resolve: (decision: HookPermissionDecision) => boolean;
79
- }
80
-
81
- interface HookPermissionDecision {
82
- behavior: "allow" | "deny";
83
- updatedPermissions?: unknown[];
84
- message?: string;
85
- interrupt?: boolean;
86
- }
87
-
88
- type HookPermissionChoice =
89
- | "allow"
90
- | "deny"
91
- | {
92
- outcome: "allow" | "deny" | "cancelled";
93
- optionId?: string;
94
- };
95
-
96
- function isLinkShellHookEntry(entry: unknown, marker?: string): boolean {
97
- let raw = "";
98
- try {
99
- raw = JSON.stringify(entry);
100
- } catch {
101
- raw = String(entry);
102
- }
103
- return (
104
- (marker ? raw.includes(`/hook?m=${marker}`) : false) ||
105
- raw.includes("/hook?m=lsh-") ||
106
- (raw.includes("/hook?m=") && raw.includes("LINKSHELL_ID"))
107
- );
108
- }
109
-
110
- function withLinkShellHookEntry<T>(
111
- entries: unknown[] | undefined,
112
- entry: T,
113
- priority: "first" | "last",
114
- ): unknown[] {
115
- const cleaned = (Array.isArray(entries) ? entries : []).filter((item) => !isLinkShellHookEntry(item));
116
- return priority === "first" ? [entry, ...cleaned] : [...cleaned, entry];
117
- }
118
-
119
- function guardPermissionCommandForLinkShell(command: unknown): unknown {
120
- if (typeof command !== "string") return command;
121
- if (command.includes(LINKSHELL_PERMISSION_GUARD_MARKER)) return command;
122
- return [
123
- `case "\${LINKSHELL_ID:-}" in lsh-*) exit 0 ;; esac`,
124
- `# ${LINKSHELL_PERMISSION_GUARD_MARKER}`,
125
- command,
126
- ].join("\n");
127
- }
128
-
129
- function guardPermissionHookObjectForLinkShell(
130
- hook: Record<string, unknown>,
131
- ): Record<string, unknown> {
132
- if (isLinkShellHookEntry(hook)) return hook;
133
- const next: Record<string, unknown> = { ...hook };
134
- if (typeof next.command === "string") {
135
- next.command = guardPermissionCommandForLinkShell(next.command);
136
- }
137
- if (typeof next.bash === "string") {
138
- next.bash = guardPermissionCommandForLinkShell(next.bash);
139
- }
140
- return next;
141
- }
142
-
143
- function guardPermissionHookEntryForLinkShell(entry: unknown): unknown {
144
- if (isLinkShellHookEntry(entry)) return entry;
145
- if (typeof entry === "string") return guardPermissionCommandForLinkShell(entry);
146
- if (Array.isArray(entry)) return entry.map(guardPermissionHookEntryForLinkShell);
147
- if (!entry || typeof entry !== "object") return entry;
148
-
149
- const next = { ...(entry as Record<string, unknown>) };
150
- if (Array.isArray(next.hooks)) {
151
- next.hooks = next.hooks.map((hook) =>
152
- hook && typeof hook === "object" && !Array.isArray(hook)
153
- ? guardPermissionHookObjectForLinkShell(hook as Record<string, unknown>)
154
- : guardPermissionHookEntryForLinkShell(hook),
155
- );
156
- }
157
- if (typeof next.command === "string" || typeof next.bash === "string") {
158
- return guardPermissionHookObjectForLinkShell(next);
159
- }
160
- return next;
161
- }
162
-
163
- function withBlockingLinkShellPermissionEntry<T>(
164
- entries: unknown[] | undefined,
165
- entry: T,
166
- ): unknown[] {
167
- const cleaned = (Array.isArray(entries) ? entries : [])
168
- .filter((item) => !isLinkShellHookEntry(item))
169
- .map(guardPermissionHookEntryForLinkShell);
170
- return [entry, ...cleaned];
171
- }
172
-
173
- function stringifyHookInput(value: unknown): string {
174
- if (typeof value === "string") return value.slice(0, 1200);
175
- if (typeof value === "object" && value) {
176
- try {
177
- return JSON.stringify(value, null, 2).slice(0, 1200);
178
- } catch {
179
- return String(value).slice(0, 1200);
180
- }
181
- }
182
- return "";
183
- }
184
-
185
- function hookPermissionSuggestions(event: Record<string, unknown>): unknown[] {
186
- if (isCodexPermissionRequest(event)) return [];
187
- const snake = event.permission_suggestions;
188
- const camel = event.permissionSuggestions;
189
- if (Array.isArray(snake)) return snake;
190
- if (Array.isArray(camel)) return camel;
191
- return [];
192
- }
193
-
194
- function isCodexPermissionRequest(event: Record<string, unknown>): boolean {
195
- if (typeof event.turn_id === "string" || typeof event.turnId === "string") return true;
196
- const transcriptPath = event.transcript_path ?? event.transcriptPath;
197
- return typeof transcriptPath === "string" && transcriptPath.includes("/.codex/");
198
- }
199
-
200
- function hookPermissionOptions(suggestions: unknown[]): Array<{
201
- id: string;
202
- label: string;
203
- kind: "allow" | "deny" | "other";
204
- }> {
205
- return [
206
- { id: "deny", label: "拒绝", kind: "deny" },
207
- { id: "allow_once", label: "允许一次", kind: "allow" },
208
- ...(suggestions.length > 0
209
- ? [{ id: "allow_always" as const, label: "始终允许", kind: "allow" as const }]
210
- : []),
211
- ];
212
60
  }
213
61
 
214
62
  function getPairingGatewayParam(gatewayHttpUrl: string): string | undefined {
@@ -285,16 +133,6 @@ export class BridgeSession {
285
133
  private sessionId = "";
286
134
  private exited = false;
287
135
  private stopped = false;
288
- private permissionStacks = new Map<string, Array<{
289
- requestId: string;
290
- toolName: string;
291
- toolInput: string;
292
- permissionRequest: string;
293
- timestamp: number;
294
- }>>();
295
- // Pending permission responses: requestId → HTTP response callback
296
- private pendingPermissions = new Map<string, PendingPermission>();
297
- private hookMarker = `lsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
298
136
  private screenCapture: ScreenFallback | undefined;
299
137
  private screenShare: ScreenShare | undefined;
300
138
  private tunnelSockets = new Map<string, WebSocket>();
@@ -305,7 +143,7 @@ export class BridgeSession {
305
143
 
306
144
  constructor(options: BridgeSessionOptions) {
307
145
  this.options = options;
308
- this.sessionId = options.sessionId ?? "";
146
+ this.sessionId = options.hostDeviceId ?? "";
309
147
  }
310
148
 
311
149
  private log(msg: string): void {
@@ -314,26 +152,19 @@ export class BridgeSession {
314
152
  }
315
153
  }
316
154
 
317
- private terminalHookMarker(terminalId: string): string {
318
- const safeTerminalId = terminalId.replace(/[^a-zA-Z0-9_-]+/g, "-");
319
- return `${this.hookMarker}-${safeTerminalId}`;
320
- }
321
-
322
155
  async start(): Promise<void> {
323
156
  this.log(
324
- `starting session (gateway=${this.options.gatewayUrl}, provider=${this.options.providerConfig.provider})`,
157
+ `starting device bridge (gateway=${this.options.gatewayUrl}, terminal=shell)`,
325
158
  );
326
159
  this.machineIdentity = loadOrCreateMachineIdentity();
327
- if (!this.sessionId) {
328
- await this.createPairing();
329
- }
160
+ this.sessionId ||= this.machineIdentity.machineId;
161
+ await this.createPairing();
330
162
  if (this.options.keepAwake) {
331
163
  this.keepAwake = startKeepAwake();
332
164
  } else {
333
165
  process.stderr.write("[bridge] keep-awake disabled\n");
334
166
  }
335
167
  if (this.options.agentUi) {
336
- process.env.LINKSHELL_ID = this.terminalHookMarker(DEFAULT_TERMINAL_ID);
337
168
  const availableProviders = this.options.agentProvider
338
169
  ? [normalizeAgentProvider(this.options.agentProvider)]
339
170
  : detectAvailableProviders();
@@ -366,17 +197,17 @@ export class BridgeSession {
366
197
  const res = await fetch(`${this.options.gatewayHttpUrl}/pairings`, {
367
198
  method: "POST",
368
199
  headers,
369
- body: JSON.stringify({}),
200
+ body: JSON.stringify({ hostDeviceId: this.sessionId }),
370
201
  });
371
202
  if (!res.ok) {
372
203
  throw new Error(`Failed to create pairing: ${res.status}`);
373
204
  }
374
205
  const body = (await res.json()) as {
375
- sessionId: string;
206
+ hostDeviceId: string;
376
207
  pairingCode: string;
377
208
  expiresAt: string;
378
209
  };
379
- this.sessionId = body.sessionId;
210
+ this.sessionId = body.hostDeviceId;
380
211
 
381
212
  const pairingGateway = resolvePairingGateway(
382
213
  this.options.gatewayHttpUrl,
@@ -389,7 +220,7 @@ export class BridgeSession {
389
220
  process.stderr.write(
390
221
  `\n \x1b[1mPairing code: \x1b[36m${body.pairingCode}\x1b[0m\n`,
391
222
  );
392
- process.stderr.write(` Session: ${body.sessionId}\n`);
223
+ process.stderr.write(` Host device: ${body.hostDeviceId}\n`);
393
224
  process.stderr.write(` Expires: ${body.expiresAt}\n\n`);
394
225
  if (!pairingGateway) {
395
226
  process.stderr.write(
@@ -444,7 +275,7 @@ export class BridgeSession {
444
275
  }
445
276
 
446
277
  const url = new URL(this.options.gatewayUrl);
447
- url.searchParams.set("sessionId", this.sessionId);
278
+ url.searchParams.set("hostDeviceId", this.sessionId);
448
279
  url.searchParams.set("role", "host");
449
280
  const authToken = await this.resolveAuthToken();
450
281
  if (authToken) {
@@ -463,18 +294,22 @@ export class BridgeSession {
463
294
  this.reconnecting = false;
464
295
  this.send(
465
296
  createEnvelope({
466
- type: "session.connect",
467
- sessionId: this.sessionId,
297
+ type: "device.connect",
298
+ hostDeviceId: this.sessionId,
468
299
  payload: {
469
300
  role: "host" as const,
470
301
  clientName: this.options.clientName,
471
- provider: this.options.providerConfig.provider,
472
302
  protocolVersion: PROTOCOL_VERSION,
473
303
  machineId: this.machineIdentity?.machineId,
474
304
  hostname: this.options.hostname || hostname(),
475
305
  platform: platform(),
476
306
  cwd: process.cwd(),
477
- projectName: basename(process.cwd()),
307
+ capabilities: [
308
+ "terminal",
309
+ ...(this.options.agentUi ? ["agent-ui"] : []),
310
+ ...(this.options.screen ? ["screen"] : []),
311
+ "tunnel",
312
+ ],
478
313
  },
479
314
  }),
480
315
  );
@@ -531,7 +366,7 @@ export class BridgeSession {
531
366
  }
532
367
  case "terminal.spawn": {
533
368
  const p = parseTypedPayload("terminal.spawn", envelope.payload);
534
- const normalizedCwd = resolve(p.cwd);
369
+ const normalizedCwd = resolve(p.cwd ?? process.cwd());
535
370
  // Dedup: if a running terminal already exists for this cwd, return it
536
371
  const existing = [...this.terminals.values()].find(
537
372
  (t) => t.status === "running" && resolve(t.cwd) === normalizedCwd,
@@ -541,17 +376,17 @@ export class BridgeSession {
541
376
  type: "terminal.spawned",
542
377
  sessionId: this.sessionId,
543
378
  terminalId: existing.id,
544
- payload: { terminalId: existing.id, cwd: existing.cwd, projectName: existing.projectName, provider: existing.provider },
379
+ payload: { terminalId: existing.id, cwd: existing.cwd, shell: this.options.providerConfig.command },
545
380
  }));
546
381
  } else {
547
382
  const newId = `term-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
548
383
  try {
549
- await this.spawnTerminal(newId, normalizedCwd, p.provider);
384
+ await this.spawnTerminal(newId, normalizedCwd);
550
385
  this.send(createEnvelope({
551
386
  type: "terminal.spawned",
552
387
  sessionId: this.sessionId,
553
388
  terminalId: newId,
554
- payload: { terminalId: newId, cwd: normalizedCwd, projectName: basename(normalizedCwd), provider: p.provider },
389
+ payload: { terminalId: newId, cwd: normalizedCwd, shell: this.options.providerConfig.command },
555
390
  }));
556
391
  } catch (err) {
557
392
  this.log(`failed to spawn terminal ${newId}: ${err}`);
@@ -730,16 +565,18 @@ export class BridgeSession {
730
565
  }));
731
566
  break;
732
567
  }
568
+ case "device.ack":
733
569
  case "session.ack": {
734
- const p = parseTypedPayload("session.ack", envelope.payload);
570
+ const p = parseTypedPayload(envelope.type === "device.ack" ? "device.ack" : "session.ack", envelope.payload);
735
571
  const term = this.terminals.get(tid);
736
572
  if (term) {
737
573
  term.scrollback.trimUpTo(p.seq);
738
574
  }
739
575
  break;
740
576
  }
577
+ case "device.resume":
741
578
  case "session.resume": {
742
- const p = parseTypedPayload("session.resume", envelope.payload);
579
+ const p = parseTypedPayload(envelope.type === "device.resume" ? "device.resume" : "session.resume", envelope.payload);
743
580
  // Replay all terminals
744
581
  for (const [termId, term] of this.terminals) {
745
582
  this.replayFrom(
@@ -752,6 +589,7 @@ export class BridgeSession {
752
589
  this.sendTerminalList();
753
590
  break;
754
591
  }
592
+ case "device.heartbeat":
755
593
  case "session.heartbeat":
756
594
  break;
757
595
  case "screen.start": {
@@ -803,18 +641,10 @@ export class BridgeSession {
803
641
  );
804
642
  break;
805
643
  }
806
- if (envelope.type === "agent.prompt") this.refreshAgentPermissionHooks();
807
644
  await this.agentSession.handleEnvelope(envelope);
808
645
  break;
809
646
  }
810
647
  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
648
  if (!this.agentSession) {
819
649
  this.send(
820
650
  createEnvelope({
@@ -876,7 +706,6 @@ export class BridgeSession {
876
706
  );
877
707
  break;
878
708
  }
879
- if (envelope.type === "agent.v2.prompt" || envelope.type === "agent.v2.command.execute") this.refreshAgentPermissionHooks();
880
709
  await this.agentWorkspace.handleEnvelope(envelope);
881
710
  break;
882
711
  }
@@ -892,44 +721,6 @@ export class BridgeSession {
892
721
  }
893
722
  break;
894
723
  }
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
724
  case "tunnel.request": {
934
725
  const p = parseTypedPayload("tunnel.request", envelope.payload);
935
726
  this.handleTunnelRequest(p);
@@ -1139,9 +930,8 @@ export class BridgeSession {
1139
930
  const terminals = [...this.terminals.values()].map((t) => ({
1140
931
  terminalId: t.id,
1141
932
  cwd: t.cwd,
1142
- projectName: t.projectName,
1143
- provider: t.provider,
1144
933
  status: t.status,
934
+ shell: this.options.providerConfig.command,
1145
935
  }));
1146
936
  this.send(createEnvelope({
1147
937
  type: "terminal.list",
@@ -1172,41 +962,14 @@ export class BridgeSession {
1172
962
  }
1173
963
  }
1174
964
 
1175
- private async spawnTerminal(terminalId: string, cwd: string, providerOverride?: string): Promise<void> {
965
+ private async spawnTerminal(terminalId: string, cwd: string): Promise<void> {
1176
966
  const cleanEnv: Record<string, string> = {};
1177
967
  for (const [k, v] of Object.entries(this.options.providerConfig.env)) {
1178
968
  if (v !== undefined) cleanEnv[k] = v;
1179
969
  }
1180
- const hookMarker = this.terminalHookMarker(terminalId);
1181
- // Inject marker so child CLIs' hook commands carry our identity
1182
- cleanEnv["LINKSHELL_ID"] = hookMarker;
1183
970
 
1184
- const provider = providerOverride ?? this.options.providerConfig.provider;
1185
971
  const args = [...this.options.providerConfig.args];
1186
972
 
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
973
  const term: TerminalInstance = {
1211
974
  id: terminalId,
1212
975
  pty: pty.spawn(
@@ -1221,16 +984,9 @@ export class BridgeSession {
1221
984
  },
1222
985
  ),
1223
986
  cwd,
1224
- projectName: basename(cwd),
1225
- provider,
1226
987
  scrollback: new ScrollbackBuffer(1000),
1227
988
  outputSeq: 0,
1228
- statusSeq: 0,
1229
989
  status: "running",
1230
- hookServer,
1231
- hookPort,
1232
- hookMarker,
1233
- hookConfigPaths,
1234
990
  };
1235
991
 
1236
992
  term.pty.onData((data) => {
@@ -1254,7 +1010,6 @@ export class BridgeSession {
1254
1010
 
1255
1011
  term.pty.onExit(({ exitCode, signal }) => {
1256
1012
  term.status = "exited";
1257
- this.cleanupHookServer(term);
1258
1013
  this.send(createEnvelope({
1259
1014
  type: "terminal.exit",
1260
1015
  sessionId: this.sessionId,
@@ -1279,727 +1034,12 @@ export class BridgeSession {
1279
1034
  this.log(`spawned terminal ${terminalId} in ${cwd}`);
1280
1035
  }
1281
1036
 
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
1037
  private send(message: Envelope): void {
1997
1038
  if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
1998
1039
  return;
1999
1040
  }
2000
1041
  const machineId = this.machineIdentity?.machineId;
2001
1042
  const enriched = machineId && (
2002
- message.type === "terminal.status" ||
2003
1043
  message.type === "agent.capabilities" ||
2004
1044
  message.type === "agent.snapshot" ||
2005
1045
  message.type === "agent.v2.capabilities" ||
@@ -2021,8 +1061,8 @@ export class BridgeSession {
2021
1061
  this.heartbeatTimer = setInterval(() => {
2022
1062
  this.send(
2023
1063
  createEnvelope({
2024
- type: "session.heartbeat",
2025
- sessionId: this.sessionId,
1064
+ type: "device.heartbeat",
1065
+ hostDeviceId: this.sessionId,
2026
1066
  payload: { ts: Date.now() },
2027
1067
  }),
2028
1068
  );
@@ -2155,7 +1195,6 @@ export class BridgeSession {
2155
1195
  }
2156
1196
  this.tunnelSockets.clear();
2157
1197
  for (const term of this.terminals.values()) {
2158
- this.cleanupHookServer(term);
2159
1198
  if (term.status === "running") term.pty.kill();
2160
1199
  }
2161
1200
  this.terminals.clear();