linkshell-cli 0.2.64 → 0.2.66
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/README.md +3 -0
- package/dist/cli/src/index.js +28 -1
- package/dist/cli/src/index.js.map +1 -1
- package/dist/cli/src/runtime/acp/acp-client.d.ts +41 -0
- package/dist/cli/src/runtime/acp/acp-client.js +102 -0
- package/dist/cli/src/runtime/acp/acp-client.js.map +1 -0
- package/dist/cli/src/runtime/acp/agent-session.d.ts +42 -0
- package/dist/cli/src/runtime/acp/agent-session.js +492 -0
- package/dist/cli/src/runtime/acp/agent-session.js.map +1 -0
- package/dist/cli/src/runtime/acp/json-rpc.d.ts +21 -0
- package/dist/cli/src/runtime/acp/json-rpc.js +150 -0
- package/dist/cli/src/runtime/acp/json-rpc.js.map +1 -0
- package/dist/cli/src/runtime/acp/provider-resolver.d.ts +13 -0
- package/dist/cli/src/runtime/acp/provider-resolver.js +22 -0
- package/dist/cli/src/runtime/acp/provider-resolver.js.map +1 -0
- package/dist/cli/src/runtime/bridge-session.d.ts +9 -0
- package/dist/cli/src/runtime/bridge-session.js +163 -41
- package/dist/cli/src/runtime/bridge-session.js.map +1 -1
- package/dist/cli/src/utils/daemon.d.ts +6 -0
- package/dist/cli/src/utils/daemon.js +22 -0
- package/dist/cli/src/utils/daemon.js.map +1 -1
- package/dist/cli/src/utils/keep-awake.d.ts +6 -0
- package/dist/cli/src/utils/keep-awake.js +48 -0
- package/dist/cli/src/utils/keep-awake.js.map +1 -0
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +1108 -54
- package/dist/shared-protocol/src/index.js +112 -1
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +38 -1
- package/src/runtime/acp/acp-client.ts +133 -0
- package/src/runtime/acp/agent-session.ts +589 -0
- package/src/runtime/acp/json-rpc.ts +177 -0
- package/src/runtime/acp/provider-resolver.ts +37 -0
- package/src/runtime/bridge-session.ts +189 -41
- package/src/utils/daemon.ts +28 -0
- 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;
|
|
@@ -39,6 +46,10 @@ const RECONNECT_BASE_DELAY = 1_000;
|
|
|
39
46
|
const RECONNECT_MAX_DELAY = 30_000;
|
|
40
47
|
const RECONNECT_MAX_ATTEMPTS = 20;
|
|
41
48
|
const DEFAULT_TERMINAL_ID = "default";
|
|
49
|
+
const HOOK_BODY_LIMIT = 256 * 1024;
|
|
50
|
+
const PERMISSION_REQUEST_TIMEOUT_MS = Number(
|
|
51
|
+
process.env.LINKSHELL_PERMISSION_TIMEOUT_MS ?? 5 * 60_000,
|
|
52
|
+
);
|
|
42
53
|
|
|
43
54
|
interface TerminalInstance {
|
|
44
55
|
id: string;
|
|
@@ -55,6 +66,17 @@ interface TerminalInstance {
|
|
|
55
66
|
hookConfigPaths: string[];
|
|
56
67
|
}
|
|
57
68
|
|
|
69
|
+
interface PendingPermission {
|
|
70
|
+
terminalId: string;
|
|
71
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
72
|
+
resolve: (decision: "allow" | "deny") => void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isLinkShellHookEntry(entry: unknown): boolean {
|
|
76
|
+
const raw = JSON.stringify(entry);
|
|
77
|
+
return /\/hook\?m=lsh-[^"'\s]+/.test(raw);
|
|
78
|
+
}
|
|
79
|
+
|
|
58
80
|
function getPairingGatewayParam(gatewayHttpUrl: string): string | undefined {
|
|
59
81
|
try {
|
|
60
82
|
const url = new URL(gatewayHttpUrl);
|
|
@@ -111,6 +133,13 @@ function resolvePairingGateway(
|
|
|
111
133
|
}
|
|
112
134
|
}
|
|
113
135
|
|
|
136
|
+
function normalizeAgentProvider(provider: unknown): AgentProvider {
|
|
137
|
+
if (provider === "claude" || provider === "custom") {
|
|
138
|
+
return provider;
|
|
139
|
+
}
|
|
140
|
+
return "codex";
|
|
141
|
+
}
|
|
142
|
+
|
|
114
143
|
export class BridgeSession {
|
|
115
144
|
private readonly options: BridgeSessionOptions;
|
|
116
145
|
private socket: WebSocket | undefined;
|
|
@@ -130,11 +159,13 @@ export class BridgeSession {
|
|
|
130
159
|
timestamp: number;
|
|
131
160
|
}>>();
|
|
132
161
|
// Pending permission responses: requestId → HTTP response callback
|
|
133
|
-
private pendingPermissions = new Map<string,
|
|
162
|
+
private pendingPermissions = new Map<string, PendingPermission>();
|
|
134
163
|
private hookMarker = `lsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
135
164
|
private screenCapture: ScreenFallback | undefined;
|
|
136
165
|
private screenShare: ScreenShare | undefined;
|
|
137
166
|
private tunnelSockets = new Map<string, WebSocket>();
|
|
167
|
+
private keepAwake: KeepAwakeHandle | undefined;
|
|
168
|
+
private agentSession: AgentSessionProxy | undefined;
|
|
138
169
|
|
|
139
170
|
constructor(options: BridgeSessionOptions) {
|
|
140
171
|
this.options = options;
|
|
@@ -154,6 +185,25 @@ export class BridgeSession {
|
|
|
154
185
|
if (!this.sessionId) {
|
|
155
186
|
await this.createPairing();
|
|
156
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
|
+
}
|
|
157
207
|
await this.spawnTerminal(DEFAULT_TERMINAL_ID, process.cwd());
|
|
158
208
|
this.connectGateway();
|
|
159
209
|
}
|
|
@@ -261,7 +311,13 @@ export class BridgeSession {
|
|
|
261
311
|
});
|
|
262
312
|
|
|
263
313
|
this.socket.on("message", (data) => {
|
|
264
|
-
|
|
314
|
+
let envelope: Envelope;
|
|
315
|
+
try {
|
|
316
|
+
envelope = parseEnvelope(data.toString());
|
|
317
|
+
} catch (err) {
|
|
318
|
+
this.log(`invalid gateway message ignored: ${err}`);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
265
321
|
this.log(
|
|
266
322
|
`recv ${envelope.type}${envelope.seq !== undefined ? ` seq=${envelope.seq}` : ""}`,
|
|
267
323
|
);
|
|
@@ -457,7 +513,11 @@ export class BridgeSession {
|
|
|
457
513
|
const p = parseTypedPayload("session.resume", envelope.payload);
|
|
458
514
|
// Replay all terminals
|
|
459
515
|
for (const [termId, term] of this.terminals) {
|
|
460
|
-
this.replayFrom(
|
|
516
|
+
this.replayFrom(
|
|
517
|
+
termId,
|
|
518
|
+
term,
|
|
519
|
+
p.lastAckedSeqByTerminal[termId] ?? p.lastAckedSeq,
|
|
520
|
+
);
|
|
461
521
|
}
|
|
462
522
|
// Also send terminal list so client knows what's available
|
|
463
523
|
this.sendTerminalList();
|
|
@@ -484,6 +544,39 @@ export class BridgeSession {
|
|
|
484
544
|
this.screenShare?.handleIceCandidate(p.candidate, p.sdpMid, p.sdpMLineIndex);
|
|
485
545
|
break;
|
|
486
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
|
+
}
|
|
487
580
|
case "file.upload": {
|
|
488
581
|
const p = parseTypedPayload("file.upload", envelope.payload);
|
|
489
582
|
const ext = p.filename.split(".").pop() || "png";
|
|
@@ -498,20 +591,8 @@ export class BridgeSession {
|
|
|
498
591
|
}
|
|
499
592
|
case "permission.decision": {
|
|
500
593
|
const p = envelope.payload as { requestId: string; decision: "allow" | "deny" };
|
|
501
|
-
|
|
502
|
-
if (resolve) {
|
|
503
|
-
this.pendingPermissions.delete(p.requestId);
|
|
504
|
-
resolve(p.decision);
|
|
594
|
+
if (this.resolvePendingPermission(p.requestId, p.decision)) {
|
|
505
595
|
this.log(`permission decision for ${p.requestId}: ${p.decision}`);
|
|
506
|
-
// Pop from permission stack
|
|
507
|
-
if (p.decision === "allow" || p.decision === "deny") {
|
|
508
|
-
const stack = this.permissionStacks.get(tid);
|
|
509
|
-
if (stack) {
|
|
510
|
-
const idx = stack.findIndex((s) => s.requestId === p.requestId);
|
|
511
|
-
if (idx >= 0) stack.splice(idx, 1);
|
|
512
|
-
if (stack.length === 0) this.permissionStacks.delete(tid);
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
596
|
} else {
|
|
516
597
|
this.log(`no pending permission for ${p.requestId}`);
|
|
517
598
|
}
|
|
@@ -889,8 +970,19 @@ export class BridgeSession {
|
|
|
889
970
|
return;
|
|
890
971
|
}
|
|
891
972
|
let body = "";
|
|
892
|
-
|
|
973
|
+
let bodyTooLarge = false;
|
|
974
|
+
req.on("data", (chunk: Buffer) => {
|
|
975
|
+
if (bodyTooLarge) return;
|
|
976
|
+
body += chunk.toString();
|
|
977
|
+
if (Buffer.byteLength(body, "utf8") > HOOK_BODY_LIMIT) {
|
|
978
|
+
bodyTooLarge = true;
|
|
979
|
+
res.writeHead(413);
|
|
980
|
+
res.end("payload too large");
|
|
981
|
+
req.destroy();
|
|
982
|
+
}
|
|
983
|
+
});
|
|
893
984
|
req.on("end", () => {
|
|
985
|
+
if (bodyTooLarge || res.writableEnded) return;
|
|
894
986
|
this.log(`hook body (${body.length} bytes): ${body.slice(0, 200)}`);
|
|
895
987
|
try {
|
|
896
988
|
const event = JSON.parse(body);
|
|
@@ -899,15 +991,26 @@ export class BridgeSession {
|
|
|
899
991
|
// PermissionRequest: hold connection, wait for user decision from mobile app
|
|
900
992
|
if (hookName === "PermissionRequest") {
|
|
901
993
|
const requestId = `pr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
994
|
+
const timeout = setTimeout(() => {
|
|
995
|
+
if (this.resolvePendingPermission(requestId, "deny")) {
|
|
996
|
+
this.log(`permission request ${requestId} timed out`);
|
|
997
|
+
this.sendPermissionSnapshot(terminalId, "thinking", "permission timed out");
|
|
998
|
+
}
|
|
999
|
+
}, PERMISSION_REQUEST_TIMEOUT_MS);
|
|
1000
|
+
this.pendingPermissions.set(requestId, {
|
|
1001
|
+
terminalId,
|
|
1002
|
+
timeout,
|
|
1003
|
+
resolve: (decision) => {
|
|
1004
|
+
if (res.writableEnded) return;
|
|
1005
|
+
const responseJson = JSON.stringify({
|
|
1006
|
+
hookSpecificOutput: {
|
|
1007
|
+
hookEventName: "PermissionRequest",
|
|
1008
|
+
decision: { behavior: decision },
|
|
1009
|
+
},
|
|
1010
|
+
});
|
|
1011
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1012
|
+
res.end(responseJson);
|
|
1013
|
+
},
|
|
911
1014
|
});
|
|
912
1015
|
// Send status with requestId so app can route decision back
|
|
913
1016
|
this.handleHookEvent(terminalId, event, provider, requestId);
|
|
@@ -964,9 +1067,16 @@ export class BridgeSession {
|
|
|
964
1067
|
} catch { /* doesn't exist yet */ }
|
|
965
1068
|
|
|
966
1069
|
const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
|
|
967
|
-
const permissionEntry = {
|
|
1070
|
+
const permissionEntry = {
|
|
1071
|
+
matcher: "",
|
|
1072
|
+
hooks: [{
|
|
1073
|
+
type: "command",
|
|
1074
|
+
command: curlCmd,
|
|
1075
|
+
timeout: Math.ceil((PERMISSION_REQUEST_TIMEOUT_MS + 30_000) / 1000),
|
|
1076
|
+
}],
|
|
1077
|
+
};
|
|
968
1078
|
|
|
969
|
-
const hookEvents: Record<string, typeof hookEntry> = {
|
|
1079
|
+
const hookEvents: Record<string, typeof hookEntry | typeof permissionEntry> = {
|
|
970
1080
|
PreToolUse: hookEntry,
|
|
971
1081
|
PostToolUse: hookEntry,
|
|
972
1082
|
PostToolUseFailure: hookEntry,
|
|
@@ -981,7 +1091,7 @@ export class BridgeSession {
|
|
|
981
1091
|
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
982
1092
|
let arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
|
|
983
1093
|
// Remove any dead linkshell hook entries (from previous instances)
|
|
984
|
-
arr = arr.filter((e) => !
|
|
1094
|
+
arr = arr.filter((e) => !isLinkShellHookEntry(e));
|
|
985
1095
|
arr.push(entry);
|
|
986
1096
|
existingHooks[eventName] = arr;
|
|
987
1097
|
}
|
|
@@ -1036,7 +1146,7 @@ export class BridgeSession {
|
|
|
1036
1146
|
const existingHooks = existing.hooks ?? {};
|
|
1037
1147
|
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1038
1148
|
let arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
|
|
1039
|
-
arr = arr.filter((e) => !
|
|
1149
|
+
arr = arr.filter((e) => !isLinkShellHookEntry(e));
|
|
1040
1150
|
arr.push(entry);
|
|
1041
1151
|
existingHooks[eventName] = arr;
|
|
1042
1152
|
}
|
|
@@ -1069,7 +1179,7 @@ export class BridgeSession {
|
|
|
1069
1179
|
const existingHooks = (existing.hooks ?? {}) as Record<string, unknown[]>;
|
|
1070
1180
|
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1071
1181
|
let arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
|
|
1072
|
-
arr = arr.filter((e) => !
|
|
1182
|
+
arr = arr.filter((e) => !isLinkShellHookEntry(e));
|
|
1073
1183
|
arr.push(entry);
|
|
1074
1184
|
existingHooks[eventName] = arr;
|
|
1075
1185
|
}
|
|
@@ -1104,7 +1214,7 @@ export class BridgeSession {
|
|
|
1104
1214
|
const existingHooks = existing.hooks ?? {};
|
|
1105
1215
|
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1106
1216
|
let arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
|
|
1107
|
-
arr = arr.filter((e) => !
|
|
1217
|
+
arr = arr.filter((e) => !isLinkShellHookEntry(e));
|
|
1108
1218
|
arr.push(entry);
|
|
1109
1219
|
existingHooks[eventName] = arr;
|
|
1110
1220
|
}
|
|
@@ -1334,10 +1444,7 @@ export class BridgeSession {
|
|
|
1334
1444
|
|
|
1335
1445
|
/** Auto-resolve a single pending permission (user acted in terminal) */
|
|
1336
1446
|
private autoResolvePending(requestId: string): void {
|
|
1337
|
-
|
|
1338
|
-
if (resolve) {
|
|
1339
|
-
this.pendingPermissions.delete(requestId);
|
|
1340
|
-
resolve("allow");
|
|
1447
|
+
if (this.resolvePendingPermission(requestId, "allow")) {
|
|
1341
1448
|
this.log(`auto-resolved pending permission ${requestId} (user acted in terminal)`);
|
|
1342
1449
|
}
|
|
1343
1450
|
}
|
|
@@ -1346,16 +1453,53 @@ export class BridgeSession {
|
|
|
1346
1453
|
private drainPendingPermissions(terminalId: string): void {
|
|
1347
1454
|
const stack = this.permissionStacks.get(terminalId);
|
|
1348
1455
|
if (!stack) return;
|
|
1349
|
-
for (const entry of stack) {
|
|
1350
|
-
|
|
1351
|
-
if (resolve) {
|
|
1352
|
-
this.pendingPermissions.delete(entry.requestId);
|
|
1353
|
-
resolve("deny");
|
|
1456
|
+
for (const entry of [...stack]) {
|
|
1457
|
+
if (this.resolvePendingPermission(entry.requestId, "deny")) {
|
|
1354
1458
|
this.log(`drained pending permission ${entry.requestId}`);
|
|
1355
1459
|
}
|
|
1356
1460
|
}
|
|
1357
1461
|
}
|
|
1358
1462
|
|
|
1463
|
+
private resolvePendingPermission(requestId: string, decision: "allow" | "deny"): boolean {
|
|
1464
|
+
const pending = this.pendingPermissions.get(requestId);
|
|
1465
|
+
if (!pending) return false;
|
|
1466
|
+
this.pendingPermissions.delete(requestId);
|
|
1467
|
+
clearTimeout(pending.timeout);
|
|
1468
|
+
pending.resolve(decision);
|
|
1469
|
+
|
|
1470
|
+
const stack = this.permissionStacks.get(pending.terminalId);
|
|
1471
|
+
if (stack) {
|
|
1472
|
+
const idx = stack.findIndex((entry) => entry.requestId === requestId);
|
|
1473
|
+
if (idx >= 0) stack.splice(idx, 1);
|
|
1474
|
+
if (stack.length === 0) this.permissionStacks.delete(pending.terminalId);
|
|
1475
|
+
}
|
|
1476
|
+
return true;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
private sendPermissionSnapshot(
|
|
1480
|
+
terminalId: string,
|
|
1481
|
+
phase: string,
|
|
1482
|
+
summary?: string,
|
|
1483
|
+
): void {
|
|
1484
|
+
const stack = this.permissionStacks.get(terminalId);
|
|
1485
|
+
const topPermission = stack && stack.length > 0 ? stack[stack.length - 1] : undefined;
|
|
1486
|
+
const pendingPermissionCount = stack?.length ?? 0;
|
|
1487
|
+
const term = this.terminals.get(terminalId);
|
|
1488
|
+
const seq = term ? term.statusSeq++ : 0;
|
|
1489
|
+
this.send(createEnvelope({
|
|
1490
|
+
type: "terminal.status",
|
|
1491
|
+
sessionId: this.sessionId,
|
|
1492
|
+
terminalId,
|
|
1493
|
+
payload: {
|
|
1494
|
+
phase,
|
|
1495
|
+
seq,
|
|
1496
|
+
...(summary && { summary }),
|
|
1497
|
+
...(topPermission && { topPermission }),
|
|
1498
|
+
...(pendingPermissionCount > 0 && { pendingPermissionCount }),
|
|
1499
|
+
},
|
|
1500
|
+
}));
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1359
1503
|
private cleanupHookServer(term: TerminalInstance): void {
|
|
1360
1504
|
// Drain any pending permission requests for this terminal
|
|
1361
1505
|
this.drainPendingPermissions(term.id);
|
|
@@ -1546,6 +1690,10 @@ export class BridgeSession {
|
|
|
1546
1690
|
this.exited = true;
|
|
1547
1691
|
this.stopHeartbeat();
|
|
1548
1692
|
this.stopScreenCapture();
|
|
1693
|
+
this.agentSession?.stop();
|
|
1694
|
+
this.agentSession = undefined;
|
|
1695
|
+
this.keepAwake?.stop();
|
|
1696
|
+
this.keepAwake = undefined;
|
|
1549
1697
|
if (this.reconnectTimer) {
|
|
1550
1698
|
clearTimeout(this.reconnectTimer);
|
|
1551
1699
|
this.reconnectTimer = undefined;
|
package/src/utils/daemon.ts
CHANGED
|
@@ -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);
|