@gleapai/kai-bridge 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,116 @@
1
+ #!/usr/bin/env node
2
+ // Minimal stdio MCP server exposing ONE tool: `todo_write` — the todo
3
+ // bridge that stands in for Claude Code's native task tools. Zero
4
+ // dependencies; speaks newline-delimited JSON-RPC 2.0 per the MCP
5
+ // stdio transport (same skeleton as ask-user-mcp.mjs).
6
+ //
7
+ // Why it exists: the SDK's own task tools (TaskCreate/TaskUpdate — the
8
+ // TodoWrite successors) drop out of resumed ACP sessions on BYO
9
+ // machines (the CLI's deferred-tool reconciliation removes them on
10
+ // every resume when the ambient claude.ai connector set is loaded), and
11
+ // Codex never had a todo tool at all. This server gives every harness a
12
+ // stable, schema'd todo tool; the runner's mapper folds its calls onto
13
+ // the canonical TodoWrite handling and emits the dashboard's `todos`
14
+ // events. The tool itself is a signalling no-op — the INPUT is the
15
+ // product.
16
+
17
+ import { createInterface } from "node:readline";
18
+
19
+ const TOOL = {
20
+ name: "todo_write",
21
+ description:
22
+ "Publish your CURRENT task list to the user's dashboard. Pass the " +
23
+ "FULL updated list every time (not a delta) whenever you start, " +
24
+ "finish, or add a step. Use this to keep the user oriented during " +
25
+ "multi-step work.",
26
+ inputSchema: {
27
+ type: "object",
28
+ properties: {
29
+ todos: {
30
+ type: "array",
31
+ minItems: 1,
32
+ items: {
33
+ type: "object",
34
+ properties: {
35
+ content: {
36
+ type: "string",
37
+ description: "Imperative description of the task.",
38
+ },
39
+ status: {
40
+ type: "string",
41
+ enum: ["pending", "in_progress", "completed"],
42
+ description: "Current state of this task.",
43
+ },
44
+ priority: {
45
+ type: "string",
46
+ enum: ["high", "medium", "low"],
47
+ description: "Optional priority.",
48
+ },
49
+ },
50
+ required: ["content", "status"],
51
+ },
52
+ },
53
+ },
54
+ required: ["todos"],
55
+ },
56
+ };
57
+
58
+ function send(message) {
59
+ process.stdout.write(`${JSON.stringify(message)}\n`);
60
+ }
61
+
62
+ function reply(id, result) {
63
+ send({ jsonrpc: "2.0", id, result });
64
+ }
65
+
66
+ function replyError(id, code, message) {
67
+ send({ jsonrpc: "2.0", id, error: { code, message } });
68
+ }
69
+
70
+ const rl = createInterface({ input: process.stdin, terminal: false });
71
+ rl.on("line", (line) => {
72
+ const trimmed = line.trim();
73
+ if (!trimmed) return;
74
+ let msg;
75
+ try {
76
+ msg = JSON.parse(trimmed);
77
+ } catch {
78
+ return;
79
+ }
80
+ const { id, method } = msg ?? {};
81
+ if (typeof method !== "string") return;
82
+
83
+ if (method === "initialize") {
84
+ reply(id, {
85
+ protocolVersion: msg.params?.protocolVersion ?? "2025-06-18",
86
+ capabilities: { tools: {} },
87
+ serverInfo: { name: "kai-todos", version: "1.0.0" },
88
+ });
89
+ return;
90
+ }
91
+ if (method === "notifications/initialized" || id == null) {
92
+ return; // notifications need no response
93
+ }
94
+ if (method === "tools/list") {
95
+ reply(id, { tools: [TOOL] });
96
+ return;
97
+ }
98
+ if (method === "tools/call") {
99
+ if (msg.params?.name !== TOOL.name) {
100
+ replyError(id, -32602, `unknown tool: ${msg.params?.name}`);
101
+ return;
102
+ }
103
+ const todos = msg.params?.arguments?.todos;
104
+ const count = Array.isArray(todos) ? todos.length : 0;
105
+ reply(id, {
106
+ content: [
107
+ {
108
+ type: "text",
109
+ text: `Todo list updated (${count} task${count === 1 ? "" : "s"}). Continue working.`,
110
+ },
111
+ ],
112
+ });
113
+ return;
114
+ }
115
+ replyError(id, -32601, `unknown method: ${method}`);
116
+ });
@@ -0,0 +1,24 @@
1
+ // npm postinstall — hand off to the guided setup when a human can see it.
2
+ //
3
+ // npm ≥7 runs lifecycle scripts with stdio piped (nothing shows, stdin is
4
+ // not a TTY), so in a normal `npm i -g` this exits silently and the user's
5
+ // first `kai-bridge` runs the wizard instead. With `--foreground-scripts`
6
+ // (or npm 6) stdio is inherited and the wizard starts right away —
7
+ // re-running the install re-runs the setup, as intended.
8
+ //
9
+ // MUST never fail or block an install: CI, docker builds, and dependency
10
+ // installs all run this too.
11
+ try {
12
+ const interactive = process.stdin.isTTY && process.stdout.isTTY && !process.env.CI;
13
+ const isGlobal = process.env.npm_config_global === "true";
14
+ if (interactive && isGlobal) {
15
+ const { runSetup } = await import("../src/setup.mjs");
16
+ const { fileURLToPath } = await import("node:url");
17
+ const { join, dirname } = await import("node:path");
18
+ const binPath = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "kai-bridge.mjs");
19
+ await runSetup({ binPath });
20
+ }
21
+ } catch {
22
+ // Setup can always be run later — never break `npm i`.
23
+ }
24
+ process.exit(0);
package/src/api.mjs ADDED
@@ -0,0 +1,141 @@
1
+ // Thin REST client for the Gleap bridge endpoints (device-token auth).
2
+ //
3
+ // POST /gleapcode/bridge/pair/start → { code, pollToken, url } (no auth)
4
+ // POST /gleapcode/bridge/pair/poll → { status, device?, token? } (pollToken)
5
+ // PUT /gleapcode/bridge/devices/me/hello { name, platform, version, profiles, repos, roots }
6
+ // POST /gleapcode/bridge/devices/me/heartbeat { running: [turnIds] }
7
+ // POST /gleapcode/bridge/turns/:id/events { events: [contract lines] }
8
+ // POST /gleapcode/bridge/turns/:id/result { result, changes, status }
9
+ // POST /users/me/pusher { socket_id, channel_name } (channel auth)
10
+
11
+ export class BridgeApi {
12
+ constructor({ apiBase, token, fetchImpl = fetch }) {
13
+ this.apiBase = String(apiBase).replace(/\/+$/, "");
14
+ this.token = token;
15
+ this.fetch = fetchImpl;
16
+ }
17
+
18
+ /**
19
+ * One HTTP call. Always time-bounded: a laptop that switches wifi or
20
+ * wakes onto a captive portal leaves sockets black-holed, and undici's
21
+ * default would park this await for five minutes — long enough to
22
+ * stall the event batcher and look like a hung turn.
23
+ */
24
+ async request(method, path, body, { auth = true, headers = {}, timeoutMs = 30_000 } = {}) {
25
+ const res = await this.fetch(`${this.apiBase}${path}`, {
26
+ method,
27
+ headers: {
28
+ "content-type": "application/json",
29
+ ...(auth && this.token ? { authorization: `Bearer ${this.token}` } : {}),
30
+ ...headers,
31
+ },
32
+ body: body === undefined ? undefined : JSON.stringify(body),
33
+ signal: AbortSignal.timeout(timeoutMs),
34
+ });
35
+ const text = await res.text();
36
+ let data = null;
37
+ try {
38
+ data = text ? JSON.parse(text) : null;
39
+ } catch {
40
+ data = { raw: text };
41
+ }
42
+ if (!res.ok) {
43
+ const err = new Error(`${method} ${path} → ${res.status}: ${data?.message ?? data?.error ?? text.slice(0, 200)}`);
44
+ err.status = res.status;
45
+ err.data = data;
46
+ throw err;
47
+ }
48
+ return data;
49
+ }
50
+
51
+ /**
52
+ * Retry a report until it lands. Losing one of these is worse than
53
+ * being slow: a dropped `turnResult` leaves the session running
54
+ * forever, and a 5xx during a deploy is routine. 4xx (except 429) is
55
+ * permanent — a revoked token or an ended turn — so it stops there.
56
+ */
57
+ async requestWithRetry(method, path, body, { tries = 6, onRetry } = {}) {
58
+ let delay = 1_000;
59
+ for (let attempt = 1; ; attempt += 1) {
60
+ try {
61
+ return await this.request(method, path, body);
62
+ } catch (err) {
63
+ const permanent = err.status >= 400 && err.status < 500 && err.status !== 429;
64
+ if (permanent || attempt >= tries) throw err;
65
+ onRetry?.(err, attempt, delay);
66
+ await new Promise((r) => setTimeout(r, delay));
67
+ delay = Math.min(delay * 2, 30_000);
68
+ }
69
+ }
70
+ }
71
+
72
+ pairStart(device) {
73
+ return this.request("POST", "/gleapcode/bridge/pair/start", device, { auth: false });
74
+ }
75
+ pairPoll(pollToken) {
76
+ return this.request("POST", "/gleapcode/bridge/pair/poll", { pollToken }, { auth: false });
77
+ }
78
+ hello(payload) {
79
+ return this.request("PUT", "/gleapcode/bridge/devices/me/hello", payload);
80
+ }
81
+ /** Revoke THIS device's pairing (the CLI `logout`). */
82
+ logout() {
83
+ return this.request("POST", "/gleapcode/bridge/devices/me/logout", {});
84
+ }
85
+ heartbeat(payload) {
86
+ return this.request("POST", "/gleapcode/bridge/devices/me/heartbeat", payload);
87
+ }
88
+ turnEvents(turnId, events) {
89
+ return this.request("POST", `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/events`, { events });
90
+ }
91
+ turnPreview(turnId, payload) {
92
+ return this.request("POST", `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/preview`, payload);
93
+ }
94
+ /** Session-keyed preview report — works after every turn has ended
95
+ * (on-demand Start/Stop preview from the dashboard). */
96
+ sessionPreview(sessionId, payload) {
97
+ return this.request("POST", `/gleapcode/bridge/sessions/${encodeURIComponent(sessionId)}/preview`, payload);
98
+ }
99
+ turnResult(turnId, payload, opts) {
100
+ return this.requestWithRetry(
101
+ "POST",
102
+ `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/result`,
103
+ payload,
104
+ opts
105
+ );
106
+ }
107
+
108
+ /** Turns the server still believes this device is running. */
109
+ pendingTurns() {
110
+ return this.request("GET", "/gleapcode/bridge/devices/me/pending");
111
+ }
112
+ commandAck(commandId, payload) {
113
+ return this.request("POST", `/gleapcode/bridge/commands/${encodeURIComponent(commandId)}/ack`, payload);
114
+ }
115
+ }
116
+
117
+ /** Batches contract events so a chatty turn doesn't POST per line. */
118
+ export function createEventBatcher({ api, turnId, flushMs = 400, maxBatch = 50, onError = () => {} }) {
119
+ let queue = [];
120
+ let timer = null;
121
+ let inflight = Promise.resolve();
122
+ const flush = () => {
123
+ if (timer) {
124
+ clearTimeout(timer);
125
+ timer = null;
126
+ }
127
+ if (queue.length === 0) return inflight;
128
+ const batch = queue;
129
+ queue = [];
130
+ inflight = inflight.then(() => api.turnEvents(turnId, batch)).catch(onError);
131
+ return inflight;
132
+ };
133
+ return {
134
+ push(event) {
135
+ queue.push(event);
136
+ if (queue.length >= maxBatch) flush();
137
+ else if (!timer) timer = setTimeout(flush, flushMs);
138
+ },
139
+ flush,
140
+ };
141
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,76 @@
1
+ // Bridge state on disk: `~/.kai/` (override with KAI_HOME).
2
+ //
3
+ // config.json — device identity + pairing token (mode 0600), API base,
4
+ // scan roots, profiles, primary-checkout overrides,
5
+ // per-repo last mode.
6
+ // accounts/ — managed harness profiles (see profiles.mjs)
7
+ // worktrees/ — session worktrees (see workspace.mjs)
8
+ // state/ — harness config dirs used by the ACP runner (transcripts)
9
+ // logs/ — daemon log
10
+ //
11
+ // The pairing token is the only secret the bridge stores; harness logins
12
+ // stay inside the harnesses' own dirs and are never read or uploaded.
13
+
14
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { homedir, hostname, platform } from "node:os";
16
+ import { join } from "node:path";
17
+
18
+ export const KAI_HOME = process.env.KAI_HOME || join(homedir(), ".kai");
19
+ export const CONFIG_PATH = join(KAI_HOME, "config.json");
20
+ export const DEFAULT_API_BASE = process.env.KAI_API_BASE || "https://api.gleap.io/v3";
21
+ export const DEFAULT_APP_BASE = process.env.KAI_APP_BASE || "https://app.gleap.io";
22
+ export const DEFAULT_REALTIME = {
23
+ appKey: process.env.KAI_REALTIME_APP_KEY || "29b0a09928856b262405",
24
+ wsHost: process.env.KAI_SOCKUDO_HOST || "sockets.gleap.io",
25
+ };
26
+
27
+ export function defaultConfig() {
28
+ return {
29
+ version: 1,
30
+ apiBase: DEFAULT_API_BASE,
31
+ appBase: DEFAULT_APP_BASE,
32
+ realtime: DEFAULT_REALTIME,
33
+ device: null, // { id, name, token, organisationId, userId }
34
+ roots: [], // extra scan roots
35
+ profiles: [
36
+ { id: "claude-ambient", harness: "claude", kind: "ambient", label: "Claude (this machine)" },
37
+ { id: "codex-ambient", harness: "codex", kind: "ambient", label: "Codex (this machine)" },
38
+ { id: "cursor-ambient", harness: "cursor", kind: "ambient", label: "Cursor (this machine)" },
39
+ ],
40
+ primaryOverrides: {}, // repoKey → path
41
+ repoModes: {}, // repoKey → 'worktree' | 'local'
42
+ };
43
+ }
44
+
45
+ export function ensureHome() {
46
+ for (const d of ["", "accounts", "worktrees", "state", "logs"]) mkdirSync(join(KAI_HOME, d), { recursive: true });
47
+ }
48
+
49
+ export function loadConfig() {
50
+ ensureHome();
51
+ if (!existsSync(CONFIG_PATH)) return defaultConfig();
52
+ try {
53
+ const parsed = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
54
+ return { ...defaultConfig(), ...parsed, realtime: { ...DEFAULT_REALTIME, ...(parsed.realtime || {}) } };
55
+ } catch {
56
+ return defaultConfig();
57
+ }
58
+ }
59
+
60
+ export function saveConfig(config, kaiHome = KAI_HOME) {
61
+ // Always write into the caller's home — a daemon constructed with an
62
+ // injected kaiHome (tests, embedded hosts) must never touch the real
63
+ // ~/.kai/config.json. (An e2e run once replaced a live pairing.)
64
+ mkdirSync(kaiHome, { recursive: true });
65
+ const path = join(kaiHome, "config.json");
66
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
67
+ try {
68
+ chmodSync(path, 0o600);
69
+ } catch {
70
+ /* windows */
71
+ }
72
+ }
73
+
74
+ export function deviceDefaults() {
75
+ return { name: hostname().replace(/\.local$/, ""), platform: platform() };
76
+ }