linkshell-cli 0.2.32 → 0.2.34

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,6 +15,7 @@ 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";
18
19
  import { ScreenFallback } from "./screen-fallback.js";
19
20
  import { ScreenShare } from "./screen-share.js";
20
21
  import { getLanIp } from "../utils/lan-ip.js";
@@ -41,7 +42,9 @@ const DEFAULT_TERMINAL_ID = "default";
41
42
 
42
43
  interface TerminalInstance {
43
44
  id: string;
44
- pty: pty.IPty;
45
+ pty: pty.IPty | null;
46
+ acpRelay: AcpRelay | null;
47
+ mode: "pty" | "acp";
45
48
  cwd: string;
46
49
  projectName: string;
47
50
  provider: string;
@@ -281,21 +284,29 @@ export class BridgeSession {
281
284
  case "terminal.input": {
282
285
  const p = parseTypedPayload("terminal.input", envelope.payload);
283
286
  const term = this.terminals.get(tid);
284
- if (term && term.status === "running") term.pty.write(p.data);
287
+ if (term && term.status === "running" && term.pty) term.pty.write(p.data);
285
288
  break;
286
289
  }
287
290
  case "terminal.resize": {
288
291
  const p = parseTypedPayload("terminal.resize", envelope.payload);
289
292
  const term = this.terminals.get(tid);
290
- if (term && term.status === "running") term.pty.resize(p.cols, p.rows);
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
+ }
291
301
  break;
292
302
  }
293
303
  case "terminal.spawn": {
294
304
  const p = parseTypedPayload("terminal.spawn", envelope.payload);
295
305
  const normalizedCwd = resolve(p.cwd);
296
- // Dedup: if a running terminal already exists for this cwd, return it
306
+ const mode = p.mode ?? "pty";
307
+ // Dedup: if a running terminal already exists for this cwd and mode, return it
297
308
  const existing = [...this.terminals.values()].find(
298
- (t) => t.status === "running" && resolve(t.cwd) === normalizedCwd,
309
+ (t) => t.status === "running" && resolve(t.cwd) === normalizedCwd && t.mode === mode,
299
310
  );
300
311
  if (existing) {
301
312
  this.send(createEnvelope({
@@ -307,7 +318,7 @@ export class BridgeSession {
307
318
  } else {
308
319
  const newId = `term-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
309
320
  try {
310
- await this.spawnTerminal(newId, normalizedCwd, p.provider);
321
+ await this.spawnTerminal(newId, normalizedCwd, p.provider, mode);
311
322
  this.send(createEnvelope({
312
323
  type: "terminal.spawned",
313
324
  sessionId: this.sessionId,
@@ -331,7 +342,8 @@ export class BridgeSession {
331
342
  const p = parseTypedPayload("terminal.kill", envelope.payload);
332
343
  const term = this.terminals.get(p.terminalId);
333
344
  if (term && term.status === "running") {
334
- term.pty.kill();
345
+ if (term.pty) term.pty.kill();
346
+ if (term.acpRelay) term.acpRelay.kill();
335
347
  }
336
348
  break;
337
349
  }
@@ -413,7 +425,7 @@ export class BridgeSession {
413
425
  writeFileSync(tempPath, Buffer.from(p.data, "base64"));
414
426
  this.log(`image saved to ${tempPath}`);
415
427
  const term = this.terminals.get(tid);
416
- if (term && term.status === "running") {
428
+ if (term && term.status === "running" && term.pty) {
417
429
  term.pty.write(`\x1b[200~${tempPath}\x1b[201~`);
418
430
  }
419
431
  break;
@@ -430,6 +442,7 @@ export class BridgeSession {
430
442
  projectName: t.projectName,
431
443
  provider: t.provider,
432
444
  status: t.status,
445
+ mode: t.mode,
433
446
  }));
434
447
  this.send(createEnvelope({
435
448
  type: "terminal.list",
@@ -460,7 +473,7 @@ export class BridgeSession {
460
473
  }
461
474
  }
462
475
 
463
- private async spawnTerminal(terminalId: string, cwd: string, providerOverride?: string): Promise<void> {
476
+ private async spawnTerminal(terminalId: string, cwd: string, providerOverride?: string, mode: "pty" | "acp" = "pty"): Promise<void> {
464
477
  const cleanEnv: Record<string, string> = {};
465
478
  for (const [k, v] of Object.entries(this.options.providerConfig.env)) {
466
479
  if (v !== undefined) cleanEnv[k] = v;
@@ -469,6 +482,58 @@ export class BridgeSession {
469
482
  const provider = providerOverride ?? this.options.providerConfig.provider;
470
483
  const args = [...this.options.providerConfig.args];
471
484
 
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
472
537
  // Set up hook server for structured status (all supported providers)
473
538
  // For "custom" shell, set up hooks for all providers since user may launch any of them
474
539
  let hookServer: http.Server | undefined;
@@ -505,6 +570,8 @@ export class BridgeSession {
505
570
  env: cleanEnv,
506
571
  },
507
572
  ),
573
+ acpRelay: null,
574
+ mode: "pty",
508
575
  cwd,
509
576
  projectName: basename(cwd),
510
577
  provider,
@@ -517,7 +584,7 @@ export class BridgeSession {
517
584
  hookConfigPath,
518
585
  };
519
586
 
520
- term.pty.onData((data) => {
587
+ term.pty!.onData((data) => {
521
588
  const seq = term.outputSeq++;
522
589
  const envelope = createEnvelope({
523
590
  type: "terminal.output",
@@ -536,7 +603,7 @@ export class BridgeSession {
536
603
  this.send(envelope);
537
604
  });
538
605
 
539
- term.pty.onExit(({ exitCode, signal }) => {
606
+ term.pty!.onExit(({ exitCode, signal }) => {
540
607
  term.status = "exited";
541
608
  this.cleanupHookServer(term);
542
609
  this.send(createEnvelope({
@@ -776,8 +843,15 @@ export class BridgeSession {
776
843
  if (detectedProvider !== "custom") {
777
844
  hookTerm.provider = detectedProvider;
778
845
  this.log(`detected provider for ${terminalId}: ${detectedProvider}`);
846
+ this.permissionStacks.delete(terminalId);
779
847
  this.sendTerminalList();
780
848
  }
849
+ } else if (hookTerm && hookTerm.provider !== "custom" && detectedProvider !== hookTerm.provider) {
850
+ // Provider switched mid-session (e.g., user exited claude and started codex)
851
+ hookTerm.provider = detectedProvider;
852
+ this.log(`provider switched for ${terminalId}: ${detectedProvider}`);
853
+ this.permissionStacks.delete(terminalId);
854
+ this.sendTerminalList();
781
855
  }
782
856
 
783
857
  // Normalize hook event names from different providers to unified names
@@ -1117,7 +1191,10 @@ export class BridgeSession {
1117
1191
  this.socket = undefined;
1118
1192
  for (const term of this.terminals.values()) {
1119
1193
  this.cleanupHookServer(term);
1120
- if (term.status === "running") term.pty.kill();
1194
+ if (term.status === "running") {
1195
+ if (term.pty) term.pty.kill();
1196
+ if (term.acpRelay) term.acpRelay.kill();
1197
+ }
1121
1198
  }
1122
1199
  this.terminals.clear();
1123
1200
  process.exitCode = exitCode;