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