toolstile 0.1.2 → 0.2.0

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.
@@ -0,0 +1,338 @@
1
+ import { spawn } from "node:child_process";
2
+ import { hostname } from "node:os";
3
+ import { ManualEditError, proxyCommand, readEntry, isProxyEntry, targets, writeEntry } from "./agents.js";
4
+ import { ApiError } from "./api.js";
5
+ import { checklist } from "./prompt.js";
6
+ import { runProxy } from "./proxy.js";
7
+ import { tildify } from "./report.js";
8
+ import { CLIENT_NAMES } from "./sources.js";
9
+ import { loadState, saveState } from "./state.js";
10
+ /** A failure explained to the user; exits 1 without a stack trace. */
11
+ export class UserError extends Error {
12
+ }
13
+ const stateEnv = (ctx) => ({ home: ctx.home, env: ctx.env, platform: ctx.platform });
14
+ const print = (ctx, text = "") => ctx.stdout.write(`${text}\n`);
15
+ const interactive = (ctx) => ctx.stdin.isTTY === true && ctx.stdout.isTTY === true;
16
+ /** HTTPS, except a loopback server for local development. */
17
+ export function normalizeServer(value) {
18
+ let url;
19
+ try {
20
+ url = new URL(value);
21
+ }
22
+ catch {
23
+ throw new UserError(`--server must be a URL, for example https://toolstile.com`);
24
+ }
25
+ const loopback = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\])$/.test(url.hostname);
26
+ if (url.protocol !== "https:" && !(loopback && url.protocol === "http:"))
27
+ throw new UserError("--server must use https://");
28
+ return url.origin;
29
+ }
30
+ function defaultOpenBrowser(ctx) {
31
+ return (url) => {
32
+ // Over SSH or without a display the URL and code are enough; nothing to open.
33
+ if (ctx.env.SSH_CONNECTION || ctx.env.SSH_TTY)
34
+ return;
35
+ if (ctx.platform === "linux" && !ctx.env.DISPLAY && !ctx.env.WAYLAND_DISPLAY)
36
+ return;
37
+ const [command, args] = ctx.platform === "darwin" ? ["open", [url]] : ctx.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
38
+ try {
39
+ spawn(command, args, { stdio: "ignore", detached: true }).on("error", () => { }).unref();
40
+ }
41
+ catch { }
42
+ };
43
+ }
44
+ const formatCode = (code) => (code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code);
45
+ export async function login(ctx, options) {
46
+ const s = ctx.style;
47
+ const state = loadState(stateEnv(ctx));
48
+ const server = normalizeServer(options.server ?? ctx.env.TOOLSTILE_URL ?? state.server);
49
+ const api = ctx.api(server);
50
+ const code = await api.deviceCode();
51
+ print(ctx, `Open this page on any device with a browser, sign in to Toolstile and enter the code:`);
52
+ print(ctx);
53
+ print(ctx, ` ${s.bold(code.verification_uri)}`);
54
+ print(ctx, ` ${s.bold(formatCode(code.user_code))}`);
55
+ print(ctx);
56
+ print(ctx, s.dim(`Waiting for approval. The code expires in ${Math.round(code.expires_in / 60)} minutes.`));
57
+ if (options.browser)
58
+ (ctx.openBrowser ?? defaultOpenBrowser(ctx))(code.verification_uri);
59
+ const sleep = ctx.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
60
+ let interval = Math.max(code.interval, 1) * 1000;
61
+ const deadline = Date.now() + code.expires_in * 1000;
62
+ let token;
63
+ while (!token) {
64
+ if (Date.now() > deadline)
65
+ throw new UserError("The code expired. Run toolstile auth login again.");
66
+ await sleep(interval);
67
+ try {
68
+ token = (await api.deviceToken(code.device_code)).access_token;
69
+ }
70
+ catch (error) {
71
+ if (!(error instanceof ApiError))
72
+ throw error;
73
+ if (error.code === "authorization_pending")
74
+ continue;
75
+ if (error.code === "slow_down") {
76
+ interval += 5000;
77
+ continue;
78
+ }
79
+ if (error.code === "access_denied")
80
+ throw new UserError("Sign-in was denied in the browser.");
81
+ if (error.code === "expired_token")
82
+ throw new UserError("The code expired. Run toolstile auth login again.");
83
+ throw error;
84
+ }
85
+ }
86
+ const account = await api.account(token);
87
+ const previous = state.session;
88
+ // The old session is ended where it was issued, never sent to the new server.
89
+ if (previous)
90
+ await ctx.api(previous.server ?? state.server).signOut(previous.token).catch(() => undefined);
91
+ if (server !== state.server || (previous && previous.email !== account.email)) {
92
+ // Another account's workspace and device do not apply; enabled agents keep their own access.
93
+ delete state.workspace;
94
+ delete state.device;
95
+ }
96
+ state.server = server;
97
+ state.session = { token, email: account.email, expiresAt: account.expiresAt, server };
98
+ saveState(state, stateEnv(ctx));
99
+ print(ctx);
100
+ print(ctx, `Signed in as ${s.bold(account.email)}. Next: ${s.bold("toolstile mcp setup")}`);
101
+ return 0;
102
+ }
103
+ export async function logout(ctx) {
104
+ const state = loadState(stateEnv(ctx));
105
+ if (!state.session) {
106
+ print(ctx, "Not signed in.");
107
+ return 0;
108
+ }
109
+ let reached = true;
110
+ await ctx
111
+ .api(state.session.server ?? state.server)
112
+ .signOut(state.session.token)
113
+ .catch((error) => {
114
+ // 401 means the session had already ended; anything else is worth saying.
115
+ if (!(error instanceof ApiError && error.status === 401))
116
+ reached = false;
117
+ });
118
+ delete state.session;
119
+ saveState(state, stateEnv(ctx));
120
+ print(ctx, reached ? "Signed out." : "Signed out on this machine. Toolstile could not be reached, so the session ends when it expires.");
121
+ const enabled = Object.keys(state.agents).length;
122
+ if (enabled)
123
+ print(ctx, ctx.style.dim(`${enabled === 1 ? "The agent" : `The ${enabled} agents`} you enabled keep working. To turn ${enabled === 1 ? "it" : "them"} off, run toolstile mcp setup while signed in, or delete this device in the Toolstile dashboard.`));
124
+ return 0;
125
+ }
126
+ function parseClients(list, flag) {
127
+ if (!list)
128
+ return [];
129
+ const ids = Object.keys(CLIENT_NAMES);
130
+ return list.split(",").map((raw) => {
131
+ const value = raw.trim().toLowerCase();
132
+ const match = ids.find((id) => id === value || CLIENT_NAMES[id].toLowerCase().replace(/\s+/g, "-") === value);
133
+ if (!match)
134
+ throw new UserError(`${flag}: unknown agent "${raw.trim()}". Use ${ids.join(", ")}.`);
135
+ return match;
136
+ });
137
+ }
138
+ async function chooseWorkspace(ctx, state, workspaces, wanted) {
139
+ if (wanted) {
140
+ const value = wanted.toLowerCase();
141
+ const match = workspaces.find((w) => w.slug === value || w.id === wanted || w.name.toLowerCase() === value);
142
+ if (!match)
143
+ throw new UserError(`No workspace "${wanted}". Yours: ${workspaces.map((w) => w.slug).join(", ")}.`);
144
+ return match;
145
+ }
146
+ const current = workspaces.find((w) => w.id === state.workspace?.id) ?? workspaces.find((w) => w.personal) ?? workspaces[0];
147
+ if (!current)
148
+ throw new UserError("Your account has no workspace yet. Open Toolstile in a browser once, then try again.");
149
+ if (workspaces.length === 1 || !interactive(ctx))
150
+ return current;
151
+ const picked = await checklist("Which workspace should this machine's agents use?", workspaces.map((w) => ({ label: w.name, hint: w.personal ? "personal" : w.slug, checked: w.id === current.id })), ctx.stdin, ctx.stdout, { single: true });
152
+ if (!picked)
153
+ throw new UserError("Cancelled; nothing changed.");
154
+ return workspaces[picked.indexOf(true)];
155
+ }
156
+ export async function setup(ctx, options) {
157
+ const s = ctx.style;
158
+ const state = loadState(stateEnv(ctx));
159
+ const session = state.session;
160
+ if (!session)
161
+ throw new UserError("Sign in first: toolstile auth login");
162
+ const api = ctx.api(session.server ?? state.server);
163
+ const account = await api.account(session.token).catch((error) => {
164
+ if (error instanceof ApiError && error.status === 401)
165
+ throw new UserError("Your sign-in has expired. Run toolstile auth login again.");
166
+ throw error;
167
+ });
168
+ const enable = parseClients(options.enable, "--enable");
169
+ const disable = parseClients(options.disable, "--disable");
170
+ const both = enable.find((c) => disable.includes(c));
171
+ if (both)
172
+ throw new UserError(`${CLIENT_NAMES[both]} is in both --enable and --disable.`);
173
+ const workspace = await chooseWorkspace(ctx, state, account.workspaces, options.workspace);
174
+ state.workspace = { id: workspace.id, slug: workspace.slug, name: workspace.name };
175
+ saveState(state, stateEnv(ctx));
176
+ /** Created (or renamed) only when something needs it, so listing changes nothing. */
177
+ let device;
178
+ const ensureDevice = async () => {
179
+ device ??= await api.device(session.token, {
180
+ workspaceId: workspace.id,
181
+ id: state.device?.id,
182
+ label: options.deviceName ?? state.device?.label ?? hostname().replace(/\.local$/, ""),
183
+ rename: options.deviceName !== undefined,
184
+ });
185
+ state.device = device;
186
+ saveState(state, stateEnv(ctx));
187
+ return device;
188
+ };
189
+ if (options.deviceName)
190
+ await ensureDevice();
191
+ // Agents deleted from the dashboard, or left in another workspace, are no longer enabled.
192
+ const remote = new Set(state.device ? (await api.agents(session.token, workspace.id, state.device.id)).agents.map((a) => a.id) : []);
193
+ const live = (client) => {
194
+ const link = state.agents[client];
195
+ return !!link && link.server === state.server && link.workspaceId === workspace.id && remote.has(link.id);
196
+ };
197
+ /** Removes an agent where it was created; links from another server are just dropped. */
198
+ const removeRemote = (link) => link.server === state.server
199
+ ? api.removeAgent(session.token, link.workspaceId, link.id).catch((error) => {
200
+ if (!(error instanceof ApiError && (error.status === 404 || error.status === 403)))
201
+ throw error;
202
+ })
203
+ : Promise.resolve();
204
+ const deviceLabel = state.device?.label ?? hostname().replace(/\.local$/, "");
205
+ const all = targets(ctx);
206
+ const shown = all.filter((t) => t.detected || live(t.client) || state.agents[t.client] || enable.includes(t.client));
207
+ if (!shown.length) {
208
+ print(ctx, "No supported agents found on this machine. Supported: " + all.map((t) => t.name).join(", ") + ".");
209
+ return 0;
210
+ }
211
+ let wanted;
212
+ if (enable.length || disable.length) {
213
+ wanted = new Set(all.filter((t) => live(t.client)).map((t) => t.client));
214
+ for (const c of enable)
215
+ wanted.add(c);
216
+ for (const c of disable)
217
+ wanted.delete(c);
218
+ }
219
+ else if (interactive(ctx)) {
220
+ print(ctx, `Signed in as ${account.email} · workspace ${s.bold(workspace.name)} · device ${s.bold(deviceLabel)}`);
221
+ const picked = await checklist("Which agents on this machine should use Toolstile?", shown.map((t) => ({
222
+ label: t.name,
223
+ hint: t.detected ? tildify(t.path, ctx.home) : "not found on this machine",
224
+ checked: live(t.client),
225
+ })), ctx.stdin, ctx.stdout);
226
+ if (!picked) {
227
+ print(ctx, "Cancelled; nothing changed.");
228
+ return 0;
229
+ }
230
+ wanted = new Set(shown.filter((_, i) => picked[i]).map((t) => t.client));
231
+ }
232
+ else {
233
+ print(ctx, `Workspace ${workspace.name} · device ${deviceLabel}`);
234
+ for (const t of shown)
235
+ print(ctx, ` ${live(t.client) ? "enabled " : "disabled"} ${t.client.padEnd(14)} ${t.name}`);
236
+ print(ctx);
237
+ print(ctx, "Choose agents with --enable and --disable, for example: toolstile mcp setup --enable claude-code,codex");
238
+ return 0;
239
+ }
240
+ const manual = [];
241
+ const changes = [];
242
+ const replaced = [];
243
+ for (const target of all) {
244
+ const want = wanted.has(target.client);
245
+ const link = state.agents[target.client];
246
+ if (want) {
247
+ if (!live(target.client)) {
248
+ if (link)
249
+ await removeRemote(link).catch(() => undefined);
250
+ const { id: deviceId } = await ensureDevice();
251
+ const created = await api.addAgent(session.token, { workspaceId: workspace.id, deviceId, label: target.name });
252
+ state.agents[target.client] = {
253
+ id: created.id,
254
+ workspaceId: workspace.id,
255
+ server: state.server,
256
+ label: created.label,
257
+ token: created.token,
258
+ };
259
+ // Saved before touching the agent's config, so a token is never lost.
260
+ saveState(state, stateEnv(ctx));
261
+ }
262
+ try {
263
+ const result = writeEntry(target, proxyCommand(target.client, ctx.version, ctx.platform));
264
+ if (!live(target.client) || result !== "unchanged")
265
+ changes.push(`${s.bold("+")} ${target.name} ${s.dim(tildify(target.path, ctx.home))}`);
266
+ if (result === "replaced")
267
+ replaced.push(target.name);
268
+ }
269
+ catch (error) {
270
+ if (!(error instanceof ManualEditError))
271
+ throw error;
272
+ manual.push({ target, error, enable: true });
273
+ }
274
+ }
275
+ else if (link || isProxyEntry(readEntry(target))) {
276
+ try {
277
+ writeEntry(target, null);
278
+ }
279
+ catch (error) {
280
+ if (!(error instanceof ManualEditError))
281
+ throw error;
282
+ manual.push({ target, error, enable: false });
283
+ }
284
+ if (link) {
285
+ await removeRemote(link);
286
+ delete state.agents[target.client];
287
+ saveState(state, stateEnv(ctx));
288
+ }
289
+ changes.push(`${s.bold("-")} ${target.name}`);
290
+ }
291
+ }
292
+ print(ctx);
293
+ if (changes.length) {
294
+ for (const line of changes)
295
+ print(ctx, line);
296
+ print(ctx);
297
+ print(ctx, "Restart agents that are running to pick up the change.");
298
+ }
299
+ else
300
+ print(ctx, "Nothing to change.");
301
+ if (replaced.length) {
302
+ print(ctx);
303
+ print(ctx, `Replaced an existing "toolstile" server in ${replaced.join(", ")}. If it signed in directly, remove that older agent under Devices & agents in the dashboard.`);
304
+ }
305
+ for (const { target, error, enable } of manual) {
306
+ print(ctx);
307
+ print(ctx, `${target.name}: ${tildify(target.path, ctx.home)} was not changed because ${error.message}.`);
308
+ if (enable) {
309
+ print(ctx, "Add this entry yourself:");
310
+ print(ctx, error.snippet.replace(/^/gm, " "));
311
+ }
312
+ else
313
+ print(ctx, `Remove its "toolstile" server entry yourself.`);
314
+ }
315
+ if ([...wanted].length) {
316
+ print(ctx);
317
+ print(ctx, `Choose which MCP servers they can use: ${state.server}/app/${workspace.slug}/access`);
318
+ }
319
+ return manual.length ? 1 : 0;
320
+ }
321
+ export async function proxy(ctx, agent) {
322
+ const [client] = parseClients(agent, "--agent");
323
+ if (!client)
324
+ throw new UserError("--agent is required");
325
+ const state = loadState(stateEnv(ctx));
326
+ const link = state.agents[client];
327
+ if (!link)
328
+ throw new UserError(`${CLIENT_NAMES[client]} is not enabled on this machine. Run toolstile mcp setup.`);
329
+ await runProxy({
330
+ // The token goes only to the server that issued it, whatever the environment says.
331
+ endpoint: `${link.server}/mcp`,
332
+ token: link.token,
333
+ input: ctx.stdin,
334
+ output: ctx.stdout,
335
+ log: (message) => ctx.stderr.write(`toolstile: ${message}\n`),
336
+ });
337
+ return 0;
338
+ }
@@ -0,0 +1,10 @@
1
+ import type { ReadStream, WriteStream } from "node:tty";
2
+ export interface Choice {
3
+ label: string;
4
+ hint?: string;
5
+ checked: boolean;
6
+ }
7
+ /** Terminal checklist: arrows (or j/k) move, space toggles, a toggles all, enter saves. */
8
+ export declare function checklist(title: string, choices: Choice[], input: ReadStream, output: WriteStream, options?: {
9
+ single?: boolean;
10
+ }): Promise<boolean[] | null>;
package/dist/prompt.js ADDED
@@ -0,0 +1,54 @@
1
+ import { emitKeypressEvents } from "node:readline";
2
+ /** Terminal checklist: arrows (or j/k) move, space toggles, a toggles all, enter saves. */
3
+ export function checklist(title, choices, input, output, options = {}) {
4
+ const state = choices.map((c) => c.checked);
5
+ let cursor = Math.max(0, options.single ? state.indexOf(true) : 0);
6
+ let drawn = 0;
7
+ const help = options.single ? "↑/↓ move · enter select · esc cancel" : "↑/↓ move · space toggle · a all · enter save · esc cancel";
8
+ const render = () => {
9
+ if (drawn)
10
+ output.write(`\x1b[${drawn}A\x1b[0J`);
11
+ const lines = [
12
+ title,
13
+ ...choices.map((c, i) => {
14
+ const mark = options.single ? (i === cursor ? "●" : "○") : state[i] ? "◉" : "○";
15
+ const pointer = i === cursor ? "›" : " ";
16
+ return `${pointer} ${mark} ${c.label}${c.hint ? ` \x1b[2m${c.hint}\x1b[22m` : ""}`;
17
+ }),
18
+ `\x1b[2m${help}\x1b[22m`,
19
+ ];
20
+ output.write(`${lines.join("\n")}\n`);
21
+ drawn = lines.length;
22
+ };
23
+ return new Promise((resolve) => {
24
+ emitKeypressEvents(input);
25
+ const raw = input.isRaw;
26
+ input.setRawMode(true);
27
+ input.resume();
28
+ output.write("\x1b[?25l");
29
+ const finish = (value) => {
30
+ input.off("keypress", onKey);
31
+ input.setRawMode(raw);
32
+ input.pause();
33
+ output.write("\x1b[?25h");
34
+ resolve(value);
35
+ };
36
+ const onKey = (text, key = {}) => {
37
+ if ((key.ctrl && key.name === "c") || key.name === "escape")
38
+ return finish(null);
39
+ if (key.name === "up" || text === "k")
40
+ cursor = (cursor - 1 + choices.length) % choices.length;
41
+ else if (key.name === "down" || text === "j")
42
+ cursor = (cursor + 1) % choices.length;
43
+ else if (!options.single && (key.name === "space" || text === " "))
44
+ state[cursor] = !state[cursor];
45
+ else if (!options.single && text === "a")
46
+ state.fill(!state.every(Boolean));
47
+ else if (key.name === "return" || key.name === "enter")
48
+ return finish(options.single ? choices.map((_, i) => i === cursor) : state);
49
+ render();
50
+ };
51
+ input.on("keypress", onKey);
52
+ render();
53
+ });
54
+ }
@@ -0,0 +1,15 @@
1
+ import type { Readable, Writable } from "node:stream";
2
+ /**
3
+ * Bridges an agent's stdio MCP connection to the Toolstile gateway's Streamable HTTP endpoint,
4
+ * so the agent's config holds a command instead of a credential. Messages pass through
5
+ * unchanged; only the transport differs.
6
+ */
7
+ export interface ProxyOptions {
8
+ endpoint: string;
9
+ token: string;
10
+ input: Readable;
11
+ output: Writable;
12
+ log: (message: string) => void;
13
+ fetch?: typeof fetch;
14
+ }
15
+ export declare function runProxy(options: ProxyOptions): Promise<void>;
package/dist/proxy.js ADDED
@@ -0,0 +1,235 @@
1
+ import { createInterface } from "node:readline";
2
+ /** Yields each SSE event's data and id. */
3
+ async function* events(body) {
4
+ const decoder = new TextDecoder();
5
+ let buffer = "";
6
+ let data = [];
7
+ let id;
8
+ for await (const chunk of body) {
9
+ buffer += decoder.decode(chunk, { stream: true });
10
+ let newline;
11
+ while ((newline = buffer.search(/\r?\n/)) !== -1) {
12
+ const line = buffer.slice(0, newline);
13
+ buffer = buffer.slice(buffer[newline] === "\r" ? newline + 2 : newline + 1);
14
+ if (line === "") {
15
+ if (data.length)
16
+ yield { data: data.join("\n"), id };
17
+ data = [];
18
+ continue;
19
+ }
20
+ if (line.startsWith(":"))
21
+ continue;
22
+ const colon = line.indexOf(":");
23
+ const field = colon === -1 ? line : line.slice(0, colon);
24
+ const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
25
+ if (field === "data")
26
+ data.push(value);
27
+ else if (field === "id")
28
+ id = value;
29
+ }
30
+ }
31
+ if (data.length)
32
+ yield { data: data.join("\n"), id };
33
+ }
34
+ /** How long in-flight requests may finish after the agent closes stdin. */
35
+ const CLOSE_GRACE_MS = 3000;
36
+ export function runProxy(options) {
37
+ const fetcher = options.fetch ?? fetch;
38
+ let sessionId;
39
+ let protocolVersion;
40
+ /** The agent's own `initialize`, replayed if the gateway forgets the session. */
41
+ let opening;
42
+ let listening = false;
43
+ let closed = false;
44
+ /** Aborts every request and the listener's waits once the agent has gone. */
45
+ const shutdown = new AbortController();
46
+ const pending = new Set();
47
+ /** Later messages wait for `initialize`, so they carry the session it opens. */
48
+ let initialized = Promise.resolve();
49
+ const write = (message) => {
50
+ if (!options.output.writableEnded)
51
+ options.output.write(`${JSON.stringify(message)}\n`);
52
+ };
53
+ const headers = (extra = {}) => ({
54
+ Authorization: `Bearer ${options.token}`,
55
+ ...(sessionId ? { "Mcp-Session-Id": sessionId } : {}),
56
+ ...(protocolVersion ? { "Mcp-Protocol-Version": protocolVersion } : {}),
57
+ ...extra,
58
+ });
59
+ /** Never follow a redirect: the token is only for the gateway endpoint. */
60
+ const request = (init) => fetcher(options.endpoint, { ...init, redirect: "error", signal: shutdown.signal });
61
+ const post = (body) => request({
62
+ method: "POST",
63
+ headers: headers({ "Content-Type": "application/json", Accept: "application/json, text/event-stream" }),
64
+ body,
65
+ });
66
+ const explain = (status, text) => status === 401
67
+ ? "Toolstile no longer accepts this agent's access (removed from the dashboard?). Run `toolstile mcp setup` to enable it again."
68
+ : status === 403
69
+ ? `Toolstile refused this agent: ${text || "access denied"}`
70
+ : status === 429
71
+ ? "Toolstile is rate limiting this agent; try again shortly."
72
+ : `Toolstile returned ${status}${text ? `: ${text}` : ""}`;
73
+ const deliver = (message, answered) => {
74
+ if (message.id !== undefined && message.result && typeof message.result === "object") {
75
+ const version = message.result.protocolVersion;
76
+ if (typeof version === "string")
77
+ protocolVersion = version;
78
+ }
79
+ if (message.method === undefined && message.id !== undefined)
80
+ answered?.add(message.id);
81
+ write(message);
82
+ };
83
+ async function readBody(response, answered, quiet = false) {
84
+ const type = response.headers.get("content-type") ?? "";
85
+ const take = (message) => (quiet ? answered.add(message.id) : deliver(message, answered));
86
+ if (!response.body)
87
+ return;
88
+ if (type.includes("text/event-stream")) {
89
+ for await (const event of events(response.body)) {
90
+ try {
91
+ take(JSON.parse(event.data));
92
+ }
93
+ catch {
94
+ options.log("Ignored a malformed message from Toolstile.");
95
+ }
96
+ }
97
+ return;
98
+ }
99
+ if (type.includes("application/json")) {
100
+ const body = (await response.json());
101
+ for (const message of Array.isArray(body) ? body : [body])
102
+ take(message);
103
+ }
104
+ }
105
+ const wait = (ms) => new Promise((resolve) => {
106
+ const timer = setTimeout(resolve, ms);
107
+ shutdown.signal.addEventListener("abort", () => (clearTimeout(timer), resolve()), { once: true });
108
+ });
109
+ /** Server-initiated messages (list changes, approvals) arrive on a long-lived GET stream. */
110
+ async function listen() {
111
+ if (listening || !sessionId)
112
+ return;
113
+ listening = true;
114
+ let lastEventId;
115
+ for (let attempt = 0; !closed && attempt < 8; attempt++) {
116
+ try {
117
+ const response = await request({
118
+ method: "GET",
119
+ headers: headers({ Accept: "text/event-stream", ...(lastEventId ? { "Last-Event-ID": lastEventId } : {}) }),
120
+ });
121
+ // 405: this server offers no stream; that is allowed.
122
+ if (response.status === 405 || !response.ok || !response.body)
123
+ break;
124
+ attempt = 0;
125
+ for await (const event of events(response.body)) {
126
+ if (event.id)
127
+ lastEventId = event.id;
128
+ try {
129
+ deliver(JSON.parse(event.data));
130
+ }
131
+ catch { }
132
+ }
133
+ }
134
+ catch {
135
+ if (closed)
136
+ break;
137
+ }
138
+ await wait(Math.min(30_000, 1000 * 2 ** attempt));
139
+ }
140
+ listening = false;
141
+ }
142
+ /**
143
+ * The gateway answers 404 for a session it no longer has (for example after a restart).
144
+ * Open a new one with the agent's original `initialize`, without showing its reply again.
145
+ */
146
+ async function reopen() {
147
+ if (!opening)
148
+ return false;
149
+ sessionId = undefined;
150
+ const response = await post(opening);
151
+ sessionId = response.headers.get("mcp-session-id") ?? undefined;
152
+ if (!response.ok)
153
+ return false;
154
+ await readBody(response, new Set(), true);
155
+ await post(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })).then((r) => r.body?.cancel());
156
+ void listen();
157
+ return true;
158
+ }
159
+ async function send(line) {
160
+ let messages;
161
+ try {
162
+ const parsed = JSON.parse(line);
163
+ messages = Array.isArray(parsed) ? parsed : [parsed];
164
+ }
165
+ catch {
166
+ write({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
167
+ return;
168
+ }
169
+ const requests = messages.filter((m) => m.method !== undefined && m.id !== undefined);
170
+ const answered = new Set();
171
+ const fail = (message) => {
172
+ for (const request of requests)
173
+ if (!answered.has(request.id))
174
+ write({ jsonrpc: "2.0", id: request.id, error: { code: -32000, message } });
175
+ };
176
+ if (messages.some((m) => m.method === "initialize"))
177
+ opening = line;
178
+ try {
179
+ let response = await post(line);
180
+ if (response.status === 404 && sessionId && !messages.some((m) => m.method === "initialize")) {
181
+ await response.body?.cancel();
182
+ if (await reopen())
183
+ response = await post(line);
184
+ }
185
+ const session = response.headers.get("mcp-session-id");
186
+ if (session)
187
+ sessionId = session;
188
+ if (!response.ok) {
189
+ const text = (await response.text().catch(() => "")).trim().slice(0, 300);
190
+ const message = explain(response.status, text);
191
+ options.log(message);
192
+ fail(message);
193
+ return;
194
+ }
195
+ await readBody(response, answered);
196
+ if (messages.some((m) => m.method === "notifications/initialized"))
197
+ void listen();
198
+ // A stream that ended early must still answer every request, or the agent waits forever.
199
+ fail("Toolstile closed the connection before answering. Try again.");
200
+ }
201
+ catch (error) {
202
+ if (closed)
203
+ return fail("The agent closed the connection.");
204
+ const message = `Could not reach Toolstile: ${error.message}`;
205
+ options.log(message);
206
+ fail(message);
207
+ }
208
+ }
209
+ return new Promise((resolve) => {
210
+ const lines = createInterface({ input: options.input, crlfDelay: Number.POSITIVE_INFINITY });
211
+ lines.on("line", (line) => {
212
+ if (!line.trim())
213
+ return;
214
+ const opens = /"method"\s*:\s*"initialize"/.test(line);
215
+ const task = (opens ? send(line) : initialized.then(() => send(line))).finally(() => pending.delete(task));
216
+ if (opens)
217
+ initialized = task;
218
+ pending.add(task);
219
+ });
220
+ lines.on("close", async () => {
221
+ // Let replies already on their way finish, but never hang on a long call or stream.
222
+ const grace = setTimeout(() => {
223
+ closed = true;
224
+ shutdown.abort();
225
+ }, CLOSE_GRACE_MS);
226
+ await Promise.allSettled([...pending]);
227
+ clearTimeout(grace);
228
+ closed = true;
229
+ shutdown.abort();
230
+ if (sessionId)
231
+ await fetcher(options.endpoint, { method: "DELETE", headers: headers(), redirect: "error" }).catch(() => undefined);
232
+ resolve();
233
+ });
234
+ });
235
+ }
package/dist/sources.d.ts CHANGED
@@ -7,6 +7,7 @@ export interface ScanOptions {
7
7
  env: Record<string, string | undefined>;
8
8
  }
9
9
  export declare const CLIENT_NAMES: Record<ClientId, string>;
10
+ export declare function appData({ home, platform, env }: Omit<ScanOptions, "cwd">): string;
10
11
  /** Appends a key to a pointer, quoting keys that are not plain identifiers. */
11
12
  export declare function pointerJoin(base: string, key: string): string;
12
13
  /**
package/dist/sources.js CHANGED
@@ -11,7 +11,7 @@ export const CLIENT_NAMES = {
11
11
  gemini: "Gemini CLI",
12
12
  cline: "Cline",
13
13
  };
14
- function appData({ home, platform, env }) {
14
+ export function appData({ home, platform, env }) {
15
15
  if (platform === "darwin")
16
16
  return join(home, "Library", "Application Support");
17
17
  if (platform === "win32")