gaslamp 1.1.0 → 2.1.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,56 @@
1
+ // threads.mjs — named session aliases: the handle you can actually remember.
2
+ //
3
+ // A thread is a tiny pointer file mapping <backend, name> → session id, bound
4
+ // the moment a fresh consult reports its session and re-pointed on every
5
+ // consult after. `--thread spar` therefore means: continue the spar if it
6
+ // exists, start (and bind) it if it doesn't — idempotent, no fishing UUIDs
7
+ // out of old trailers. This matters doubly on claude, where every resume
8
+ // forks a NEW session id: the pointer tracks the latest one automatically,
9
+ // which manual --resume juggling cannot.
10
+ //
11
+ // Deliberately NOT a conversation manager: one file, one pointer, no history
12
+ // (the job records already hold that).
13
+
14
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { HOME } from "./jobs.mjs";
17
+
18
+ export const threadsDir = () => join(HOME(), "threads");
19
+ const threadPath = (backend, name) => join(threadsDir(), `${backend}--${name}.json`);
20
+
21
+ // Slug-shaped on purpose: thread names ride in file names and shell commands.
22
+ export const validThreadName = (s) => /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(String(s));
23
+
24
+ export function readThread(backend, name) {
25
+ try { return JSON.parse(readFileSync(threadPath(backend, name), "utf8")); }
26
+ catch { return null; }
27
+ }
28
+
29
+ export function writeThread(t) {
30
+ mkdirSync(threadsDir(), { recursive: true });
31
+ writeFileSync(threadPath(t.backend, t.name), JSON.stringify(t, null, 2) + "\n");
32
+ }
33
+
34
+ export function listThreads() {
35
+ let files;
36
+ try { files = readdirSync(threadsDir()); } catch { return []; }
37
+ return files.filter((f) => f.endsWith(".json"))
38
+ .map((f) => { try { return JSON.parse(readFileSync(join(threadsDir(), f), "utf8")); } catch { return null; } })
39
+ .filter(Boolean)
40
+ .sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")));
41
+ }
42
+
43
+ // gaslamp threads — the named spars, latest activity first.
44
+ export function runThreads() {
45
+ const ts = listThreads();
46
+ if (!ts.length) { console.log(`no threads yet under ${threadsDir()} — start one with --thread <name>`); return; }
47
+ for (const t of ts) {
48
+ console.log([
49
+ String(t.name ?? "?").padEnd(24),
50
+ String(t.backend ?? "?").padEnd(7),
51
+ String(t.sessionId ?? "-").slice(0, 8).padEnd(9),
52
+ String(t.lastJobId ?? "-").padEnd(23),
53
+ t.updatedAt ?? "",
54
+ ].join(" "));
55
+ }
56
+ }
package/src/doctor.mjs DELETED
@@ -1,61 +0,0 @@
1
- // doctor.mjs — non-invasive health check for a gaslamp install. Verifies the
2
- // pieces are present and both directions are registered, without spending an
3
- // actual Claude or Codex round-trip. Exits nonzero if anything critical is off.
4
-
5
- import { spawnSync } from "node:child_process";
6
- import { existsSync } from "node:fs";
7
- import { homedir } from "node:os";
8
- import { join } from "node:path";
9
- import { which } from "./which.mjs";
10
-
11
- function resolveClaude() {
12
- if (process.env.CLAUDE_BIN && existsSync(process.env.CLAUDE_BIN)) return process.env.CLAUDE_BIN;
13
- const onPath = which("claude");
14
- if (onPath) return onPath;
15
- const local = join(homedir(), ".local/bin/claude");
16
- return existsSync(local) ? local : null;
17
- }
18
-
19
- // Read `<cli> mcp list` and report whether a server named `gaslamp` is present.
20
- function registered(cli) {
21
- const r = spawnSync(cli, ["mcp", "list"], { encoding: "utf8" });
22
- return /(^|\s)gaslamp(\s|:|$)/m.test((r.stdout || "") + (r.stderr || ""));
23
- }
24
-
25
- export function runDoctor() {
26
- const checks = [];
27
- const add = (pass, label, detail) => checks.push({ pass, label, detail });
28
-
29
- // Node
30
- const major = Number(process.versions.node.split(".")[0]);
31
- add(major >= 18, "node", `${process.version}${major < 18 ? " (need >= 18)" : ""}`);
32
-
33
- // Binaries
34
- const claude = resolveClaude();
35
- add(!!claude, "claude binary", claude || "not found (set CLAUDE_BIN or add to PATH)");
36
- const codex = which("codex");
37
- add(!!codex, "codex binary", codex || "not found on PATH");
38
-
39
- // Registrations (only checkable if the relevant CLI exists)
40
- if (codex) {
41
- const reg = registered("codex");
42
- add(reg, "codex -> claude", reg ? "gaslamp registered in Codex" : "not registered — run `gaslamp setup`");
43
- }
44
- if (claude) {
45
- const reg = registered("claude");
46
- add(reg, "claude -> codex", reg ? "gaslamp registered in Claude" : "not registered — run `gaslamp setup`");
47
- }
48
-
49
- let allGood = true;
50
- for (const c of checks) {
51
- process.stdout.write(` ${c.pass ? "✓" : "✗"} ${c.label}: ${c.detail}\n`);
52
- if (!c.pass) allGood = false;
53
- }
54
- console.log();
55
- if (allGood) {
56
- console.log("all good. (doctor doesn't round-trip; for a live check, consult from one agent to the other.)");
57
- } else {
58
- console.error("some checks failed — see above.");
59
- process.exitCode = 1;
60
- }
61
- }
package/src/server.mjs DELETED
@@ -1,281 +0,0 @@
1
- // server.mjs — let Codex consult Claude over MCP, built as a mirror image of
2
- // Codex's native `codex mcp-server` so the two directions are symmetric. Either
3
- // agent can hand work to the other for a review, a second opinion, or a fix.
4
- //
5
- // Tools exposed to Codex (mirror of codex / codex-reply):
6
- // gaslamp start a Claude session (review / fix / second opinion)
7
- // gaslamp-reply continue a prior consultation by sessionId
8
- //
9
- // Symmetry with `codex mcp-server`:
10
- // - Per-call `sandbox` arg; OMITTING it defers to the user's own Claude config
11
- // (~/.claude settings: permission defaultMode, allow/deny rules, model, MCP,
12
- // CLAUDE.md), the mirror of Codex deferring to `sandbox_mode` in config.toml.
13
- // - No bespoke timeout — a consulted Claude loads the user's full config/MCP,
14
- // exactly as a consulted Codex does.
15
- // - Recursion guard (default on): a consult is one hop. The Claude we spawn is
16
- // denied the Claude→Codex bridge (`--disallowedTools mcp__gaslamp`, a deny
17
- // rule that holds even under --dangerously-skip-permissions), and if THIS
18
- // server is itself running under a consulted Codex (GASLAMP_NESTED, set on
19
- // the Claude→Codex registration by `gaslamp setup`) it refuses the call.
20
- // GASLAMP_ALLOW_RECURSION=1 restores the old unbounded symmetric handoff.
21
- // - The lone Claude-only step (no Codex analog, so not an asymmetry):
22
- // ANTHROPIC_API_KEY is stripped from the child env so keychain OAuth is
23
- // authoritative. A stale env key 401s every call otherwise.
24
- //
25
- // Claude has no filesystem-scoped sandbox, so there is no honest `workspace-write`
26
- // middle ground (it would map to the same full access as danger-full-access). The
27
- // `sandbox` arg therefore has two explicit overrides, and omitting it is the
28
- // default:
29
- // - omitted → pass no permission flag; the consulted Claude uses
30
- // the user's own ~/.claude config (see above). Since
31
- // `claude -p` loads settings by default, this is exactly
32
- // how the user's interactive Claude would behave here.
33
- // - read-only → --permission-mode default --allowedTools <read set>,
34
- // which overrides the user's defaultMode (even bypass)
35
- // and restricts to read tools (advisory reviewer).
36
- // - danger-full-access → --dangerously-skip-permissions (force full read/write).
37
- //
38
- // Zero dependencies. Newline-delimited JSON-RPC over stdio (MCP stdio transport).
39
-
40
- import { spawn } from "node:child_process";
41
- import { existsSync, appendFileSync, readFileSync } from "node:fs";
42
- import { homedir } from "node:os";
43
- import { join, dirname } from "node:path";
44
- import { fileURLToPath } from "node:url";
45
-
46
- // Single source of truth for the version: package.json. Read at runtime (rather
47
- // than imported with an attribute) so it works across the whole engines range.
48
- const VERSION = (() => {
49
- try {
50
- const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
51
- return JSON.parse(readFileSync(pkgPath, "utf8")).version || "0.0.0";
52
- } catch {
53
- return "0.0.0";
54
- }
55
- })();
56
-
57
- const SANDBOXES = ["read-only", "danger-full-access"];
58
- // Tools permitted under the `read-only` override. Pure read set by default; set
59
- // GASLAMP_ALLOWED_TOOLS (space- or comma-separated) to widen it, e.g. add
60
- // `Bash(git diff:*)`. This is the ONLY thing this var affects now — there is no
61
- // bespoke default sandbox; an omitted `sandbox` defers to the user's own config.
62
- const ALLOWED_TOOLS = (process.env.GASLAMP_ALLOWED_TOOLS || "Read Grep Glob WebFetch WebSearch")
63
- .split(/[\s,]+/).filter(Boolean).join(",");
64
- const DEBUG = !!process.env.GASLAMP_DEBUG;
65
-
66
- // Recursion guard: a consult is one hop unless explicitly opted out of.
67
- // ALLOW_RECURSION — restore the old symmetric, unbounded handoff.
68
- // NESTED — this server is running under a Codex that is itself a
69
- // gaslamp consult (tagged GASLAMP_NESTED=1 on the
70
- // Claude→Codex registration); refuse to hand work back.
71
- const ALLOW_RECURSION = !!process.env.GASLAMP_ALLOW_RECURSION;
72
- const NESTED = !!process.env.GASLAMP_NESTED && !ALLOW_RECURSION;
73
-
74
- // Persistent consultation transcript (the lightweight "watch them talk" log).
75
- // Defaults to ~/.codex/gaslamp.log; set GASLAMP_LOGFILE=off to disable.
76
- const LOGFILE = process.env.GASLAMP_LOGFILE === "off" ? null
77
- : (process.env.GASLAMP_LOGFILE || join(homedir(), ".codex", "gaslamp.log"));
78
- const ts = () => new Date().toISOString();
79
- function log(...a) { if (DEBUG) process.stderr.write("[gaslamp] " + a.join(" ") + "\n"); }
80
- function transcript(line) { if (!LOGFILE) return; try { appendFileSync(LOGFILE, ts() + " " + line + "\n"); } catch {} }
81
- const clip = (s, n = 200) => { s = String(s).replace(/\s+/g, " ").trim(); return s.length > n ? s.slice(0, n) + "…" : s; };
82
-
83
- function resolveClaude() {
84
- const cands = [process.env.CLAUDE_BIN, join(homedir(), ".local/bin/claude")].filter(Boolean);
85
- for (const c of cands) if (existsSync(c)) return c;
86
- return "claude"; // last resort: rely on PATH
87
- }
88
- const CLAUDE_BIN = resolveClaude();
89
-
90
- // ---- MCP plumbing -----------------------------------------------------------
91
- function send(msg) { process.stdout.write(JSON.stringify(msg) + "\n"); }
92
- function reply(id, result) { send({ jsonrpc: "2.0", id, result }); }
93
- function fail(id, code, message) { send({ jsonrpc: "2.0", id, error: { code, message } }); }
94
-
95
- // Refuse a consult that would be a second hop: this server runs under a Codex
96
- // that is itself a gaslamp consult. Returned as a readable tool result (not a
97
- // protocol error) so the consulting Codex sees why, mirroring the claude-error
98
- // shape in runClaude.
99
- function refuseNested(id, name) {
100
- const text =
101
- "Recursive gaslamp consultation is disabled. This Codex is itself a gaslamp " +
102
- "consult (GASLAMP_NESTED is set), so it can't hand work back to Claude — a " +
103
- "consult is one hop. Set GASLAMP_ALLOW_RECURSION=1 to allow nested handoffs.";
104
- log("refuse nested", name);
105
- transcript(`← refused ${name} (nested; recursion disabled)`);
106
- reply(id, { content: [{ type: "text", text }], structuredContent: { sessionId: null, content: text }, isError: true });
107
- }
108
-
109
- // Mirrors codex's `{ threadId, content }` outputSchema. sessionId may be null on
110
- // a hard failure, so only `content` is required.
111
- const OUTPUT_SCHEMA = {
112
- type: "object",
113
- properties: {
114
- sessionId: { type: "string", description: "The Claude session id; pass to gaslamp-reply to continue." },
115
- content: { type: "string" },
116
- },
117
- required: ["content"],
118
- };
119
-
120
- const SANDBOX_DESC =
121
- "Permission override. Omit to use the user's own Claude config (their permission " +
122
- "default, allow/deny rules, model, MCP) — this is the default. `read-only` forces " +
123
- "an advisory, no-edit reviewer; `danger-full-access` forces full read/write. " +
124
- "(Claude has no filesystem sandbox, so there is no honest `workspace-write`.)";
125
-
126
- const TOOLS = [
127
- {
128
- name: "gaslamp",
129
- title: "Claude",
130
- // Mirrors codex mcp-server's `codex` description ("Run a Codex session.
131
- // Accepts configuration parameters matching the Codex Config struct.") so the
132
- // two directions read as twins. "config" (not "Config struct") because Claude
133
- // Code has no such struct and gaslamp takes no `config` param — honest mirror.
134
- description:
135
- "Run a Claude session. Accepts configuration parameters matching the Claude Code config.",
136
- inputSchema: {
137
- type: "object",
138
- additionalProperties: false,
139
- properties: {
140
- prompt: { type: "string", description: "The initial prompt for Claude. Include the relevant context/paths." },
141
- cwd: { type: "string", description: "Working directory for the session. If relative, resolved against the server process's cwd." },
142
- model: { type: "string", description: "Optional Claude model id or alias (e.g. 'sonnet', 'opus'). Omit for the account default." },
143
- sandbox: { type: "string", enum: SANDBOXES, description: SANDBOX_DESC },
144
- },
145
- required: ["prompt"],
146
- },
147
- outputSchema: OUTPUT_SCHEMA,
148
- },
149
- {
150
- name: "gaslamp-reply",
151
- title: "Claude Reply",
152
- // Mirrors codex's `codex-reply`: "Continue a Codex conversation by providing
153
- // the thread id and prompt." (thread id → session id).
154
- description: "Continue a Claude conversation by providing the session id and prompt.",
155
- inputSchema: {
156
- type: "object",
157
- additionalProperties: false,
158
- properties: {
159
- sessionId: { type: "string", description: "The sessionId returned by a prior gaslamp / gaslamp-reply call." },
160
- prompt: { type: "string", description: "The next prompt to continue the Claude conversation." },
161
- cwd: { type: "string", description: "Working directory. If relative, resolved against the server process's cwd." },
162
- model: { type: "string", description: "Optional Claude model id or alias." },
163
- sandbox: { type: "string", enum: SANDBOXES, description: SANDBOX_DESC },
164
- },
165
- required: ["sessionId", "prompt"],
166
- },
167
- outputSchema: OUTPUT_SCHEMA,
168
- },
169
- ];
170
-
171
- function runClaude({ prompt, cwd, model, sandbox, resume }, id) {
172
- const mode = SANDBOXES.includes(sandbox) ? sandbox : null; // null = defer to user's config
173
- const modeLabel = mode || "user-config";
174
- const args = ["-p", prompt, "--output-format", "json"];
175
- if (mode === "read-only") {
176
- // --permission-mode default overrides the user's defaultMode (even bypass),
177
- // and the allowlist restricts to read tools; anything else is denied in
178
- // headless. A real read-only that holds regardless of the user's config.
179
- args.push("--permission-mode", "default", "--allowedTools", ALLOWED_TOOLS);
180
- } else if (mode === "danger-full-access") {
181
- args.push("--dangerously-skip-permissions");
182
- }
183
- // else (omitted): pass no permission flag — the consulted Claude uses the
184
- // user's own ~/.claude config, mirroring Codex deferring to config.toml.
185
-
186
- // Recursion guard: deny the consulted Claude the Claude→Codex bridge so it
187
- // can't open a further sub-session. A bare server name removes all of
188
- // gaslamp's tools from its context, and deny rules hold even under
189
- // --dangerously-skip-permissions. GASLAMP_ALLOW_RECURSION=1 opts back in.
190
- if (!ALLOW_RECURSION) args.push("--disallowedTools", "mcp__gaslamp");
191
- if (resume) args.push("--resume", resume);
192
- if (model) args.push("--model", model);
193
-
194
- // Strip ANTHROPIC_API_KEY so the consulted Claude authenticates via keychain
195
- // OAuth. This has no Codex analog (Codex doesn't read this var), so it isn't an
196
- // asymmetry — it's what makes Claude's auth "just work" from its keychain the
197
- // way Codex's does. A stale/invalid env key would otherwise 401 every call.
198
- const env = { ...process.env };
199
- delete env.ANTHROPIC_API_KEY;
200
-
201
- const t0 = Date.now();
202
- log("spawn", CLAUDE_BIN, "mode=" + modeLabel, "resume=" + (resume || "-"), "cwd=" + (cwd || process.cwd()));
203
- transcript(`→ codex asks claude${resume ? " (reply " + resume.slice(0, 8) + ")" : ""} [${modeLabel}] @ ${cwd || process.cwd()}: ${clip(prompt)}`);
204
- const child = spawn(CLAUDE_BIN, args, { cwd: cwd || process.cwd(), env, stdio: ["ignore", "pipe", "pipe"] });
205
-
206
- let out = "", err = "", done = false;
207
- const finish = (fn) => { if (done) return; done = true; fn(); };
208
- child.stdout.on("data", (d) => (out += d));
209
- child.stderr.on("data", (d) => (err += d));
210
- child.on("error", (e) => finish(() => fail(id, -32000, `Failed to launch claude: ${e.message}`)));
211
- child.on("close", (code) => finish(() => {
212
- log("close code=" + code + " ms=" + (Date.now() - t0) + " outlen=" + out.length + " errlen=" + err.length);
213
- let parsed;
214
- try { parsed = JSON.parse(out); } catch { parsed = null; }
215
- const r = Array.isArray(parsed) ? parsed.find((m) => m && m.type === "result") : parsed;
216
- const text = r ? (r.result ?? r.content ?? JSON.stringify(r)) : (out.trim() || err.trim() || `claude exited ${code} with no output`);
217
- const sessionId = r?.session_id ?? resume ?? null;
218
- const isError = r ? !!r.is_error : code !== 0;
219
- transcript(`← claude ${isError ? "ERR" : "ok"} (${Date.now() - t0}ms, session ${sessionId ? sessionId.slice(0, 8) : "-"}): ${clip(text)}`);
220
- reply(id, {
221
- content: [{ type: "text", text: String(text) }],
222
- structuredContent: { sessionId, content: String(text) },
223
- isError,
224
- });
225
- }));
226
- }
227
-
228
- function handle(msg) {
229
- const { id, method, params } = msg;
230
- if (method === "initialize") {
231
- reply(id, {
232
- protocolVersion: params?.protocolVersion || "2025-06-18",
233
- capabilities: { tools: {} },
234
- serverInfo: { name: "gaslamp", title: "Claude", version: VERSION },
235
- });
236
- } else if (method?.startsWith("notifications/")) {
237
- /* no response to notifications */
238
- } else if (method === "ping") {
239
- reply(id, {});
240
- } else if (method === "tools/list") {
241
- reply(id, { tools: TOOLS });
242
- } else if (method === "tools/call") {
243
- const name = params?.name;
244
- const a = params?.arguments || {};
245
- if (NESTED && (name === "gaslamp" || name === "gaslamp-reply")) return refuseNested(id, name);
246
- if (name === "gaslamp") {
247
- if (!a.prompt) return fail(id, -32602, "missing required arg: prompt");
248
- runClaude({ prompt: a.prompt, cwd: a.cwd, model: a.model, sandbox: a.sandbox }, id);
249
- } else if (name === "gaslamp-reply") {
250
- if (!a.sessionId || !a.prompt) return fail(id, -32602, "missing required arg: sessionId and/or prompt");
251
- runClaude({ prompt: a.prompt, cwd: a.cwd, model: a.model, sandbox: a.sandbox, resume: a.sessionId }, id);
252
- } else {
253
- fail(id, -32601, `unknown tool: ${name}`);
254
- }
255
- } else if (id !== undefined) {
256
- fail(id, -32601, `unknown method: ${method}`);
257
- }
258
- }
259
-
260
- // ---- stdio loop -------------------------------------------------------------
261
- // Start the MCP server: read newline-delimited JSON-RPC on stdin, write
262
- // responses on stdout. Blocks (keeps the process alive) until stdin closes.
263
- export function startServer() {
264
- let buf = "";
265
- process.stdin.on("data", (d) => {
266
- buf += d.toString();
267
- let i;
268
- while ((i = buf.indexOf("\n")) >= 0) {
269
- const line = buf.slice(0, i).trim();
270
- buf = buf.slice(i + 1);
271
- if (!line) continue;
272
- log("recv", line.slice(0, 300));
273
- try { handle(JSON.parse(line)); }
274
- catch (e) { log("parse error", e.message, "on", line.slice(0, 120)); }
275
- }
276
- });
277
- process.stdin.on("end", () => process.exit(0));
278
- log("ready; claude=" + CLAUDE_BIN + " version=" + VERSION);
279
- }
280
-
281
- export { VERSION, TOOLS };