linkshell-cli 0.2.34 → 0.2.36

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.
@@ -15,7 +15,6 @@ import {
15
15
  import type { Envelope } from "@linkshell/protocol";
16
16
  import type { ProviderConfig } from "../providers.js";
17
17
  import { ScrollbackBuffer } from "./scrollback.js";
18
- import { AcpRelay } from "./acp-relay.js";
19
18
  import { ScreenFallback } from "./screen-fallback.js";
20
19
  import { ScreenShare } from "./screen-share.js";
21
20
  import { getLanIp } from "../utils/lan-ip.js";
@@ -42,9 +41,7 @@ const DEFAULT_TERMINAL_ID = "default";
42
41
 
43
42
  interface TerminalInstance {
44
43
  id: string;
45
- pty: pty.IPty | null;
46
- acpRelay: AcpRelay | null;
47
- mode: "pty" | "acp";
44
+ pty: pty.IPty;
48
45
  cwd: string;
49
46
  projectName: string;
50
47
  provider: string;
@@ -131,6 +128,8 @@ export class BridgeSession {
131
128
  permissionRequest: string;
132
129
  timestamp: number;
133
130
  }>>();
131
+ // Pending permission responses: requestId → HTTP response callback
132
+ private pendingPermissions = new Map<string, (decision: "allow" | "deny") => void>();
134
133
  private screenCapture: ScreenFallback | undefined;
135
134
  private screenShare: ScreenShare | undefined;
136
135
 
@@ -284,29 +283,21 @@ export class BridgeSession {
284
283
  case "terminal.input": {
285
284
  const p = parseTypedPayload("terminal.input", envelope.payload);
286
285
  const term = this.terminals.get(tid);
287
- if (term && term.status === "running" && term.pty) term.pty.write(p.data);
286
+ if (term && term.status === "running") term.pty.write(p.data);
288
287
  break;
289
288
  }
290
289
  case "terminal.resize": {
291
290
  const p = parseTypedPayload("terminal.resize", envelope.payload);
292
291
  const term = this.terminals.get(tid);
293
- if (term && term.status === "running" && term.pty) term.pty.resize(p.cols, p.rows);
294
- break;
295
- }
296
- case "acp.rpc": {
297
- const term = this.terminals.get(tid);
298
- if (term && term.status === "running" && term.acpRelay) {
299
- term.acpRelay.write(envelope.payload);
300
- }
292
+ if (term && term.status === "running") term.pty.resize(p.cols, p.rows);
301
293
  break;
302
294
  }
303
295
  case "terminal.spawn": {
304
296
  const p = parseTypedPayload("terminal.spawn", envelope.payload);
305
297
  const normalizedCwd = resolve(p.cwd);
306
- const mode = p.mode ?? "pty";
307
- // Dedup: if a running terminal already exists for this cwd and mode, return it
298
+ // Dedup: if a running terminal already exists for this cwd, return it
308
299
  const existing = [...this.terminals.values()].find(
309
- (t) => t.status === "running" && resolve(t.cwd) === normalizedCwd && t.mode === mode,
300
+ (t) => t.status === "running" && resolve(t.cwd) === normalizedCwd,
310
301
  );
311
302
  if (existing) {
312
303
  this.send(createEnvelope({
@@ -318,7 +309,7 @@ export class BridgeSession {
318
309
  } else {
319
310
  const newId = `term-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
320
311
  try {
321
- await this.spawnTerminal(newId, normalizedCwd, p.provider, mode);
312
+ await this.spawnTerminal(newId, normalizedCwd, p.provider);
322
313
  this.send(createEnvelope({
323
314
  type: "terminal.spawned",
324
315
  sessionId: this.sessionId,
@@ -342,8 +333,7 @@ export class BridgeSession {
342
333
  const p = parseTypedPayload("terminal.kill", envelope.payload);
343
334
  const term = this.terminals.get(p.terminalId);
344
335
  if (term && term.status === "running") {
345
- if (term.pty) term.pty.kill();
346
- if (term.acpRelay) term.acpRelay.kill();
336
+ term.pty.kill();
347
337
  }
348
338
  break;
349
339
  }
@@ -425,11 +415,32 @@ export class BridgeSession {
425
415
  writeFileSync(tempPath, Buffer.from(p.data, "base64"));
426
416
  this.log(`image saved to ${tempPath}`);
427
417
  const term = this.terminals.get(tid);
428
- if (term && term.status === "running" && term.pty) {
418
+ if (term && term.status === "running") {
429
419
  term.pty.write(`\x1b[200~${tempPath}\x1b[201~`);
430
420
  }
431
421
  break;
432
422
  }
423
+ case "permission.decision": {
424
+ const p = envelope.payload as { requestId: string; decision: "allow" | "deny" };
425
+ const resolve = this.pendingPermissions.get(p.requestId);
426
+ if (resolve) {
427
+ this.pendingPermissions.delete(p.requestId);
428
+ resolve(p.decision);
429
+ this.log(`permission decision for ${p.requestId}: ${p.decision}`);
430
+ // Pop from permission stack
431
+ if (p.decision === "allow" || p.decision === "deny") {
432
+ const stack = this.permissionStacks.get(tid);
433
+ if (stack) {
434
+ const idx = stack.findIndex((s) => s.requestId === p.requestId);
435
+ if (idx >= 0) stack.splice(idx, 1);
436
+ if (stack.length === 0) this.permissionStacks.delete(tid);
437
+ }
438
+ }
439
+ } else {
440
+ this.log(`no pending permission for ${p.requestId}`);
441
+ }
442
+ break;
443
+ }
433
444
  default:
434
445
  break;
435
446
  }
@@ -442,7 +453,6 @@ export class BridgeSession {
442
453
  projectName: t.projectName,
443
454
  provider: t.provider,
444
455
  status: t.status,
445
- mode: t.mode,
446
456
  }));
447
457
  this.send(createEnvelope({
448
458
  type: "terminal.list",
@@ -473,7 +483,7 @@ export class BridgeSession {
473
483
  }
474
484
  }
475
485
 
476
- private async spawnTerminal(terminalId: string, cwd: string, providerOverride?: string, mode: "pty" | "acp" = "pty"): Promise<void> {
486
+ private async spawnTerminal(terminalId: string, cwd: string, providerOverride?: string): Promise<void> {
477
487
  const cleanEnv: Record<string, string> = {};
478
488
  for (const [k, v] of Object.entries(this.options.providerConfig.env)) {
479
489
  if (v !== undefined) cleanEnv[k] = v;
@@ -482,58 +492,6 @@ export class BridgeSession {
482
492
  const provider = providerOverride ?? this.options.providerConfig.provider;
483
493
  const args = [...this.options.providerConfig.args];
484
494
 
485
- // ACP mode: spawn as child process with JSON-RPC over stdio
486
- if (mode === "acp") {
487
- const term: TerminalInstance = {
488
- id: terminalId,
489
- pty: null,
490
- acpRelay: new AcpRelay({
491
- command: this.options.providerConfig.command,
492
- args,
493
- cwd,
494
- env: cleanEnv,
495
- sessionId: this.sessionId,
496
- terminalId,
497
- send: (envelope) => this.send(envelope),
498
- log: (msg) => this.log(msg),
499
- }),
500
- mode: "acp",
501
- cwd,
502
- projectName: basename(cwd),
503
- provider,
504
- scrollback: new ScrollbackBuffer(1000),
505
- outputSeq: 0,
506
- statusSeq: 0,
507
- status: "running",
508
- };
509
-
510
- term.acpRelay!.onExit((exitCode, signal) => {
511
- term.status = "exited";
512
- this.send(createEnvelope({
513
- type: "terminal.exit",
514
- sessionId: this.sessionId,
515
- terminalId,
516
- payload: { exitCode, signal: signal ? parseInt(signal, 10) || 0 : 0 },
517
- }));
518
- this.sendTerminalList();
519
-
520
- const allExited = [...this.terminals.values()].every((t) => t.status === "exited");
521
- if (allExited) {
522
- this.exited = true;
523
- setTimeout(() => {
524
- this.stopHeartbeat();
525
- this.socket?.close();
526
- }, 500);
527
- process.exitCode = exitCode ?? 0;
528
- }
529
- });
530
-
531
- this.terminals.set(terminalId, term);
532
- this.log(`spawned ACP terminal ${terminalId} in ${cwd}`);
533
- return;
534
- }
535
-
536
- // PTY mode: existing behavior
537
495
  // Set up hook server for structured status (all supported providers)
538
496
  // For "custom" shell, set up hooks for all providers since user may launch any of them
539
497
  let hookServer: http.Server | undefined;
@@ -570,8 +528,6 @@ export class BridgeSession {
570
528
  env: cleanEnv,
571
529
  },
572
530
  ),
573
- acpRelay: null,
574
- mode: "pty",
575
531
  cwd,
576
532
  projectName: basename(cwd),
577
533
  provider,
@@ -584,7 +540,7 @@ export class BridgeSession {
584
540
  hookConfigPath,
585
541
  };
586
542
 
587
- term.pty!.onData((data) => {
543
+ term.pty.onData((data) => {
588
544
  const seq = term.outputSeq++;
589
545
  const envelope = createEnvelope({
590
546
  type: "terminal.output",
@@ -603,7 +559,7 @@ export class BridgeSession {
603
559
  this.send(envelope);
604
560
  });
605
561
 
606
- term.pty!.onExit(({ exitCode, signal }) => {
562
+ term.pty.onExit(({ exitCode, signal }) => {
607
563
  term.status = "exited";
608
564
  this.cleanupHookServer(term);
609
565
  this.send(createEnvelope({
@@ -645,13 +601,35 @@ export class BridgeSession {
645
601
  let body = "";
646
602
  req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
647
603
  req.on("end", () => {
648
- res.writeHead(200);
649
- res.end("ok");
650
604
  this.log(`hook body (${body.length} bytes): ${body.slice(0, 200)}`);
651
605
  try {
652
606
  const event = JSON.parse(body);
653
- this.handleHookEvent(terminalId, event, provider);
607
+ const hookName = (event.hook_event_name ?? event.event_name) as string | undefined;
608
+
609
+ // PermissionRequest: hold connection, wait for user decision from mobile app
610
+ if (hookName === "PermissionRequest") {
611
+ const requestId = `pr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
612
+ this.pendingPermissions.set(requestId, (decision) => {
613
+ const responseJson = JSON.stringify({
614
+ hookSpecificOutput: {
615
+ hookEventName: "PermissionRequest",
616
+ decision: { behavior: decision },
617
+ },
618
+ });
619
+ res.writeHead(200, { "Content-Type": "application/json" });
620
+ res.end(responseJson);
621
+ });
622
+ // Send status with requestId so app can route decision back
623
+ this.handleHookEvent(terminalId, event, provider, requestId);
624
+ } else {
625
+ // All other hooks: respond immediately
626
+ res.writeHead(200);
627
+ res.end("ok");
628
+ this.handleHookEvent(terminalId, event, provider);
629
+ }
654
630
  } catch (e) {
631
+ res.writeHead(200);
632
+ res.end("ok");
655
633
  this.log(`hook parse error: ${e}`);
656
634
  }
657
635
  });
@@ -822,7 +800,7 @@ export class BridgeSession {
822
800
  return hooksPath;
823
801
  }
824
802
 
825
- private handleHookEvent(terminalId: string, event: Record<string, unknown>, provider: string): void {
803
+ private handleHookEvent(terminalId: string, event: Record<string, unknown>, provider: string, permissionRequestId?: string): void {
826
804
  const rawHookName = (event.hook_event_name ?? event.event_name) as string | undefined;
827
805
  if (!rawHookName) return;
828
806
 
@@ -878,11 +856,12 @@ export class BridgeSession {
878
856
  case "PostToolUse":
879
857
  phase = "thinking";
880
858
  toolName = (event.tool_name ?? event.toolName) as string | undefined;
881
- // Pop permission stack: tool completed
859
+ // Pop permission stack + auto-resolve pending HTTP connection
882
860
  {
883
861
  const stack = this.permissionStacks.get(terminalId);
884
862
  if (stack && stack.length > 0) {
885
- stack.pop();
863
+ const popped = stack.pop();
864
+ if (popped) this.autoResolvePending(popped.requestId);
886
865
  if (stack.length === 0) this.permissionStacks.delete(terminalId);
887
866
  }
888
867
  }
@@ -893,7 +872,8 @@ export class BridgeSession {
893
872
  {
894
873
  const stack = this.permissionStacks.get(terminalId);
895
874
  if (stack && stack.length > 0) {
896
- stack.pop();
875
+ const popped = stack.pop();
876
+ if (popped) this.autoResolvePending(popped.requestId);
897
877
  if (stack.length === 0) this.permissionStacks.delete(terminalId);
898
878
  }
899
879
  }
@@ -901,6 +881,7 @@ export class BridgeSession {
901
881
  case "Stop":
902
882
  phase = "idle";
903
883
  if (event.stop_reason) summary = String(event.stop_reason);
884
+ this.drainPendingPermissions(terminalId);
904
885
  this.permissionStacks.delete(terminalId);
905
886
  break;
906
887
  case "PermissionRequest":
@@ -912,14 +893,14 @@ export class BridgeSession {
912
893
  } else if (event.toolInput && typeof event.toolInput === "object") {
913
894
  permissionRequest = JSON.stringify(event.toolInput).slice(0, 300);
914
895
  }
915
- // Push to permission stack
896
+ // Push to permission stack (use requestId from hook server if available)
916
897
  {
917
- const requestId = `pr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
898
+ const reqId = permissionRequestId ?? `pr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
918
899
  if (!this.permissionStacks.has(terminalId)) {
919
900
  this.permissionStacks.set(terminalId, []);
920
901
  }
921
902
  this.permissionStacks.get(terminalId)!.push({
922
- requestId,
903
+ requestId: reqId,
923
904
  toolName: toolName ?? "unknown",
924
905
  toolInput: toolInput ?? (permissionRequest ?? ""),
925
906
  permissionRequest: permissionRequest ?? "",
@@ -933,6 +914,7 @@ export class BridgeSession {
933
914
  break;
934
915
  case "UserPromptSubmit":
935
916
  phase = "thinking";
917
+ this.drainPendingPermissions(terminalId);
936
918
  this.permissionStacks.delete(terminalId);
937
919
  break;
938
920
  default:
@@ -1023,7 +1005,33 @@ export class BridgeSession {
1023
1005
  return undefined;
1024
1006
  }
1025
1007
 
1008
+ /** Auto-resolve a single pending permission (user acted in terminal) */
1009
+ private autoResolvePending(requestId: string): void {
1010
+ const resolve = this.pendingPermissions.get(requestId);
1011
+ if (resolve) {
1012
+ this.pendingPermissions.delete(requestId);
1013
+ resolve("allow");
1014
+ this.log(`auto-resolved pending permission ${requestId} (user acted in terminal)`);
1015
+ }
1016
+ }
1017
+
1018
+ /** Drain all pending permissions for a terminal (session ended, stop, etc.) */
1019
+ private drainPendingPermissions(terminalId: string): void {
1020
+ const stack = this.permissionStacks.get(terminalId);
1021
+ if (!stack) return;
1022
+ for (const entry of stack) {
1023
+ const resolve = this.pendingPermissions.get(entry.requestId);
1024
+ if (resolve) {
1025
+ this.pendingPermissions.delete(entry.requestId);
1026
+ resolve("deny");
1027
+ this.log(`drained pending permission ${entry.requestId}`);
1028
+ }
1029
+ }
1030
+ }
1031
+
1026
1032
  private cleanupHookServer(term: TerminalInstance): void {
1033
+ // Drain any pending permission requests for this terminal
1034
+ this.drainPendingPermissions(term.id);
1027
1035
  if (term.hookServer) {
1028
1036
  term.hookServer.close();
1029
1037
  term.hookServer = undefined;
@@ -1191,10 +1199,7 @@ export class BridgeSession {
1191
1199
  this.socket = undefined;
1192
1200
  for (const term of this.terminals.values()) {
1193
1201
  this.cleanupHookServer(term);
1194
- if (term.status === "running") {
1195
- if (term.pty) term.pty.kill();
1196
- if (term.acpRelay) term.acpRelay.kill();
1197
- }
1202
+ if (term.status === "running") term.pty.kill();
1198
1203
  }
1199
1204
  this.terminals.clear();
1200
1205
  process.exitCode = exitCode;
@@ -1,92 +0,0 @@
1
- import { spawn, type ChildProcess } from "node:child_process";
2
- import { createInterface } from "node:readline";
3
- import { createEnvelope, serializeEnvelope } from "@linkshell/protocol";
4
- import type { Envelope } from "@linkshell/protocol";
5
-
6
- export interface AcpRelayOptions {
7
- command: string;
8
- args: string[];
9
- cwd: string;
10
- env: Record<string, string>;
11
- sessionId: string;
12
- terminalId: string;
13
- send: (envelope: Envelope) => void;
14
- log: (msg: string) => void;
15
- }
16
-
17
- export class AcpRelay {
18
- private child: ChildProcess;
19
- private readonly options: AcpRelayOptions;
20
- private exited = false;
21
-
22
- constructor(options: AcpRelayOptions) {
23
- this.options = options;
24
-
25
- this.child = spawn(options.command, options.args, {
26
- cwd: options.cwd,
27
- env: options.env,
28
- stdio: ["pipe", "pipe", "pipe"],
29
- });
30
-
31
- // Read newline-delimited JSON from stdout
32
- const rl = createInterface({ input: this.child.stdout! });
33
- rl.on("line", (line) => {
34
- if (!line.trim()) return;
35
- try {
36
- const msg = JSON.parse(line);
37
- // Wrap JSON-RPC message in acp.rpc envelope and send to gateway
38
- this.options.send(createEnvelope({
39
- type: "acp.rpc",
40
- sessionId: this.options.sessionId,
41
- terminalId: this.options.terminalId,
42
- payload: msg,
43
- }));
44
- } catch (e) {
45
- this.options.log(`acp-relay: failed to parse stdout line: ${e}`);
46
- }
47
- });
48
-
49
- // Log stderr
50
- this.child.stderr?.on("data", (chunk: Buffer) => {
51
- this.options.log(`acp-relay stderr: ${chunk.toString().trimEnd()}`);
52
- });
53
-
54
- this.child.on("exit", (code, signal) => {
55
- this.exited = true;
56
- this.options.log(`acp-relay: process exited (code=${code}, signal=${signal})`);
57
- });
58
-
59
- this.child.on("error", (err) => {
60
- this.options.log(`acp-relay: process error: ${err.message}`);
61
- });
62
- }
63
-
64
- /** Forward an acp.rpc envelope payload to the child's stdin */
65
- write(payload: unknown): void {
66
- if (this.exited || !this.child.stdin?.writable) return;
67
- try {
68
- const line = JSON.stringify(payload) + "\n";
69
- this.child.stdin.write(line);
70
- } catch (e) {
71
- this.options.log(`acp-relay: failed to write to stdin: ${e}`);
72
- }
73
- }
74
-
75
- kill(): void {
76
- if (!this.exited) {
77
- this.child.kill("SIGTERM");
78
- }
79
- }
80
-
81
- get isExited(): boolean {
82
- return this.exited;
83
- }
84
-
85
- get pid(): number | undefined {
86
- return this.child.pid;
87
- }
88
-
89
- onExit(cb: (code: number | null, signal: string | null) => void): void {
90
- this.child.on("exit", cb);
91
- }
92
- }