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.
Files changed (37) hide show
  1. package/README.md +3 -0
  2. package/dist/cli/src/index.js +28 -1
  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 +9 -0
  17. package/dist/cli/src/runtime/bridge-session.js +163 -41
  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 +1108 -54
  27. package/dist/shared-protocol/src/index.js +112 -1
  28. package/dist/shared-protocol/src/index.js.map +1 -1
  29. package/package.json +4 -4
  30. package/src/index.ts +38 -1
  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 +189 -41
  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;
@@ -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, (decision: "allow" | "deny") => void>();
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
- const envelope = parseEnvelope(data.toString());
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(termId, term, p.lastAckedSeq);
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
- const resolve = this.pendingPermissions.get(p.requestId);
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
- req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
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
- this.pendingPermissions.set(requestId, (decision) => {
903
- const responseJson = JSON.stringify({
904
- hookSpecificOutput: {
905
- hookEventName: "PermissionRequest",
906
- decision: { behavior: decision },
907
- },
908
- });
909
- res.writeHead(200, { "Content-Type": "application/json" });
910
- res.end(responseJson);
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 = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 86400 }] };
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) => !JSON.stringify(e).includes("/hook"));
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) => !JSON.stringify(e).includes("/hook"));
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) => !JSON.stringify(e).includes("/hook"));
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) => !JSON.stringify(e).includes("/hook"));
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
- const resolve = this.pendingPermissions.get(requestId);
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
- const resolve = this.pendingPermissions.get(entry.requestId);
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;
@@ -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);