linkshell-cli 0.2.125 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/cli/src/commands/setup.js +2 -20
  2. package/dist/cli/src/commands/setup.js.map +1 -1
  3. package/dist/cli/src/index.js +26 -28
  4. package/dist/cli/src/index.js.map +1 -1
  5. package/dist/cli/src/providers.d.ts +7 -3
  6. package/dist/cli/src/providers.js +19 -76
  7. package/dist/cli/src/providers.js.map +1 -1
  8. package/dist/cli/src/runtime/acp/agent-session.d.ts +1 -1
  9. package/dist/cli/src/runtime/acp/agent-session.js +4 -4
  10. package/dist/cli/src/runtime/acp/agent-session.js.map +1 -1
  11. package/dist/cli/src/runtime/acp/agent-workspace.d.ts +1 -1
  12. package/dist/cli/src/runtime/acp/agent-workspace.js +17 -62
  13. package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
  14. package/dist/cli/src/runtime/bridge-session.d.ts +1 -31
  15. package/dist/cli/src/runtime/bridge-session.js +57 -993
  16. package/dist/cli/src/runtime/bridge-session.js.map +1 -1
  17. package/dist/cli/src/runtime/screen-fallback.d.ts +1 -1
  18. package/dist/cli/src/runtime/screen-fallback.js +4 -4
  19. package/dist/cli/src/runtime/screen-fallback.js.map +1 -1
  20. package/dist/cli/src/runtime/screen-share.d.ts +1 -1
  21. package/dist/cli/src/runtime/screen-share.js +7 -7
  22. package/dist/cli/src/runtime/screen-share.js.map +1 -1
  23. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  24. package/dist/shared-protocol/src/index.d.ts +3743 -5570
  25. package/dist/shared-protocol/src/index.js +19 -84
  26. package/dist/shared-protocol/src/index.js.map +1 -1
  27. package/package.json +12 -12
  28. package/src/commands/setup.ts +5 -31
  29. package/src/index.ts +29 -34
  30. package/src/providers.ts +26 -108
  31. package/src/runtime/acp/agent-workspace.ts +18 -63
  32. package/src/runtime/bridge-session.ts +57 -1091
  33. package/src/runtime/screen-fallback.ts +5 -5
  34. package/src/runtime/screen-share.ts +8 -8
  35. package/src/types/linkshell-gateway.d.ts +18 -0
  36. package/dist/cli/src/runtime/acp-relay.d.ts +0 -23
  37. package/dist/cli/src/runtime/acp-relay.js +0 -73
  38. package/dist/cli/src/runtime/acp-relay.js.map +0 -1
  39. package/src/runtime/acp/agent-session.ts +0 -1180
@@ -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, 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
- 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";
@@ -13,7 +13,6 @@ import { getLanIp } from "../utils/lan-ip.js";
13
13
  import { startKeepAwake } from "../utils/keep-awake.js";
14
14
  import { loadOrCreateMachineIdentity } from "../machine-id.js";
15
15
  import { getValidToken } from "../auth.js";
16
- import { AgentSessionProxy } from "./acp/agent-session.js";
17
16
  import { AgentWorkspaceProxy } from "./acp/agent-workspace.js";
18
17
  import { detectAvailableProviders } from "./acp/provider-resolver.js";
19
18
  const HEARTBEAT_INTERVAL = 15_000;
@@ -21,113 +20,6 @@ const RECONNECT_BASE_DELAY = 1_000;
21
20
  const RECONNECT_MAX_DELAY = 30_000;
22
21
  const RECONNECT_MAX_ATTEMPTS = 20;
23
22
  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
23
  function getPairingGatewayParam(gatewayHttpUrl) {
132
24
  try {
133
25
  const url = new URL(gatewayHttpUrl);
@@ -194,36 +86,26 @@ export class BridgeSession {
194
86
  sessionId = "";
195
87
  exited = false;
196
88
  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
89
  screenCapture;
202
90
  screenShare;
203
91
  tunnelSockets = new Map();
204
92
  keepAwake;
205
- agentSession;
206
93
  agentWorkspace;
207
94
  machineIdentity;
208
95
  constructor(options) {
209
96
  this.options = options;
210
- this.sessionId = options.sessionId ?? "";
97
+ this.sessionId = options.hostDeviceId ?? "";
211
98
  }
212
99
  log(msg) {
213
100
  if (this.options.verbose) {
214
101
  process.stderr.write(`[bridge:verbose] ${msg}\n`);
215
102
  }
216
103
  }
217
- terminalHookMarker(terminalId) {
218
- const safeTerminalId = terminalId.replace(/[^a-zA-Z0-9_-]+/g, "-");
219
- return `${this.hookMarker}-${safeTerminalId}`;
220
- }
221
104
  async start() {
222
- this.log(`starting session (gateway=${this.options.gatewayUrl}, provider=${this.options.providerConfig.provider})`);
105
+ this.log(`starting device bridge (gateway=${this.options.gatewayUrl}, terminal=shell)`);
223
106
  this.machineIdentity = loadOrCreateMachineIdentity();
224
- if (!this.sessionId) {
225
- await this.createPairing();
226
- }
107
+ this.sessionId ||= this.machineIdentity.machineId;
108
+ await this.createPairing();
227
109
  if (this.options.keepAwake) {
228
110
  this.keepAwake = startKeepAwake();
229
111
  }
@@ -231,21 +113,17 @@ export class BridgeSession {
231
113
  process.stderr.write("[bridge] keep-awake disabled\n");
232
114
  }
233
115
  if (this.options.agentUi) {
234
- process.env.LINKSHELL_ID = this.terminalHookMarker(DEFAULT_TERMINAL_ID);
235
116
  const availableProviders = this.options.agentProvider
236
117
  ? [normalizeAgentProvider(this.options.agentProvider)]
237
118
  : detectAvailableProviders();
238
119
  const agentOptions = {
239
- sessionId: this.sessionId,
120
+ hostDeviceId: this.sessionId,
240
121
  cwd: process.cwd(),
241
122
  availableProviders,
242
123
  command: this.options.agentCommand,
243
124
  verbose: this.options.verbose,
244
125
  send: (envelope) => this.send(envelope),
245
126
  };
246
- this.agentSession = new AgentSessionProxy({
247
- ...agentOptions,
248
- });
249
127
  this.agentWorkspace = new AgentWorkspaceProxy({
250
128
  ...agentOptions,
251
129
  });
@@ -263,19 +141,19 @@ export class BridgeSession {
263
141
  const res = await fetch(`${this.options.gatewayHttpUrl}/pairings`, {
264
142
  method: "POST",
265
143
  headers,
266
- body: JSON.stringify({}),
144
+ body: JSON.stringify({ hostDeviceId: this.sessionId }),
267
145
  });
268
146
  if (!res.ok) {
269
147
  throw new Error(`Failed to create pairing: ${res.status}`);
270
148
  }
271
149
  const body = (await res.json());
272
- this.sessionId = body.sessionId;
150
+ this.sessionId = body.hostDeviceId;
273
151
  const pairingGateway = resolvePairingGateway(this.options.gatewayHttpUrl, this.options.pairingGateway);
274
152
  const deepLink = pairingGateway
275
153
  ? `linkshell://pair?code=${body.pairingCode}&gateway=${encodeURIComponent(pairingGateway)}`
276
154
  : `linkshell://pair?code=${body.pairingCode}`;
277
155
  process.stderr.write(`\n \x1b[1mPairing code: \x1b[36m${body.pairingCode}\x1b[0m\n`);
278
- process.stderr.write(` Session: ${body.sessionId}\n`);
156
+ process.stderr.write(` Host device: ${body.hostDeviceId}\n`);
279
157
  process.stderr.write(` Expires: ${body.expiresAt}\n\n`);
280
158
  if (!pairingGateway) {
281
159
  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 +203,7 @@ export class BridgeSession {
325
203
  return;
326
204
  }
327
205
  const url = new URL(this.options.gatewayUrl);
328
- url.searchParams.set("sessionId", this.sessionId);
206
+ url.searchParams.set("hostDeviceId", this.sessionId);
329
207
  url.searchParams.set("role", "host");
330
208
  const authToken = await this.resolveAuthToken();
331
209
  if (authToken) {
@@ -339,18 +217,22 @@ export class BridgeSession {
339
217
  this.reconnectAttempts = 0;
340
218
  this.reconnecting = false;
341
219
  this.send(createEnvelope({
342
- type: "session.connect",
343
- sessionId: this.sessionId,
220
+ type: "device.connect",
221
+ hostDeviceId: this.sessionId,
344
222
  payload: {
345
223
  role: "host",
346
224
  clientName: this.options.clientName,
347
- provider: this.options.providerConfig.provider,
348
225
  protocolVersion: PROTOCOL_VERSION,
349
226
  machineId: this.machineIdentity?.machineId,
350
227
  hostname: this.options.hostname || hostname(),
351
228
  platform: platform(),
352
229
  cwd: process.cwd(),
353
- projectName: basename(process.cwd()),
230
+ capabilities: [
231
+ "terminal",
232
+ ...(this.options.agentUi ? ["agent-ui"] : []),
233
+ ...(this.options.screen ? ["screen"] : []),
234
+ "tunnel",
235
+ ],
354
236
  },
355
237
  }));
356
238
  this.startHeartbeat();
@@ -401,33 +283,33 @@ export class BridgeSession {
401
283
  }
402
284
  case "terminal.spawn": {
403
285
  const p = parseTypedPayload("terminal.spawn", envelope.payload);
404
- const normalizedCwd = resolve(p.cwd);
286
+ const normalizedCwd = resolve(p.cwd ?? process.cwd());
405
287
  // Dedup: if a running terminal already exists for this cwd, return it
406
288
  const existing = [...this.terminals.values()].find((t) => t.status === "running" && resolve(t.cwd) === normalizedCwd);
407
289
  if (existing) {
408
290
  this.send(createEnvelope({
409
291
  type: "terminal.spawned",
410
- sessionId: this.sessionId,
292
+ hostDeviceId: this.sessionId,
411
293
  terminalId: existing.id,
412
- payload: { terminalId: existing.id, cwd: existing.cwd, projectName: existing.projectName, provider: existing.provider },
294
+ payload: { terminalId: existing.id, cwd: existing.cwd, shell: this.options.providerConfig.command },
413
295
  }));
414
296
  }
415
297
  else {
416
298
  const newId = `term-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
417
299
  try {
418
- await this.spawnTerminal(newId, normalizedCwd, p.provider);
300
+ await this.spawnTerminal(newId, normalizedCwd);
419
301
  this.send(createEnvelope({
420
302
  type: "terminal.spawned",
421
- sessionId: this.sessionId,
303
+ hostDeviceId: this.sessionId,
422
304
  terminalId: newId,
423
- payload: { terminalId: newId, cwd: normalizedCwd, projectName: basename(normalizedCwd), provider: p.provider },
305
+ payload: { terminalId: newId, cwd: normalizedCwd, shell: this.options.providerConfig.command },
424
306
  }));
425
307
  }
426
308
  catch (err) {
427
309
  this.log(`failed to spawn terminal ${newId}: ${err}`);
428
310
  this.send(createEnvelope({
429
311
  type: "terminal.exit",
430
- sessionId: this.sessionId,
312
+ hostDeviceId: this.sessionId,
431
313
  terminalId: newId,
432
314
  payload: { exitCode: 1, signal: 0 },
433
315
  }));
@@ -470,14 +352,14 @@ export class BridgeSession {
470
352
  });
471
353
  this.send(createEnvelope({
472
354
  type: "terminal.browse.result",
473
- sessionId: this.sessionId,
355
+ hostDeviceId: this.sessionId,
474
356
  payload: { path: browsePath, entries, requestId: p.requestId },
475
357
  }));
476
358
  }
477
359
  catch (err) {
478
360
  this.send(createEnvelope({
479
361
  type: "terminal.browse.result",
480
- sessionId: this.sessionId,
362
+ hostDeviceId: this.sessionId,
481
363
  payload: { path: browsePath, entries: [], error: err.message, requestId: p.requestId },
482
364
  }));
483
365
  }
@@ -507,7 +389,7 @@ export class BridgeSession {
507
389
  }
508
390
  this.send(createEnvelope({
509
391
  type: "terminal.file.read.result",
510
- sessionId: this.sessionId,
392
+ hostDeviceId: this.sessionId,
511
393
  payload: {
512
394
  path: filePath,
513
395
  content: buffer.toString("utf8"),
@@ -521,7 +403,7 @@ export class BridgeSession {
521
403
  catch (err) {
522
404
  this.send(createEnvelope({
523
405
  type: "terminal.file.read.result",
524
- sessionId: this.sessionId,
406
+ hostDeviceId: this.sessionId,
525
407
  payload: {
526
408
  path: filePath,
527
409
  content: "",
@@ -552,14 +434,14 @@ export class BridgeSession {
552
434
  }));
553
435
  this.send(createEnvelope({
554
436
  type: "terminal.browse.result",
555
- sessionId: this.sessionId,
437
+ hostDeviceId: this.sessionId,
556
438
  payload: { path: parentPath, entries },
557
439
  }));
558
440
  }
559
441
  catch (err) {
560
442
  this.send(createEnvelope({
561
443
  type: "terminal.browse.result",
562
- sessionId: this.sessionId,
444
+ hostDeviceId: this.sessionId,
563
445
  payload: { path: dirPath, entries: [], error: err.message },
564
446
  }));
565
447
  }
@@ -601,21 +483,21 @@ export class BridgeSession {
601
483
  catch { }
602
484
  this.send(createEnvelope({
603
485
  type: "terminal.history.response",
604
- sessionId: this.sessionId,
486
+ hostDeviceId: this.sessionId,
605
487
  payload: { entries, shell },
606
488
  }));
607
489
  break;
608
490
  }
609
- case "session.ack": {
610
- const p = parseTypedPayload("session.ack", envelope.payload);
491
+ case "device.ack": {
492
+ const p = parseTypedPayload("device.ack", envelope.payload);
611
493
  const term = this.terminals.get(tid);
612
494
  if (term) {
613
495
  term.scrollback.trimUpTo(p.seq);
614
496
  }
615
497
  break;
616
498
  }
617
- case "session.resume": {
618
- const p = parseTypedPayload("session.resume", envelope.payload);
499
+ case "device.resume": {
500
+ const p = parseTypedPayload("device.resume", envelope.payload);
619
501
  // Replay all terminals
620
502
  for (const [termId, term] of this.terminals) {
621
503
  this.replayFrom(termId, term, p.lastAckedSeqByTerminal[termId] ?? p.lastAckedSeq);
@@ -624,7 +506,7 @@ export class BridgeSession {
624
506
  this.sendTerminalList();
625
507
  break;
626
508
  }
627
- case "session.heartbeat":
509
+ case "device.heartbeat":
628
510
  break;
629
511
  case "screen.start": {
630
512
  const p = parseTypedPayload("screen.start", envelope.payload);
@@ -645,68 +527,6 @@ export class BridgeSession {
645
527
  this.screenShare?.handleIceCandidate(p.candidate, p.sdpMid, p.sdpMLineIndex);
646
528
  break;
647
529
  }
648
- case "agent.initialize":
649
- case "agent.session.new":
650
- case "agent.session.load":
651
- case "agent.session.list":
652
- case "agent.prompt":
653
- case "agent.cancel": {
654
- if (!this.agentSession) {
655
- this.send(createEnvelope({
656
- type: "agent.capabilities",
657
- sessionId: this.sessionId,
658
- payload: {
659
- enabled: false,
660
- provider: normalizeAgentProvider(this.options.agentProvider ?? "codex"),
661
- machineId: this.machineIdentity?.machineId,
662
- error: "Agent GUI is not enabled. Start CLI with --agent-ui.",
663
- supportsSessionList: false,
664
- supportsSessionLoad: false,
665
- supportsImages: false,
666
- supportsAudio: false,
667
- supportsPermission: false,
668
- supportsPlan: false,
669
- supportsCancel: false,
670
- },
671
- }));
672
- break;
673
- }
674
- if (envelope.type === "agent.prompt")
675
- this.refreshAgentPermissionHooks();
676
- await this.agentSession.handleEnvelope(envelope);
677
- break;
678
- }
679
- case "agent.permission.response": {
680
- const p = parseTypedPayload("agent.permission.response", envelope.payload);
681
- if (this.resolvePendingPermission(p.requestId, {
682
- outcome: p.outcome,
683
- optionId: p.optionId,
684
- }, "agent.permission.response").resolved) {
685
- break;
686
- }
687
- if (!this.agentSession) {
688
- this.send(createEnvelope({
689
- type: "agent.capabilities",
690
- sessionId: this.sessionId,
691
- payload: {
692
- enabled: false,
693
- provider: normalizeAgentProvider(this.options.agentProvider ?? "codex"),
694
- machineId: this.machineIdentity?.machineId,
695
- error: "Agent GUI is not enabled. Start CLI with --agent-ui.",
696
- supportsSessionList: false,
697
- supportsSessionLoad: false,
698
- supportsImages: false,
699
- supportsAudio: false,
700
- supportsPermission: false,
701
- supportsPlan: false,
702
- supportsCancel: false,
703
- },
704
- }));
705
- break;
706
- }
707
- await this.agentSession.handleEnvelope(envelope);
708
- break;
709
- }
710
530
  case "agent.v2.capabilities.request":
711
531
  case "agent.v2.conversation.open":
712
532
  case "agent.v2.conversation.list":
@@ -719,7 +539,7 @@ export class BridgeSession {
719
539
  if (!this.agentWorkspace) {
720
540
  this.send(createEnvelope({
721
541
  type: "agent.v2.capabilities",
722
- sessionId: this.sessionId,
542
+ hostDeviceId: this.sessionId,
723
543
  payload: {
724
544
  enabled: false,
725
545
  provider: normalizeAgentProvider(this.options.agentProvider ?? "codex"),
@@ -737,8 +557,6 @@ export class BridgeSession {
737
557
  }));
738
558
  break;
739
559
  }
740
- if (envelope.type === "agent.v2.prompt" || envelope.type === "agent.v2.command.execute")
741
- this.refreshAgentPermissionHooks();
742
560
  await this.agentWorkspace.handleEnvelope(envelope);
743
561
  break;
744
562
  }
@@ -754,37 +572,6 @@ export class BridgeSession {
754
572
  }
755
573
  break;
756
574
  }
757
- case "permission.decision": {
758
- const p = envelope.payload;
759
- const result = this.resolvePendingPermission(p.requestId, p.decision, "permission.decision");
760
- if (!result.resolved) {
761
- this.sendPermissionSnapshot(tid, "thinking", "permission not pending", {
762
- requestId: p.requestId,
763
- outcome: p.decision,
764
- source: "permission.decision",
765
- delivered: false,
766
- });
767
- }
768
- process.stderr.write(`[bridge] permission decision request=${p.requestId} decision=${p.decision} resolved=${result.resolved} delivered=${result.delivered}\n`);
769
- this.send(createEnvelope({
770
- type: "permission.decision.result",
771
- sessionId: this.sessionId,
772
- terminalId: tid,
773
- payload: {
774
- requestId: p.requestId,
775
- decision: p.decision,
776
- resolved: result.resolved,
777
- delivered: result.delivered,
778
- source: "permission.decision",
779
- message: result.delivered
780
- ? undefined
781
- : result.resolved
782
- ? "Permission resolved but response was not delivered"
783
- : "Permission request is no longer pending",
784
- },
785
- }));
786
- break;
787
- }
788
575
  case "tunnel.request": {
789
576
  const p = parseTypedPayload("tunnel.request", envelope.payload);
790
577
  this.handleTunnelRequest(p);
@@ -833,7 +620,7 @@ export class BridgeSession {
833
620
  proxyRes.on("data", (chunk) => {
834
621
  this.send(createEnvelope({
835
622
  type: "tunnel.response",
836
- sessionId: this.sessionId,
623
+ hostDeviceId: this.sessionId,
837
624
  payload: {
838
625
  requestId,
839
626
  statusCode: proxyRes.statusCode ?? 200,
@@ -847,7 +634,7 @@ export class BridgeSession {
847
634
  proxyRes.on("end", () => {
848
635
  this.send(createEnvelope({
849
636
  type: "tunnel.response",
850
- sessionId: this.sessionId,
637
+ hostDeviceId: this.sessionId,
851
638
  payload: {
852
639
  requestId,
853
640
  statusCode: proxyRes.statusCode ?? 200,
@@ -884,7 +671,7 @@ export class BridgeSession {
884
671
  const buf = typeof data === "string" ? Buffer.from(data) : data;
885
672
  this.send(createEnvelope({
886
673
  type: "tunnel.ws.data",
887
- sessionId: this.sessionId,
674
+ hostDeviceId: this.sessionId,
888
675
  payload: {
889
676
  requestId,
890
677
  data: buf.toString("base64"),
@@ -897,7 +684,7 @@ export class BridgeSession {
897
684
  const safeCode = typeof code === "number" && code >= 1000 && code <= 4999 ? code : 1000;
898
685
  this.send(createEnvelope({
899
686
  type: "tunnel.ws.close",
900
- sessionId: this.sessionId,
687
+ hostDeviceId: this.sessionId,
901
688
  payload: {
902
689
  requestId,
903
690
  code: safeCode,
@@ -909,7 +696,7 @@ export class BridgeSession {
909
696
  this.tunnelSockets.delete(requestId);
910
697
  this.send(createEnvelope({
911
698
  type: "tunnel.ws.close",
912
- sessionId: this.sessionId,
699
+ hostDeviceId: this.sessionId,
913
700
  payload: {
914
701
  requestId,
915
702
  code: 1001,
@@ -936,7 +723,7 @@ export class BridgeSession {
936
723
  sendTunnelError(requestId, statusCode, message) {
937
724
  this.send(createEnvelope({
938
725
  type: "tunnel.response",
939
- sessionId: this.sessionId,
726
+ hostDeviceId: this.sessionId,
940
727
  payload: {
941
728
  requestId,
942
729
  statusCode,
@@ -950,13 +737,12 @@ export class BridgeSession {
950
737
  const terminals = [...this.terminals.values()].map((t) => ({
951
738
  terminalId: t.id,
952
739
  cwd: t.cwd,
953
- projectName: t.projectName,
954
- provider: t.provider,
955
740
  status: t.status,
741
+ shell: this.options.providerConfig.command,
956
742
  }));
957
743
  this.send(createEnvelope({
958
744
  type: "terminal.list",
959
- sessionId: this.sessionId,
745
+ hostDeviceId: this.sessionId,
960
746
  payload: { terminals },
961
747
  }));
962
748
  }
@@ -966,46 +752,20 @@ export class BridgeSession {
966
752
  const payload = msg.payload;
967
753
  this.send(createEnvelope({
968
754
  type: "terminal.output",
969
- sessionId: this.sessionId,
755
+ hostDeviceId: this.sessionId,
970
756
  terminalId,
971
757
  seq: msg.seq,
972
758
  payload: { ...payload, isReplay: true },
973
759
  }));
974
760
  }
975
761
  }
976
- async spawnTerminal(terminalId, cwd, providerOverride) {
762
+ async spawnTerminal(terminalId, cwd) {
977
763
  const cleanEnv = {};
978
764
  for (const [k, v] of Object.entries(this.options.providerConfig.env)) {
979
765
  if (v !== undefined)
980
766
  cleanEnv[k] = v;
981
767
  }
982
- const hookMarker = this.terminalHookMarker(terminalId);
983
- // Inject marker so child CLIs' hook commands carry our identity
984
- cleanEnv["LINKSHELL_ID"] = hookMarker;
985
- const provider = providerOverride ?? this.options.providerConfig.provider;
986
768
  const args = [...this.options.providerConfig.args];
987
- // Set up hook server for structured status (all supported providers)
988
- // For "custom" shell, set up hooks for all providers since user may launch any of them
989
- let hookServer;
990
- let hookPort;
991
- const hookConfigPaths = [];
992
- if (provider === "custom") {
993
- const result = await this.setupHookServer(terminalId, args, "claude", hookMarker);
994
- hookServer = result.server;
995
- hookPort = result.port;
996
- hookConfigPaths.push(result.configPath);
997
- // Also set up hooks for other providers (curlCmd already has marker from setupHookServer)
998
- 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`;
999
- hookConfigPaths.push(this.setupCodexHooks(terminalId, curlCmd, hookMarker));
1000
- hookConfigPaths.push(this.setupGeminiHooks(terminalId, curlCmd, hookMarker));
1001
- hookConfigPaths.push(this.setupCopilotHooks(terminalId, curlCmd, hookMarker));
1002
- }
1003
- else if (provider === "claude" || provider === "codex" || provider === "gemini" || provider === "copilot") {
1004
- const result = await this.setupHookServer(terminalId, args, provider, hookMarker);
1005
- hookServer = result.server;
1006
- hookPort = result.port;
1007
- hookConfigPaths.push(result.configPath);
1008
- }
1009
769
  const term = {
1010
770
  id: terminalId,
1011
771
  pty: pty.spawn(this.options.providerConfig.command, args, {
@@ -1016,22 +776,15 @@ export class BridgeSession {
1016
776
  env: cleanEnv,
1017
777
  }),
1018
778
  cwd,
1019
- projectName: basename(cwd),
1020
- provider,
1021
779
  scrollback: new ScrollbackBuffer(1000),
1022
780
  outputSeq: 0,
1023
- statusSeq: 0,
1024
781
  status: "running",
1025
- hookServer,
1026
- hookPort,
1027
- hookMarker,
1028
- hookConfigPaths,
1029
782
  };
1030
783
  term.pty.onData((data) => {
1031
784
  const seq = term.outputSeq++;
1032
785
  const envelope = createEnvelope({
1033
786
  type: "terminal.output",
1034
- sessionId: this.sessionId,
787
+ hostDeviceId: this.sessionId,
1035
788
  terminalId,
1036
789
  seq,
1037
790
  payload: {
@@ -1047,10 +800,9 @@ export class BridgeSession {
1047
800
  });
1048
801
  term.pty.onExit(({ exitCode, signal }) => {
1049
802
  term.status = "exited";
1050
- this.cleanupHookServer(term);
1051
803
  this.send(createEnvelope({
1052
804
  type: "terminal.exit",
1053
- sessionId: this.sessionId,
805
+ hostDeviceId: this.sessionId,
1054
806
  terminalId,
1055
807
  payload: { exitCode, signal },
1056
808
  }));
@@ -1069,697 +821,12 @@ export class BridgeSession {
1069
821
  this.terminals.set(terminalId, term);
1070
822
  this.log(`spawned terminal ${terminalId} in ${cwd}`);
1071
823
  }
1072
- async setupHookServer(terminalId, args, provider, marker) {
1073
- const server = http.createServer((req, res) => {
1074
- this.log(`hook server received: ${req.method} ${req.url}`);
1075
- const reqUrl = new URL(req.url ?? "/", "http://localhost");
1076
- if (req.method !== "POST" || reqUrl.pathname !== "/hook") {
1077
- res.writeHead(404);
1078
- res.end();
1079
- return;
1080
- }
1081
- // Check marker — reject events not from our PTY
1082
- // m must match; lid must match OR be empty (some CLIs don't inherit env vars)
1083
- const reqMarker = reqUrl.searchParams.get("m");
1084
- const reqLid = reqUrl.searchParams.get("lid") ?? "";
1085
- if (reqMarker !== marker || (reqLid !== "" && reqLid !== marker)) {
1086
- this.log(`ignoring hook event: m=${reqMarker} lid=${reqLid} (expected ${marker})`);
1087
- res.writeHead(200);
1088
- res.end("ok");
1089
- return;
1090
- }
1091
- let body = "";
1092
- let bodyTooLarge = false;
1093
- req.on("data", (chunk) => {
1094
- if (bodyTooLarge)
1095
- return;
1096
- body += chunk.toString();
1097
- if (Buffer.byteLength(body, "utf8") > HOOK_BODY_LIMIT) {
1098
- bodyTooLarge = true;
1099
- res.writeHead(413);
1100
- res.end("payload too large");
1101
- req.destroy();
1102
- }
1103
- });
1104
- req.on("end", () => {
1105
- if (bodyTooLarge || res.writableEnded)
1106
- return;
1107
- this.log(`hook body (${body.length} bytes): ${body.slice(0, 200)}`);
1108
- try {
1109
- const event = JSON.parse(body);
1110
- const hookName = (event.hook_event_name ?? event.event_name);
1111
- // PermissionRequest: hold connection, wait for user decision from mobile app
1112
- if (hookName === "PermissionRequest") {
1113
- const requestId = `pr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1114
- const permissionSuggestions = hookPermissionSuggestions(event);
1115
- const timeout = setTimeout(() => {
1116
- if (this.resolvePendingPermission(requestId, "deny", "permission.timeout").resolved) {
1117
- this.log(`permission request ${requestId} timed out`);
1118
- this.sendPermissionSnapshot(terminalId, "thinking", "permission timed out");
1119
- }
1120
- }, PERMISSION_REQUEST_TIMEOUT_MS);
1121
- this.pendingPermissions.set(requestId, {
1122
- terminalId,
1123
- timeout,
1124
- permissionSuggestions,
1125
- resolve: (decision) => {
1126
- if (res.writableEnded)
1127
- return false;
1128
- const responseJson = JSON.stringify({
1129
- hookSpecificOutput: {
1130
- hookEventName: "PermissionRequest",
1131
- decision,
1132
- },
1133
- });
1134
- res.writeHead(200, { "Content-Type": "application/json" });
1135
- res.end(responseJson);
1136
- return true;
1137
- },
1138
- });
1139
- // Send status with requestId so app can route decision back
1140
- this.handleHookEvent(terminalId, event, provider, requestId);
1141
- this.sendHookPermissionRequest(terminalId, event, requestId);
1142
- }
1143
- else {
1144
- // All other hooks: respond immediately
1145
- res.writeHead(200);
1146
- res.end("ok");
1147
- this.handleHookEvent(terminalId, event, provider);
1148
- }
1149
- }
1150
- catch (e) {
1151
- res.writeHead(200);
1152
- res.end("ok");
1153
- this.log(`hook parse error: ${e}`);
1154
- }
1155
- });
1156
- });
1157
- // Listen on random port — await binding before reading address
1158
- const port = await new Promise((resolve, reject) => {
1159
- server.listen(0, "127.0.0.1", () => {
1160
- const addr = server.address();
1161
- resolve(addr.port);
1162
- });
1163
- server.on("error", reject);
1164
- });
1165
- this.log(`hook server for ${terminalId} (${provider}) listening on port ${port}, marker=${marker}`);
1166
- 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`;
1167
- let configPath;
1168
- if (provider === "codex") {
1169
- configPath = this.setupCodexHooks(terminalId, curlCmd, marker);
1170
- }
1171
- else if (provider === "gemini") {
1172
- configPath = this.setupGeminiHooks(terminalId, curlCmd, marker);
1173
- }
1174
- else if (provider === "copilot") {
1175
- configPath = this.setupCopilotHooks(terminalId, curlCmd, marker);
1176
- }
1177
- else {
1178
- // Claude (default)
1179
- configPath = this.setupClaudeHooks(terminalId, curlCmd, args, marker);
1180
- }
1181
- return { server, port, configPath };
1182
- }
1183
- refreshAgentPermissionHooks() {
1184
- const term = this.terminals.get(DEFAULT_TERMINAL_ID);
1185
- if (!term?.hookPort)
1186
- return;
1187
- const marker = term.hookMarker;
1188
- 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`;
1189
- const providers = this.options.agentProvider
1190
- ? [normalizeAgentProvider(this.options.agentProvider)]
1191
- : detectAvailableProviders();
1192
- try {
1193
- for (const provider of providers) {
1194
- if (provider === "codex") {
1195
- this.setupCodexHooks(DEFAULT_TERMINAL_ID, curlCmd, marker);
1196
- }
1197
- else {
1198
- // claude, custom
1199
- this.setupClaudeHooks(DEFAULT_TERMINAL_ID, curlCmd, [], marker);
1200
- }
1201
- }
1202
- }
1203
- catch (error) {
1204
- this.log(`failed to refresh agent permission hooks: ${error instanceof Error ? error.message : String(error)}`);
1205
- }
1206
- }
1207
- setupClaudeHooks(terminalId, curlCmd, args, marker) {
1208
- // Write hooks to ~/.claude/settings.json — Claude Code reads hooks from here
1209
- const claudeDir = join(homedir(), ".claude");
1210
- if (!existsSync(claudeDir))
1211
- mkdirSync(claudeDir, { recursive: true });
1212
- const settingsPath = join(claudeDir, "settings.json");
1213
- let existing = {};
1214
- try {
1215
- existing = JSON.parse(readFileSync(settingsPath, "utf8"));
1216
- }
1217
- catch { /* doesn't exist yet */ }
1218
- const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
1219
- const permissionEntry = {
1220
- matcher: "",
1221
- hooks: [{
1222
- type: "command",
1223
- command: curlCmd,
1224
- timeout: Math.ceil((PERMISSION_REQUEST_TIMEOUT_MS + 30_000) / 1000),
1225
- }],
1226
- };
1227
- const hookEvents = {
1228
- PreToolUse: hookEntry,
1229
- PostToolUse: hookEntry,
1230
- PostToolUseFailure: hookEntry,
1231
- Stop: hookEntry,
1232
- PermissionRequest: permissionEntry,
1233
- UserPromptSubmit: hookEntry,
1234
- SessionStart: hookEntry,
1235
- };
1236
- // Append our entries to existing hooks (first remove stale linkshell entries)
1237
- const existingHooks = (existing.hooks ?? {});
1238
- for (const [eventName, entry] of Object.entries(hookEvents)) {
1239
- existingHooks[eventName] = eventName === "PermissionRequest"
1240
- ? withBlockingLinkShellPermissionEntry(existingHooks[eventName], entry)
1241
- : withLinkShellHookEntry(existingHooks[eventName], entry, "last");
1242
- }
1243
- const merged = { ...existing, hooks: existingHooks };
1244
- writeFileSync(settingsPath, JSON.stringify(merged, null, 2));
1245
- this.log(`claude hooks appended to ${settingsPath}`);
1246
- return settingsPath;
1247
- }
1248
- setupCodexHooks(terminalId, curlCmd, marker) {
1249
- // Codex uses ~/.codex/hooks.json — same format as Claude (with matcher)
1250
- const codexDir = join(homedir(), ".codex");
1251
- if (!existsSync(codexDir))
1252
- mkdirSync(codexDir, { recursive: true });
1253
- // Ensure [features] codex_hooks = true in config.toml
1254
- const tomlPath = join(codexDir, "config.toml");
1255
- let tomlContent = "";
1256
- try {
1257
- tomlContent = readFileSync(tomlPath, "utf8");
1258
- }
1259
- catch { /* doesn't exist yet */ }
1260
- // Remove top-level codex_hooks (wrong location) and ensure it's under [features]
1261
- const hasFeatureSection = tomlContent.includes("[features]");
1262
- const hasCodexHooksUnderFeatures = hasFeatureSection &&
1263
- /\[features\][^\[]*codex_hooks\s*=\s*true/s.test(tomlContent);
1264
- if (!hasCodexHooksUnderFeatures) {
1265
- // Remove any top-level codex_hooks line
1266
- tomlContent = tomlContent.replace(/^codex_hooks\s*=.*\n?/m, "");
1267
- if (!tomlContent.includes("[features]")) {
1268
- tomlContent += `\n[features]\ncodex_hooks = true\n`;
1269
- }
1270
- else {
1271
- tomlContent = tomlContent.replace("[features]", "[features]\ncodex_hooks = true");
1272
- }
1273
- writeFileSync(tomlPath, tomlContent);
1274
- this.log(`enabled codex_hooks under [features] in ${tomlPath}`);
1275
- }
1276
- const hooksPath = join(codexDir, "hooks.json");
1277
- const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
1278
- const permissionEntry = {
1279
- matcher: "",
1280
- hooks: [{
1281
- type: "command",
1282
- command: curlCmd,
1283
- timeout: Math.ceil((PERMISSION_REQUEST_TIMEOUT_MS + 30_000) / 1000),
1284
- }],
1285
- };
1286
- const hookEvents = {
1287
- SessionStart: hookEntry,
1288
- PreToolUse: hookEntry,
1289
- PostToolUse: hookEntry,
1290
- UserPromptSubmit: hookEntry,
1291
- Stop: hookEntry,
1292
- PermissionRequest: permissionEntry,
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] = eventName === "PermissionRequest"
1303
- ? withBlockingLinkShellPermissionEntry(existingHooks[eventName], entry)
1304
- : withLinkShellHookEntry(existingHooks[eventName], entry, "last");
1305
- }
1306
- writeFileSync(hooksPath, JSON.stringify({ ...existing, hooks: existingHooks }, null, 2));
1307
- this.log(`codex hooks appended to ${hooksPath}`);
1308
- return hooksPath;
1309
- }
1310
- setupGeminiHooks(terminalId, curlCmd, marker) {
1311
- // Gemini uses ~/.gemini/settings.json — same format as Claude (with matcher)
1312
- const geminiDir = join(homedir(), ".gemini");
1313
- if (!existsSync(geminiDir))
1314
- mkdirSync(geminiDir, { recursive: true });
1315
- const settingsPath = join(geminiDir, "settings.json");
1316
- const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5000 }] };
1317
- const hookEvents = {
1318
- SessionStart: hookEntry,
1319
- SessionEnd: hookEntry,
1320
- BeforeTool: hookEntry,
1321
- AfterTool: hookEntry,
1322
- };
1323
- // Merge with existing settings if present
1324
- let existing = {};
1325
- try {
1326
- existing = JSON.parse(readFileSync(settingsPath, "utf8"));
1327
- }
1328
- catch { /* doesn't exist yet */ }
1329
- const existingHooks = (existing.hooks ?? {});
1330
- for (const [eventName, entry] of Object.entries(hookEvents)) {
1331
- existingHooks[eventName] = withLinkShellHookEntry(existingHooks[eventName], entry, "last");
1332
- }
1333
- existing.hooks = existingHooks;
1334
- writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
1335
- this.log(`gemini hooks appended to ${settingsPath}`);
1336
- return settingsPath;
1337
- }
1338
- setupCopilotHooks(terminalId, curlCmd, marker) {
1339
- // Copilot loads hooks from CWD as hooks.json
1340
- const cwd = this.terminals.get(terminalId)?.cwd ?? process.cwd();
1341
- const hooksPath = join(cwd, "hooks.json");
1342
- const mkHook = () => ({
1343
- type: "command",
1344
- bash: curlCmd,
1345
- timeoutSec: 30,
1346
- });
1347
- const hookEvents = {
1348
- sessionStart: mkHook(),
1349
- sessionEnd: mkHook(),
1350
- userPromptSubmitted: mkHook(),
1351
- preToolUse: mkHook(),
1352
- postToolUse: mkHook(),
1353
- errorOccurred: mkHook(),
1354
- };
1355
- // Read existing and append
1356
- let existing = {};
1357
- try {
1358
- existing = JSON.parse(readFileSync(hooksPath, "utf8"));
1359
- }
1360
- catch { /* doesn't exist yet */ }
1361
- const existingHooks = existing.hooks ?? {};
1362
- for (const [eventName, entry] of Object.entries(hookEvents)) {
1363
- existingHooks[eventName] = withLinkShellHookEntry(existingHooks[eventName], entry, "last");
1364
- }
1365
- writeFileSync(hooksPath, JSON.stringify({ version: 1, hooks: existingHooks }, null, 2));
1366
- this.log(`copilot hooks appended to ${hooksPath}`);
1367
- return hooksPath;
1368
- }
1369
- handleHookEvent(terminalId, event, provider, permissionRequestId) {
1370
- const rawHookName = (event.hook_event_name ?? event.event_name);
1371
- if (!rawHookName)
1372
- return;
1373
- // Auto-detect provider from hook event fields
1374
- const hookTerm = this.terminals.get(terminalId);
1375
- let detectedProvider = provider;
1376
- // Always detect from transcript_path (most reliable), regardless of current provider
1377
- const transcriptPath = typeof event.transcript_path === "string" ? event.transcript_path : "";
1378
- if (transcriptPath.includes(".claude/")) {
1379
- detectedProvider = "claude";
1380
- }
1381
- else if (transcriptPath.includes(".gemini/")) {
1382
- detectedProvider = "gemini";
1383
- }
1384
- else if (transcriptPath.includes(".codex/")) {
1385
- detectedProvider = "codex";
1386
- }
1387
- else if (hookTerm?.provider === "custom") {
1388
- // Fallback heuristics only when provider is still unknown
1389
- if (event.model && typeof event.model === "string" && /^(gpt|o[0-9]|codex)/i.test(event.model)) {
1390
- detectedProvider = "codex";
1391
- }
1392
- else if (event.session_id && !transcriptPath) {
1393
- detectedProvider = "codex";
1394
- }
1395
- else if (/^(Before|After)(Tool)$|^Session(Start|End)$/.test(rawHookName)) {
1396
- detectedProvider = "gemini";
1397
- }
1398
- else if (/^(pre|post)ToolUse$|^session(Start|End)$|^userPromptSubmitted$|^errorOccurred$/.test(rawHookName)) {
1399
- detectedProvider = "copilot";
1400
- }
1401
- }
1402
- if (hookTerm && detectedProvider !== hookTerm.provider) {
1403
- const wasCustom = hookTerm.provider === "custom";
1404
- hookTerm.provider = detectedProvider;
1405
- this.log(`${wasCustom ? "detected" : "provider switched"} provider for ${terminalId}: ${detectedProvider}`);
1406
- this.permissionStacks.delete(terminalId);
1407
- this.sendTerminalList();
1408
- }
1409
- // Normalize hook event names from different providers to unified names
1410
- const hookName = this.normalizeHookName(rawHookName, detectedProvider);
1411
- if (!hookName)
1412
- return;
1413
- let phase;
1414
- let toolName;
1415
- let toolInput;
1416
- let permissionRequest;
1417
- let summary;
1418
- switch (hookName) {
1419
- case "PreToolUse":
1420
- phase = "tool_use";
1421
- toolName = (event.tool_name ?? event.toolName);
1422
- if (event.tool_input && typeof event.tool_input === "object") {
1423
- const input = event.tool_input;
1424
- toolInput = JSON.stringify(input).slice(0, 200);
1425
- }
1426
- else if (event.toolInput && typeof event.toolInput === "object") {
1427
- toolInput = JSON.stringify(event.toolInput).slice(0, 200);
1428
- }
1429
- break;
1430
- case "PostToolUse":
1431
- phase = "thinking";
1432
- toolName = (event.tool_name ?? event.toolName);
1433
- // Pop permission stack + auto-resolve pending HTTP connection
1434
- {
1435
- const stack = this.permissionStacks.get(terminalId);
1436
- if (stack && stack.length > 0) {
1437
- const popped = stack.pop();
1438
- if (popped)
1439
- this.autoResolvePending(popped.requestId);
1440
- if (stack.length === 0)
1441
- this.permissionStacks.delete(terminalId);
1442
- }
1443
- }
1444
- break;
1445
- case "PostToolUseFailure":
1446
- phase = "error";
1447
- toolName = (event.tool_name ?? event.toolName);
1448
- {
1449
- const stack = this.permissionStacks.get(terminalId);
1450
- if (stack && stack.length > 0) {
1451
- const popped = stack.pop();
1452
- if (popped)
1453
- this.autoResolvePending(popped.requestId);
1454
- if (stack.length === 0)
1455
- this.permissionStacks.delete(terminalId);
1456
- }
1457
- }
1458
- break;
1459
- case "Stop":
1460
- phase = "idle";
1461
- if (event.stop_reason)
1462
- summary = String(event.stop_reason);
1463
- this.drainPendingPermissions(terminalId);
1464
- this.permissionStacks.delete(terminalId);
1465
- // Reset provider to "custom" when a CLI session ends inside a custom shell
1466
- if (hookTerm && this.options.providerConfig.provider === "custom") {
1467
- hookTerm.provider = "custom";
1468
- this.log(`provider reset to custom for ${terminalId} (CLI session ended)`);
1469
- this.sendTerminalList();
1470
- }
1471
- break;
1472
- case "PermissionRequest":
1473
- phase = "waiting";
1474
- toolName = (event.tool_name ?? event.toolName);
1475
- if (event.tool_input && typeof event.tool_input === "object") {
1476
- const input = event.tool_input;
1477
- permissionRequest = JSON.stringify(input).slice(0, 300);
1478
- }
1479
- else if (event.toolInput && typeof event.toolInput === "object") {
1480
- permissionRequest = JSON.stringify(event.toolInput).slice(0, 300);
1481
- }
1482
- // Push to permission stack (use requestId from hook server if available)
1483
- {
1484
- const reqId = permissionRequestId ?? `pr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1485
- if (!this.permissionStacks.has(terminalId)) {
1486
- this.permissionStacks.set(terminalId, []);
1487
- }
1488
- this.permissionStacks.get(terminalId).push({
1489
- requestId: reqId,
1490
- toolName: toolName ?? "unknown",
1491
- toolInput: toolInput ?? (permissionRequest ?? ""),
1492
- permissionRequest: permissionRequest ?? "",
1493
- timestamp: Date.now(),
1494
- });
1495
- }
1496
- break;
1497
- case "SessionStart":
1498
- phase = "idle";
1499
- summary = "session started";
1500
- break;
1501
- case "UserPromptSubmit":
1502
- phase = "thinking";
1503
- this.drainPendingPermissions(terminalId);
1504
- this.permissionStacks.delete(terminalId);
1505
- break;
1506
- default:
1507
- return;
1508
- }
1509
- this.log(`hook event [${provider}]: ${rawHookName} → ${hookName} → phase=${phase} tool=${toolName ?? "none"}`);
1510
- // Build topPermission from stack
1511
- const stack = this.permissionStacks.get(terminalId);
1512
- const topPermission = stack && stack.length > 0 ? stack[stack.length - 1] : undefined;
1513
- const pendingPermissionCount = stack?.length ?? 0;
1514
- // Increment statusSeq for ordering
1515
- const term = this.terminals.get(terminalId);
1516
- const seq = term ? term.statusSeq++ : 0;
1517
- this.send(createEnvelope({
1518
- type: "terminal.status",
1519
- sessionId: this.sessionId,
1520
- terminalId,
1521
- payload: {
1522
- phase,
1523
- seq,
1524
- ...(toolName && { toolName }),
1525
- ...(toolInput && { toolInput }),
1526
- ...(permissionRequest && { permissionRequest }),
1527
- ...(summary && { summary }),
1528
- ...(topPermission && { topPermission }),
1529
- ...(pendingPermissionCount > 0 && { pendingPermissionCount }),
1530
- },
1531
- }));
1532
- }
1533
- sendHookPermissionRequest(terminalId, event, requestId) {
1534
- const toolName = (event.tool_name ?? event.toolName);
1535
- const toolInput = stringifyHookInput(event.tool_input ?? event.toolInput);
1536
- const suggestions = hookPermissionSuggestions(event);
1537
- const context = typeof event.permission_prompt === "string"
1538
- ? event.permission_prompt
1539
- : typeof event.message === "string"
1540
- ? event.message
1541
- : undefined;
1542
- this.send(createEnvelope({
1543
- type: "agent.permission.request",
1544
- sessionId: this.sessionId,
1545
- terminalId,
1546
- payload: {
1547
- requestId,
1548
- toolName,
1549
- toolInput,
1550
- context,
1551
- options: hookPermissionOptions(suggestions),
1552
- },
1553
- }));
1554
- }
1555
- /**
1556
- * Normalize hook event names from different CLI providers to unified internal names.
1557
- * Claude: PascalCase (PreToolUse, PostToolUse, Stop, PermissionRequest)
1558
- * Codex: camelCase (preToolUse, postToolUse, sessionStart)
1559
- * Gemini: PascalCase but different names (BeforeTool, AfterTool, BeforeSubmitPrompt)
1560
- */
1561
- normalizeHookName(rawName, provider) {
1562
- // Claude events — already in our canonical format
1563
- if (provider === "claude") {
1564
- return rawName;
1565
- }
1566
- // Codex events — same as Claude (PascalCase)
1567
- if (provider === "codex") {
1568
- switch (rawName) {
1569
- case "PreToolUse":
1570
- case "preToolUse": return "PreToolUse";
1571
- case "PostToolUse":
1572
- case "postToolUse": return "PostToolUse";
1573
- case "SessionStart":
1574
- case "sessionStart": return "SessionStart";
1575
- case "UserPromptSubmit": return "UserPromptSubmit";
1576
- case "PermissionRequest": return "PermissionRequest";
1577
- case "Stop": return "Stop";
1578
- default: return undefined;
1579
- }
1580
- }
1581
- // Gemini events
1582
- if (provider === "gemini") {
1583
- switch (rawName) {
1584
- case "BeforeTool": return "PreToolUse";
1585
- case "AfterTool": return "PostToolUse";
1586
- case "SessionStart": return "SessionStart";
1587
- case "SessionEnd": return "Stop";
1588
- default: return undefined;
1589
- }
1590
- }
1591
- // Copilot events (camelCase)
1592
- if (provider === "copilot") {
1593
- switch (rawName) {
1594
- case "preToolUse": return "PreToolUse";
1595
- case "postToolUse": return "PostToolUse";
1596
- case "sessionStart": return "SessionStart";
1597
- case "sessionEnd": return "Stop";
1598
- case "userPromptSubmitted": return "UserPromptSubmit";
1599
- case "errorOccurred": return "PostToolUseFailure";
1600
- default: return undefined;
1601
- }
1602
- }
1603
- // Unknown provider — try all known formats
1604
- // This handles "custom" shell where any provider might be launched
1605
- const allProviders = ["claude", "codex", "gemini", "copilot"];
1606
- for (const p of allProviders) {
1607
- const result = this.normalizeHookName(rawName, p);
1608
- if (result)
1609
- return result;
1610
- }
1611
- return undefined;
1612
- }
1613
- /** Auto-resolve a single pending permission (user acted in terminal) */
1614
- autoResolvePending(requestId) {
1615
- if (this.resolvePendingPermission(requestId, "allow", "terminal.auto").resolved) {
1616
- this.log(`auto-resolved pending permission ${requestId} (user acted in terminal)`);
1617
- }
1618
- }
1619
- /** Drain all pending permissions for a terminal (session ended, stop, etc.) */
1620
- drainPendingPermissions(terminalId) {
1621
- const stack = this.permissionStacks.get(terminalId);
1622
- if (!stack)
1623
- return;
1624
- for (const entry of [...stack]) {
1625
- if (this.resolvePendingPermission(entry.requestId, "deny", "terminal.drain").resolved) {
1626
- this.log(`drained pending permission ${entry.requestId}`);
1627
- }
1628
- }
1629
- }
1630
- resolvePendingPermission(requestId, choice, source = "unknown") {
1631
- const pending = this.pendingPermissions.get(requestId);
1632
- const outcome = typeof choice === "string" ? choice : choice.outcome;
1633
- const optionId = typeof choice === "string" ? undefined : choice.optionId;
1634
- if (!pending) {
1635
- this.log(`no pending permission for ${requestId} via ${source}: ${outcome}:${optionId ?? "default"}`);
1636
- return { resolved: false, delivered: false };
1637
- }
1638
- this.pendingPermissions.delete(requestId);
1639
- clearTimeout(pending.timeout);
1640
- const delivered = pending.resolve(this.formatHookPermissionDecision(pending, choice));
1641
- const stack = this.permissionStacks.get(pending.terminalId);
1642
- if (stack) {
1643
- const idx = stack.findIndex((entry) => entry.requestId === requestId);
1644
- if (idx >= 0)
1645
- stack.splice(idx, 1);
1646
- if (stack.length === 0)
1647
- this.permissionStacks.delete(pending.terminalId);
1648
- }
1649
- this.log(`resolved permission ${requestId} via ${source}: ${outcome}:${optionId ?? "default"} delivered=${delivered}`);
1650
- this.sendPermissionSnapshot(pending.terminalId, "thinking", outcome === "allow" ? "permission allowed" : "permission denied", { requestId, outcome, source, delivered });
1651
- return { resolved: true, delivered };
1652
- }
1653
- formatHookPermissionDecision(permission, choice) {
1654
- const outcome = typeof choice === "string" ? choice : choice.outcome;
1655
- const optionId = typeof choice === "string" ? undefined : choice.optionId;
1656
- if (outcome === "allow") {
1657
- return {
1658
- behavior: "allow",
1659
- ...(optionId === "allow_always" && permission.permissionSuggestions.length > 0
1660
- ? { updatedPermissions: permission.permissionSuggestions }
1661
- : {}),
1662
- };
1663
- }
1664
- return {
1665
- behavior: "deny",
1666
- message: outcome === "cancelled" ? "Permission request cancelled." : "Permission denied by user.",
1667
- };
1668
- }
1669
- sendPermissionSnapshot(terminalId, phase, summary, permissionResolution) {
1670
- const stack = this.permissionStacks.get(terminalId);
1671
- const topPermission = stack && stack.length > 0 ? stack[stack.length - 1] : undefined;
1672
- const pendingPermissionCount = stack?.length ?? 0;
1673
- const term = this.terminals.get(terminalId);
1674
- const seq = term ? term.statusSeq++ : 0;
1675
- this.send(createEnvelope({
1676
- type: "terminal.status",
1677
- sessionId: this.sessionId,
1678
- terminalId,
1679
- payload: {
1680
- phase,
1681
- seq,
1682
- ...(summary && { summary }),
1683
- ...(permissionResolution && { permissionResolution }),
1684
- ...(topPermission && { topPermission }),
1685
- ...(pendingPermissionCount > 0 && { pendingPermissionCount }),
1686
- },
1687
- }));
1688
- }
1689
- cleanupHookServer(term) {
1690
- // Drain any pending permission requests for this terminal
1691
- this.drainPendingPermissions(term.id);
1692
- if (term.hookServer) {
1693
- term.hookServer.close();
1694
- term.hookServer = undefined;
1695
- this.log(`hook server closed for ${term.id}`);
1696
- }
1697
- const marker = term.hookMarker;
1698
- for (const configPath of term.hookConfigPaths) {
1699
- try {
1700
- // Copilot: per-instance file — just delete it
1701
- if (configPath.includes(`linkshell-${marker}`)) {
1702
- if (existsSync(configPath)) {
1703
- unlinkSync(configPath);
1704
- this.log(`removed copilot hook file ${configPath}`);
1705
- }
1706
- }
1707
- else {
1708
- // Claude/Codex/Gemini: remove our entries from the shared config
1709
- this.removeHookEntries(configPath, marker);
1710
- }
1711
- }
1712
- catch { /* ignore */ }
1713
- }
1714
- term.hookConfigPaths = [];
1715
- }
1716
- /** Remove hook entries containing our marker from a JSON config file */
1717
- removeHookEntries(configPath, marker) {
1718
- if (!existsSync(configPath))
1719
- return;
1720
- try {
1721
- const raw = JSON.parse(readFileSync(configPath, "utf8"));
1722
- const hooks = raw.hooks;
1723
- if (!hooks)
1724
- return;
1725
- let changed = false;
1726
- for (const [eventName, entries] of Object.entries(hooks)) {
1727
- if (!Array.isArray(entries))
1728
- continue;
1729
- const filtered = entries.filter((entry) => {
1730
- const str = JSON.stringify(entry);
1731
- return !str.includes(marker);
1732
- });
1733
- if (filtered.length !== entries.length) {
1734
- changed = true;
1735
- if (filtered.length === 0) {
1736
- delete hooks[eventName];
1737
- }
1738
- else {
1739
- hooks[eventName] = filtered;
1740
- }
1741
- }
1742
- }
1743
- if (changed) {
1744
- // If no hooks left, remove the hooks key entirely
1745
- if (Object.keys(hooks).length === 0) {
1746
- delete raw.hooks;
1747
- }
1748
- writeFileSync(configPath, JSON.stringify(raw, null, 2));
1749
- this.log(`removed our hook entries from ${configPath}`);
1750
- }
1751
- }
1752
- catch { /* ignore parse errors */ }
1753
- }
1754
824
  send(message) {
1755
825
  if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
1756
826
  return;
1757
827
  }
1758
828
  const machineId = this.machineIdentity?.machineId;
1759
- const enriched = machineId && (message.type === "terminal.status" ||
1760
- message.type === "agent.capabilities" ||
1761
- message.type === "agent.snapshot" ||
1762
- message.type === "agent.v2.capabilities" ||
829
+ const enriched = machineId && (message.type === "agent.v2.capabilities" ||
1763
830
  message.type === "agent.v2.snapshot")
1764
831
  ? {
1765
832
  ...message,
@@ -1775,8 +842,8 @@ export class BridgeSession {
1775
842
  this.stopHeartbeat();
1776
843
  this.heartbeatTimer = setInterval(() => {
1777
844
  this.send(createEnvelope({
1778
- type: "session.heartbeat",
1779
- sessionId: this.sessionId,
845
+ type: "device.heartbeat",
846
+ hostDeviceId: this.sessionId,
1780
847
  payload: { ts: Date.now() },
1781
848
  }));
1782
849
  }, HEARTBEAT_INTERVAL);
@@ -1792,7 +859,7 @@ export class BridgeSession {
1792
859
  this.log("screen sharing not enabled (use --screen)");
1793
860
  this.send(createEnvelope({
1794
861
  type: "screen.status",
1795
- sessionId: this.sessionId,
862
+ hostDeviceId: this.sessionId,
1796
863
  payload: { active: false, mode: "off", error: "Screen sharing not enabled on host. Start CLI with --screen flag." },
1797
864
  }));
1798
865
  return;
@@ -1803,7 +870,7 @@ export class BridgeSession {
1803
870
  if (ScreenShare.isAvailable()) {
1804
871
  this.log("WebRTC available, starting screen share");
1805
872
  this.screenShare = new ScreenShare({
1806
- sessionId: this.sessionId,
873
+ hostDeviceId: this.sessionId,
1807
874
  fps,
1808
875
  quality,
1809
876
  scale,
@@ -1826,7 +893,7 @@ export class BridgeSession {
1826
893
  fps,
1827
894
  quality,
1828
895
  scale,
1829
- sessionId: this.sessionId,
896
+ hostDeviceId: this.sessionId,
1830
897
  onFrame: (envelope) => this.send(envelope),
1831
898
  onStatus: (envelope) => this.send(envelope),
1832
899
  });
@@ -1873,8 +940,6 @@ export class BridgeSession {
1873
940
  this.exited = true;
1874
941
  this.stopHeartbeat();
1875
942
  this.stopScreenCapture();
1876
- this.agentSession?.stop();
1877
- this.agentSession = undefined;
1878
943
  this.agentWorkspace?.stop();
1879
944
  this.agentWorkspace = undefined;
1880
945
  this.keepAwake?.stop();
@@ -1891,7 +956,6 @@ export class BridgeSession {
1891
956
  }
1892
957
  this.tunnelSockets.clear();
1893
958
  for (const term of this.terminals.values()) {
1894
- this.cleanupHookServer(term);
1895
959
  if (term.status === "running")
1896
960
  term.pty.kill();
1897
961
  }