linkshell-cli 0.2.33 → 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({
@@ -1124,7 +1191,10 @@ export class BridgeSession {
1124
1191
  this.socket = undefined;
1125
1192
  for (const term of this.terminals.values()) {
1126
1193
  this.cleanupHookServer(term);
1127
- 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
+ }
1128
1198
  }
1129
1199
  this.terminals.clear();
1130
1200
  process.exitCode = exitCode;