@rallycry/conveyor-mcp 5.0.2 → 5.0.4

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 CHANGED
@@ -76,6 +76,12 @@ CONVEYOR_PROJECT_ID = "<project-id>"
76
76
  | `CONVEYOR_PROJECT_ID` | no | Default project for unqualified tools. Omit it for multi-project mode. |
77
77
  | `CONVEYOR_SUBPROJECT_ID` | no | Default board. Unqualified `create_task`/`list_tasks`/`search_tasks` target it. |
78
78
 
79
+ ## How the server exits
80
+
81
+ The server's lifetime is its stdin pipe. It exits when stdin reaches EOF, on
82
+ `SIGINT` or `SIGTERM`, and when its parent process changes — so a client that
83
+ force-quits, crashes, or is torn down never leaves a server behind.
84
+
79
85
  ## Why `npx -y …@latest`
80
86
 
81
87
  Every form above launches the server through
@@ -0,0 +1,45 @@
1
+ // src/lifecycle.ts
2
+ function watchForClientExit(onExit, deps = {}) {
3
+ const stdin = deps.stdin ?? process.stdin;
4
+ const signals = deps.signals ?? process;
5
+ const getPpid = deps.getPpid ?? (() => process.ppid);
6
+ const intervalMs = deps.intervalMs ?? 3e4;
7
+ const watchStdin = deps.watchStdin ?? true;
8
+ let fired = false;
9
+ const fire = () => {
10
+ if (fired) return;
11
+ fired = true;
12
+ onExit();
13
+ };
14
+ signals.on("SIGINT", fire);
15
+ signals.on("SIGTERM", fire);
16
+ signals.on("SIGHUP", fire);
17
+ if (watchStdin && stdin.isTTY !== true) {
18
+ if (deps.resumeStdin) stdin.resume?.();
19
+ stdin.on("end", fire);
20
+ stdin.on("close", fire);
21
+ }
22
+ const initialPpid = getPpid();
23
+ const orphanCheck = setInterval(() => {
24
+ if (getPpid() !== initialPpid) fire();
25
+ }, intervalMs);
26
+ orphanCheck.unref?.();
27
+ return fire;
28
+ }
29
+ function installLifecycle(conn, deps = {}) {
30
+ const exit = deps.exit ?? ((code) => process.exit(code));
31
+ let stopping = false;
32
+ function stop(code = 0) {
33
+ if (stopping) return;
34
+ stopping = true;
35
+ conn.disconnect();
36
+ exit(code);
37
+ }
38
+ watchForClientExit(() => stop(), deps);
39
+ return stop;
40
+ }
41
+
42
+ export {
43
+ watchForClientExit,
44
+ installLifecycle
45
+ };
@@ -49,10 +49,14 @@ function enrichToolError(error, fallback) {
49
49
  var ConveyorConnection = class {
50
50
  socket = null;
51
51
  config;
52
+ socketFactory;
52
53
  /** project slug → id, for resolving `<project>/<card>` card paths. */
53
54
  projectSlugIds = /* @__PURE__ */ new Map();
54
- constructor(config) {
55
+ /** Session rooms this connection has joined, replayed after every reconnect. */
56
+ subscribedSessionIds = /* @__PURE__ */ new Set();
57
+ constructor(config, socketFactory = io) {
55
58
  this.config = config;
59
+ this.socketFactory = socketFactory;
56
60
  }
57
61
  get projectId() {
58
62
  return this.resolveProjectId();
@@ -86,12 +90,32 @@ var ConveyorConnection = class {
86
90
  if (Array.isArray(record.data)) return record.data;
87
91
  return [];
88
92
  }
93
+ /**
94
+ * Re-join every room this connection needs.
95
+ *
96
+ * Runs on the FIRST connect and on every reconnect. A socket.io reconnect
97
+ * starts a fresh server-side socket that belongs to no rooms, and Cloud Run
98
+ * cuts each websocket at its 60-minute request limit, so a subscribe sent
99
+ * only once left long-lived MCP connections silently outside the project room
100
+ * for the rest of the day.
101
+ */
102
+ rejoinRooms() {
103
+ const socket = this.socket;
104
+ if (!socket) return;
105
+ socket.emit("projectService:subscribe", { id: this.config.projectId });
106
+ for (const sessionId of this.subscribedSessionIds) {
107
+ socket.emit("agentSessionService:subscribe", {
108
+ entryId: sessionId,
109
+ requiredLevel: "Read"
110
+ });
111
+ }
112
+ }
89
113
  connect() {
90
114
  return new Promise((resolve, reject) => {
91
115
  let settled = false;
92
116
  let attempts = 0;
93
117
  const maxAttempts = 15;
94
- this.socket = io(
118
+ this.socket = this.socketFactory(
95
119
  this.config.apiUrl,
96
120
  buildConveyorSocketOptions({
97
121
  projectToken: this.config.projectToken,
@@ -99,12 +123,15 @@ var ConveyorConnection = class {
99
123
  })
100
124
  );
101
125
  this.socket.on("connect", () => {
126
+ this.rejoinRooms();
102
127
  if (!settled) {
103
128
  settled = true;
104
- this.socket?.emit("projectService:subscribe", { id: this.config.projectId });
105
129
  resolve();
106
130
  }
107
131
  });
132
+ this.socket.on("disconnect", (reason) => {
133
+ if (reason === "io server disconnect") this.socket?.connect();
134
+ });
108
135
  this.socket.on("connect_error", (err) => {
109
136
  const message = err?.message ?? "unknown error";
110
137
  if (!settled && AUTH_ERROR_RE.test(message)) {
@@ -943,6 +970,7 @@ var ConveyorConnection = class {
943
970
  subscribeToSession(sessionId) {
944
971
  const socket = this.socket;
945
972
  if (!socket) throw new Error("Not connected");
973
+ this.subscribedSessionIds.add(sessionId);
946
974
  socket.emit("agentSessionService:subscribe", {
947
975
  entryId: sessionId,
948
976
  requiredLevel: "Read"
@@ -1013,6 +1041,7 @@ var ConveyorConnection = class {
1013
1041
  disconnect() {
1014
1042
  this.socket?.disconnect();
1015
1043
  this.socket = null;
1044
+ this.subscribedSessionIds.clear();
1016
1045
  }
1017
1046
  };
1018
1047