meksus 0.3.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.
- package/README.md +67 -0
- package/bin/meksus.mjs +68 -0
- package/package.json +33 -0
- package/src/access.mjs +43 -0
- package/src/api.mjs +33 -0
- package/src/auth.mjs +149 -0
- package/src/config.mjs +42 -0
- package/src/hooks.mjs +168 -0
- package/src/project.mjs +159 -0
- package/src/spool.mjs +110 -0
- package/src/tasks.mjs +169 -0
- package/src/tokens.mjs +85 -0
- package/src/version.mjs +4 -0
- package/src/watch.mjs +90 -0
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# meksus
|
|
2
|
+
|
|
3
|
+
The command-line companion to **[M.E.K.S.U.S.](https://meksus.pages.dev)**: see what your AI coding agents and builds are doing, live, and get a Discord alert when something fails or needs you.
|
|
4
|
+
|
|
5
|
+
> **Private preview.** Anyone can install the CLI. Reporting to M.E.K.S.U.S. needs a signed-in account with access. Right now that means Lore Inc. members and invited accounts; paid plans come later.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install -g meksus
|
|
11
|
+
meksus help
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
You need Node.js 20 or later. On Windows, run these in PowerShell or Windows Terminal.
|
|
15
|
+
|
|
16
|
+
## Connect a repository (about 2 minutes)
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
meksus login # once per computer: opens GitHub in your browser
|
|
20
|
+
cd path/to/your-repo
|
|
21
|
+
meksus project list # your organisations, Personal, and their projects
|
|
22
|
+
meksus project use <owner/project> # writes .meksus at the repository root
|
|
23
|
+
git add .meksus && git commit -m "Add .meksus (M.E.K.S.U.S. project)" && git push
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The project's exact `<owner/project>` name is shown on its page in the dashboard, under **Project settings → Connect this project's repository**. `.meksus` holds identifiers only (project name and id), never a key, so it's safe to commit.
|
|
27
|
+
|
|
28
|
+
## Watch Claude Code and your builds
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
meksus hooks install # in the repo: adds Claude Code hooks to .claude/settings.local.json
|
|
32
|
+
meksus task-lines steps # optional: show the step Claude is on, in one line
|
|
33
|
+
meksus watch --label "build" -- npm run build # any command; a failure opens an incident and alerts Discord
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Start a new Claude Code session after installing the hooks. It then appears on the dashboard's **Agents** page: working, waiting for you, finished, or stuck.
|
|
37
|
+
|
|
38
|
+
## Check
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
meksus status # who you're signed in as, whether you have access, and this repo's project
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## What is sent
|
|
45
|
+
|
|
46
|
+
**Status only:** session state, timings, tool-call counts and token totals, plus a command's exit code for `meksus watch`. Code, prompts and command output never leave your machine.
|
|
47
|
+
|
|
48
|
+
With `meksus task-lines` turned on (it's off by default), two more things are sent:
|
|
49
|
+
- the title of the step Claude is working on;
|
|
50
|
+
- the paths of files it's editing or reading, relative to the repository (never contents).
|
|
51
|
+
|
|
52
|
+
The server scrubs anything that looks like a secret.
|
|
53
|
+
|
|
54
|
+
## Commands
|
|
55
|
+
|
|
56
|
+
| Command | What it does |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `meksus login` / `logout` | Sign in with GitHub, or sign out |
|
|
59
|
+
| `meksus status` | Account, access, and this repo's project |
|
|
60
|
+
| `meksus project list` | Your organisations, Personal, and their projects |
|
|
61
|
+
| `meksus project create <name> [--in <parent>] [--use]` | Create a project (owners and admins) |
|
|
62
|
+
| `meksus project use <owner/project>` | Write this repo's `.meksus` |
|
|
63
|
+
| `meksus hooks install` / `uninstall` | Watch Claude Code in this repo |
|
|
64
|
+
| `meksus task-lines off \| steps \| prompt` | Opt-in one-line "working on" |
|
|
65
|
+
| `meksus watch [--label <name>] -- <cmd>` | Run a command and report how it ends |
|
|
66
|
+
|
|
67
|
+
© Lore Inc. All rights reserved.
|
package/bin/meksus.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// M.E.K.S.U.S. CLI. Zero dependencies; Node ≥ 20.
|
|
3
|
+
import { hasAccess, PREVIEW_TEXT, requireAccess } from "../src/access.mjs";
|
|
4
|
+
import { login, logout } from "../src/auth.mjs";
|
|
5
|
+
import { install, runHook, uninstall } from "../src/hooks.mjs";
|
|
6
|
+
import { apiBase, dir, readConfig } from "../src/config.mjs";
|
|
7
|
+
import { projectCommand, readProjectFile } from "../src/project.mjs";
|
|
8
|
+
import { flush } from "../src/spool.mjs";
|
|
9
|
+
import { taskLinesCommand } from "../src/tasks.mjs";
|
|
10
|
+
import { VERSION } from "../src/version.mjs";
|
|
11
|
+
import { watch } from "../src/watch.mjs";
|
|
12
|
+
|
|
13
|
+
const HELP = `M.E.K.S.U.S. CLI ${VERSION}
|
|
14
|
+
|
|
15
|
+
meksus login [--no-browser] Sign in with GitHub (your account, not this machine)
|
|
16
|
+
meksus logout End this sign-in
|
|
17
|
+
meksus status Who you are, and which project this repo reports to
|
|
18
|
+
meksus project list Your organisations, Personal, and their projects
|
|
19
|
+
meksus project create <name> [--in <parent>] [--slug <slug>] [--use]
|
|
20
|
+
meksus project use <owner/project> Write this repo's .meksus file (commit it)
|
|
21
|
+
meksus hooks install | uninstall Watch Claude Code in the current repository
|
|
22
|
+
meksus task-lines [off | steps | prompt] Show what Claude is working on, in one line (opt-in)
|
|
23
|
+
meksus watch [--label <name>] -- <cmd> … Run a command and report its status; a failure opens an incident
|
|
24
|
+
|
|
25
|
+
Status only: code, prompts and command output never leave your machine. With task-lines on, the title
|
|
26
|
+
of the step Claude is on (and, in "prompt" mode, each prompt's first line) is sent as well.
|
|
27
|
+
Access: M.E.K.S.U.S. is in private preview (Lore Inc. members and invited accounts). Anyone can install
|
|
28
|
+
the CLI; reporting needs a signed-in account with access.
|
|
29
|
+
Config: ${dir()} (override with MEKSUS_CONFIG_DIR). API: MEKSUS_API.`;
|
|
30
|
+
|
|
31
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
32
|
+
const flag = (name) => rest.includes(name);
|
|
33
|
+
|
|
34
|
+
let code = 0;
|
|
35
|
+
switch (command) {
|
|
36
|
+
case "login": code = await login({ noBrowser: flag("--no-browser") }); break;
|
|
37
|
+
case "logout": code = await logout(); break;
|
|
38
|
+
case "status": code = await status(); break;
|
|
39
|
+
case "project": case "projects": code = (await requireAccess()) ? await projectCommand(rest) : 1; break;
|
|
40
|
+
case "hooks":
|
|
41
|
+
if (rest[0] === "install") code = (await requireAccess()) ? install() : 1;
|
|
42
|
+
else if (rest[0] === "uninstall") code = uninstall();
|
|
43
|
+
else { console.error("Usage: meksus hooks install | uninstall"); code = 2; }
|
|
44
|
+
break;
|
|
45
|
+
case "hook": code = await runHook(); break; // called by Claude Code, silent
|
|
46
|
+
case "task-lines": code = taskLinesCommand(rest); break;
|
|
47
|
+
case "flush": await flush(); break; // internal, detached
|
|
48
|
+
case "watch": code = await watch(rest); break;
|
|
49
|
+
case "--version": case "-v": case "version": console.log(VERSION); break;
|
|
50
|
+
default: console.log(HELP); code = command && command !== "help" && command !== "--help" ? 2 : 0;
|
|
51
|
+
}
|
|
52
|
+
process.exitCode = code;
|
|
53
|
+
// login/logout are one-shot: exit as soon as the session is saved, whatever a socket or timer is still doing.
|
|
54
|
+
if (command === "login" || command === "logout") process.exit(code);
|
|
55
|
+
|
|
56
|
+
async function status() {
|
|
57
|
+
const c = readConfig();
|
|
58
|
+
const project = readProjectFile();
|
|
59
|
+
if (!c?.session) console.log("Not signed in. Run `meksus login`.");
|
|
60
|
+
else {
|
|
61
|
+
console.log(`Signed in as ${c.user?.name ?? "?"}${c.user?.email ? ` (${c.user.email})` : ""} since ${c.signed_in_at}`);
|
|
62
|
+
const ok = await hasAccess({ fresh: true });
|
|
63
|
+
console.log(ok === true ? "Access: yes" : ok === false ? `Access: no. ${PREVIEW_TEXT}` : "Access: couldn't check right now (offline?)");
|
|
64
|
+
}
|
|
65
|
+
console.log(project ? `This repo reports to ${project.name ?? project.id} (${project.file})` : "This repo has no .meksus file: nothing here is reported. `meksus project use <owner/project>` adds one.");
|
|
66
|
+
console.log(`API ${apiBase(c)}`);
|
|
67
|
+
return c?.session ? 0 : 1;
|
|
68
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "meksus",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "M.E.K.S.U.S. CLI: see what your AI coding agents (Claude Code) and builds are doing, live, and get Discord alerts when they fail or need you. Status only: code, prompts and output never leave your machine.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"meksus",
|
|
7
|
+
"claude-code",
|
|
8
|
+
"ai-agents",
|
|
9
|
+
"monitoring",
|
|
10
|
+
"discord",
|
|
11
|
+
"alerts",
|
|
12
|
+
"cli",
|
|
13
|
+
"devops"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://meksus.pages.dev",
|
|
16
|
+
"author": "Lore Inc.",
|
|
17
|
+
"license": "UNLICENSED",
|
|
18
|
+
"type": "module",
|
|
19
|
+
"bin": {
|
|
20
|
+
"meksus": "bin/meksus.mjs"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"bin",
|
|
24
|
+
"src",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/access.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Who may use M.E.K.S.U.S. right now (access gate, M5 slice C): the database decides, per signed-in
|
|
2
|
+
// user (Lore Inc. members, the comp list, and later paid plans). The CLI only asks the same yes/no
|
|
3
|
+
// question the dashboard asks (public.my_access) so it can explain itself instead of failing quietly.
|
|
4
|
+
// Every server path enforces the gate anyway; this is for clear messages, not for security.
|
|
5
|
+
import { getAccessToken } from "./auth.mjs";
|
|
6
|
+
import { readConfig, writeConfig } from "./config.mjs";
|
|
7
|
+
|
|
8
|
+
const CACHE_MS = 60 * 60_000; // re-asked at most hourly (hooks run constantly; this must stay cheap)
|
|
9
|
+
|
|
10
|
+
export const PREVIEW_TEXT = "M.E.K.S.U.S. is in private preview. Your account doesn't have access yet: it's open to Lore Inc. members and invited accounts (paid plans come later). See https://meksus.pages.dev";
|
|
11
|
+
|
|
12
|
+
/** true / false, or null when it can't be determined (offline, signed out). */
|
|
13
|
+
export async function hasAccess({ fresh = false, config = readConfig() } = {}) {
|
|
14
|
+
if (!config?.session) return null;
|
|
15
|
+
const c = config.access;
|
|
16
|
+
if (!fresh && c && Date.now() - c.at < CACHE_MS && c.user === config.user?.id) return c.ok;
|
|
17
|
+
const token = await getAccessToken(config);
|
|
18
|
+
if (!token) return null;
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(`${config.supabase_url.replace(/\/$/, "")}/rest/v1/rpc/my_access`, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: { apikey: config.supabase_publishable_key, Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
23
|
+
body: "{}",
|
|
24
|
+
signal: AbortSignal.timeout(8000),
|
|
25
|
+
});
|
|
26
|
+
if (!res.ok) { await res.body?.cancel(); return null; }
|
|
27
|
+
const ok = (await res.json()) === true;
|
|
28
|
+
writeConfig({ ...(readConfig() ?? config), access: { ok, at: Date.now(), user: config.user?.id } });
|
|
29
|
+
return ok;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** For commands that need an account with access: prints why not, returns false. */
|
|
36
|
+
export async function requireAccess() {
|
|
37
|
+
const config = readConfig();
|
|
38
|
+
if (!config?.session) { console.error("✖ Not signed in. Run `meksus login` first."); return false; }
|
|
39
|
+
const ok = await hasAccess({ config });
|
|
40
|
+
if (ok === false) { console.error(`✖ ${PREVIEW_TEXT}`); return false; }
|
|
41
|
+
if (ok === null) console.error("! Couldn't confirm access right now (offline?). Continuing; the server checks again.");
|
|
42
|
+
return true;
|
|
43
|
+
}
|
package/src/api.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Transport: one batch of agent session rows for one project, sent as the signed-in user
|
|
2
|
+
// (Authorization: Bearer <access token>, refreshed when needed) with the project from .meksus.
|
|
3
|
+
import { getAccessToken } from "./auth.mjs";
|
|
4
|
+
import { apiBase, readConfig, writeConfig } from "./config.mjs";
|
|
5
|
+
import { VERSION } from "./version.mjs";
|
|
6
|
+
|
|
7
|
+
/** Sends one batch. Returns the HTTP status (0 on network failure or when signed out). */
|
|
8
|
+
export async function sendAgentEvents(events, { project, config = readConfig(), timeoutMs = 8000 } = {}) {
|
|
9
|
+
if (!project || !events.length) return 0;
|
|
10
|
+
const token = await getAccessToken(config);
|
|
11
|
+
if (!token) return 0;
|
|
12
|
+
try {
|
|
13
|
+
const res = await fetch(`${apiBase(config)}/v1/agent-events`, {
|
|
14
|
+
method: "POST",
|
|
15
|
+
headers: {
|
|
16
|
+
"Content-Type": "application/json",
|
|
17
|
+
"User-Agent": `meksus-cli/${VERSION}`,
|
|
18
|
+
Authorization: `Bearer ${token}`,
|
|
19
|
+
"X-Meksus-Project": project,
|
|
20
|
+
},
|
|
21
|
+
body: JSON.stringify({ events }),
|
|
22
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
23
|
+
});
|
|
24
|
+
// 403 no_access: remember it, so `meksus status` and commands explain it (re-checked hourly).
|
|
25
|
+
if (res.status === 403) {
|
|
26
|
+
const body = await res.json().catch(() => null);
|
|
27
|
+
if (body?.error === "no_access") writeConfig({ ...(readConfig() ?? config), access: { ok: false, at: Date.now(), user: config.user?.id } });
|
|
28
|
+
} else await res.body?.cancel();
|
|
29
|
+
return res.status;
|
|
30
|
+
} catch {
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/auth.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// `meksus login` / `meksus logout`, and a fresh access token for every send.
|
|
2
|
+
//
|
|
3
|
+
// Sign-in is Supabase Auth with GitHub, using PKCE (the same as the web app), so no token is ever
|
|
4
|
+
// typed, pasted or shown:
|
|
5
|
+
// 1. ask the ingest Worker where Supabase lives (/v1/cli/config: public values only)
|
|
6
|
+
// 2. listen on http://127.0.0.1:<random port> for one callback
|
|
7
|
+
// 3. open <supabase>/auth/v1/authorize?provider=github&code_challenge=… The browser comes back to
|
|
8
|
+
// https://<app>/cli-callback, a static page that forwards ?code= to the local listener. The code is
|
|
9
|
+
// useless without this process's verifier, which never leaves it.
|
|
10
|
+
// 4. exchange code + verifier for a session; keep it in config.json (0600)
|
|
11
|
+
// Access tokens last an hour; getAccessToken() refreshes them (rotating refresh token) when needed.
|
|
12
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
13
|
+
import { createServer } from "node:http";
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { apiBase, paths, readConfig, writeConfig } from "./config.mjs";
|
|
16
|
+
import { VERSION } from "./version.mjs";
|
|
17
|
+
|
|
18
|
+
const LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
19
|
+
const b64url = (buf) => buf.toString("base64url");
|
|
20
|
+
|
|
21
|
+
export async function discover(api = apiBase()) {
|
|
22
|
+
const res = await fetch(`${api}/v1/cli/config`, { headers: { "User-Agent": `meksus-cli/${VERSION}` }, signal: AbortSignal.timeout(8000) });
|
|
23
|
+
if (!res.ok) throw new Error(`M.E.K.S.U.S. at ${api} answered HTTP ${res.status}`);
|
|
24
|
+
return res.json();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function login({ noBrowser = false } = {}) {
|
|
28
|
+
// Loaded here, not at the top: access.mjs imports getAccessToken from this module.
|
|
29
|
+
const { hasAccess, PREVIEW_TEXT } = await import("./access.mjs");
|
|
30
|
+
const api = apiBase();
|
|
31
|
+
let cfg;
|
|
32
|
+
try { cfg = await discover(api); } catch (err) { console.error(`✖ Could not reach M.E.K.S.U.S. (${err.message}).`); return 1; }
|
|
33
|
+
|
|
34
|
+
const verifier = b64url(randomBytes(32));
|
|
35
|
+
const challenge = b64url(createHash("sha256").update(verifier).digest());
|
|
36
|
+
const state = b64url(randomBytes(16));
|
|
37
|
+
|
|
38
|
+
let finish;
|
|
39
|
+
const done = new Promise((resolve) => { finish = resolve; });
|
|
40
|
+
const server = createServer((req, res) => {
|
|
41
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
42
|
+
if (url.pathname !== "/callback") { res.writeHead(404).end(); return; }
|
|
43
|
+
const refused = url.searchParams.get("state") === state && url.searchParams.get("error");
|
|
44
|
+
const ok = url.searchParams.get("state") === state && url.searchParams.get("code");
|
|
45
|
+
if (refused) {
|
|
46
|
+
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", Connection: "close" }).end("ok");
|
|
47
|
+
finish({ error: url.searchParams.get("error") });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// Connection: close, so the browser doesn't hold a keep-alive socket that keeps this process running.
|
|
51
|
+
res.writeHead(ok ? 200 : 400, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", Connection: "close" });
|
|
52
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>M.E.K.S.U.S. CLI</title><body style="font:16px system-ui;background:#08090D;color:#e6e8ec;display:grid;place-items:center;height:90vh"><p>${ok ? "✔ Signed in. You can close this tab and return to your terminal." : "✖ This sign-in link doesn't match the one your terminal started. Run <code>meksus login</code> again."}</p>`);
|
|
53
|
+
if (ok) finish(url.searchParams.get("code"));
|
|
54
|
+
});
|
|
55
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
56
|
+
const port = server.address().port;
|
|
57
|
+
|
|
58
|
+
const redirect = `${cfg.app_url.replace(/\/$/, "")}/cli-callback?port=${port}&state=${state}`;
|
|
59
|
+
const authorize = `${cfg.supabase_url.replace(/\/$/, "")}/auth/v1/authorize?${new URLSearchParams({
|
|
60
|
+
provider: "github", redirect_to: redirect, code_challenge: challenge, code_challenge_method: "s256",
|
|
61
|
+
})}`;
|
|
62
|
+
|
|
63
|
+
console.log("\n Sign in to M.E.K.S.U.S. with GitHub\n");
|
|
64
|
+
console.log(` Open: ${authorize}\n`);
|
|
65
|
+
if (!noBrowser) openBrowser(authorize);
|
|
66
|
+
process.stdout.write(" Waiting for the browser");
|
|
67
|
+
const tick = setInterval(() => process.stdout.write("."), 2000);
|
|
68
|
+
let timer;
|
|
69
|
+
const code = await Promise.race([done, new Promise((r) => { timer = setTimeout(() => r(null), LOGIN_TIMEOUT_MS); })]);
|
|
70
|
+
// Release everything that would keep Node alive: the dots, the 5-minute timer, and the listener with any
|
|
71
|
+
// socket the browser left open. bin/meksus.mjs also exits explicitly once login returns.
|
|
72
|
+
clearInterval(tick);
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
server.close();
|
|
75
|
+
server.closeAllConnections();
|
|
76
|
+
if (!code) { console.log("\n\n ✖ Timed out. Run `meksus login` again."); return 1; }
|
|
77
|
+
if (code.error) {
|
|
78
|
+
console.log(code.error === "private_preview" ? `\n\n ✖ ${PREVIEW_TEXT}\n` : `\n\n ✖ Sign-in didn't complete (${code.error}). Run \`meksus login\` again.\n`);
|
|
79
|
+
return 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const res = await fetch(`${cfg.supabase_url.replace(/\/$/, "")}/auth/v1/token?grant_type=pkce`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { apikey: cfg.supabase_publishable_key, "Content-Type": "application/json" },
|
|
85
|
+
body: JSON.stringify({ auth_code: code, code_verifier: verifier }),
|
|
86
|
+
signal: AbortSignal.timeout(10_000),
|
|
87
|
+
});
|
|
88
|
+
const s = await res.json().catch(() => null);
|
|
89
|
+
if (!res.ok || !s?.access_token) { console.log(`\n\n ✖ Sign-in failed (${s?.error_description ?? s?.msg ?? `HTTP ${res.status}`}).`); return 1; }
|
|
90
|
+
|
|
91
|
+
const user = { id: s.user.id, email: s.user.email ?? null, name: s.user.user_metadata?.full_name || s.user.user_metadata?.name || s.user.user_metadata?.user_name || s.user.email };
|
|
92
|
+
// Keep the machine's preferences (task lines) across sign-ins; only the session parts are replaced.
|
|
93
|
+
const prefs = { task_lines: readConfig()?.task_lines };
|
|
94
|
+
writeConfig({ ...prefs, api, supabase_url: cfg.supabase_url, supabase_publishable_key: cfg.supabase_publishable_key, app_url: cfg.app_url, user, session: toSession(s), signed_in_at: new Date().toISOString() });
|
|
95
|
+
// Signed in is not the same as allowed in: without access, say so and don't keep the session.
|
|
96
|
+
if ((await hasAccess({ fresh: true })) === false) {
|
|
97
|
+
await logout({ quiet: true });
|
|
98
|
+
console.log(`\n\n ✖ Signed in as ${user.name}, but ${PREVIEW_TEXT.replace(/^M\.E\.K\.S\.U\.S\. is/, "M.E.K.S.U.S. is")}\n You've been signed out again.\n`);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
console.log(`\n\n ✔ Signed in as ${user.name}${user.email ? ` (${user.email})` : ""}.`);
|
|
102
|
+
console.log(` Session saved to ${paths().config} (readable by you only). \`meksus logout\` ends it.`);
|
|
103
|
+
console.log(" Next: `meksus project list`, then `meksus project use <owner/project>` in a repo.\n");
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function logout({ quiet = false } = {}) {
|
|
108
|
+
const c = readConfig();
|
|
109
|
+
if (!c?.session) { if (!quiet) console.log("Not signed in."); return 0; }
|
|
110
|
+
// End this session on the server too (scope=local: other sign-ins, like the browser, stay).
|
|
111
|
+
try {
|
|
112
|
+
const token = await getAccessToken(c);
|
|
113
|
+
if (token) {
|
|
114
|
+
await fetch(`${c.supabase_url}/auth/v1/logout?scope=local`, { method: "POST", headers: { apikey: c.supabase_publishable_key, Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(8000) });
|
|
115
|
+
}
|
|
116
|
+
} catch { /* forget locally anyway */ }
|
|
117
|
+
writeConfig({ api: c.api, task_lines: c.task_lines });
|
|
118
|
+
if (!quiet) console.log("Signed out. Hooks and `meksus watch` stop reporting until you sign in again.");
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const toSession = (s) => ({ access_token: s.access_token, refresh_token: s.refresh_token, expires_at: s.expires_at ?? Math.floor(Date.now() / 1000) + (s.expires_in ?? 3600) });
|
|
123
|
+
|
|
124
|
+
/** A valid access token, refreshed if it expires within a minute. Null if signed out or refresh failed. */
|
|
125
|
+
export async function getAccessToken(config = readConfig()) {
|
|
126
|
+
const s = config?.session;
|
|
127
|
+
if (!s?.refresh_token) return null;
|
|
128
|
+
if (s.access_token && s.expires_at - 60 > Date.now() / 1000) return s.access_token;
|
|
129
|
+
try {
|
|
130
|
+
const res = await fetch(`${config.supabase_url}/auth/v1/token?grant_type=refresh_token`, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: { apikey: config.supabase_publishable_key, "Content-Type": "application/json" },
|
|
133
|
+
body: JSON.stringify({ refresh_token: s.refresh_token }),
|
|
134
|
+
signal: AbortSignal.timeout(8000),
|
|
135
|
+
});
|
|
136
|
+
const next = await res.json().catch(() => null);
|
|
137
|
+
if (!res.ok || !next?.access_token) return null;
|
|
138
|
+
// Re-read before writing: another meksus process may have refreshed meanwhile.
|
|
139
|
+
writeConfig({ ...(readConfig() ?? config), session: toSession(next) });
|
|
140
|
+
return next.access_token;
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function openBrowser(url) {
|
|
147
|
+
const [cmd, args] = process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
|
|
148
|
+
try { spawn(cmd, args, { stdio: "ignore", detached: true, windowsHide: true }).unref(); } catch { /* the URL is printed anyway */ }
|
|
149
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Local state for the CLI. Everything lives in one directory (default ~/.meksus, override with
|
|
2
|
+
// MEKSUS_CONFIG_DIR): config.json (your sign-in session, mode 0600), the event spool, and flush markers.
|
|
3
|
+
// Nothing about this machine is registered with M.E.K.S.U.S. (PRJ-10): the session is yours, like
|
|
4
|
+
// `gh auth login`, and `meksus logout` ends it.
|
|
5
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
// The one account-specific default in the CLI (docs/MIGRATION.md): where the ingest Worker lives.
|
|
10
|
+
// Override with MEKSUS_API for another deployment. Everything else is discovered from /v1/cli/config.
|
|
11
|
+
export const DEFAULT_API = "https://meksus-ingest.shirosakura72.workers.dev";
|
|
12
|
+
|
|
13
|
+
export const dir = () => process.env.MEKSUS_CONFIG_DIR || join(homedir(), ".meksus");
|
|
14
|
+
export const paths = () => ({
|
|
15
|
+
config: join(dir(), "config.json"),
|
|
16
|
+
spool: join(dir(), "spool.jsonl"),
|
|
17
|
+
lock: join(dir(), "flush.lock"),
|
|
18
|
+
lastFlush: join(dir(), "last-flush"),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export function ensureDir() {
|
|
22
|
+
mkdirSync(dir(), { recursive: true, mode: 0o700 });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readConfig() {
|
|
26
|
+
try { return JSON.parse(readFileSync(paths().config, "utf8")); } catch { return null; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function writeConfig(config) {
|
|
30
|
+
ensureDir();
|
|
31
|
+
const tmp = `${paths().config}.${process.pid}.tmp`;
|
|
32
|
+
writeFileSync(tmp, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
33
|
+
renameSync(tmp, paths().config);
|
|
34
|
+
try { chmodSync(paths().config, 0o600); } catch { /* Windows: ACLs of the user profile apply */ }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function apiBase(config = readConfig()) {
|
|
38
|
+
return (process.env.MEKSUS_API || config?.api || DEFAULT_API).replace(/\/$/, "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const isSignedIn = (c = readConfig()) => Boolean(c?.session?.refresh_token);
|
|
42
|
+
export const hasConfigDir = () => existsSync(dir());
|
package/src/hooks.mjs
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Claude Code integration.
|
|
2
|
+
// `meksus hook` the hook command itself: reads the hook JSON on stdin, records a status
|
|
3
|
+
// line, exits 0 and prints NOTHING (some hooks feed stdout back to Claude).
|
|
4
|
+
// `meksus hooks install` adds the hook to <repo>/.claude/settings.local.json (personal, gitignored)
|
|
5
|
+
// `meksus hooks uninstall` removes only our entries
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { readProjectFile } from "./project.mjs";
|
|
11
|
+
import { record } from "./spool.mjs";
|
|
12
|
+
import { taskFrom, taskMode } from "./tasks.mjs";
|
|
13
|
+
|
|
14
|
+
const EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Notification", "Stop", "SessionEnd"];
|
|
15
|
+
const TOOL_EVENTS = new Set(["PreToolUse", "PostToolUse"]);
|
|
16
|
+
const CLI = fileURLToPath(new URL("../bin/meksus.mjs", import.meta.url));
|
|
17
|
+
const MARK = "meksus.mjs\" hook";
|
|
18
|
+
|
|
19
|
+
export function sessionKey(agent, id) {
|
|
20
|
+
return createHash("sha256").update(`${agent}:${id}`).digest("hex").slice(0, 32); // never the raw id
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Maps one Claude Code hook payload to a status line (or null to ignore). Exported for tests. */
|
|
24
|
+
export function toStatus(input, now = new Date()) {
|
|
25
|
+
const name = input?.hook_event_name;
|
|
26
|
+
if (!name || !input.session_id) return null;
|
|
27
|
+
// PRJ-10/13: status belongs to the project named by the repo's .meksus file; no file → not reported.
|
|
28
|
+
const project = typeof input.cwd === "string" ? readProjectFile(input.cwd)?.id : null;
|
|
29
|
+
if (!project) return null;
|
|
30
|
+
const e = {
|
|
31
|
+
project,
|
|
32
|
+
session: sessionKey("claude-code", input.session_id),
|
|
33
|
+
agent: "claude-code",
|
|
34
|
+
label: repoLabel(input.cwd),
|
|
35
|
+
at: now.toISOString(),
|
|
36
|
+
transcript: typeof input.transcript_path === "string" ? input.transcript_path : null,
|
|
37
|
+
};
|
|
38
|
+
switch (name) {
|
|
39
|
+
case "SessionStart": return { ...e, state: "idle", started_at: e.at };
|
|
40
|
+
case "UserPromptSubmit": return { ...e, state: "working" };
|
|
41
|
+
case "PreToolUse": return { ...e, state: "working", tool_call: true };
|
|
42
|
+
case "PostToolUse": return { ...e, state: "working", tool_failure: toolFailed(input.tool_response) };
|
|
43
|
+
case "PostToolUseFailure": return { ...e, state: "working", tool_failure: true };
|
|
44
|
+
case "Notification": return { ...e, state: "waiting" };
|
|
45
|
+
case "Stop": return { ...e, state: "finished" };
|
|
46
|
+
case "SessionEnd": return { ...e, state: "finished", ended: true };
|
|
47
|
+
default: return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function toolFailed(r) {
|
|
52
|
+
if (!r || typeof r !== "object") return false;
|
|
53
|
+
return r.is_error === true || r.success === false || (typeof r.error === "string" && r.error.length > 0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function repoRoot(cwd) {
|
|
57
|
+
let d = resolve(cwd);
|
|
58
|
+
for (let i = 0; i < 20; i++) {
|
|
59
|
+
if (existsSync(join(d, ".git"))) return d;
|
|
60
|
+
const up = dirname(d);
|
|
61
|
+
if (up === d) break;
|
|
62
|
+
d = up;
|
|
63
|
+
}
|
|
64
|
+
return resolve(cwd);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const FILE_TOOLS = { Edit: "edit", MultiEdit: "edit", Write: "edit", NotebookEdit: "edit", Read: "read" };
|
|
68
|
+
|
|
69
|
+
/** Opt-in with task lines: the file a tool is about to touch, as a path inside the repo (a file outside
|
|
70
|
+
* the repo is reduced to its name). Never the contents, never an absolute path. Exported for tests. */
|
|
71
|
+
export function fileFrom(input, mode) {
|
|
72
|
+
if (mode === "off" || input?.hook_event_name !== "PreToolUse") return null;
|
|
73
|
+
const action = FILE_TOOLS[input.tool_name];
|
|
74
|
+
const target = input.tool_input?.file_path ?? input.tool_input?.notebook_path;
|
|
75
|
+
if (!action || typeof target !== "string" || !target || typeof input.cwd !== "string") return null;
|
|
76
|
+
const abs = resolve(input.cwd, target);
|
|
77
|
+
const rel = relative(repoRoot(input.cwd), abs);
|
|
78
|
+
const path = !rel || rel.startsWith("..") || isAbsolute(rel) ? basename(abs) : rel.split("\\").join("/");
|
|
79
|
+
return { file_path: path.slice(0, 160), file_action: action };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** "owner/repo" from the repository's GitHub remote (origin first), or null. Exported for tests. */
|
|
83
|
+
export function githubName(gitConfig) {
|
|
84
|
+
const remotes = [...gitConfig.matchAll(/\[remote "([^"]+)"\][^[]*?\burl\s*=\s*(\S+)/g)].map((m) => ({ name: m[1], url: m[2] }));
|
|
85
|
+
remotes.sort((a, b) => (a.name === "origin" ? -1 : b.name === "origin" ? 1 : 0));
|
|
86
|
+
for (const { url } of remotes) {
|
|
87
|
+
const m = /github\.com[:/]([A-Za-z0-9-]{1,39})\/([A-Za-z0-9._-]{1,100}?)(?:\.git)?\/?$/.exec(url);
|
|
88
|
+
if (m) return `${m[1]}/${m[2]}`;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Label = "owner/repo" as GitHub names it (from the remote), else the repository's folder name; never a path. */
|
|
94
|
+
export function repoLabel(cwd) {
|
|
95
|
+
if (typeof cwd !== "string" || !cwd) return null;
|
|
96
|
+
const root = repoRoot(cwd);
|
|
97
|
+
try {
|
|
98
|
+
const named = githubName(readFileSync(join(root, ".git", "config"), "utf8"));
|
|
99
|
+
if (named) return named.slice(0, 80);
|
|
100
|
+
} catch { /* no git config (worktree file, not a repo): fall back to the folder */ }
|
|
101
|
+
return basename(root).slice(0, 80);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function runHook() {
|
|
105
|
+
try {
|
|
106
|
+
const chunks = [];
|
|
107
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
108
|
+
const input = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
109
|
+
const status = toStatus(input);
|
|
110
|
+
if (status) {
|
|
111
|
+
// Opt-in one-line "working on" from the agent's own step list (`meksus task-lines`).
|
|
112
|
+
const mode = taskMode();
|
|
113
|
+
const task = taskFrom(input, status.session, mode);
|
|
114
|
+
if (task) Object.assign(status, task);
|
|
115
|
+
const file = fileFrom(input, mode);
|
|
116
|
+
if (file) Object.assign(status, file);
|
|
117
|
+
record(status);
|
|
118
|
+
}
|
|
119
|
+
} catch { /* a monitoring hook must never break the agent */ }
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── install / uninstall ─────────────────────────────────────────
|
|
124
|
+
function settingsPath(cwd) {
|
|
125
|
+
let d = resolve(cwd);
|
|
126
|
+
for (let i = 0; i < 20; i++) {
|
|
127
|
+
if (existsSync(join(d, ".git"))) break;
|
|
128
|
+
const up = dirname(d);
|
|
129
|
+
if (up === d) { d = resolve(cwd); break; }
|
|
130
|
+
d = up;
|
|
131
|
+
}
|
|
132
|
+
return join(d, ".claude", "settings.local.json");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function install(cwd = process.cwd()) {
|
|
136
|
+
const file = settingsPath(cwd);
|
|
137
|
+
const settings = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
|
|
138
|
+
settings.hooks ??= {};
|
|
139
|
+
const command = `node "${CLI}" hook`;
|
|
140
|
+
for (const event of EVENTS) {
|
|
141
|
+
const groups = (settings.hooks[event] ??= []);
|
|
142
|
+
const clean = groups.map((g) => ({ ...g, hooks: (g.hooks ?? []).filter((h) => !String(h.command).includes(MARK)) })).filter((g) => g.hooks.length);
|
|
143
|
+
clean.push({ ...(TOOL_EVENTS.has(event) ? { matcher: "*" } : {}), hooks: [{ type: "command", command, timeout: 10 }] });
|
|
144
|
+
settings.hooks[event] = clean;
|
|
145
|
+
}
|
|
146
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
147
|
+
writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`);
|
|
148
|
+
console.log(`✔ Claude Code hooks installed in ${file}`);
|
|
149
|
+
console.log(" Sent: session state, timings, tool-call counts and token totals. Never code, prompts or output.");
|
|
150
|
+
console.log(" Optional: `meksus task-lines steps` also shows what Claude is working on (its to-do step, else its current action).");
|
|
151
|
+
console.log(" Start a new Claude Code session in this repo (or review them with /hooks) to activate.");
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function uninstall(cwd = process.cwd()) {
|
|
156
|
+
const file = settingsPath(cwd);
|
|
157
|
+
if (!existsSync(file)) { console.log("No hooks file here."); return 0; }
|
|
158
|
+
const settings = JSON.parse(readFileSync(file, "utf8"));
|
|
159
|
+
for (const event of Object.keys(settings.hooks ?? {})) {
|
|
160
|
+
settings.hooks[event] = settings.hooks[event]
|
|
161
|
+
.map((g) => ({ ...g, hooks: (g.hooks ?? []).filter((h) => !String(h.command).includes(MARK)) }))
|
|
162
|
+
.filter((g) => g.hooks.length);
|
|
163
|
+
if (!settings.hooks[event].length) delete settings.hooks[event];
|
|
164
|
+
}
|
|
165
|
+
writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`);
|
|
166
|
+
console.log(`✔ M.E.K.S.U.S. hooks removed from ${file}`);
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
package/src/project.mjs
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Projects (PRJ-2, PRJ-12, PRJ-13).
|
|
2
|
+
// meksus project list your parents (Personal, organisations) and their projects
|
|
3
|
+
// meksus project create <name> [--in <parent>] [--slug <slug>] [--use]
|
|
4
|
+
// meksus project use <owner/project | id> write this repo's .meksus file
|
|
5
|
+
//
|
|
6
|
+
// .meksus is committed to the project's repo so teammates, clones and AI agents can see that
|
|
7
|
+
// M.E.K.S.U.S. tracks it. It holds identifiers only, never a key: the file is public to anyone who
|
|
8
|
+
// can read the repo. Agent status from this repo is attributed to that project.
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { dirname, join, resolve } from "node:path";
|
|
11
|
+
import { getAccessToken } from "./auth.mjs";
|
|
12
|
+
import { apiBase, readConfig } from "./config.mjs";
|
|
13
|
+
|
|
14
|
+
export const PROJECT_FILE = ".meksus";
|
|
15
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
16
|
+
|
|
17
|
+
/** The repo root (nearest folder with .git), or the folder itself. */
|
|
18
|
+
export function repoRoot(cwd = process.cwd()) {
|
|
19
|
+
let d = resolve(cwd);
|
|
20
|
+
for (let i = 0; i < 40; i++) {
|
|
21
|
+
if (existsSync(join(d, ".git"))) return d;
|
|
22
|
+
const up = dirname(d);
|
|
23
|
+
if (up === d) break;
|
|
24
|
+
d = up;
|
|
25
|
+
}
|
|
26
|
+
return resolve(cwd);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The project named by the nearest .meksus file above `cwd`, or null. */
|
|
30
|
+
export function readProjectFile(cwd = process.cwd()) {
|
|
31
|
+
let d = resolve(cwd);
|
|
32
|
+
for (let i = 0; i < 40; i++) {
|
|
33
|
+
const f = join(d, PROJECT_FILE);
|
|
34
|
+
if (existsSync(f)) {
|
|
35
|
+
try {
|
|
36
|
+
const p = JSON.parse(readFileSync(f, "utf8"));
|
|
37
|
+
return UUID.test(p?.id ?? "") ? { id: p.id.toLowerCase(), name: typeof p.project === "string" ? p.project : null, file: f } : null;
|
|
38
|
+
} catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
const up = dirname(d);
|
|
41
|
+
if (up === d) break;
|
|
42
|
+
d = up;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function call(method, path, body) {
|
|
48
|
+
const config = readConfig();
|
|
49
|
+
const token = await getAccessToken(config);
|
|
50
|
+
if (!token) { console.error("✖ Not signed in. Run `meksus login` first."); return null; }
|
|
51
|
+
const res = await fetch(`${apiBase(config)}${path}`, {
|
|
52
|
+
method,
|
|
53
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "User-Agent": "meksus-cli/0.2" },
|
|
54
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
55
|
+
signal: AbortSignal.timeout(10_000),
|
|
56
|
+
}).catch(() => null);
|
|
57
|
+
if (!res) { console.error("✖ Could not reach M.E.K.S.U.S."); return null; }
|
|
58
|
+
const json = await res.json().catch(() => null);
|
|
59
|
+
if (res.status === 401) { console.error("✖ Your sign-in has expired. Run `meksus login` again."); return null; }
|
|
60
|
+
return { status: res.status, json };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const ownerOf = (ws) => ws?.github_owner || ws?.name || "?";
|
|
64
|
+
export const fullName = (ws, p) => `${ownerOf(ws)}/${p.slug}`;
|
|
65
|
+
|
|
66
|
+
async function load() {
|
|
67
|
+
const r = await call("GET", "/v1/cli/projects");
|
|
68
|
+
if (!r) return null;
|
|
69
|
+
if (r.status !== 200) { console.error(`✖ Could not list projects (${r.json?.error ?? `HTTP ${r.status}`}).`); return null; }
|
|
70
|
+
return r.json;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function projectCommand(args) {
|
|
74
|
+
const [sub, ...rest] = args;
|
|
75
|
+
const value = (flag) => (rest.includes(flag) ? rest[rest.indexOf(flag) + 1] : undefined);
|
|
76
|
+
if (sub === "list" || sub === "ls" || !sub) return list();
|
|
77
|
+
if (sub === "use") return use(rest[0]);
|
|
78
|
+
if (sub === "create") {
|
|
79
|
+
const name = rest.filter((a, i) => !a.startsWith("--") && !["--in", "--slug"].includes(rest[i - 1])).join(" ").trim();
|
|
80
|
+
return create(name, { parent: value("--in"), slug: value("--slug"), use: rest.includes("--use") });
|
|
81
|
+
}
|
|
82
|
+
console.error("Usage: meksus project list | create <name> [--in <parent>] [--slug <slug>] [--use] | use <owner/project>");
|
|
83
|
+
return 2;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function list() {
|
|
87
|
+
const data = await load();
|
|
88
|
+
if (!data) return 1;
|
|
89
|
+
const here = readProjectFile();
|
|
90
|
+
for (const ws of data.workspaces) {
|
|
91
|
+
console.log(`\n ${ws.name}${ws.github_owner ? ` (${ws.github_owner})` : ""} · ${ws.kind === "personal" ? "Personal" : "Organization"}`);
|
|
92
|
+
const mine = data.projects.filter((p) => p.workspace_id === ws.id);
|
|
93
|
+
if (!mine.length) console.log(" (no projects yet)");
|
|
94
|
+
for (const p of mine) {
|
|
95
|
+
const mark = here?.id === p.id ? "▶" : " ";
|
|
96
|
+
console.log(` ${mark} ${fullName(ws, p).padEnd(34)} ${p.name.padEnd(16)} ${p.discord_guild_name ? `Discord: ${p.discord_guild_name}` : "Discord: not bound"}`);
|
|
97
|
+
console.log(` ${p.id}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
console.log(here ? `\n ▶ = this repo (${here.file})\n` : "\n This repo has no .meksus file. `meksus project use <owner/project>` adds one.\n");
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function find(data, ref) {
|
|
105
|
+
if (!ref) return null;
|
|
106
|
+
const r = ref.toLowerCase();
|
|
107
|
+
for (const p of data.projects) {
|
|
108
|
+
const ws = data.workspaces.find((w) => w.id === p.workspace_id);
|
|
109
|
+
if (p.id === r || fullName(ws, p).toLowerCase() === r) return { p, ws };
|
|
110
|
+
}
|
|
111
|
+
const bySlug = data.projects.filter((p) => p.slug.toLowerCase() === r || p.name.toLowerCase() === r);
|
|
112
|
+
return bySlug.length === 1 ? { p: bySlug[0], ws: data.workspaces.find((w) => w.id === bySlug[0].workspace_id) } : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function writeProjectFile(ws, p, root = repoRoot()) {
|
|
116
|
+
const file = join(root, PROJECT_FILE);
|
|
117
|
+
const content = {
|
|
118
|
+
about: "This repository is watched by M.E.K.S.U.S. Identifiers only; never put a key or secret here.",
|
|
119
|
+
project: fullName(ws, p),
|
|
120
|
+
id: p.id,
|
|
121
|
+
};
|
|
122
|
+
writeFileSync(file, `${JSON.stringify(content, null, 2)}\n`);
|
|
123
|
+
return file;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function use(ref) {
|
|
127
|
+
if (!ref) { console.error("Usage: meksus project use <owner/project | id>"); return 2; }
|
|
128
|
+
const data = await load();
|
|
129
|
+
if (!data) return 1;
|
|
130
|
+
const hit = find(data, ref);
|
|
131
|
+
if (!hit) { console.error(`✖ No project "${ref}" among yours. \`meksus project list\` shows them.`); return 1; }
|
|
132
|
+
const file = writeProjectFile(hit.ws, hit.p);
|
|
133
|
+
console.log(`✔ ${file} now names ${fullName(hit.ws, hit.p)}. Commit it so everyone working in this repo reports to the same project.`);
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function create(name, { parent, slug, use: andUse }) {
|
|
138
|
+
if (!name) { console.error("Usage: meksus project create <name> [--in <parent>] [--slug <slug>] [--use]"); return 2; }
|
|
139
|
+
const data = await load();
|
|
140
|
+
if (!data) return 1;
|
|
141
|
+
const p = (parent ?? "").toLowerCase();
|
|
142
|
+
const ws = parent
|
|
143
|
+
? data.workspaces.find((w) => w.id === p || w.name.toLowerCase() === p || (w.github_owner ?? "").toLowerCase() === p)
|
|
144
|
+
: data.workspaces.find((w) => w.kind === "personal");
|
|
145
|
+
if (!ws) { console.error(`✖ No parent "${parent}". Yours: ${data.workspaces.map((w) => w.name).join(", ")}.`); return 1; }
|
|
146
|
+
|
|
147
|
+
const r = await call("POST", "/v1/cli/projects", { workspace: ws.id, name, slug });
|
|
148
|
+
if (!r) return 1;
|
|
149
|
+
if (r.status !== 201) {
|
|
150
|
+
const why = { not_allowed: "only owners and admins can create projects there", slug_taken: "that name is taken in this parent (try --slug)", bad_slug: "use letters, digits, '.', '-' or '_' for --slug", bad_name: "the name must be 1–60 characters", too_many_projects: "this parent already has 100 projects" };
|
|
151
|
+
console.error(`✖ Not created: ${why[r.json?.error] ?? r.json?.error ?? `HTTP ${r.status}`}.`);
|
|
152
|
+
return 1;
|
|
153
|
+
}
|
|
154
|
+
const project = r.json.project;
|
|
155
|
+
console.log(`✔ Created ${fullName(ws, project)} (${project.id}).`);
|
|
156
|
+
console.log(" Its Discord server and signing key are on the project page in the dashboard.");
|
|
157
|
+
if (andUse) console.log(`✔ ${writeProjectFile(ws, project)} written. Commit it.`);
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
package/src/spool.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Local event spool + batched flush (G-1: never one network call per hook event).
|
|
2
|
+
// record(): append one status line to spool.jsonl (atomic small append) and, if due, start a
|
|
3
|
+
// detached `meksus flush` so the caller (a Claude Code hook) returns immediately.
|
|
4
|
+
// flush(): take a lock, move the spool aside, aggregate ONE row per session, send ONE request per
|
|
5
|
+
// project (usually one), then drop the batch whatever happened (G-11: no retry storms).
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { appendFileSync, closeSync, openSync, readFileSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { sendAgentEvents } from "./api.mjs";
|
|
10
|
+
import { ensureDir, paths, readConfig } from "./config.mjs";
|
|
11
|
+
import { transcriptUsage } from "./tokens.mjs";
|
|
12
|
+
|
|
13
|
+
export const FLUSH_EVERY_MS = 15_000;
|
|
14
|
+
const URGENT = new Set(["waiting", "finished", "error"]); // "needs you" / "done": send now
|
|
15
|
+
const CLI = fileURLToPath(new URL("../bin/meksus.mjs", import.meta.url));
|
|
16
|
+
|
|
17
|
+
export function record(event) {
|
|
18
|
+
ensureDir();
|
|
19
|
+
appendFileSync(paths().spool, `${JSON.stringify(event)}\n`);
|
|
20
|
+
if (URGENT.has(event.state) || event.ended || msSinceFlush() > FLUSH_EVERY_MS) startFlusher();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function msSinceFlush() {
|
|
24
|
+
try { return Date.now() - statSync(paths().lastFlush).mtimeMs; } catch { return Infinity; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function startFlusher() {
|
|
28
|
+
try {
|
|
29
|
+
spawn(process.execPath, [CLI, "flush"], { detached: true, stdio: "ignore", windowsHide: true, env: process.env }).unref();
|
|
30
|
+
} catch { /* next event will try again */ }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function flush() {
|
|
34
|
+
const p = paths();
|
|
35
|
+
if (!acquireLock(p.lock)) return 0;
|
|
36
|
+
try {
|
|
37
|
+
const sending = `${p.spool}.${process.pid}.sending`;
|
|
38
|
+
try { renameSync(p.spool, sending); } catch { return 0; } // nothing spooled
|
|
39
|
+
touch(p.lastFlush);
|
|
40
|
+
const lines = readFileSync(sending, "utf8").split("\n").filter(Boolean);
|
|
41
|
+
rmSync(sending, { force: true });
|
|
42
|
+
|
|
43
|
+
const events = lines.map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter((e) => e?.project);
|
|
44
|
+
const config = readConfig();
|
|
45
|
+
if (!events.length || !config?.session) return 0; // signed out, or no .meksus: status is simply discarded
|
|
46
|
+
let status = 0;
|
|
47
|
+
for (const project of new Set(events.map((e) => e.project))) {
|
|
48
|
+
const rows = aggregate(events.filter((e) => e.project === project));
|
|
49
|
+
if (rows.length) status = await sendAgentEvents(rows, { project, config });
|
|
50
|
+
}
|
|
51
|
+
return status;
|
|
52
|
+
} finally {
|
|
53
|
+
rmSync(p.lock, { force: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One row per session: latest state wins, deltas are summed, token totals read from the transcript. */
|
|
58
|
+
export function aggregate(events) {
|
|
59
|
+
const bySession = new Map();
|
|
60
|
+
for (const e of events.sort((a, b) => a.at.localeCompare(b.at))) {
|
|
61
|
+
const cur = bySession.get(e.session) ?? {
|
|
62
|
+
session: e.session, agent: e.agent, label: e.label ?? null, state: e.state, started_at: e.started_at ?? e.at,
|
|
63
|
+
at: e.at, activity_at: e.at, tool_calls: 0, tool_failures: 0, input_tokens: 0, output_tokens: 0,
|
|
64
|
+
exit_code: null, ended: false, transcript: null, project: e.project,
|
|
65
|
+
};
|
|
66
|
+
cur.state = e.state;
|
|
67
|
+
cur.at = e.at;
|
|
68
|
+
cur.activity_at = e.activity_at ?? e.at;
|
|
69
|
+
cur.label = e.label ?? cur.label;
|
|
70
|
+
cur.tool_calls += e.tool_call ? 1 : 0;
|
|
71
|
+
cur.tool_failures += e.tool_failure ? 1 : 0;
|
|
72
|
+
if (e.exit_code !== undefined && e.exit_code !== null) cur.exit_code = e.exit_code;
|
|
73
|
+
if (e.ended) cur.ended = true;
|
|
74
|
+
if (e.transcript) cur.transcript = e.transcript;
|
|
75
|
+
if (e.started_at && e.started_at < cur.started_at) cur.started_at = e.started_at;
|
|
76
|
+
if (typeof e.task === "string") {
|
|
77
|
+
Object.assign(cur, { task: e.task, task_source: e.task_source, task_done: e.task_done ?? 0, task_total: e.task_total ?? 0, task_at: e.at });
|
|
78
|
+
// Every distinct line in the batch, for the agent's live feed (not just the last one).
|
|
79
|
+
const log = (cur.task_log ??= []);
|
|
80
|
+
if (log.at(-1)?.task !== e.task) log.push({ at: e.at, task: e.task, done: e.task_done ?? 0, total: e.task_total ?? 0 });
|
|
81
|
+
if (log.length > 30) log.splice(0, log.length - 30);
|
|
82
|
+
}
|
|
83
|
+
if (typeof e.file_path === "string") {
|
|
84
|
+
const files = (cur.files ??= []).filter((f) => f.path !== e.file_path);
|
|
85
|
+
files.push({ path: e.file_path, action: e.file_action === "read" ? "read" : "edit", at: e.at });
|
|
86
|
+
cur.files = files.slice(-20);
|
|
87
|
+
}
|
|
88
|
+
bySession.set(e.session, cur);
|
|
89
|
+
}
|
|
90
|
+
return [...bySession.values()].map(({ transcript, project, ...row }) => {
|
|
91
|
+
if (transcript) Object.assign(row, transcriptUsage(transcript)); // counted locally; only numbers leave
|
|
92
|
+
return row;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function acquireLock(lock) {
|
|
97
|
+
try {
|
|
98
|
+
closeSync(openSync(lock, "wx"));
|
|
99
|
+
return true;
|
|
100
|
+
} catch {
|
|
101
|
+
try {
|
|
102
|
+
if (Date.now() - statSync(lock).mtimeMs > 30_000) { rmSync(lock, { force: true }); closeSync(openSync(lock, "wx")); return true; }
|
|
103
|
+
} catch { /* someone else got it */ }
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function touch(file) {
|
|
109
|
+
try { utimesSync(file, new Date(), new Date()); } catch { writeFileSync(file, ""); }
|
|
110
|
+
}
|
package/src/tasks.mjs
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// "What is the agent working on right now", in one line (opt-in per machine: `meksus task-lines`).
|
|
2
|
+
//
|
|
3
|
+
// The line comes from the agent's OWN step list, not from your prompt: when Claude Code breaks work into
|
|
4
|
+
// steps it writes a to-do list (TodoWrite, or the newer TaskCreate / TaskUpdate tools) and marks one step
|
|
5
|
+
// in progress at a time. The hook sees those tool calls, so the step title ("Running the test suite") and
|
|
6
|
+
// its position ("3 of 5") are all that's sent. When no step is in progress (or the agent has no to-do tool,
|
|
7
|
+
// e.g. some IDE sessions), the line follows the agent's actions instead: the plain-English label it gives each
|
|
8
|
+
// command ("Build and deploy the web app"), or "Editing app.js" (a file's name, never its path or contents).
|
|
9
|
+
// Mode "prompt" also sends the first line of each prompt (clipped, and scrubbed like incident text on the
|
|
10
|
+
// server). Nothing to say → no line (the dashboard shows nothing rather than a placeholder).
|
|
11
|
+
// off nothing (default)
|
|
12
|
+
// steps step titles, else the current action
|
|
13
|
+
// prompt the same, plus the first line of each prompt
|
|
14
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { basename, join } from "node:path";
|
|
16
|
+
import { dir, readConfig, writeConfig } from "./config.mjs";
|
|
17
|
+
|
|
18
|
+
export const MODES = ["off", "steps", "prompt"];
|
|
19
|
+
const MAX_LINE = 140;
|
|
20
|
+
const MAX_TASKS = 200;
|
|
21
|
+
|
|
22
|
+
export const taskMode = (config = readConfig()) => (MODES.includes(config?.task_lines) ? config.task_lines : "off");
|
|
23
|
+
|
|
24
|
+
const oneLine = (s) => (typeof s === "string" ? s.replace(/\s+/g, " ").trim().slice(0, MAX_LINE) : "");
|
|
25
|
+
|
|
26
|
+
/** First meaningful line of a prompt: skips blank lines and code fences; never the whole prompt. */
|
|
27
|
+
export function promptLine(prompt) {
|
|
28
|
+
if (typeof prompt !== "string") return null;
|
|
29
|
+
let fenced = false;
|
|
30
|
+
for (const raw of prompt.split(/\r?\n/)) {
|
|
31
|
+
const l = raw.trim();
|
|
32
|
+
if (l.startsWith("```")) { fenced = !fenced; continue; }
|
|
33
|
+
if (!l || fenced) continue;
|
|
34
|
+
return oneLine(l.replace(/^[#>*\-\s]+/, "")) || null;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** TodoWrite: the in-progress item's present-tense label, and how far along the list is. */
|
|
40
|
+
export function fromTodos(todos) {
|
|
41
|
+
if (!Array.isArray(todos) || !todos.length) return null;
|
|
42
|
+
const items = todos.filter((t) => t && typeof t === "object");
|
|
43
|
+
const done = items.filter((t) => t.status === "completed").length;
|
|
44
|
+
const current = items.find((t) => t.status === "in_progress");
|
|
45
|
+
if (current) return { task: oneLine(current.activeForm || current.content), task_source: "step", task_done: done, task_total: items.length };
|
|
46
|
+
if (done === items.length) return { task: `All ${items.length} steps done`, task_source: "step", task_done: done, task_total: items.length };
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// TaskCreate / TaskUpdate carry only an id on update, so titles are remembered per session, locally.
|
|
51
|
+
const mapFile = (session) => join(dir(), "tasks", `${session}.json`);
|
|
52
|
+
function readMap(session) { try { return JSON.parse(readFileSync(mapFile(session), "utf8")); } catch { return { next: 1, tasks: {} }; } }
|
|
53
|
+
function writeMap(session, map) {
|
|
54
|
+
mkdirSync(join(dir(), "tasks"), { recursive: true });
|
|
55
|
+
const ids = Object.keys(map.tasks);
|
|
56
|
+
if (ids.length > MAX_TASKS) for (const id of ids.slice(0, ids.length - MAX_TASKS)) delete map.tasks[id];
|
|
57
|
+
writeFileSync(mapFile(session), JSON.stringify(map));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function progress(map, line) {
|
|
61
|
+
const all = Object.values(map.tasks).filter((t) => t.status !== "deleted");
|
|
62
|
+
return { task: line, task_source: "step", task_done: all.filter((t) => t.status === "completed").length, task_total: all.length };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A step is in progress: the step title wins over the action line.
|
|
66
|
+
const stepActive = (map) => map.todo_active === true || Object.values(map.tasks).some((t) => t.status === "in_progress");
|
|
67
|
+
|
|
68
|
+
const fileName = (p) => (typeof p === "string" && p ? basename(p.replace(/\\/g, "/")).slice(0, 80) : null);
|
|
69
|
+
|
|
70
|
+
/** The action line for one tool call, or null when the tool says nothing useful (the last line stays). */
|
|
71
|
+
export function actionLine(tool, args = {}) {
|
|
72
|
+
const described = oneLine(args.description);
|
|
73
|
+
switch (tool) {
|
|
74
|
+
case "Bash": case "PowerShell": return described || "Running a command";
|
|
75
|
+
case "Agent": case "Task": return described || "Working with a sub-agent";
|
|
76
|
+
case "Edit": case "MultiEdit": case "Write": case "NotebookEdit": {
|
|
77
|
+
const f = fileName(args.file_path ?? args.notebook_path);
|
|
78
|
+
return f ? `Editing ${f}` : "Editing code";
|
|
79
|
+
}
|
|
80
|
+
case "Read": { const f = fileName(args.file_path); return f ? `Reading ${f}` : "Reading code"; }
|
|
81
|
+
case "Grep": case "Glob": return "Searching the code";
|
|
82
|
+
case "WebFetch": case "WebSearch": return "Looking something up on the web";
|
|
83
|
+
default: return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const action = (line) => (line ? { task: line, task_source: "step", task_done: 0, task_total: 0 } : null);
|
|
87
|
+
|
|
88
|
+
function createdId(response) {
|
|
89
|
+
const text = typeof response === "string" ? response : JSON.stringify(response ?? "");
|
|
90
|
+
const m = /"id"\s*:\s*"?(\d+)|#(\d+)/.exec(text);
|
|
91
|
+
return m ? m[1] ?? m[2] : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The task fields for one hook payload, or null when it says nothing about the current step. */
|
|
95
|
+
export function taskFrom(input, session, mode) {
|
|
96
|
+
if (mode === "off") return null;
|
|
97
|
+
const tool = input?.tool_name;
|
|
98
|
+
const args = input?.tool_input ?? {};
|
|
99
|
+
const event = input?.hook_event_name;
|
|
100
|
+
|
|
101
|
+
if (event === "PreToolUse" && tool === "TodoWrite") {
|
|
102
|
+
const line = fromTodos(args.todos);
|
|
103
|
+
const map = readMap(session);
|
|
104
|
+
const active = Array.isArray(args.todos) && args.todos.some((t) => t?.status === "in_progress");
|
|
105
|
+
if (map.todo_active !== active) { map.todo_active = active; writeMap(session, map); }
|
|
106
|
+
return line;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (event === "PostToolUse" && tool === "TaskCreate") {
|
|
110
|
+
const map = readMap(session);
|
|
111
|
+
const id = createdId(input.tool_response) ?? String(map.next);
|
|
112
|
+
map.tasks[id] = { subject: oneLine(args.subject), active: oneLine(args.activeForm), status: "pending" };
|
|
113
|
+
map.next = Math.max(map.next, Number(id) + 1 || map.next + 1);
|
|
114
|
+
writeMap(session, map);
|
|
115
|
+
return null; // created, not started
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (event === "PreToolUse" && tool === "TaskUpdate" && args.taskId !== undefined) {
|
|
119
|
+
const map = readMap(session);
|
|
120
|
+
const id = String(args.taskId);
|
|
121
|
+
const t = (map.tasks[id] ??= { subject: "", active: "", status: "pending" });
|
|
122
|
+
if (args.subject) t.subject = oneLine(args.subject);
|
|
123
|
+
if (args.activeForm) t.active = oneLine(args.activeForm);
|
|
124
|
+
if (args.status) t.status = args.status;
|
|
125
|
+
writeMap(session, map);
|
|
126
|
+
if (args.status === "in_progress") return progress(map, t.active || t.subject || `Step ${id}`);
|
|
127
|
+
if (args.status === "completed") {
|
|
128
|
+
const next = Object.values(map.tasks).find((x) => x.status === "in_progress");
|
|
129
|
+
const all = Object.values(map.tasks).filter((x) => x.status !== "deleted");
|
|
130
|
+
if (next) return progress(map, next.active || next.subject);
|
|
131
|
+
if (all.length && all.every((x) => x.status === "completed")) return progress(map, `All ${all.length} steps done`);
|
|
132
|
+
return progress(map, `Done: ${t.subject || `step ${id}`}`);
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Any other tool call: follow the action, unless a to-do step is in progress.
|
|
138
|
+
if (event === "PreToolUse" && tool && !["TaskCreate", "TaskUpdate", "TaskList", "TaskGet"].includes(tool)) {
|
|
139
|
+
const line = actionLine(tool, args);
|
|
140
|
+
return line && !stepActive(readMap(session)) ? action(line) : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (event === "UserPromptSubmit" && mode === "prompt") {
|
|
144
|
+
const line = promptLine(input.prompt);
|
|
145
|
+
return line ? { task: line, task_source: "prompt", task_done: 0, task_total: 0 } : null;
|
|
146
|
+
}
|
|
147
|
+
if (event === "SessionEnd") { try { rmSync(mapFile(session), { force: true }); } catch { /* ignore */ } }
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** `meksus task-lines [off|steps|prompt]` */
|
|
152
|
+
export function taskLinesCommand(args) {
|
|
153
|
+
const want = args[0];
|
|
154
|
+
const config = readConfig() ?? {};
|
|
155
|
+
if (!want) {
|
|
156
|
+
console.log(`Task lines: ${taskMode(config)} (off | steps | prompt)`);
|
|
157
|
+
console.log(" steps the step Claude Code is on (from its own to-do list), e.g. \"Running the test suite · 3 of 5\";");
|
|
158
|
+
console.log(" with no step in progress, its current action, e.g. \"Build and deploy the web app\" or \"Editing app.js\"");
|
|
159
|
+
console.log(" prompt the same, plus the first line of each prompt");
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
if (!MODES.includes(want)) { console.error("Usage: meksus task-lines off | steps | prompt"); return 2; }
|
|
163
|
+
writeConfig({ ...config, task_lines: want });
|
|
164
|
+
console.log(want === "off"
|
|
165
|
+
? "✔ Task lines off: only status, timings and token counts are sent."
|
|
166
|
+
: `✔ Task lines: ${want}. The Agents page shows what Claude is working on (its step titles, else its current action${want === "prompt" ? ", and each prompt's first line" : ""}). Code and output still never leave this machine.`);
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
|
package/src/tokens.mjs
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Token usage for a Claude Code session, read locally from its transcript (JSONL) and from the
|
|
2
|
+
// subagent transcripts next to it, if any. Only counts leave the machine: per-model token numbers,
|
|
3
|
+
// the model id, and the current context size. Never message text.
|
|
4
|
+
// Assistant messages can appear several times while streaming, so usage is de-duplicated by message
|
|
5
|
+
// id (last one wins).
|
|
6
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
const MAX_BYTES = 64 * 1024 * 1024; // skip absurdly large transcripts rather than stall
|
|
10
|
+
const MODEL_ID = /^[a-z0-9][a-z0-9.\-[\]]{0,62}$/;
|
|
11
|
+
|
|
12
|
+
function readUsage(file, into) {
|
|
13
|
+
try {
|
|
14
|
+
if (statSync(file).size > MAX_BYTES) return null;
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
let last = null;
|
|
19
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
20
|
+
if (!line.includes('"usage"')) continue;
|
|
21
|
+
let entry;
|
|
22
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
23
|
+
const msg = entry?.message;
|
|
24
|
+
if (entry?.type !== "assistant" || !msg?.usage) continue;
|
|
25
|
+
// "<synthetic>" (Claude Code's own placeholder messages) and malformed ids are skipped.
|
|
26
|
+
const model = msg.model === undefined ? "unknown" : typeof msg.model === "string" && MODEL_ID.test(msg.model) ? msg.model : null;
|
|
27
|
+
if (!model) continue;
|
|
28
|
+
const row = { model, usage: msg.usage };
|
|
29
|
+
into.set(msg.id ?? entry.uuid ?? `${file}:${into.size}`, row);
|
|
30
|
+
last = row;
|
|
31
|
+
}
|
|
32
|
+
return last;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* {input_tokens, output_tokens, model, context_tokens, usage:{"<model>[@fast]": {in,out,cw5,cw1h,cr,ws}}}
|
|
37
|
+
* or {} when there's nothing to report.
|
|
38
|
+
*/
|
|
39
|
+
export function transcriptUsage(transcriptPath) {
|
|
40
|
+
try {
|
|
41
|
+
const messages = new Map();
|
|
42
|
+
const last = readUsage(transcriptPath, messages);
|
|
43
|
+
// Subagents (Task tool) write their own transcripts in <session>/subagents/*.jsonl.
|
|
44
|
+
const subDir = join(transcriptPath.replace(/\.jsonl$/, ""), "subagents");
|
|
45
|
+
try {
|
|
46
|
+
for (const f of readdirSync(subDir)) if (f.endsWith(".jsonl")) readUsage(join(subDir, f), messages);
|
|
47
|
+
} catch { /* no subagents */ }
|
|
48
|
+
if (!messages.size) return {};
|
|
49
|
+
|
|
50
|
+
const usage = {};
|
|
51
|
+
let input = 0;
|
|
52
|
+
let output = 0;
|
|
53
|
+
for (const { model, usage: u } of messages.values()) {
|
|
54
|
+
const key = u.speed === "fast" ? `${model}@fast` : model;
|
|
55
|
+
const m = (usage[key] ??= { in: 0, out: 0, cw5: 0, cw1h: 0, cr: 0, ws: 0 });
|
|
56
|
+
const cacheWrite = u.cache_creation_input_tokens ?? 0;
|
|
57
|
+
const cw1h = u.cache_creation?.ephemeral_1h_input_tokens ?? 0;
|
|
58
|
+
m.in += u.input_tokens ?? 0;
|
|
59
|
+
m.out += u.output_tokens ?? 0;
|
|
60
|
+
m.cw1h += cw1h;
|
|
61
|
+
m.cw5 += Math.max(0, cacheWrite - cw1h); // unlabelled cache writes are 5-minute writes
|
|
62
|
+
m.cr += u.cache_read_input_tokens ?? 0;
|
|
63
|
+
m.ws += u.server_tool_use?.web_search_requests ?? 0;
|
|
64
|
+
input += (u.input_tokens ?? 0) + cacheWrite + (u.cache_read_input_tokens ?? 0);
|
|
65
|
+
output += u.output_tokens ?? 0;
|
|
66
|
+
}
|
|
67
|
+
const lu = last?.usage;
|
|
68
|
+
return {
|
|
69
|
+
input_tokens: input,
|
|
70
|
+
output_tokens: output,
|
|
71
|
+
model: last?.model ?? null,
|
|
72
|
+
// What the model saw on its latest turn = the session's current context size.
|
|
73
|
+
context_tokens: lu ? (lu.input_tokens ?? 0) + (lu.cache_creation_input_tokens ?? 0) + (lu.cache_read_input_tokens ?? 0) : 0,
|
|
74
|
+
usage,
|
|
75
|
+
};
|
|
76
|
+
} catch {
|
|
77
|
+
return {};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Kept for callers of the M4 API: just the two totals. */
|
|
82
|
+
export function tokenTotals(transcriptPath) {
|
|
83
|
+
const { input_tokens, output_tokens } = transcriptUsage(transcriptPath);
|
|
84
|
+
return input_tokens === undefined ? {} : { input_tokens, output_tokens };
|
|
85
|
+
}
|
package/src/version.mjs
ADDED
package/src/watch.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// `meksus watch [--label <name>] -- <command> [args…]`
|
|
2
|
+
// Runs any process (a test run, a dev server, Codex, …) and reports its status: working while it runs,
|
|
3
|
+
// its last output time (so the dashboard can say "quiet for 6 min"), and how it ended. Output is
|
|
4
|
+
// passed straight through to your terminal and never sent. A non-zero exit opens an incident.
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
import { basename } from "node:path";
|
|
8
|
+
import { sendAgentEvents } from "./api.mjs";
|
|
9
|
+
import { PREVIEW_TEXT } from "./access.mjs";
|
|
10
|
+
import { readConfig } from "./config.mjs";
|
|
11
|
+
import { readProjectFile } from "./project.mjs";
|
|
12
|
+
|
|
13
|
+
const HEARTBEAT_MS = 60_000; // ≤ 1 request per minute while running (G-1/G-2)
|
|
14
|
+
|
|
15
|
+
export async function watch(argv) {
|
|
16
|
+
const sep = argv.indexOf("--");
|
|
17
|
+
const opts = sep >= 0 ? argv.slice(0, sep) : [];
|
|
18
|
+
const cmd = sep >= 0 ? argv.slice(sep + 1) : argv;
|
|
19
|
+
if (!cmd.length) { console.error("Usage: meksus watch [--label <name>] -- <command> [args…]"); return 2; }
|
|
20
|
+
|
|
21
|
+
const labelIdx = opts.indexOf("--label");
|
|
22
|
+
// Default label: program + first non-flag argument ("npm test"), never the full command line (args can hold secrets).
|
|
23
|
+
const label = (labelIdx >= 0 ? opts[labelIdx + 1] : [basename(cmd[0]).replace(/\.(cmd|exe|bat)$/i, ""), cmd[1] && !cmd[1].startsWith("-") ? cmd[1] : null].filter(Boolean).join(" ")).slice(0, 80);
|
|
24
|
+
|
|
25
|
+
const config = readConfig();
|
|
26
|
+
const project = readProjectFile()?.id;
|
|
27
|
+
const denied = config?.access?.ok === false; // learned from login, status or a 403 (re-checked hourly)
|
|
28
|
+
const reporting = Boolean(config?.session && project && !denied);
|
|
29
|
+
if (!config?.session) console.error("meksus: not signed in. Running without reporting (run `meksus login`).");
|
|
30
|
+
else if (denied) console.error(`meksus: ${PREVIEW_TEXT} Running without reporting.`);
|
|
31
|
+
else if (!project) console.error("meksus: this repo has no .meksus file. Running without reporting (run `meksus project use <owner/project>`).");
|
|
32
|
+
|
|
33
|
+
const session = randomBytes(16).toString("hex");
|
|
34
|
+
const startedAt = new Date().toISOString();
|
|
35
|
+
let lastOutput = startedAt;
|
|
36
|
+
const report = (state, extra = {}) =>
|
|
37
|
+
reporting
|
|
38
|
+
? sendAgentEvents([{ session, agent: "watch", label, state, started_at: startedAt, at: new Date().toISOString(), activity_at: lastOutput, ...extra }], { project, config: readConfig(), timeoutMs: 5000 })
|
|
39
|
+
: Promise.resolve(0);
|
|
40
|
+
|
|
41
|
+
report("working");
|
|
42
|
+
|
|
43
|
+
const child = await start(cmd);
|
|
44
|
+
const onOutput = (target) => (chunk) => { lastOutput = new Date().toISOString(); target.write(chunk); };
|
|
45
|
+
child.stdout?.on("data", onOutput(process.stdout));
|
|
46
|
+
child.stderr?.on("data", onOutput(process.stderr));
|
|
47
|
+
|
|
48
|
+
// Alive heartbeat. If the process went silent, activity_at stays old and the dashboard shows "quiet".
|
|
49
|
+
const beat = setInterval(() => report("working"), HEARTBEAT_MS);
|
|
50
|
+
|
|
51
|
+
const forward = (sig) => () => child.kill(sig);
|
|
52
|
+
process.on("SIGINT", forward("SIGINT"));
|
|
53
|
+
process.on("SIGTERM", forward("SIGTERM"));
|
|
54
|
+
|
|
55
|
+
const code = await new Promise((resolve) => {
|
|
56
|
+
child.on("error", (err) => { console.error(`meksus: could not start ${cmd[0]}: ${err.message}`); resolve(127); });
|
|
57
|
+
child.on("close", (c, signal) => resolve(c ?? (signal ? 128 : 1)));
|
|
58
|
+
});
|
|
59
|
+
clearInterval(beat);
|
|
60
|
+
await report(code === 0 ? "finished" : "error", { exit_code: code, ended: true });
|
|
61
|
+
return code;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Process start without a shell (arguments stay exactly as typed) ──────────
|
|
65
|
+
const OPTIONS = { stdio: ["inherit", "pipe", "pipe"], env: { ...process.env, FORCE_COLOR: process.env.FORCE_COLOR ?? "1" } };
|
|
66
|
+
|
|
67
|
+
/** Direct spawn; on Windows, if the program isn't an .exe on PATH (npm, npx, … are .cmd shims), retry via cmd.exe with escaped args. */
|
|
68
|
+
function start(cmd) {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
const direct = spawn(cmd[0], cmd.slice(1), OPTIONS);
|
|
71
|
+
direct.once("spawn", () => resolve(direct));
|
|
72
|
+
direct.once("error", (err) => {
|
|
73
|
+
if (process.platform !== "win32" || err.code !== "ENOENT") return resolve(direct);
|
|
74
|
+
const line = [escapeCommand(cmd[0]), ...cmd.slice(1).map((a) => escapeArg(a, true))].join(" ");
|
|
75
|
+
resolve(spawn(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `"${line}"`], { ...OPTIONS, windowsVerbatimArguments: true }));
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// cmd.exe escaping (same rules as cross-spawn): quote for CommandLineToArgvW, then caret-escape cmd
|
|
81
|
+
// metacharacters; .cmd shims re-parse their arguments, so those need escaping twice.
|
|
82
|
+
const META = /([()\][%!^"`<>&|;, *?])/g;
|
|
83
|
+
export function escapeCommand(s) {
|
|
84
|
+
return s.replace(META, "^$1");
|
|
85
|
+
}
|
|
86
|
+
export function escapeArg(arg, doubleEscape = false) {
|
|
87
|
+
let a = `${arg}`.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"').replace(/(?=(\\+?)?)\1$/, "$1$1");
|
|
88
|
+
a = `"${a}"`.replace(META, "^$1");
|
|
89
|
+
return doubleEscape ? a.replace(META, "^$1") : a;
|
|
90
|
+
}
|