linkshell-cli 0.2.124 → 0.2.126

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