jarvis-hq 0.0.3

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/dist/jarvis.js ADDED
@@ -0,0 +1,341 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.jarvisDir = jarvisDir;
4
+ exports.projectsDir = projectsDir;
5
+ exports.ensureJarvisDir = ensureJarvisDir;
6
+ exports.slugify = slugify;
7
+ exports.projectFile = projectFile;
8
+ exports.listProjects = listProjects;
9
+ exports.findProject = findProject;
10
+ exports.createProject = createProject;
11
+ exports.getProject = getProject;
12
+ exports.updateProjectNotes = updateProjectNotes;
13
+ exports.unregisteredFolders = unregisteredFolders;
14
+ exports.jarvisBot = jarvisBot;
15
+ exports.ensureJarvisBot = ensureJarvisBot;
16
+ exports.projectBot = projectBot;
17
+ exports.ensureProjectBot = ensureProjectBot;
18
+ exports.syncProjectBots = syncProjectBots;
19
+ exports.jarvisInstructions = jarvisInstructions;
20
+ exports.projectInstructions = projectInstructions;
21
+ const fs_1 = require("fs");
22
+ const path_1 = require("path");
23
+ const os_1 = require("os");
24
+ const bot_store_1 = require("./bot-store");
25
+ const workspace_1 = require("./workspace");
26
+ /**
27
+ * Jarvis is the persistent engineering partner: one agent that knows every
28
+ * project and decides where work should happen. Its world is a directory:
29
+ *
30
+ * ~/jarvis/
31
+ * CLAUDE.md notes about the user, editable by hand and by Jarvis
32
+ * projects/
33
+ * grass.md one file per project: where it lives, what it is
34
+ *
35
+ * A project is a Markdown file with a small front matter (name, path, repo)
36
+ * and free-form notes under it. Nothing more structured than that: the file is
37
+ * meant to grow as Jarvis learns the project, not to be filled in like a form.
38
+ *
39
+ * Both Jarvis and each project are Bots in the hub, so threads, resume, the
40
+ * transcript reader and the UI all work unchanged. This module owns the files
41
+ * and keeps those bots in step with them.
42
+ */
43
+ // --- Directories ---
44
+ function jarvisDir() {
45
+ return process.env.JARVIS_DIR || (0, path_1.join)((0, os_1.homedir)(), "jarvis");
46
+ }
47
+ function projectsDir() {
48
+ return (0, path_1.join)(jarvisDir(), "projects");
49
+ }
50
+ /** Creates the Jarvis directory on first run, with a starter CLAUDE.md. */
51
+ function ensureJarvisDir() {
52
+ const dir = jarvisDir();
53
+ if (!(0, fs_1.existsSync)(projectsDir()))
54
+ (0, fs_1.mkdirSync)(projectsDir(), { recursive: true });
55
+ const notes = (0, path_1.join)(dir, "CLAUDE.md");
56
+ if (!(0, fs_1.existsSync)(notes))
57
+ (0, fs_1.writeFileSync)(notes, STARTER_NOTES, "utf-8");
58
+ }
59
+ const STARTER_NOTES = `# Jarvis notes
60
+
61
+ This file is loaded into every Jarvis conversation. Keep here what Jarvis
62
+ should always know: who you are, how you like work reported, standing rules
63
+ ("never merge without asking"), people and their GitHub handles, and anything
64
+ else that is not specific to one project. Project-specific knowledge belongs
65
+ in projects/<name>.md instead.
66
+
67
+ ## About me
68
+
69
+ ## Standing rules
70
+
71
+ - Never merge, push to a shared branch, or delete anything unless I explicitly
72
+ ask for it in the conversation.
73
+
74
+ ## People
75
+ `;
76
+ function slugify(name) {
77
+ return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
78
+ }
79
+ function projectFile(slug) {
80
+ return (0, path_1.join)(projectsDir(), `${slug}.md`);
81
+ }
82
+ /** Parses a project file: `key: value` lines between `---` fences, then notes. */
83
+ function parseProject(slug, raw, mtime) {
84
+ const meta = {};
85
+ let notes = raw;
86
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
87
+ if (m) {
88
+ for (const line of m[1].split(/\r?\n/)) {
89
+ const kv = line.match(/^([A-Za-z_]+):\s*(.*)$/);
90
+ if (kv)
91
+ meta[kv[1].toLowerCase()] = kv[2].trim();
92
+ }
93
+ notes = raw.slice(m[0].length);
94
+ }
95
+ const path = meta.path || "";
96
+ return {
97
+ slug,
98
+ name: meta.name || slug,
99
+ path,
100
+ pathMissing: path ? !(0, fs_1.existsSync)(path) : undefined,
101
+ repo: meta.repo || undefined,
102
+ notes: notes.trim(),
103
+ updatedAt: mtime.toISOString(),
104
+ };
105
+ }
106
+ function serializeProject(p) {
107
+ const head = [`name: ${p.name}`, `path: ${p.path}`, ...(p.repo ? [`repo: ${p.repo}`] : [])];
108
+ return `---\n${head.join("\n")}\n---\n\n${p.notes.trim()}\n`;
109
+ }
110
+ function listProjects() {
111
+ const dir = projectsDir();
112
+ if (!(0, fs_1.existsSync)(dir))
113
+ return [];
114
+ const out = [];
115
+ for (const f of (0, fs_1.readdirSync)(dir)) {
116
+ if (!f.endsWith(".md"))
117
+ continue;
118
+ const file = (0, path_1.join)(dir, f);
119
+ try {
120
+ out.push(parseProject(f.slice(0, -3), (0, fs_1.readFileSync)(file, "utf-8"), (0, fs_1.statSync)(file).mtime));
121
+ }
122
+ catch (err) {
123
+ console.error(`[jarvis] could not read ${file}: ${err.message}`);
124
+ }
125
+ }
126
+ return out.sort((a, b) => a.name.localeCompare(b.name));
127
+ }
128
+ /**
129
+ * Finds a project by however the user referred to it: slug, name, or a loose
130
+ * match ("zap eve" → zap-eve, "the grass repo" → grass). Returns undefined
131
+ * rather than guessing when nothing is close.
132
+ */
133
+ function findProject(ref) {
134
+ const projects = listProjects();
135
+ const want = slugify(ref);
136
+ if (!want)
137
+ return undefined;
138
+ return projects.find((p) => p.slug === want)
139
+ ?? projects.find((p) => slugify(p.name) === want)
140
+ ?? projects.find((p) => want.includes(p.slug) || p.slug.includes(want))
141
+ ?? projects.find((p) => (0, path_1.basename)(p.path) && slugify((0, path_1.basename)(p.path)) === want);
142
+ }
143
+ /**
144
+ * Registers a project. Clones the repo into the workspace when no checkout is
145
+ * given, writes the project file, and makes sure the project has a bot.
146
+ */
147
+ function createProject(input, workspaceCwd) {
148
+ const name = input.name.trim();
149
+ if (!name)
150
+ throw new Error("A project needs a name");
151
+ const slug = slugify(name);
152
+ if ((0, fs_1.existsSync)(projectFile(slug)))
153
+ throw new Error(`Project "${name}" already exists`);
154
+ let path = input.path ? (0, path_1.resolve)(input.path) : "";
155
+ if (path && !(0, fs_1.existsSync)(path))
156
+ throw new Error(`No such directory: ${path}`);
157
+ if (!path) {
158
+ if (!input.repo)
159
+ throw new Error("Give either a repo URL to clone or the path of an existing checkout");
160
+ const folder = (0, path_1.basename)(input.repo.replace(/\/+$/, "")).replace(/\.git$/, "");
161
+ const existing = (0, path_1.join)(workspaceCwd, folder);
162
+ // A checkout already sitting in the workspace is the project; don't clone beside it.
163
+ path = (0, fs_1.existsSync)((0, path_1.join)(existing, ".git")) ? existing : (0, workspace_1.cloneRepo)(input.repo, workspaceCwd);
164
+ }
165
+ const repo = input.repo || detectRemote(path);
166
+ (0, fs_1.writeFileSync)(projectFile(slug), serializeProject({ name, path, repo, notes: input.notes ?? `# ${name}\n` }), "utf-8");
167
+ const project = getProject(slug);
168
+ ensureProjectBot(project);
169
+ return project;
170
+ }
171
+ function getProject(slug) {
172
+ const file = projectFile(slug);
173
+ if (!(0, fs_1.existsSync)(file))
174
+ return undefined;
175
+ return parseProject(slug, (0, fs_1.readFileSync)(file, "utf-8"), (0, fs_1.statSync)(file).mtime);
176
+ }
177
+ /** Rewrites the notes (everything under the front matter); metadata stays. */
178
+ function updateProjectNotes(slug, notes) {
179
+ const p = getProject(slug);
180
+ if (!p)
181
+ throw new Error(`No project "${slug}"`);
182
+ (0, fs_1.writeFileSync)(projectFile(slug), serializeProject({ ...p, notes }), "utf-8");
183
+ const next = getProject(slug);
184
+ ensureProjectBot(next);
185
+ return next;
186
+ }
187
+ function detectRemote(path) {
188
+ try {
189
+ const { execSync } = require("child_process");
190
+ return execSync("git remote get-url origin", { cwd: path, encoding: "utf-8", stdio: "pipe" }).trim() || undefined;
191
+ }
192
+ catch {
193
+ return undefined;
194
+ }
195
+ }
196
+ /** Folders in the workspace that no project claims — candidates for adoption. */
197
+ function unregisteredFolders(workspaceCwd) {
198
+ const claimed = new Set(listProjects().map((p) => (0, path_1.resolve)(p.path)));
199
+ try {
200
+ return (0, fs_1.readdirSync)(workspaceCwd, { withFileTypes: true })
201
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
202
+ .map((e) => ({ name: e.name, path: (0, path_1.join)(workspaceCwd, e.name), isGit: (0, fs_1.existsSync)((0, path_1.join)(workspaceCwd, e.name, ".git")) }))
203
+ .filter((d) => !claimed.has((0, path_1.resolve)(d.path)));
204
+ }
205
+ catch {
206
+ return [];
207
+ }
208
+ }
209
+ // --- Bots ---
210
+ // Jarvis and each project are bots, so the rest of the hub needs no special
211
+ // cases. Their definitions are derived, so they are regenerated on every
212
+ // ensure rather than edited by hand.
213
+ function jarvisBot() {
214
+ return (0, bot_store_1.listBots)().find((b) => b.role === "jarvis");
215
+ }
216
+ function ensureJarvisBot() {
217
+ ensureJarvisDir();
218
+ const def = {
219
+ name: "Jarvis",
220
+ emoji: "\u{1F9E0}",
221
+ description: "Your engineering partner. Knows every project; decides where work happens.",
222
+ instructions: jarvisInstructions(),
223
+ repoPath: jarvisDir(),
224
+ permissionMode: "auto-approve",
225
+ // No shell: Jarvis keeps its notes with the file tools and does everything
226
+ // else through project agents, which is the point of it.
227
+ disallowedTools: ["Bash"],
228
+ role: "jarvis",
229
+ };
230
+ const existing = jarvisBot();
231
+ if (existing)
232
+ return (0, bot_store_1.updateBot)(existing.id, def) ?? existing;
233
+ return (0, bot_store_1.createBot)(def);
234
+ }
235
+ function projectBot(slug) {
236
+ return (0, bot_store_1.listBots)().find((b) => b.role === "project" && b.projectSlug === slug);
237
+ }
238
+ function ensureProjectBot(project) {
239
+ const def = {
240
+ name: project.name,
241
+ emoji: "\u{1F4E6}",
242
+ description: firstLine(project.notes) || `The ${project.name} project agent.`,
243
+ instructions: projectInstructions(project),
244
+ repoPath: project.path,
245
+ permissionMode: "auto-approve",
246
+ role: "project",
247
+ projectSlug: project.slug,
248
+ };
249
+ const existing = projectBot(project.slug);
250
+ if (existing)
251
+ return (0, bot_store_1.updateBot)(existing.id, def) ?? existing;
252
+ return (0, bot_store_1.createBot)(def);
253
+ }
254
+ /** Brings every project bot up to date with its file; run at startup. */
255
+ function syncProjectBots() {
256
+ for (const p of listProjects())
257
+ ensureProjectBot(p);
258
+ }
259
+ function firstLine(md) {
260
+ return md.split("\n").map((l) => l.replace(/^#+\s*/, "").trim()).find((l) => l && !l.startsWith("#")) ?? "";
261
+ }
262
+ // --- Prompts ---
263
+ /**
264
+ * What Jarvis is and how it works. Appended to Claude Code's own prompt; the
265
+ * user's CLAUDE.md under ~/jarvis rides in through the normal project-settings
266
+ * path, so this stays about the role and the tools.
267
+ */
268
+ function jarvisInstructions() {
269
+ return `You are Jarvis, a persistent AI engineering partner. The user talks to you first about
270
+ anything engineering-related; you work out which project it concerns and how the work should
271
+ be carried out. You are not the coding agent: real repo-level work is done by project agents
272
+ (Claude Code sessions running inside a project's checkout) that you dispatch.
273
+
274
+ YOUR WORLD
275
+ - Your working directory (${jarvisDir()}) holds CLAUDE.md (notes about the user) and
276
+ projects/<slug>.md, one file per project. Read and edit these files freely; they are yours.
277
+ - A project is more than a repository: the file records where the checkout is, the remote,
278
+ people, useful commands, architecture notes and what has been done before. Add to a project's
279
+ notes whenever you learn something that will save time next time (use update_project).
280
+
281
+ TOOLS
282
+ - list_projects: every project you know, plus workspace folders nobody has claimed yet.
283
+ - read_project: a project's full notes.
284
+ - create_project: register a project from a repo URL (cloned into the workspace) or an existing
285
+ folder. Use it once you know enough; then continue the original request.
286
+ - update_project: rewrite a project's notes.
287
+ - delegate: give a project agent a bounded task and get its report back. It blocks until the
288
+ agent finishes, which may take minutes. You may call it for several projects at once.
289
+ - handoff: move the conversation into a project. The project agent takes over from the next
290
+ message on and answers the user directly; your turn should end right after the call with at
291
+ most one short line. Use the message argument to pass the user's request in full, with any
292
+ context they gave you.
293
+
294
+ HANDOFF OR DELEGATE
295
+ - Handoff is the default whenever a request concerns one project, however small: a question
296
+ about it, a review, a fix. The user's attention is moving into that project and their
297
+ follow-ups ("why did we do that?", "review it properly") should land there, with the context
298
+ the agent already has. Do not delegate a single-project request just because it looks quick.
299
+ - Delegate when you must stay in charge: the request spans several projects, you need a result
300
+ in order to combine it with something else, or the user asked you for a synthesis.
301
+ - Never do repo-level work yourself (reading code, running tests, git). Dispatch it.
302
+
303
+ UNKNOWN PROJECTS
304
+ - If the user names a project you do not have, do not fail and do not poke at the folder
305
+ yourself. Check list_projects: an unclaimed workspace folder with a matching name is almost
306
+ certainly it, so create_project with that path, say so in a few words, and carry on with the
307
+ original request through the new project agent. Otherwise ask which repository it is (URL or
308
+ path), then create_project and continue.
309
+
310
+ RULES
311
+ - Project agents may edit code, run tests and open branches, but nothing is merged, pushed to a
312
+ shared branch or deleted unless the user said so in this conversation. Pass that constraint
313
+ along explicitly when you dispatch work.
314
+ - Be brief. The user is often on a phone. Lead with the answer or the outcome; no preamble.
315
+ - Refer to people, projects and PRs by the names the user uses.`;
316
+ }
317
+ /**
318
+ * The project agent is ordinary Claude Code in the checkout, with the project
319
+ * file as standing context and the guardrails Jarvis relies on.
320
+ */
321
+ function projectInstructions(project) {
322
+ return `You are the project agent for "${project.name}". You run inside its checkout at
323
+ ${project.path}${project.repo ? ` (remote: ${project.repo})` : ""} and do the actual engineering
324
+ work here: reading code, git history, reviewing changes, running tests and dev servers, using
325
+ gh, Playwright and whatever else is installed, and making changes when asked.
326
+
327
+ The user reached you through Jarvis, their engineering partner, and may be on a phone. They
328
+ may continue this conversation directly. Lead with the outcome; keep reports short and
329
+ concrete: what you looked at, what you found, what you changed, what remains.
330
+
331
+ Standing rules:
332
+ - Do not merge, push to a shared branch, force-push or delete anything unless the user
333
+ explicitly asked for it in this conversation. Work on a branch when changing code.
334
+ - When a task was delegated by Jarvis, finish it and end with a clear report; Jarvis passes
335
+ your final message on.
336
+
337
+ Project notes (kept at ${projectFile(project.slug)}; update them when you learn something
338
+ durable about this project):
339
+
340
+ ${project.notes || "(no notes yet)"}`;
341
+ }
package/dist/ls.js ADDED
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ls = ls;
4
+ const prompts_1 = require("@inquirer/prompts");
5
+ const progress_1 = require("./progress");
6
+ const sandboxes = [
7
+ { name: "my-saas-app", agent: "Claude", lastOpened: "2 minutes ago" },
8
+ { name: "blog-redesign", agent: "OpenCode", lastOpened: "1 hour ago" },
9
+ { name: "api-server", agent: "Claude", lastOpened: "3 hours ago" },
10
+ { name: "mobile-backend", agent: "Cursor", lastOpened: "yesterday" },
11
+ { name: "landing-page", agent: "Windsurf", lastOpened: "2 days ago" },
12
+ { name: "data-pipeline", agent: "Claude", lastOpened: "3 days ago" },
13
+ { name: "chrome-extension", agent: "OpenCode", lastOpened: "1 week ago" },
14
+ ];
15
+ function pad(str, len) {
16
+ return str.padEnd(len);
17
+ }
18
+ async function ls() {
19
+ console.log();
20
+ console.log(" Available sandboxes:");
21
+ console.log();
22
+ const nameCol = 20;
23
+ const agentCol = 12;
24
+ let chosen;
25
+ try {
26
+ chosen = await (0, prompts_1.select)({
27
+ message: "Select a sandbox",
28
+ choices: sandboxes.map((s) => ({
29
+ name: `${pad(s.name, nameCol)} ${pad(s.agent, agentCol)} ${s.lastOpened}`,
30
+ value: s,
31
+ })),
32
+ });
33
+ }
34
+ catch {
35
+ console.log();
36
+ process.exit(0);
37
+ }
38
+ console.log();
39
+ console.log(` Loading ${chosen.name}...`);
40
+ console.log();
41
+ await (0, progress_1.runProgress)([
42
+ { label: "Connecting to sandbox...", duration: 2000 },
43
+ { label: "Restoring session...", duration: 1500 },
44
+ { label: "Ready!", duration: 1500 },
45
+ ]);
46
+ }
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.runProgress = runProgress;
7
+ const cli_progress_1 = __importDefault(require("cli-progress"));
8
+ function sleep(ms) {
9
+ return new Promise((resolve) => setTimeout(resolve, ms));
10
+ }
11
+ async function runProgress(steps) {
12
+ const bar = new cli_progress_1.default.SingleBar({
13
+ format: "{step} [{bar}] {percentage}%",
14
+ hideCursor: true,
15
+ barCompleteChar: "\u2588",
16
+ barIncompleteChar: "\u2591",
17
+ }, cli_progress_1.default.Presets.shades_classic);
18
+ const totalDuration = steps.reduce((sum, s) => sum + s.duration, 0);
19
+ let elapsed = 0;
20
+ bar.start(totalDuration, 0, { step: steps[0].label });
21
+ for (const step of steps) {
22
+ bar.update(elapsed, { step: step.label });
23
+ const tickInterval = 100;
24
+ const ticks = step.duration / tickInterval;
25
+ for (let i = 0; i < ticks; i++) {
26
+ await sleep(tickInterval);
27
+ elapsed += tickInterval;
28
+ bar.update(elapsed, { step: step.label });
29
+ }
30
+ }
31
+ bar.update(totalDuration, { step: "Done!" });
32
+ bar.stop();
33
+ }
@@ -0,0 +1,216 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.startRelayMode = startRelayMode;
7
+ const ws_1 = __importDefault(require("ws"));
8
+ const crypto_1 = require("crypto");
9
+ const promises_1 = require("fs/promises");
10
+ const server_common_1 = require("./server-common");
11
+ const server_1 = require("./server");
12
+ // --- RelayRequest ---
13
+ // Implements IRequest without depending on a real socket.
14
+ // The body is already available as a string from the WS frame.
15
+ class RelayRequest {
16
+ constructor(method, url, headers, body) {
17
+ this._closeListeners = [];
18
+ this._dataListeners = [];
19
+ this._endListeners = [];
20
+ this._errorListeners = [];
21
+ this.method = method;
22
+ this.url = url;
23
+ this.headers = headers;
24
+ this._body = body;
25
+ }
26
+ on(event, listener) {
27
+ if (event === "close")
28
+ this._closeListeners.push(listener);
29
+ else if (event === "data")
30
+ this._dataListeners.push(listener);
31
+ else if (event === "end")
32
+ this._endListeners.push(listener);
33
+ else if (event === "error")
34
+ this._errorListeners.push(listener);
35
+ return this;
36
+ }
37
+ // Called by relay-client to deliver the body to readBody() callers
38
+ emitBody() {
39
+ if (this._body) {
40
+ for (const fn of this._dataListeners)
41
+ fn(this._body);
42
+ }
43
+ for (const fn of this._endListeners)
44
+ fn();
45
+ }
46
+ // Called when the WS closes — notifies SSE listeners to detach
47
+ emitClose() {
48
+ for (const fn of this._closeListeners)
49
+ fn();
50
+ }
51
+ }
52
+ // --- RelayResponse ---
53
+ // Captures writeHead/write/end calls and forwards them as WS frames to the relay.
54
+ class RelayResponse {
55
+ constructor(requestId, ws) {
56
+ this.headersSent = false;
57
+ this.writableEnded = false;
58
+ this._requestId = requestId;
59
+ this._ws = ws;
60
+ }
61
+ _send(frame) {
62
+ if (this._ws.readyState === ws_1.default.OPEN) {
63
+ this._ws.send(JSON.stringify(frame));
64
+ }
65
+ }
66
+ writeHead(statusCode, headers) {
67
+ if (this.headersSent)
68
+ return;
69
+ this.headersSent = true;
70
+ this._send({ requestId: this._requestId, type: "response_start", statusCode, headers: headers ?? {} });
71
+ }
72
+ write(chunk) {
73
+ if (this.writableEnded)
74
+ return;
75
+ this._send({ requestId: this._requestId, type: "data", chunk });
76
+ }
77
+ end(chunk) {
78
+ if (this.writableEnded)
79
+ return;
80
+ if (chunk)
81
+ this.write(chunk);
82
+ this.writableEnded = true;
83
+ this._send({ requestId: this._requestId, type: "end" });
84
+ }
85
+ }
86
+ // --- Token persistence ---
87
+ async function loadOrCreateToken(workspaceCwd) {
88
+ const tokenPath = (0, server_common_1.relayTokenPath)(workspaceCwd);
89
+ try {
90
+ const existing = (await (0, promises_1.readFile)(tokenPath, "utf-8")).trim();
91
+ if (existing.length >= 16) {
92
+ return existing;
93
+ }
94
+ throw new Error("token too short");
95
+ }
96
+ catch {
97
+ // File missing, unreadable, or invalid — generate a fresh one
98
+ const token = (0, crypto_1.randomBytes)(32).toString("base64url");
99
+ try {
100
+ await (0, promises_1.writeFile)(tokenPath, token, "utf-8");
101
+ }
102
+ catch (writeErr) {
103
+ console.warn(`[relay] could not save token to ${tokenPath}: ${writeErr.message} (token will not persist across restarts)`);
104
+ }
105
+ return token;
106
+ }
107
+ }
108
+ // --- startRelayMode ---
109
+ const BACKOFF_INITIAL_MS = 1000;
110
+ const BACKOFF_MAX_MS = 30000;
111
+ async function startRelayMode(relayUrl, availableAgents, workspaceCwd) {
112
+ // Normalise: accept http(s):// or ws(s):// — convert to ws(s)://
113
+ const wsUrl = relayUrl
114
+ .replace(/^https:\/\//, "wss://")
115
+ .replace(/^http:\/\//, "ws://");
116
+ // Load or generate a stable token for this workspace — done once, reused across reconnects
117
+ const token = await loadOrCreateToken(workspaceCwd);
118
+ // Track active RelayRequests so we can fire their close listeners on WS disconnect
119
+ const activeRequests = new Map();
120
+ let backoff = BACKOFF_INITIAL_MS;
121
+ let stopping = false;
122
+ function connect() {
123
+ if (stopping)
124
+ return;
125
+ const ws = new ws_1.default(`${wsUrl}/grass-connect`);
126
+ ws.on("open", () => {
127
+ backoff = BACKOFF_INITIAL_MS;
128
+ const registerFrame = { type: "register", token };
129
+ ws.send(JSON.stringify(registerFrame));
130
+ (0, server_common_1.setPushNotificationSender)((title, body, data) => {
131
+ if (ws.readyState === ws_1.default.OPEN) {
132
+ ws.send(JSON.stringify({ type: "push_notification", title, body, data }));
133
+ }
134
+ });
135
+ });
136
+ ws.on("message", async (raw) => {
137
+ let frame;
138
+ try {
139
+ frame = JSON.parse(raw.toString());
140
+ }
141
+ catch {
142
+ return;
143
+ }
144
+ if (frame.type === "registered") {
145
+ // Registration accepted — show QR for app to scan
146
+ await (0, server_common_1.showRelayQR)(relayUrl, token);
147
+ return;
148
+ }
149
+ if (frame.type === "register_error") {
150
+ console.error(`[relay] registration rejected: ${frame.reason}`);
151
+ stopping = true; // don't reconnect — token conflict won't resolve on its own
152
+ ws.close();
153
+ return;
154
+ }
155
+ if (frame.type === "request") {
156
+ const { requestId, method, path, headers, body } = frame;
157
+ const req = new RelayRequest(method, path, headers, body);
158
+ const res = new RelayResponse(requestId, ws);
159
+ activeRequests.set(requestId, req);
160
+ // Deliver body asynchronously so handler can attach data/end listeners first
161
+ setImmediate(() => req.emitBody());
162
+ try {
163
+ await (0, server_1.handleRequest)(req, res, availableAgents, workspaceCwd);
164
+ }
165
+ catch (err) {
166
+ console.error(`[relay] unhandled error for requestId=${requestId}:`, err.message);
167
+ if (!res.headersSent)
168
+ res.writeHead(500, { "Content-Type": "application/json" });
169
+ if (!res.writableEnded)
170
+ res.end(JSON.stringify({ error: "Internal server error" }));
171
+ }
172
+ finally {
173
+ // Only remove from active map once the response is fully ended.
174
+ // SSE responses stay active until res.end() is called by the event listener.
175
+ if (res.writableEnded) {
176
+ activeRequests.delete(requestId);
177
+ }
178
+ }
179
+ return;
180
+ }
181
+ });
182
+ ws.on("close", (code) => {
183
+ (0, server_common_1.setPushNotificationSender)(null);
184
+ // Notify all active SSE listeners so they detach from the session emitter
185
+ for (const [id, req] of activeRequests) {
186
+ req.emitClose();
187
+ activeRequests.delete(id);
188
+ }
189
+ if (stopping)
190
+ return;
191
+ // Code 4000 means the relay evicted this connection because a newer one
192
+ // registered with the same token — the new connection is already live, so
193
+ // do NOT reconnect (that would evict the new connection and loop forever).
194
+ if (code === 4000)
195
+ return;
196
+ setTimeout(() => {
197
+ connect();
198
+ }, backoff);
199
+ backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
200
+ });
201
+ ws.on("error", (err) => {
202
+ console.error(`[relay] ws error: ${err.message}`);
203
+ // 'close' event fires after 'error', reconnect happens there
204
+ });
205
+ }
206
+ // Handle graceful shutdown — stopping flag prevents reconnect loop
207
+ process.on("SIGINT", () => {
208
+ stopping = true;
209
+ });
210
+ process.on("SIGTERM", () => {
211
+ stopping = true;
212
+ });
213
+ connect();
214
+ // Keep process alive — relay mode has no HTTP server to hold the event loop open
215
+ await new Promise(() => { });
216
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });