linkshell-cli 0.2.65 → 0.2.67

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 (37) hide show
  1. package/README.md +3 -0
  2. package/dist/cli/src/index.js +42 -2
  3. package/dist/cli/src/index.js.map +1 -1
  4. package/dist/cli/src/runtime/acp/acp-client.d.ts +41 -0
  5. package/dist/cli/src/runtime/acp/acp-client.js +102 -0
  6. package/dist/cli/src/runtime/acp/acp-client.js.map +1 -0
  7. package/dist/cli/src/runtime/acp/agent-session.d.ts +42 -0
  8. package/dist/cli/src/runtime/acp/agent-session.js +492 -0
  9. package/dist/cli/src/runtime/acp/agent-session.js.map +1 -0
  10. package/dist/cli/src/runtime/acp/json-rpc.d.ts +21 -0
  11. package/dist/cli/src/runtime/acp/json-rpc.js +150 -0
  12. package/dist/cli/src/runtime/acp/json-rpc.js.map +1 -0
  13. package/dist/cli/src/runtime/acp/provider-resolver.d.ts +13 -0
  14. package/dist/cli/src/runtime/acp/provider-resolver.js +22 -0
  15. package/dist/cli/src/runtime/acp/provider-resolver.js.map +1 -0
  16. package/dist/cli/src/runtime/bridge-session.d.ts +7 -0
  17. package/dist/cli/src/runtime/bridge-session.js +61 -0
  18. package/dist/cli/src/runtime/bridge-session.js.map +1 -1
  19. package/dist/cli/src/utils/daemon.d.ts +6 -0
  20. package/dist/cli/src/utils/daemon.js +22 -0
  21. package/dist/cli/src/utils/daemon.js.map +1 -1
  22. package/dist/cli/src/utils/keep-awake.d.ts +6 -0
  23. package/dist/cli/src/utils/keep-awake.js +48 -0
  24. package/dist/cli/src/utils/keep-awake.js.map +1 -0
  25. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  26. package/dist/shared-protocol/src/index.d.ts +1076 -28
  27. package/dist/shared-protocol/src/index.js +108 -0
  28. package/dist/shared-protocol/src/index.js.map +1 -1
  29. package/package.json +4 -4
  30. package/src/index.ts +49 -2
  31. package/src/runtime/acp/acp-client.ts +133 -0
  32. package/src/runtime/acp/agent-session.ts +589 -0
  33. package/src/runtime/acp/json-rpc.ts +177 -0
  34. package/src/runtime/acp/provider-resolver.ts +37 -0
  35. package/src/runtime/bridge-session.ts +72 -0
  36. package/src/utils/daemon.ts +28 -0
  37. package/src/utils/keep-awake.ts +61 -0
@@ -0,0 +1,177 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import type { AgentFraming } from "./provider-resolver.js";
3
+
4
+ interface JsonRpcRequest {
5
+ jsonrpc: "2.0";
6
+ id: number | string;
7
+ method: string;
8
+ params?: unknown;
9
+ }
10
+
11
+ interface JsonRpcResponse {
12
+ jsonrpc: "2.0";
13
+ id: number | string;
14
+ result?: unknown;
15
+ error?: { code: number; message: string; data?: unknown };
16
+ }
17
+
18
+ interface JsonRpcNotification {
19
+ jsonrpc: "2.0";
20
+ method: string;
21
+ params?: unknown;
22
+ }
23
+
24
+ type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification;
25
+
26
+ export class JsonRpcStdioTransport {
27
+ private child: ChildProcessWithoutNullStreams | undefined;
28
+ private nextId = 1;
29
+ private pending = new Map<
30
+ number | string,
31
+ {
32
+ resolve: (value: unknown) => void;
33
+ reject: (error: Error) => void;
34
+ timer: ReturnType<typeof setTimeout>;
35
+ }
36
+ >();
37
+ private buffer = "";
38
+
39
+ constructor(
40
+ private readonly command: string,
41
+ private readonly framing: AgentFraming,
42
+ private readonly onNotification: (method: string, params: unknown) => void,
43
+ private readonly onRequest: (method: string, params: unknown) => Promise<unknown> | unknown,
44
+ private readonly onExit: (message: string) => void,
45
+ ) {}
46
+
47
+ start(cwd: string): void {
48
+ if (this.child) return;
49
+ this.child = spawn(this.command, {
50
+ cwd,
51
+ shell: true,
52
+ stdio: ["pipe", "pipe", "pipe"],
53
+ env: process.env,
54
+ });
55
+ this.child.stdout.setEncoding("utf8");
56
+ this.child.stderr.setEncoding("utf8");
57
+ this.child.stdout.on("data", (chunk: string) => this.read(chunk));
58
+ this.child.stderr.on("data", (chunk: string) => {
59
+ const trimmed = chunk.trim();
60
+ if (trimmed) process.stderr.write(`[agent:stderr] ${trimmed}\n`);
61
+ });
62
+ this.child.on("error", (error) => this.failAll(error.message));
63
+ this.child.on("exit", (code, signal) => {
64
+ this.failAll(`ACP agent exited (code=${code ?? "null"}, signal=${signal ?? "null"})`);
65
+ });
66
+ }
67
+
68
+ request(method: string, params?: unknown, timeoutMs = 30_000): Promise<unknown> {
69
+ if (!this.child || this.child.stdin.destroyed) {
70
+ return Promise.reject(new Error("ACP agent is not running"));
71
+ }
72
+ const id = this.nextId++;
73
+ const message: JsonRpcRequest = { jsonrpc: "2.0", id, method, params };
74
+ return new Promise((resolve, reject) => {
75
+ const timer = setTimeout(() => {
76
+ this.pending.delete(id);
77
+ reject(new Error(`ACP request timed out: ${method}`));
78
+ }, timeoutMs);
79
+ this.pending.set(id, { resolve, reject, timer });
80
+ this.write(message);
81
+ });
82
+ }
83
+
84
+ notify(method: string, params?: unknown): void {
85
+ this.write({ jsonrpc: "2.0", method, params });
86
+ }
87
+
88
+ stop(): void {
89
+ const child = this.child;
90
+ this.child = undefined;
91
+ if (child && !child.killed) child.kill("SIGTERM");
92
+ this.failAll("ACP transport stopped");
93
+ }
94
+
95
+ private write(message: JsonRpcMessage): void {
96
+ const raw = JSON.stringify(message);
97
+ if (this.framing === "newline") {
98
+ this.child?.stdin.write(`${raw}\n`);
99
+ return;
100
+ }
101
+ this.child?.stdin.write(`Content-Length: ${Buffer.byteLength(raw, "utf8")}\r\n\r\n${raw}`);
102
+ }
103
+
104
+ private read(chunk: string): void {
105
+ this.buffer += chunk;
106
+ while (this.buffer.length > 0) {
107
+ const contentLengthMatch = this.buffer.match(/^Content-Length:\s*(\d+)\r?\n/i);
108
+ if (contentLengthMatch) {
109
+ const headerEnd = this.buffer.indexOf("\r\n\r\n");
110
+ const altHeaderEnd = this.buffer.indexOf("\n\n");
111
+ const end = headerEnd >= 0 ? headerEnd + 4 : altHeaderEnd >= 0 ? altHeaderEnd + 2 : -1;
112
+ if (end < 0) return;
113
+ const length = Number(contentLengthMatch[1]);
114
+ if (this.buffer.length < end + length) return;
115
+ const raw = this.buffer.slice(end, end + length);
116
+ this.buffer = this.buffer.slice(end + length);
117
+ this.dispatch(raw);
118
+ continue;
119
+ }
120
+
121
+ const newline = this.buffer.indexOf("\n");
122
+ if (newline < 0) return;
123
+ const raw = this.buffer.slice(0, newline).trim();
124
+ this.buffer = this.buffer.slice(newline + 1);
125
+ if (raw) this.dispatch(raw);
126
+ }
127
+ }
128
+
129
+ private dispatch(raw: string): void {
130
+ let message: JsonRpcMessage;
131
+ try {
132
+ message = JSON.parse(raw) as JsonRpcMessage;
133
+ } catch {
134
+ return;
135
+ }
136
+
137
+ if ("id" in message && ("result" in message || "error" in message)) {
138
+ const pending = this.pending.get(message.id);
139
+ if (!pending) return;
140
+ this.pending.delete(message.id);
141
+ clearTimeout(pending.timer);
142
+ const response = message as JsonRpcResponse;
143
+ if (response.error) pending.reject(new Error(response.error.message));
144
+ else pending.resolve(response.result);
145
+ return;
146
+ }
147
+
148
+ if ("method" in message && "id" in message) {
149
+ Promise.resolve(this.onRequest(message.method, message.params))
150
+ .then((result) => this.write({ jsonrpc: "2.0", id: message.id, result }))
151
+ .catch((error) => {
152
+ this.write({
153
+ jsonrpc: "2.0",
154
+ id: message.id,
155
+ error: {
156
+ code: -32000,
157
+ message: error instanceof Error ? error.message : String(error),
158
+ },
159
+ });
160
+ });
161
+ return;
162
+ }
163
+
164
+ if ("method" in message) {
165
+ this.onNotification(message.method, message.params);
166
+ }
167
+ }
168
+
169
+ private failAll(message: string): void {
170
+ this.onExit(message);
171
+ for (const [, pending] of this.pending) {
172
+ clearTimeout(pending.timer);
173
+ pending.reject(new Error(message));
174
+ }
175
+ this.pending.clear();
176
+ }
177
+ }
@@ -0,0 +1,37 @@
1
+ export type AgentProvider = "codex" | "claude" | "custom";
2
+ export type AgentProtocol = "acp" | "codex-app-server";
3
+ export type AgentFraming = "content-length" | "newline";
4
+
5
+ export interface AgentCommandConfig {
6
+ command: string;
7
+ provider: AgentProvider;
8
+ protocol: AgentProtocol;
9
+ framing: AgentFraming;
10
+ }
11
+
12
+ export function resolveAgentCommand(input: {
13
+ provider: AgentProvider;
14
+ command?: string;
15
+ }): AgentCommandConfig | null {
16
+ const explicit = input.command?.trim();
17
+ if (explicit) {
18
+ const isCodexAppServer = /\bcodex\b/.test(explicit) && /\bapp-server\b/.test(explicit);
19
+ return {
20
+ provider: input.provider,
21
+ command: explicit,
22
+ protocol: isCodexAppServer ? "codex-app-server" : "acp",
23
+ framing: isCodexAppServer ? "newline" : "content-length",
24
+ };
25
+ }
26
+
27
+ if (input.provider === "codex") {
28
+ return {
29
+ provider: "codex",
30
+ command: "codex app-server --listen stdio://",
31
+ protocol: "codex-app-server",
32
+ framing: "newline",
33
+ };
34
+ }
35
+
36
+ return null;
37
+ }
@@ -18,6 +18,9 @@ import { ScrollbackBuffer } from "./scrollback.js";
18
18
  import { ScreenFallback } from "./screen-fallback.js";
19
19
  import { ScreenShare } from "./screen-share.js";
20
20
  import { getLanIp } from "../utils/lan-ip.js";
21
+ import { startKeepAwake, type KeepAwakeHandle } from "../utils/keep-awake.js";
22
+ import { AgentSessionProxy } from "./acp/agent-session.js";
23
+ import type { AgentProvider } from "./acp/provider-resolver.js";
21
24
 
22
25
  export interface BridgeSessionOptions {
23
26
  gatewayUrl: string;
@@ -32,6 +35,10 @@ export interface BridgeSessionOptions {
32
35
  screen?: boolean;
33
36
  providerConfig: ProviderConfig;
34
37
  authToken?: string;
38
+ keepAwake?: boolean;
39
+ agentUi?: boolean;
40
+ agentProvider?: AgentProvider;
41
+ agentCommand?: string;
35
42
  }
36
43
 
37
44
  const HEARTBEAT_INTERVAL = 15_000;
@@ -126,6 +133,13 @@ function resolvePairingGateway(
126
133
  }
127
134
  }
128
135
 
136
+ function normalizeAgentProvider(provider: unknown): AgentProvider {
137
+ if (provider === "claude" || provider === "custom") {
138
+ return provider;
139
+ }
140
+ return "codex";
141
+ }
142
+
129
143
  export class BridgeSession {
130
144
  private readonly options: BridgeSessionOptions;
131
145
  private socket: WebSocket | undefined;
@@ -150,6 +164,8 @@ export class BridgeSession {
150
164
  private screenCapture: ScreenFallback | undefined;
151
165
  private screenShare: ScreenShare | undefined;
152
166
  private tunnelSockets = new Map<string, WebSocket>();
167
+ private keepAwake: KeepAwakeHandle | undefined;
168
+ private agentSession: AgentSessionProxy | undefined;
153
169
 
154
170
  constructor(options: BridgeSessionOptions) {
155
171
  this.options = options;
@@ -169,6 +185,25 @@ export class BridgeSession {
169
185
  if (!this.sessionId) {
170
186
  await this.createPairing();
171
187
  }
188
+ if (this.options.keepAwake) {
189
+ this.keepAwake = startKeepAwake();
190
+ } else {
191
+ process.stderr.write("[bridge] keep-awake disabled\n");
192
+ }
193
+ if (this.options.agentUi) {
194
+ const agentProvider = normalizeAgentProvider(
195
+ this.options.agentProvider ?? this.options.providerConfig.provider,
196
+ );
197
+ this.agentSession = new AgentSessionProxy({
198
+ sessionId: this.sessionId,
199
+ cwd: process.cwd(),
200
+ provider: agentProvider,
201
+ command: this.options.agentCommand,
202
+ verbose: this.options.verbose,
203
+ send: (envelope) => this.send(envelope),
204
+ });
205
+ process.stderr.write("[bridge] agent GUI channel enabled\n");
206
+ }
172
207
  await this.spawnTerminal(DEFAULT_TERMINAL_ID, process.cwd());
173
208
  this.connectGateway();
174
209
  }
@@ -509,6 +544,39 @@ export class BridgeSession {
509
544
  this.screenShare?.handleIceCandidate(p.candidate, p.sdpMid, p.sdpMLineIndex);
510
545
  break;
511
546
  }
547
+ case "agent.initialize":
548
+ case "agent.session.new":
549
+ case "agent.session.load":
550
+ case "agent.session.list":
551
+ case "agent.prompt":
552
+ case "agent.cancel":
553
+ case "agent.permission.response": {
554
+ if (!this.agentSession) {
555
+ this.send(
556
+ createEnvelope({
557
+ type: "agent.capabilities",
558
+ sessionId: this.sessionId,
559
+ payload: {
560
+ enabled: false,
561
+ provider: normalizeAgentProvider(
562
+ this.options.agentProvider ?? this.options.providerConfig.provider,
563
+ ),
564
+ error: "Agent GUI is not enabled. Start CLI with --agent-ui.",
565
+ supportsSessionList: false,
566
+ supportsSessionLoad: false,
567
+ supportsImages: false,
568
+ supportsAudio: false,
569
+ supportsPermission: false,
570
+ supportsPlan: false,
571
+ supportsCancel: false,
572
+ },
573
+ }),
574
+ );
575
+ break;
576
+ }
577
+ await this.agentSession.handleEnvelope(envelope);
578
+ break;
579
+ }
512
580
  case "file.upload": {
513
581
  const p = parseTypedPayload("file.upload", envelope.payload);
514
582
  const ext = p.filename.split(".").pop() || "png";
@@ -1622,6 +1690,10 @@ export class BridgeSession {
1622
1690
  this.exited = true;
1623
1691
  this.stopHeartbeat();
1624
1692
  this.stopScreenCapture();
1693
+ this.agentSession?.stop();
1694
+ this.agentSession = undefined;
1695
+ this.keepAwake?.stop();
1696
+ this.keepAwake = undefined;
1625
1697
  if (this.reconnectTimer) {
1626
1698
  clearTimeout(this.reconnectTimer);
1627
1699
  this.reconnectTimer = undefined;
@@ -7,6 +7,11 @@ const LINKSHELL_DIR = join(homedir(), ".linkshell");
7
7
 
8
8
  type ServiceName = "gateway" | "bridge";
9
9
 
10
+ export interface ServiceMetadata {
11
+ keepAwake?: boolean;
12
+ startedAt?: number;
13
+ }
14
+
10
15
  function pidFile(service: ServiceName): string {
11
16
  return join(LINKSHELL_DIR, `${service}.pid`);
12
17
  }
@@ -15,6 +20,10 @@ function logFile(service: ServiceName): string {
15
20
  return join(LINKSHELL_DIR, `${service}.log`);
16
21
  }
17
22
 
23
+ function metadataFile(service: ServiceName): string {
24
+ return join(LINKSHELL_DIR, `${service}.json`);
25
+ }
26
+
18
27
  export function savePid(service: ServiceName, pid: number): void {
19
28
  mkdirSync(LINKSHELL_DIR, { recursive: true });
20
29
  writeFileSync(pidFile(service), String(pid), "utf8");
@@ -40,6 +49,7 @@ export function readPid(service: ServiceName): number | null {
40
49
 
41
50
  export function removePid(service: ServiceName): void {
42
51
  try { unlinkSync(pidFile(service)); } catch {}
52
+ try { unlinkSync(metadataFile(service)); } catch {}
43
53
  }
44
54
 
45
55
  export function getLogFile(service: ServiceName): string {
@@ -50,6 +60,24 @@ export function getPidFile(service: ServiceName): string {
50
60
  return pidFile(service);
51
61
  }
52
62
 
63
+ export function saveMetadata(
64
+ service: ServiceName,
65
+ metadata: ServiceMetadata,
66
+ ): void {
67
+ mkdirSync(LINKSHELL_DIR, { recursive: true });
68
+ writeFileSync(metadataFile(service), JSON.stringify(metadata), "utf8");
69
+ }
70
+
71
+ export function readMetadata(service: ServiceName): ServiceMetadata | null {
72
+ try {
73
+ const file = metadataFile(service);
74
+ if (!existsSync(file)) return null;
75
+ return JSON.parse(readFileSync(file, "utf8")) as ServiceMetadata;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
53
81
  export function spawnDaemon(service: ServiceName, args: string[]): number {
54
82
  mkdirSync(LINKSHELL_DIR, { recursive: true });
55
83
  const log = logFile(service);
@@ -0,0 +1,61 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { platform } from "node:os";
3
+
4
+ export interface KeepAwakeHandle {
5
+ enabled: boolean;
6
+ stop: () => void;
7
+ }
8
+
9
+ export function shouldKeepAwake(optionEnabled: boolean | undefined): boolean {
10
+ if (process.env.LINKSHELL_KEEP_AWAKE === "0") return false;
11
+ if (platform() !== "darwin") return false;
12
+ return optionEnabled !== false;
13
+ }
14
+
15
+ export function startKeepAwake(): KeepAwakeHandle {
16
+ if (platform() !== "darwin") {
17
+ return { enabled: false, stop: () => {} };
18
+ }
19
+
20
+ let child: ChildProcess | undefined;
21
+ let stopping = false;
22
+
23
+ try {
24
+ child = spawn("caffeinate", ["-i", "-w", String(process.pid)], {
25
+ stdio: "ignore",
26
+ detached: false,
27
+ });
28
+ } catch (error) {
29
+ process.stderr.write(
30
+ `[bridge] keep-awake unavailable: ${error instanceof Error ? error.message : String(error)}\n`,
31
+ );
32
+ return { enabled: false, stop: () => {} };
33
+ }
34
+
35
+ child.on("error", (error) => {
36
+ if (stopping) return;
37
+ process.stderr.write(`[bridge] keep-awake unavailable: ${error.message}\n`);
38
+ });
39
+
40
+ child.on("exit", (code, signal) => {
41
+ if (stopping) return;
42
+ process.stderr.write(
43
+ `[bridge] keep-awake stopped unexpectedly (code=${code ?? "null"}, signal=${signal ?? "null"})\n`,
44
+ );
45
+ });
46
+
47
+ process.stderr.write(
48
+ "[bridge] keep-awake enabled (macOS idle sleep prevention)\n",
49
+ );
50
+
51
+ return {
52
+ enabled: true,
53
+ stop: () => {
54
+ stopping = true;
55
+ if (child && !child.killed) {
56
+ child.kill("SIGTERM");
57
+ }
58
+ child = undefined;
59
+ },
60
+ };
61
+ }