jarvis-hq 0.0.3 → 0.0.5

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/README.md CHANGED
@@ -368,7 +368,7 @@ Sessions are the core abstraction. A session is created when a `/chat` POST is r
368
368
 
369
369
  Jarvis detects which harnesses are available at startup by checking for the `claude` CLI, the `@opencode-ai/sdk` package, and the `codex` CLI. It reports the available agents at `/agents`. A bot's `model` and `permissionMode` are applied to whichever harness runs its threads.
370
370
 
371
- **Claude Code** (`claude-code`): Uses the `@anthropic-ai/claude-agent-sdk` `query()` function. Runs the `claude-opus-4-6` model in `default` permission mode. Supports `canUseTool` for per-tool permission prompts. Session transcripts are stored at `~/.claude/projects/<cwd>/<session-id>.jsonl`.
371
+ **Claude Code** (`claude-code`): Uses the `@anthropic-ai/claude-agent-sdk` `query()` function. Runs the `claude-opus-5[1m]` model (Opus 5 with the 1M-token context window) in `default` permission mode. Supports `canUseTool` for per-tool permission prompts. Session transcripts are stored at `~/.claude/projects/<cwd>/<session-id>.jsonl`.
372
372
 
373
373
  **Opencode** (`opencode`): Uses the `@opencode-ai/sdk`. Jarvis spawns an Opencode server process at startup (or connects to one already running on port 4096). Per-directory clients are maintained so sessions can be scoped to different repos simultaneously. Events are received via a persistent Opencode event stream (`client.event.subscribe()`). If the stream fails, it reconnects automatically after 2 seconds.
374
374
 
@@ -72,6 +72,16 @@ exports.html = `<!DOCTYPE html>
72
72
  .bar-title { flex: 1; min-width: 0; }
73
73
  .bar-title h2 { font-size: 17px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
74
74
  .bar-title .sub { color: var(--muted); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
75
+ /* Context meter: how full the model's window is, in the thread's own header. */
76
+ .ctx { display: flex; align-items: center; gap: 6px; margin-top: 2px; font-size: 11px; color: var(--muted); }
77
+ .ctx-model { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
78
+ .ctx-dots { display: flex; gap: 2px; flex: none; }
79
+ .ctx-dots i { width: 5px; height: 5px; border-radius: 50%; background: var(--border); }
80
+ .ctx-dots i.on { background: var(--muted); }
81
+ .ctx-num { flex: none; font-variant-numeric: tabular-nums; }
82
+ /* Past three quarters full the meter stops being background detail. */
83
+ .ctx.full .ctx-dots i.on { background: var(--danger); }
84
+ .ctx.full .ctx-num { color: var(--danger); }
75
85
 
76
86
  .back {
77
87
  display: inline-flex; align-items: center; gap: 5px; flex: none;
@@ -560,6 +570,11 @@ exports.html = `<!DOCTYPE html>
560
570
  <div class="bar-title">
561
571
  <h2 id="thread-title"></h2>
562
572
  <div class="sub" id="thread-sub"></div>
573
+ <div class="ctx" id="thread-ctx" hidden>
574
+ <span class="ctx-model" id="ctx-model"></span>
575
+ <span class="ctx-dots" id="ctx-dots"></span>
576
+ <span class="ctx-num" id="ctx-num"></span>
577
+ </div>
563
578
  </div>
564
579
  </div></div>
565
580
  <div class="messages" id="messages"><div class="wrap narrow" id="messages-inner"></div></div>
@@ -1279,6 +1294,39 @@ exports.html = `<!DOCTYPE html>
1279
1294
  load(startPath);
1280
1295
  }
1281
1296
 
1297
+ // --- Context meter ---
1298
+ // What the model is carrying into its next turn, against what it can hold.
1299
+ // Ten dots read at a glance on a phone; the exact numbers sit beside them and
1300
+ // the full sentence is in the tooltip.
1301
+
1302
+ function fmtTokens(n) {
1303
+ if (!n) return "0";
1304
+ return n < 1000 ? String(n) : Math.round(n / 1000) + "k";
1305
+ }
1306
+
1307
+ function renderContext(ctx) {
1308
+ var box = $("thread-ctx");
1309
+ if (!ctx || !ctx.used || !ctx.window) { box.hidden = true; return; }
1310
+ var pct = Math.min(100, Math.round((ctx.used / ctx.window) * 100));
1311
+ // "/1000k" already says the window is the long one, so the meter drops the
1312
+ // "(1M context)" tail that would otherwise eat the line on a phone. Plain
1313
+ // string work, not a regex: this file is a template literal, and its
1314
+ // backslashes belong to the build, not to the browser.
1315
+ var name = ctx.label || ctx.model || "";
1316
+ var tail = name.indexOf(" (1M");
1317
+ $("ctx-model").textContent = tail === -1 ? name : name.slice(0, tail);
1318
+ $("ctx-num").textContent = fmtTokens(ctx.used) + "/" + fmtTokens(ctx.window) + " (" + pct + "%)";
1319
+ var dots = $("ctx-dots");
1320
+ dots.innerHTML = "";
1321
+ var lit = Math.min(10, Math.max(1, Math.ceil(pct / 10)));
1322
+ for (var i = 0; i < 10; i++) dots.appendChild(el("i", i < lit ? "on" : null));
1323
+ box.className = "ctx" + (pct >= 75 ? " full" : "");
1324
+ box.title = (ctx.label ? ctx.label + " \u2014 " : "")
1325
+ + ctx.used.toLocaleString() + " of " + ctx.window.toLocaleString()
1326
+ + " tokens in context (" + pct + "% used)";
1327
+ box.hidden = false;
1328
+ }
1329
+
1282
1330
  // --- Thread ---
1283
1331
  function openThread(thread) {
1284
1332
  activeThread = thread;
@@ -1286,6 +1334,7 @@ exports.html = `<!DOCTYPE html>
1286
1334
  $("thread-title").textContent = thread.title;
1287
1335
  $("thread-sub").textContent = shortPath(thread.repoPath, 30);
1288
1336
  $("thread-sub").title = thread.repoPath || "";
1337
+ renderContext(thread.context);
1289
1338
  $("to-bot-label").textContent = threadBack === "jarvis" ? "Jarvis" : (activeBot ? activeBot.name : "Back");
1290
1339
  var av = $("thread-avatar");
1291
1340
  av.textContent = activeBot ? (activeBot.emoji || "\\u{1F916}") : "\\u{1F916}";
@@ -1529,7 +1578,7 @@ exports.html = `<!DOCTYPE html>
1529
1578
 
1530
1579
  // Every event type the agent emits on the turn stream.
1531
1580
  var STREAM_EVENTS = ["user_prompt", "assistant", "tool_use", "status", "permission_request", "aborted", "done", "result", "error",
1532
- "delegation", "handoff", "project_created"];
1581
+ "delegation", "handoff", "project_created", "context"];
1533
1582
 
1534
1583
  function attachStream(sid, onEvent) {
1535
1584
  closeStream();
@@ -1563,6 +1612,11 @@ exports.html = `<!DOCTYPE html>
1563
1612
  appendTool(liveBubble, data.tool_name, data.tool_input);
1564
1613
  break;
1565
1614
 
1615
+ case "context":
1616
+ if (activeThread) activeThread.context = data;
1617
+ renderContext(data);
1618
+ break;
1619
+
1566
1620
  case "status":
1567
1621
  if (state === "aborting") break;
1568
1622
  if (data.status === "thinking") setActivity("Thinking\u2026");
@@ -2122,7 +2176,7 @@ exports.html = `<!DOCTYPE html>
2122
2176
 
2123
2177
  var model = field("Model", "optional", el("input"));
2124
2178
  model.value = bot ? (bot.model || "") : "";
2125
- model.placeholder = "claude-sonnet-4-6";
2179
+ model.placeholder = "claude-opus-5[1m]";
2126
2180
 
2127
2181
  var permissionMode = field("Permissions", null, el("select"));
2128
2182
  [["ask-permissions", "Ask before each tool"], ["auto-approve", "Auto-approve tools"], ["plan", "Plan only (no edits)"]]
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.contextWindowFor = contextWindowFor;
4
+ exports.tokensInContext = tokensInContext;
5
+ exports.modelLabel = modelLabel;
6
+ exports.contextUsage = contextUsage;
7
+ const MILLION = 1000000;
8
+ /** What Claude Code assumes for any model that does not ask for the long window. */
9
+ const DEFAULT_WINDOW = 200000;
10
+ /** The [1m] suffix is how the SDK is told to budget a 1M window; mirror that rule. */
11
+ function contextWindowFor(model) {
12
+ return model?.includes("[1m]") ? MILLION : DEFAULT_WINDOW;
13
+ }
14
+ /** Everything the next request resends, from one message's usage block. */
15
+ function tokensInContext(usage) {
16
+ if (!usage)
17
+ return 0;
18
+ const n = (v) => (typeof v === "number" && isFinite(v) && v > 0 ? v : 0);
19
+ return n(usage.input_tokens)
20
+ + n(usage.cache_read_input_tokens)
21
+ + n(usage.cache_creation_input_tokens)
22
+ + n(usage.output_tokens);
23
+ }
24
+ /**
25
+ * "claude-opus-5[1m]" -> "Opus 5 (1M context)", "claude-sonnet-4-6" ->
26
+ * "Sonnet 4.6". Derived rather than looked up, so a model released tomorrow
27
+ * still reads properly.
28
+ */
29
+ function modelLabel(model) {
30
+ if (!model)
31
+ return "";
32
+ const long = model.includes("[1m]");
33
+ const parts = model.replace(/\[1m\]/g, "").replace(/^claude-/, "").split("-").filter(Boolean);
34
+ const family = parts.shift() ?? "";
35
+ const name = family.charAt(0).toUpperCase() + family.slice(1);
36
+ const version = parts.join(".");
37
+ return `${[name, version].filter(Boolean).join(" ")}${long ? " (1M context)" : ""}`;
38
+ }
39
+ function contextUsage(used, model, window) {
40
+ return {
41
+ used,
42
+ window: window && window > 0 ? window : contextWindowFor(model),
43
+ model: model ?? "",
44
+ label: modelLabel(model),
45
+ };
46
+ }
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.JARVIS_TOOL_NAMES = exports.JARVIS_MCP_NAME = void 0;
4
4
  exports.buildJarvisTools = buildJarvisTools;
5
5
  const claude_agent_sdk_1 = require("@anthropic-ai/claude-agent-sdk");
6
+ const child_process_1 = require("child_process");
7
+ const util_1 = require("util");
6
8
  const zod_1 = require("zod");
7
9
  const server_common_1 = require("./server-common");
8
10
  const bot_store_1 = require("./bot-store");
@@ -67,6 +69,32 @@ function buildJarvisTools(store, workspaceCwd) {
67
69
  return fail(err?.message ?? "Could not update the project");
68
70
  }
69
71
  }),
72
+ (0, claude_agent_sdk_1.tool)("find_repos", "Search the user's GitHub for a repository: their own repos and every organisation they belong to. Use it when the user names a project you do not have and no workspace folder matches, before asking them for a URL — then pass the clone URL to create_project.", {
73
+ query: zod_1.z.string().optional().describe("Words to match against the repo name and description, e.g. 'relay' or 'expo client'. Omit to list everything."),
74
+ owner: zod_1.z.string().optional().describe("Restrict to one user or organisation login. Omit to search the user's account and all their orgs."),
75
+ limit: zod_1.z.number().optional().describe("Maximum repos to return (default 30)"),
76
+ }, async ({ query, owner, limit }) => {
77
+ try {
78
+ const { repos, searched, warnings } = await findRepos({ query, owner, limit: limit ?? 30 });
79
+ const note = warnings.length ? { incomplete_results: warnings, tell_the_user: "Say this to the user; it is something only they can fix." } : {};
80
+ if (!repos.length) {
81
+ return text(JSON.stringify({
82
+ repos: [],
83
+ searched_owners: searched,
84
+ ...note,
85
+ next: `Nothing on GitHub matches ${query ? `"${query}"` : "that"}${owner ? ` under ${owner}` : ""}. Report any incomplete_results above, then ask the user for the repo URL.`,
86
+ }, null, 2));
87
+ }
88
+ return text(JSON.stringify({ repos, searched_owners: searched, ...note }, null, 2));
89
+ }
90
+ catch (err) {
91
+ if (err instanceof GhSetupError) {
92
+ (0, server_common_1.emitEvent)(store, "github_unavailable", { kind: err.kind, message: err.message });
93
+ return fail(`GITHUB IS NOT SET UP ON THIS MACHINE. Tell the user exactly this, including the command, and do not ask them for a repo URL until you have:\n\n${err.message}`);
94
+ }
95
+ return fail(`${err?.message ?? "Could not reach GitHub"}. Tell the user the search failed and why.`);
96
+ }
97
+ }),
70
98
  (0, claude_agent_sdk_1.tool)("delegate", "Give a project's agent a bounded task and wait for its report, so you can combine it with other things. Blocks until the agent finishes (seconds to many minutes); call it for several projects in parallel when a request spans projects. Not for a request about a single project, however small — that is a handoff, so the user's follow-ups land in the project.", {
71
99
  project: zod_1.z.string().describe("Project name or slug"),
72
100
  task: zod_1.z.string().describe("The task, self-contained: what to do, what to report back, and any constraints such as 'do not merge'"),
@@ -121,7 +149,7 @@ function buildJarvisTools(store, workspaceCwd) {
121
149
  });
122
150
  }
123
151
  /** Tool names as Claude Code sees them, for the allowed-tools list. */
124
- exports.JARVIS_TOOL_NAMES = ["list_projects", "read_project", "create_project", "update_project", "delegate", "handoff"]
152
+ exports.JARVIS_TOOL_NAMES = ["list_projects", "read_project", "create_project", "update_project", "find_repos", "delegate", "handoff"]
125
153
  .map((t) => `mcp__${exports.JARVIS_MCP_NAME}__${t}`);
126
154
  function summary(p) {
127
155
  return {
@@ -141,3 +169,156 @@ function titleFor(text) {
141
169
  return undefined;
142
170
  return line.length > 60 ? line.slice(0, 57).replace(/\s+\S*$/, "") + "…" : line;
143
171
  }
172
+ // --- GitHub ---
173
+ // Jarvis has no shell, so `gh` is exposed as one read-only tool rather than as
174
+ // a command line: fixed subcommands, arguments passed as an argv array (never
175
+ // through a shell), and only what is needed to turn a name the user said into
176
+ // a repository to clone.
177
+ //
178
+ // gh lives on the machine, not in the conversation, so when it is missing,
179
+ // logged out or short a scope only the user can fix it. Those cases are
180
+ // separated from ordinary failures and carry the exact command to run, which
181
+ // find_repos tells Jarvis to pass on verbatim rather than quietly falling back
182
+ // to "what is the repo URL?".
183
+ const run = (0, util_1.promisify)(child_process_1.execFile);
184
+ /** A problem with the machine's gh install that the user has to fix. */
185
+ class GhSetupError extends Error {
186
+ constructor(kind, message) {
187
+ super(message);
188
+ this.kind = kind;
189
+ }
190
+ }
191
+ const INSTALL_HINT = process.platform === "darwin"
192
+ ? "install it with `brew install gh`"
193
+ : process.platform === "win32"
194
+ ? "install it with `winget install --id GitHub.cli`"
195
+ : "install it from https://cli.github.com (on Debian/Ubuntu: `sudo apt install gh`)";
196
+ /**
197
+ * Confirms gh is installed and logged in before any real call, so a setup
198
+ * problem is reported once, precisely, instead of as a puzzling empty search.
199
+ * Successful results are cached; a failure is always re-checked, so the user
200
+ * can fix it and retry immediately.
201
+ */
202
+ let authCache;
203
+ async function ghAuth() {
204
+ if (authCache && Date.now() - authCache.at < 10 * 60 * 1000)
205
+ return authCache;
206
+ let out;
207
+ try {
208
+ const r = await run("gh", ["auth", "status"], { timeout: 20000 });
209
+ out = r.stdout + r.stderr;
210
+ }
211
+ catch (err) {
212
+ if (err?.code === "ENOENT") {
213
+ throw new GhSetupError("missing", `The GitHub CLI (gh) is not installed on the machine running Jarvis, so I cannot search GitHub. To fix it, ${INSTALL_HINT}, then run \`gh auth login\`.`);
214
+ }
215
+ const detail = String(err?.stderr || err?.stdout || err?.message || "").trim();
216
+ if (/not logged|no accounts|gh auth login|authentication/i.test(detail) || typeof err?.code === "number") {
217
+ throw new GhSetupError("auth", "The GitHub CLI is installed but not logged in, so I cannot search GitHub. To fix it, run `gh auth login` on the machine running Jarvis (or set a GH_TOKEN with `repo` and `read:org` scopes) and ask me again.");
218
+ }
219
+ throw new GhSetupError("auth", `Could not check the GitHub CLI's login: ${firstLine(detail) || "unknown error"}. Try \`gh auth status\` on the machine running Jarvis.`);
220
+ }
221
+ const login = /account (\S+)/.exec(out)?.[1] ?? "";
222
+ const scopes = Array.from(out.matchAll(/'([^']+)'/g)).map((m) => m[1]);
223
+ if (!login) {
224
+ // gh exited 0 but named no account: a token with no user, or an unexpected format.
225
+ const me = await ghRaw(["api", "user", "--jq", ".login"]).catch(() => "");
226
+ if (!me.trim())
227
+ throw new GhSetupError("auth", "The GitHub CLI is not logged in to an account I can use. Run `gh auth login` on the machine running Jarvis and ask me again.");
228
+ authCache = { at: Date.now(), login: me.trim(), scopes };
229
+ return authCache;
230
+ }
231
+ authCache = { at: Date.now(), login, scopes };
232
+ return authCache;
233
+ }
234
+ /** The logins to search: the account itself plus every org it belongs to. */
235
+ let ownersCache;
236
+ async function accountOwners() {
237
+ if (ownersCache && Date.now() - ownersCache.at < 10 * 60 * 1000)
238
+ return ownersCache;
239
+ const { login, scopes } = await ghAuth();
240
+ const warnings = [];
241
+ let orgs = [];
242
+ try {
243
+ orgs = (await ghRaw(["api", "user/orgs", "--jq", ".[].login"])).split("\n").map((l) => l.trim()).filter(Boolean);
244
+ }
245
+ catch (err) {
246
+ warnings.push(`I could not list your GitHub organisations, so I only searched your personal repositories (${firstLine(err?.message) || "unknown error"}). Try \`gh auth status\` on the machine running Jarvis.`);
247
+ }
248
+ // Without the read:org scope the orgs endpoint does not fail — it quietly
249
+ // returns only public memberships, so a private org's repos look like they
250
+ // do not exist. Say so whenever the scope is absent.
251
+ if (scopes.length && !scopes.includes("read:org")) {
252
+ warnings.push(`The GitHub CLI's token has no \`read:org\` scope, so I can only see organisations you are a public member of${orgs.length ? ` (${orgs.join(", ")})` : ""} — private ones are invisible to me. To fix it, run \`gh auth refresh -s read:org\` on the machine running Jarvis.`);
253
+ }
254
+ ownersCache = { at: Date.now(), owners: [login, ...orgs], warnings };
255
+ return ownersCache;
256
+ }
257
+ async function findRepos({ query, owner, limit }) {
258
+ const warnings = [];
259
+ let owners;
260
+ if (owner) {
261
+ await ghAuth();
262
+ owners = [owner];
263
+ }
264
+ else {
265
+ const found = await accountOwners();
266
+ owners = found.owners;
267
+ warnings.push(...found.warnings);
268
+ }
269
+ const perOwner = Math.max(limit, 100);
270
+ const searched = [];
271
+ const lists = await Promise.all(owners.map(async (o) => {
272
+ try {
273
+ const out = await ghRaw(["repo", "list", o, "--limit", String(perOwner), "--json", "nameWithOwner,description,url,isPrivate,primaryLanguage,updatedAt"]);
274
+ searched.push(o);
275
+ return JSON.parse(out);
276
+ }
277
+ catch (err) {
278
+ // One unreadable org should not sink the whole search, but the user
279
+ // should know their results are incomplete and why.
280
+ warnings.push(`Could not list repositories for "${o}": ${firstLine(err?.message) || "unknown error"}.`);
281
+ return [];
282
+ }
283
+ }));
284
+ const terms = (query ?? "").toLowerCase().split(/\s+/).filter(Boolean);
285
+ const repos = lists
286
+ .flat()
287
+ .map((r) => ({
288
+ nameWithOwner: r.nameWithOwner,
289
+ description: r.description ?? "",
290
+ url: r.url,
291
+ isPrivate: !!r.isPrivate,
292
+ language: r.primaryLanguage?.name ?? null,
293
+ updatedAt: r.updatedAt,
294
+ }))
295
+ .filter((r) => terms.every((t) => `${r.nameWithOwner} ${r.description}`.toLowerCase().includes(t)))
296
+ .sort((a, b) => score(b, terms) - score(a, terms) || b.updatedAt.localeCompare(a.updatedAt))
297
+ .slice(0, limit);
298
+ return { repos, searched, warnings };
299
+ }
300
+ /** A hit in the repo name beats one that only matched the description. */
301
+ function score(r, terms) {
302
+ const name = r.nameWithOwner.split("/")[1]?.toLowerCase() ?? "";
303
+ return terms.filter((t) => name.includes(t)).length;
304
+ }
305
+ /** One gh call. Setup problems are classified; everything else is the caller's to handle. */
306
+ async function ghRaw(args) {
307
+ try {
308
+ const { stdout } = await run("gh", args, { maxBuffer: 8 * 1024 * 1024, timeout: 30000 });
309
+ return stdout;
310
+ }
311
+ catch (err) {
312
+ if (err?.code === "ENOENT")
313
+ throw new GhSetupError("missing", `The GitHub CLI (gh) is not installed on the machine running Jarvis. To fix it, ${INSTALL_HINT}, then run \`gh auth login\`.`);
314
+ const detail = firstLine(String(err?.stderr || err?.message || ""));
315
+ if (/not logged|gh auth login|bad credentials|401/i.test(detail)) {
316
+ authCache = undefined;
317
+ throw new GhSetupError("auth", "The GitHub CLI's login has expired or was revoked. To fix it, run `gh auth login` on the machine running Jarvis and ask me again.");
318
+ }
319
+ throw new Error(`gh ${args[0]} ${args[1] ?? ""}`.trim() + ` failed: ${detail || "unknown error"}`);
320
+ }
321
+ }
322
+ function firstLine(s) {
323
+ return String(s ?? "").trim().split("\n")[0].trim();
324
+ }
package/dist/jarvis.js CHANGED
@@ -284,6 +284,12 @@ TOOLS
284
284
  - create_project: register a project from a repo URL (cloned into the workspace) or an existing
285
285
  folder. Use it once you know enough; then continue the original request.
286
286
  - update_project: rewrite a project's notes.
287
+ - find_repos: search the user's GitHub for a repository by name or words in its description.
288
+ It covers their own account and every organisation they belong to, private repos included;
289
+ the result says which owners it actually searched. Use it to turn a project name into a repo
290
+ URL. If it reports that the GitHub CLI is missing, logged out or short a scope, tell the user
291
+ straight away, with the command that fixes it — only they can fix it, and until then any
292
+ search of theirs is incomplete or impossible.
287
293
  - delegate: give a project agent a bounded task and get its report back. It blocks until the
288
294
  agent finishes, which may take minutes. You may call it for several projects at once.
289
295
  - handoff: move the conversation into a project. The project agent takes over from the next
@@ -304,8 +310,9 @@ UNKNOWN PROJECTS
304
310
  - If the user names a project you do not have, do not fail and do not poke at the folder
305
311
  yourself. Check list_projects: an unclaimed workspace folder with a matching name is almost
306
312
  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.
313
+ original request through the new project agent. If nothing matches, call find_repos: one
314
+ clear hit on GitHub is it, so create_project with its URL (which clones it) and continue.
315
+ Only when that is ambiguous or empty, ask the user which repository it is.
309
316
 
310
317
  RULES
311
318
  - Project agents may edit code, run tests and open branches, but nothing is merged, pushed to a
@@ -400,7 +400,11 @@ async function createHttpServer(opts) {
400
400
  const server = node_http_1.default.createServer();
401
401
  // Serve the SPA for GET /
402
402
  server.on("request", (req, res) => {
403
- if (req.method === "GET" && (req.url === "/" || req.url === "")) {
403
+ // Match on the path alone: the API listener skips the root, so a root URL
404
+ // carrying a query string ("/?v=2") would otherwise be answered by nobody
405
+ // and hang until the client gave up.
406
+ const path = (req.url ?? "/").split("?")[0];
407
+ if (req.method === "GET" && (path === "/" || path === "")) {
404
408
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
405
409
  // Function replacement: the vendor bundles contain `$&`-style sequences
406
410
  // that a string replacement would interpret.
@@ -17,8 +17,20 @@ const crypto_1 = require("crypto");
17
17
  const server_common_1 = require("./server-common");
18
18
  const bot_store_1 = require("./bot-store");
19
19
  const jarvis_tools_1 = require("./jarvis-tools");
20
+ const context_window_1 = require("./context-window");
20
21
  // Where Jarvis's project checkouts live; set once by the server at startup.
21
22
  let workspaceCwd = process.cwd();
23
+ /**
24
+ * The model every Claude Code session runs unless a bot names its own. Jarvis
25
+ * and its project agents both reason about whole repositories, so the default
26
+ * is the strongest model rather than the cheapest.
27
+ *
28
+ * The [1m] suffix is what the agent SDK reads to budget a 1M-token context
29
+ * instead of 200K; it asks for the long-context beta and is stripped before
30
+ * the request reaches the API. Long sessions in a large checkout then run
31
+ * much further before compaction, at the cost of burning rate limits faster.
32
+ */
33
+ const DEFAULT_MODEL = "claude-opus-5[1m]";
22
34
  function setWorkspaceCwd(cwd) { workspaceCwd = cwd; }
23
35
  async function initAgent() {
24
36
  try {
@@ -82,10 +94,13 @@ async function runAgent(store) {
82
94
  env: { ...process.env, CLAUDE_CODE_STREAM_CLOSE_TIMEOUT: String(6 * 60 * 60 * 1000) },
83
95
  }
84
96
  : {};
97
+ // Settle the model before the run rather than at the call, so the context
98
+ // meter can name it and size its window.
99
+ store.model = store.model ?? DEFAULT_MODEL;
85
100
  const q = (0, claude_agent_sdk_1.query)({
86
101
  prompt: promptParam,
87
102
  options: {
88
- model: store.model ?? "claude-sonnet-4-6",
103
+ model: store.model,
89
104
  permissionMode: store.mode === "plan" ? "plan" : "default",
90
105
  abortController,
91
106
  includePartialMessages: true,
@@ -143,6 +158,18 @@ async function runAgent(store) {
143
158
  }
144
159
  if (msg.type === "result")
145
160
  receivedResult = true;
161
+ // The context meter: an assistant message reports what the request
162
+ // carried and what it wrote, which together are what the next request
163
+ // will resend. The result message knows the window the CLI budgeted.
164
+ if (msg.type === "assistant") {
165
+ const used = (0, context_window_1.tokensInContext)(msg.message?.usage);
166
+ if (used > 0)
167
+ reportContext(store, used);
168
+ }
169
+ else if (msg.type === "result") {
170
+ const window = msg.modelUsage?.[store.model ?? ""]?.contextWindow;
171
+ reportContext(store, store.context?.used ?? 0, window, true);
172
+ }
146
173
  if (!modelLogged && msg.type === "assistant" && msg.message?.model) {
147
174
  modelLogged = true;
148
175
  }
@@ -555,3 +582,19 @@ function formatToolInput(toolName, input) {
555
582
  return JSON.stringify(input);
556
583
  }
557
584
  }
585
+ /**
586
+ * Publishes how full the window is: live to the open thread, and — once the
587
+ * turn ends — onto the thread itself, so reopening it days later still shows
588
+ * where the conversation stands rather than nothing until the next turn.
589
+ */
590
+ function reportContext(store, used, window, persist = false) {
591
+ if (!used)
592
+ return;
593
+ const next = (0, context_window_1.contextUsage)(used, store.model, window);
594
+ const changed = JSON.stringify(next) !== JSON.stringify(store.context);
595
+ store.context = next;
596
+ if (changed)
597
+ (0, server_common_1.emitEvent)(store, "context", next);
598
+ if (persist && store.threadId)
599
+ (0, bot_store_1.updateThread)(store.threadId, { context: next });
600
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "jarvis-hq",
3
- "version": "0.0.3",
4
- "description": "Jarvis \u2014 a persistent AI engineering partner. One entry point from any device; project-aware handoff and delegation to Claude Code running on your machine.",
3
+ "version": "0.0.5",
4
+ "description": "Jarvis — a persistent AI engineering partner. One entry point from any device; project-aware handoff and delegation to Claude Code running on your machine.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
7
7
  "jarvis": "dist/index.js"
@@ -11,7 +11,7 @@
11
11
  "skills"
12
12
  ],
13
13
  "scripts": {
14
- "build": "tsc",
14
+ "build": "tsc && chmod +x dist/index.js",
15
15
  "dev": "tsx src/index.ts",
16
16
  "prepublishOnly": "npm run build"
17
17
  },
package/dist/client.js DELETED
@@ -1,118 +0,0 @@
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.client = client;
7
- const node_http_1 = __importDefault(require("node:http"));
8
- const node_os_1 = require("node:os");
9
- const node_child_process_1 = require("node:child_process");
10
- const qrcode_terminal_1 = __importDefault(require("qrcode-terminal"));
11
- const client_html_1 = require("./client-html");
12
- const PORT = 3001;
13
- function getLocalIP() {
14
- const nets = (0, node_os_1.networkInterfaces)();
15
- for (const name of Object.keys(nets)) {
16
- for (const net of nets[name]) {
17
- if (net.family === "IPv4" && !net.internal) {
18
- return net.address;
19
- }
20
- }
21
- }
22
- return "localhost";
23
- }
24
- function getTailscaleIP() {
25
- return new Promise((resolve) => {
26
- (0, node_child_process_1.execFile)("tailscale", ["ip", "-4"], { timeout: 2000 }, (err, stdout) => {
27
- if (err)
28
- return resolve(null);
29
- const ip = stdout.trim().split("\n")[0];
30
- resolve(ip || null);
31
- });
32
- });
33
- }
34
- async function client() {
35
- const server = node_http_1.default.createServer((_req, res) => {
36
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
37
- res.end(client_html_1.html);
38
- });
39
- server.listen(PORT, async () => {
40
- const ip = getLocalIP();
41
- const tsIP = await getTailscaleIP();
42
- const urls = [
43
- { label: "My Local Network", url: `http://${ip}:${PORT}` },
44
- ];
45
- if (tsIP) {
46
- urls.push({ label: "Tailscale", url: `http://${tsIP}:${PORT}` });
47
- }
48
- // Pre-generate all QR codes
49
- const qrCodes = await Promise.all(urls.map((u) => new Promise((resolve) => {
50
- qrcode_terminal_1.default.generate(u.url, { small: true }, (code) => {
51
- resolve(code.trimEnd());
52
- });
53
- })));
54
- // Print header
55
- const headerLines = [
56
- "",
57
- " \x1b[1mgrass client\x1b[0m",
58
- "",
59
- ...urls.map((u) => ` ${u.label.padEnd(9)} ${u.url}`),
60
- "",
61
- ];
62
- for (const line of headerLines)
63
- console.log(line);
64
- // QR cycling state
65
- let qrIdx = urls.length > 1 ? 1 : 0;
66
- let qrLineCount = 0;
67
- function renderQR() {
68
- const hint = urls.length > 1 ? " (\u2191\u2193 to switch)" : "";
69
- const label = ` QR: ${urls[qrIdx].label}${hint}`;
70
- const block = `${label}\n${qrCodes[qrIdx]}`;
71
- const lines = block.split("\n");
72
- // Move cursor up to overwrite previous QR block
73
- if (qrLineCount > 0) {
74
- process.stdout.write(`\x1b[${qrLineCount}A`);
75
- }
76
- for (const line of lines) {
77
- process.stdout.write(`\r${line}\x1b[K\n`);
78
- }
79
- // Clear leftover lines if previous block was taller
80
- for (let i = lines.length; i < qrLineCount; i++) {
81
- process.stdout.write(`\r\x1b[K\n`);
82
- }
83
- qrLineCount = Math.max(lines.length, qrLineCount);
84
- }
85
- renderQR();
86
- // Listen for arrow keys
87
- if (process.stdin.isTTY) {
88
- process.stdin.setRawMode(true);
89
- process.stdin.resume();
90
- process.stdin.on("data", (key) => {
91
- const s = key.toString();
92
- if (s === "\x03") {
93
- shutdown();
94
- return;
95
- }
96
- if (s === "\x1b[A") {
97
- qrIdx = (qrIdx - 1 + urls.length) % urls.length;
98
- renderQR();
99
- }
100
- else if (s === "\x1b[B") {
101
- qrIdx = (qrIdx + 1) % urls.length;
102
- renderQR();
103
- }
104
- });
105
- }
106
- });
107
- const shutdown = () => {
108
- console.log("\nShutting down client...");
109
- if (process.stdin.isTTY) {
110
- process.stdin.setRawMode(false);
111
- }
112
- server.close(() => {
113
- process.exit(0);
114
- });
115
- };
116
- process.on("SIGINT", shutdown);
117
- process.on("SIGTERM", shutdown);
118
- }