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/LICENSE +21 -0
- package/README.md +484 -0
- package/dist/bot-routes.js +196 -0
- package/dist/bot-store.js +297 -0
- package/dist/client-html.js +2241 -0
- package/dist/client-vendor.js +32 -0
- package/dist/client.js +118 -0
- package/dist/dictation.js +128 -0
- package/dist/env-file.js +79 -0
- package/dist/index.js +41 -0
- package/dist/jarvis-tools.js +143 -0
- package/dist/jarvis.js +341 -0
- package/dist/ls.js +46 -0
- package/dist/progress.js +33 -0
- package/dist/relay-client.js +216 -0
- package/dist/relay-types.js +2 -0
- package/dist/server-common.js +663 -0
- package/dist/server.js +530 -0
- package/dist/start-claude-code.js +557 -0
- package/dist/start-codex.js +886 -0
- package/dist/start-opencode.js +452 -0
- package/dist/start.js +742 -0
- package/dist/sync.js +15 -0
- package/dist/trophy.js +7 -0
- package/dist/turns.js +91 -0
- package/dist/workspace.js +152 -0
- package/package.json +55 -0
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
|
+
}
|
package/dist/progress.js
ADDED
|
@@ -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
|
+
}
|