linkshell-cli 0.3.27 → 0.3.29

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