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.
- package/dist/cli/src/runtime/acp-relay.d.ts +23 -0
- package/dist/cli/src/runtime/acp-relay.js +73 -0
- package/dist/cli/src/runtime/acp-relay.js.map +1 -0
- package/dist/cli/src/runtime/bridge-session.js +85 -10
- package/dist/cli/src/runtime/bridge-session.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +133 -30
- package/dist/shared-protocol/src/index.js +16 -0
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +1 -1
- package/src/runtime/acp-relay.ts +92 -0
- package/src/runtime/bridge-session.ts +89 -12
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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")
|
|
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;
|