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