linkshell-cli 0.2.63 → 0.2.65

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.
@@ -39,6 +39,10 @@ const RECONNECT_BASE_DELAY = 1_000;
39
39
  const RECONNECT_MAX_DELAY = 30_000;
40
40
  const RECONNECT_MAX_ATTEMPTS = 20;
41
41
  const DEFAULT_TERMINAL_ID = "default";
42
+ const HOOK_BODY_LIMIT = 256 * 1024;
43
+ const PERMISSION_REQUEST_TIMEOUT_MS = Number(
44
+ process.env.LINKSHELL_PERMISSION_TIMEOUT_MS ?? 5 * 60_000,
45
+ );
42
46
 
43
47
  interface TerminalInstance {
44
48
  id: string;
@@ -55,6 +59,17 @@ interface TerminalInstance {
55
59
  hookConfigPaths: string[];
56
60
  }
57
61
 
62
+ interface PendingPermission {
63
+ terminalId: string;
64
+ timeout: ReturnType<typeof setTimeout>;
65
+ resolve: (decision: "allow" | "deny") => void;
66
+ }
67
+
68
+ function isLinkShellHookEntry(entry: unknown): boolean {
69
+ const raw = JSON.stringify(entry);
70
+ return /\/hook\?m=lsh-[^"'\s]+/.test(raw);
71
+ }
72
+
58
73
  function getPairingGatewayParam(gatewayHttpUrl: string): string | undefined {
59
74
  try {
60
75
  const url = new URL(gatewayHttpUrl);
@@ -130,7 +145,7 @@ export class BridgeSession {
130
145
  timestamp: number;
131
146
  }>>();
132
147
  // Pending permission responses: requestId → HTTP response callback
133
- private pendingPermissions = new Map<string, (decision: "allow" | "deny") => void>();
148
+ private pendingPermissions = new Map<string, PendingPermission>();
134
149
  private hookMarker = `lsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
135
150
  private screenCapture: ScreenFallback | undefined;
136
151
  private screenShare: ScreenShare | undefined;
@@ -261,7 +276,13 @@ export class BridgeSession {
261
276
  });
262
277
 
263
278
  this.socket.on("message", (data) => {
264
- const envelope = parseEnvelope(data.toString());
279
+ let envelope: Envelope;
280
+ try {
281
+ envelope = parseEnvelope(data.toString());
282
+ } catch (err) {
283
+ this.log(`invalid gateway message ignored: ${err}`);
284
+ return;
285
+ }
265
286
  this.log(
266
287
  `recv ${envelope.type}${envelope.seq !== undefined ? ` seq=${envelope.seq}` : ""}`,
267
288
  );
@@ -457,7 +478,11 @@ export class BridgeSession {
457
478
  const p = parseTypedPayload("session.resume", envelope.payload);
458
479
  // Replay all terminals
459
480
  for (const [termId, term] of this.terminals) {
460
- this.replayFrom(termId, term, p.lastAckedSeq);
481
+ this.replayFrom(
482
+ termId,
483
+ term,
484
+ p.lastAckedSeqByTerminal[termId] ?? p.lastAckedSeq,
485
+ );
461
486
  }
462
487
  // Also send terminal list so client knows what's available
463
488
  this.sendTerminalList();
@@ -498,20 +523,8 @@ export class BridgeSession {
498
523
  }
499
524
  case "permission.decision": {
500
525
  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);
526
+ if (this.resolvePendingPermission(p.requestId, p.decision)) {
505
527
  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
528
  } else {
516
529
  this.log(`no pending permission for ${p.requestId}`);
517
530
  }
@@ -889,8 +902,19 @@ export class BridgeSession {
889
902
  return;
890
903
  }
891
904
  let body = "";
892
- req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
905
+ let bodyTooLarge = false;
906
+ req.on("data", (chunk: Buffer) => {
907
+ if (bodyTooLarge) return;
908
+ body += chunk.toString();
909
+ if (Buffer.byteLength(body, "utf8") > HOOK_BODY_LIMIT) {
910
+ bodyTooLarge = true;
911
+ res.writeHead(413);
912
+ res.end("payload too large");
913
+ req.destroy();
914
+ }
915
+ });
893
916
  req.on("end", () => {
917
+ if (bodyTooLarge || res.writableEnded) return;
894
918
  this.log(`hook body (${body.length} bytes): ${body.slice(0, 200)}`);
895
919
  try {
896
920
  const event = JSON.parse(body);
@@ -899,15 +923,26 @@ export class BridgeSession {
899
923
  // PermissionRequest: hold connection, wait for user decision from mobile app
900
924
  if (hookName === "PermissionRequest") {
901
925
  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);
926
+ const timeout = setTimeout(() => {
927
+ if (this.resolvePendingPermission(requestId, "deny")) {
928
+ this.log(`permission request ${requestId} timed out`);
929
+ this.sendPermissionSnapshot(terminalId, "thinking", "permission timed out");
930
+ }
931
+ }, PERMISSION_REQUEST_TIMEOUT_MS);
932
+ this.pendingPermissions.set(requestId, {
933
+ terminalId,
934
+ timeout,
935
+ resolve: (decision) => {
936
+ if (res.writableEnded) return;
937
+ const responseJson = JSON.stringify({
938
+ hookSpecificOutput: {
939
+ hookEventName: "PermissionRequest",
940
+ decision: { behavior: decision },
941
+ },
942
+ });
943
+ res.writeHead(200, { "Content-Type": "application/json" });
944
+ res.end(responseJson);
945
+ },
911
946
  });
912
947
  // Send status with requestId so app can route decision back
913
948
  this.handleHookEvent(terminalId, event, provider, requestId);
@@ -964,9 +999,16 @@ export class BridgeSession {
964
999
  } catch { /* doesn't exist yet */ }
965
1000
 
966
1001
  const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
967
- const permissionEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 86400 }] };
1002
+ const permissionEntry = {
1003
+ matcher: "",
1004
+ hooks: [{
1005
+ type: "command",
1006
+ command: curlCmd,
1007
+ timeout: Math.ceil((PERMISSION_REQUEST_TIMEOUT_MS + 30_000) / 1000),
1008
+ }],
1009
+ };
968
1010
 
969
- const hookEvents: Record<string, typeof hookEntry> = {
1011
+ const hookEvents: Record<string, typeof hookEntry | typeof permissionEntry> = {
970
1012
  PreToolUse: hookEntry,
971
1013
  PostToolUse: hookEntry,
972
1014
  PostToolUseFailure: hookEntry,
@@ -981,7 +1023,7 @@ export class BridgeSession {
981
1023
  for (const [eventName, entry] of Object.entries(hookEvents)) {
982
1024
  let arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
983
1025
  // Remove any dead linkshell hook entries (from previous instances)
984
- arr = arr.filter((e) => !JSON.stringify(e).includes("/hook"));
1026
+ arr = arr.filter((e) => !isLinkShellHookEntry(e));
985
1027
  arr.push(entry);
986
1028
  existingHooks[eventName] = arr;
987
1029
  }
@@ -1036,7 +1078,7 @@ export class BridgeSession {
1036
1078
  const existingHooks = existing.hooks ?? {};
1037
1079
  for (const [eventName, entry] of Object.entries(hookEvents)) {
1038
1080
  let arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
1039
- arr = arr.filter((e) => !JSON.stringify(e).includes("/hook"));
1081
+ arr = arr.filter((e) => !isLinkShellHookEntry(e));
1040
1082
  arr.push(entry);
1041
1083
  existingHooks[eventName] = arr;
1042
1084
  }
@@ -1069,7 +1111,7 @@ export class BridgeSession {
1069
1111
  const existingHooks = (existing.hooks ?? {}) as Record<string, unknown[]>;
1070
1112
  for (const [eventName, entry] of Object.entries(hookEvents)) {
1071
1113
  let arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
1072
- arr = arr.filter((e) => !JSON.stringify(e).includes("/hook"));
1114
+ arr = arr.filter((e) => !isLinkShellHookEntry(e));
1073
1115
  arr.push(entry);
1074
1116
  existingHooks[eventName] = arr;
1075
1117
  }
@@ -1104,7 +1146,7 @@ export class BridgeSession {
1104
1146
  const existingHooks = existing.hooks ?? {};
1105
1147
  for (const [eventName, entry] of Object.entries(hookEvents)) {
1106
1148
  let arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
1107
- arr = arr.filter((e) => !JSON.stringify(e).includes("/hook"));
1149
+ arr = arr.filter((e) => !isLinkShellHookEntry(e));
1108
1150
  arr.push(entry);
1109
1151
  existingHooks[eventName] = arr;
1110
1152
  }
@@ -1334,10 +1376,7 @@ export class BridgeSession {
1334
1376
 
1335
1377
  /** Auto-resolve a single pending permission (user acted in terminal) */
1336
1378
  private autoResolvePending(requestId: string): void {
1337
- const resolve = this.pendingPermissions.get(requestId);
1338
- if (resolve) {
1339
- this.pendingPermissions.delete(requestId);
1340
- resolve("allow");
1379
+ if (this.resolvePendingPermission(requestId, "allow")) {
1341
1380
  this.log(`auto-resolved pending permission ${requestId} (user acted in terminal)`);
1342
1381
  }
1343
1382
  }
@@ -1346,16 +1385,53 @@ export class BridgeSession {
1346
1385
  private drainPendingPermissions(terminalId: string): void {
1347
1386
  const stack = this.permissionStacks.get(terminalId);
1348
1387
  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");
1388
+ for (const entry of [...stack]) {
1389
+ if (this.resolvePendingPermission(entry.requestId, "deny")) {
1354
1390
  this.log(`drained pending permission ${entry.requestId}`);
1355
1391
  }
1356
1392
  }
1357
1393
  }
1358
1394
 
1395
+ private resolvePendingPermission(requestId: string, decision: "allow" | "deny"): boolean {
1396
+ const pending = this.pendingPermissions.get(requestId);
1397
+ if (!pending) return false;
1398
+ this.pendingPermissions.delete(requestId);
1399
+ clearTimeout(pending.timeout);
1400
+ pending.resolve(decision);
1401
+
1402
+ const stack = this.permissionStacks.get(pending.terminalId);
1403
+ if (stack) {
1404
+ const idx = stack.findIndex((entry) => entry.requestId === requestId);
1405
+ if (idx >= 0) stack.splice(idx, 1);
1406
+ if (stack.length === 0) this.permissionStacks.delete(pending.terminalId);
1407
+ }
1408
+ return true;
1409
+ }
1410
+
1411
+ private sendPermissionSnapshot(
1412
+ terminalId: string,
1413
+ phase: string,
1414
+ summary?: string,
1415
+ ): void {
1416
+ const stack = this.permissionStacks.get(terminalId);
1417
+ const topPermission = stack && stack.length > 0 ? stack[stack.length - 1] : undefined;
1418
+ const pendingPermissionCount = stack?.length ?? 0;
1419
+ const term = this.terminals.get(terminalId);
1420
+ const seq = term ? term.statusSeq++ : 0;
1421
+ this.send(createEnvelope({
1422
+ type: "terminal.status",
1423
+ sessionId: this.sessionId,
1424
+ terminalId,
1425
+ payload: {
1426
+ phase,
1427
+ seq,
1428
+ ...(summary && { summary }),
1429
+ ...(topPermission && { topPermission }),
1430
+ ...(pendingPermissionCount > 0 && { pendingPermissionCount }),
1431
+ },
1432
+ }));
1433
+ }
1434
+
1359
1435
  private cleanupHookServer(term: TerminalInstance): void {
1360
1436
  // Drain any pending permission requests for this terminal
1361
1437
  this.drainPendingPermissions(term.id);
@@ -1508,13 +1584,16 @@ export class BridgeSession {
1508
1584
  }
1509
1585
 
1510
1586
  private scheduleReconnect(): void {
1511
- if (this.reconnecting || this.reconnectAttempts >= RECONNECT_MAX_ATTEMPTS) {
1587
+ if (this.reconnecting) return;
1588
+
1589
+ // In daemon mode, never give up — reset attempts after hitting max
1590
+ if (this.reconnectAttempts >= RECONNECT_MAX_ATTEMPTS) {
1512
1591
  process.stderr.write(
1513
- "[bridge] max reconnect attempts reached, stopping bridge session\n",
1592
+ "[bridge] max reconnect attempts reached, resetting counter and continuing...\n",
1514
1593
  );
1515
- this.stop(1);
1516
- return;
1594
+ this.reconnectAttempts = 0;
1517
1595
  }
1596
+
1518
1597
  this.reconnecting = true;
1519
1598
  const delay = Math.min(
1520
1599
  RECONNECT_BASE_DELAY * 2 ** this.reconnectAttempts,