meksus 0.3.1 → 0.5.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 +18 -2
- package/bin/meksus.mjs +14 -2
- package/package.json +1 -1
- package/src/cards.mjs +132 -0
- package/src/hooks.mjs +11 -2
- package/src/spool.mjs +1 -0
- package/src/statusline.mjs +135 -0
- package/src/turns.mjs +41 -0
package/README.md
CHANGED
|
@@ -35,10 +35,20 @@ meksus watch --label "build" -- npm run build # any command; a failure opens an
|
|
|
35
35
|
|
|
36
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
37
|
|
|
38
|
+
## Claude plan limits on the dashboard
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
meksus statusline install --global # once per computer (use --replace if you already have a status line)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Claude Code's status line then shows how much of your plan's 5-hour and weekly limits you've used and when each resets, and the dashboard's **Agents** page shows the same with a live countdown. A Discord DM tells you when a limit resets (turn it off on the Agents page). Claude Code provides these numbers for claude.ai Pro and Max plans, after the first reply in a session.
|
|
45
|
+
|
|
38
46
|
## Check
|
|
39
47
|
|
|
40
48
|
```sh
|
|
41
|
-
meksus status # who you're signed in as,
|
|
49
|
+
meksus status # who you're signed in as, this repo's project, then three cards:
|
|
50
|
+
# AI assistants, services & spending, problems to fix (--all: every project)
|
|
51
|
+
meksus ack 3f2a # acknowledge a problem (the id's first characters are enough); resolve works the same
|
|
42
52
|
```
|
|
43
53
|
|
|
44
54
|
## What is sent
|
|
@@ -51,17 +61,23 @@ With `meksus task-lines` turned on (it's off by default), two more things are se
|
|
|
51
61
|
|
|
52
62
|
The server scrubs anything that looks like a secret.
|
|
53
63
|
|
|
64
|
+
When a Claude turn finishes, three numbers describe its size: how long it took, how many tool calls it made and how many files it changed (counted locally; file names never leave). Discord channels can then ask for big or small finished tasks only.
|
|
65
|
+
|
|
66
|
+
With `meksus statusline` installed, your Claude plan limits are sent too: the used percentage and reset time of the 5-hour and weekly windows, at most once a minute. Nothing else from the status line is sent.
|
|
67
|
+
|
|
54
68
|
## Commands
|
|
55
69
|
|
|
56
70
|
| Command | What it does |
|
|
57
71
|
|---|---|
|
|
58
72
|
| `meksus login` / `logout` | Sign in with GitHub, or sign out |
|
|
59
|
-
| `meksus status` | Account
|
|
73
|
+
| `meksus status [--all] [--json]` | Account and this repo's project, then the dashboard's three cards: AI assistants, services & spending, problems to fix |
|
|
74
|
+
| `meksus ack <id>` · `meksus resolve <id>` | Acknowledge or resolve a problem from the terminal; the Discord card updates for everyone |
|
|
60
75
|
| `meksus project list` | Your organisations, Personal, and their projects |
|
|
61
76
|
| `meksus project create <name> [--in <parent>] [--use]` | Create a project (owners and admins) |
|
|
62
77
|
| `meksus project use <owner/project>` | Write this repo's `.meksus` |
|
|
63
78
|
| `meksus hooks install` / `uninstall` | Watch Claude Code in this repo |
|
|
64
79
|
| `meksus task-lines off \| steps \| prompt` | Opt-in one-line "working on" |
|
|
80
|
+
| `meksus statusline install [--global]` / `uninstall` | Claude plan limits in the status line and on the dashboard |
|
|
65
81
|
| `meksus watch [--label <name>] -- <cmd>` | Run a command and report how it ends |
|
|
66
82
|
|
|
67
83
|
© Lore Inc. All rights reserved.
|
package/bin/meksus.mjs
CHANGED
|
@@ -5,7 +5,9 @@ import { login, logout } from "../src/auth.mjs";
|
|
|
5
5
|
import { install, runHook, uninstall } from "../src/hooks.mjs";
|
|
6
6
|
import { apiBase, dir, readConfig } from "../src/config.mjs";
|
|
7
7
|
import { projectCommand, readProjectFile } from "../src/project.mjs";
|
|
8
|
+
import { incidentCommand, printCards } from "../src/cards.mjs";
|
|
8
9
|
import { flush } from "../src/spool.mjs";
|
|
10
|
+
import { limitsFlush, runStatusline, statuslineCommand } from "../src/statusline.mjs";
|
|
9
11
|
import { taskLinesCommand } from "../src/tasks.mjs";
|
|
10
12
|
import { VERSION } from "../src/version.mjs";
|
|
11
13
|
import { watch } from "../src/watch.mjs";
|
|
@@ -14,12 +16,15 @@ const HELP = `M.E.K.S.U.S. CLI ${VERSION}
|
|
|
14
16
|
|
|
15
17
|
meksus login [--no-browser] Sign in with GitHub (your account, not this machine)
|
|
16
18
|
meksus logout End this sign-in
|
|
17
|
-
meksus status
|
|
19
|
+
meksus status [--all] [--json] Who you are, then AI assistants, services & spend, and problems
|
|
20
|
+
meksus ack <id> | meksus resolve <id> Acknowledge or resolve a problem (id from status; a prefix is enough)
|
|
18
21
|
meksus project list Your organisations, Personal, and their projects
|
|
19
22
|
meksus project create <name> [--in <parent>] [--slug <slug>] [--use]
|
|
20
23
|
meksus project use <owner/project> Write this repo's .meksus file (commit it)
|
|
21
24
|
meksus hooks install | uninstall Watch Claude Code in the current repository
|
|
22
25
|
meksus task-lines [off | steps | prompt] Show what Claude is working on, in one line (opt-in)
|
|
26
|
+
meksus statusline install [--global] Claude's plan limits (5-hour and weekly) in Claude Code's
|
|
27
|
+
status line and on your M.E.K.S.U.S. dashboard
|
|
23
28
|
meksus watch [--label <name>] -- <cmd> … Run a command and report its status; a failure opens an incident
|
|
24
29
|
|
|
25
30
|
Status only: code, prompts and command output never leave your machine. With task-lines on, the title
|
|
@@ -36,6 +41,8 @@ switch (command) {
|
|
|
36
41
|
case "login": code = await login({ noBrowser: flag("--no-browser") }); break;
|
|
37
42
|
case "logout": code = await logout(); break;
|
|
38
43
|
case "status": code = await status(); break;
|
|
44
|
+
case "ack": case "acknowledge": code = await incidentCommand("acknowledge", rest[0]); break;
|
|
45
|
+
case "resolve": code = await incidentCommand("resolve", rest[0]); break;
|
|
39
46
|
case "project": case "projects": code = (await requireAccess()) ? await projectCommand(rest) : 1; break;
|
|
40
47
|
case "hooks":
|
|
41
48
|
if (rest[0] === "install") code = (await requireAccess()) ? install() : 1;
|
|
@@ -44,6 +51,10 @@ switch (command) {
|
|
|
44
51
|
break;
|
|
45
52
|
case "hook": code = await runHook(); break; // called by Claude Code, silent
|
|
46
53
|
case "task-lines": code = taskLinesCommand(rest); break;
|
|
54
|
+
case "statusline":
|
|
55
|
+
code = rest.length ? ((rest[0] === "install" && !(await requireAccess())) ? 1 : statuslineCommand(rest)) : await runStatusline();
|
|
56
|
+
break;
|
|
57
|
+
case "limits-flush": await limitsFlush(); break; // internal, detached
|
|
47
58
|
case "flush": await flush(); break; // internal, detached
|
|
48
59
|
case "watch": code = await watch(rest); break;
|
|
49
60
|
case "--version": case "-v": case "version": console.log(VERSION); break;
|
|
@@ -64,5 +75,6 @@ async function status() {
|
|
|
64
75
|
}
|
|
65
76
|
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
77
|
console.log(`API ${apiBase(c)}`);
|
|
67
|
-
|
|
78
|
+
if (!c?.session) return 1;
|
|
79
|
+
return printCards({ json: flag("--json"), all: flag("--all") });
|
|
68
80
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "meksus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"meksus",
|
package/src/cards.mjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// `meksus status` (the dashboard's three cards in the terminal) and `meksus ack|resolve <id>` (CLI-3/4).
|
|
2
|
+
// Reads go straight to the database as the signed-in user, so its access rules decide what's shown,
|
|
3
|
+
// exactly as on the dashboard. Actions use the same incident_action as the dashboard (recorded with
|
|
4
|
+
// source "cli"), then ask the ingest Worker to redraw the Discord card for everyone.
|
|
5
|
+
import { getAccessToken } from "./auth.mjs";
|
|
6
|
+
import { apiBase, readConfig } from "./config.mjs";
|
|
7
|
+
import { readProjectFile } from "./project.mjs";
|
|
8
|
+
import { VERSION } from "./version.mjs";
|
|
9
|
+
|
|
10
|
+
const color = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
11
|
+
const paint = (code) => (s) => (color ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
12
|
+
const dim = paint("2"), bold = paint("1"), red = paint("31"), orange = paint("33"), green = paint("32"), cyan = paint("36");
|
|
13
|
+
const SEV = { critical: (s) => red(bold(s)), high: red, medium: orange, low: dim };
|
|
14
|
+
|
|
15
|
+
export const shortId = (id) => id.slice(0, 8);
|
|
16
|
+
|
|
17
|
+
async function db(config, token, path, init = {}) {
|
|
18
|
+
const res = await fetch(`${config.supabase_url.replace(/\/$/, "")}/rest/v1/${path}`, {
|
|
19
|
+
...init,
|
|
20
|
+
headers: { apikey: config.supabase_publishable_key, Authorization: `Bearer ${token}`, "Content-Type": "application/json",
|
|
21
|
+
"User-Agent": `meksus-cli/${VERSION}`, "X-Meksus-Source": "cli", ...(init.headers ?? {}) },
|
|
22
|
+
signal: AbortSignal.timeout(10_000),
|
|
23
|
+
});
|
|
24
|
+
const body = await res.json().catch(() => null);
|
|
25
|
+
return { status: res.status, body };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ago = (iso) => {
|
|
29
|
+
const s = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000));
|
|
30
|
+
return s < 60 ? `${s}s ago` : s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago`;
|
|
31
|
+
};
|
|
32
|
+
const clip = (s, n) => (s && s.length > n ? `${s.slice(0, n - 1)}…` : s ?? "");
|
|
33
|
+
const usd = (v) => (typeof v === "number" ? `$${v.toFixed(2)}` : "—");
|
|
34
|
+
|
|
35
|
+
/** The three cards' data for one project (the repo's .meksus) or every project the user can see. */
|
|
36
|
+
export async function loadOverview(config, token, projectId) {
|
|
37
|
+
const only = projectId ? `&project_id=eq.${projectId}` : "";
|
|
38
|
+
const [projects, incidents, agents, connectors] = await Promise.all([
|
|
39
|
+
db(config, token, `projects?select=id,name${projectId ? `&id=eq.${projectId}` : ""}&order=name`),
|
|
40
|
+
db(config, token, `incidents?select=id,title,severity,status,occurrences,last_seen_at,project_id,muted&status=neq.resolved${only}&order=last_seen_at.desc&limit=100`),
|
|
41
|
+
db(config, token, `agent_sessions?select=id,label,agent,state,task,last_event_at,project_id&ended_at=is.null${only}&order=last_event_at.desc&limit=20`),
|
|
42
|
+
db(config, token, `connectors?select=provider,status,snapshot,project_id${only}`),
|
|
43
|
+
]);
|
|
44
|
+
for (const r of [projects, incidents, agents, connectors]) if (r.status === 401) return { error: "signed_out" };
|
|
45
|
+
if ([projects, incidents, agents, connectors].some((r) => !Array.isArray(r.body))) return { error: "unavailable" };
|
|
46
|
+
const name = Object.fromEntries(projects.body.map((p) => [p.id, p.name]));
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
const live = agents.body.filter((a) => now - Date.parse(a.last_event_at) < 30 * 60_000);
|
|
49
|
+
const order = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
50
|
+
const problems = incidents.body.filter((i) => !i.muted)
|
|
51
|
+
.sort((a, b) => (order[a.severity] - order[b.severity]) || Date.parse(b.last_seen_at) - Date.parse(a.last_seen_at));
|
|
52
|
+
return {
|
|
53
|
+
scope: projectId ? name[projectId] ?? "this project" : `${projects.body.length} project${projects.body.length === 1 ? "" : "s"}`,
|
|
54
|
+
agents: live.map((a) => ({ ...a, project: name[a.project_id], quiet: a.state !== "waiting" && now - Date.parse(a.last_event_at) > 5 * 60_000 })),
|
|
55
|
+
spend: connectors.body.map((c) => ({ provider: c.provider, status: c.status, today_usd: c.snapshot?.usage_daily_usd ?? null, remaining_usd: c.snapshot?.remaining_usd ?? null, project: name[c.project_id] })),
|
|
56
|
+
problems: problems.map((i) => ({ ...i, project: name[i.project_id] })),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Plain-text cards (no colour when piped or NO_COLOR is set). */
|
|
61
|
+
export function renderCards(o, { width = 78 } = {}) {
|
|
62
|
+
const out = [];
|
|
63
|
+
const head = (title, note) => { out.push(""); out.push(`${bold(title)}${note ? ` ${dim(note)}` : ""}`); out.push(dim("─".repeat(Math.min(width, 78)))); };
|
|
64
|
+
const waiting = o.agents.filter((a) => a.state === "waiting").length;
|
|
65
|
+
head("AI assistants", o.agents.length ? `${o.agents.length} live · ${waiting} waiting for you` : "none running");
|
|
66
|
+
if (!o.agents.length) out.push(dim(" Claude Code and anything under `meksus watch` show up here while they work."));
|
|
67
|
+
for (const a of o.agents.slice(0, 5)) {
|
|
68
|
+
const state = a.state === "waiting" ? orange("waiting for you") : a.quiet ? orange("gone quiet") : a.state === "working" ? green("working") : a.state;
|
|
69
|
+
out.push(` ${clip(a.label ?? a.agent, 32).padEnd(32)} ${state} ${dim(ago(a.last_event_at))}${a.task ? `\n ${dim("→")} ${clip(a.task, width - 6)}` : ""}`);
|
|
70
|
+
}
|
|
71
|
+
head("Service health & spending", o.spend.length ? `${o.spend.length} connected` : null);
|
|
72
|
+
if (!o.spend.length) out.push(dim(" No services connected yet. Connect one on the dashboard (Integrations / Spend)."));
|
|
73
|
+
for (const s of o.spend) {
|
|
74
|
+
const st = s.status === "ok" ? green("ok") : red(s.status);
|
|
75
|
+
out.push(` ${(s.provider === "openrouter" ? "OpenRouter" : s.provider).padEnd(14)} ${st} ${usd(s.today_usd)} today · ${usd(s.remaining_usd)} left${s.project ? dim(` ${s.project}`) : ""}`);
|
|
76
|
+
}
|
|
77
|
+
const needs = o.problems.filter((p) => p.status === "open").length;
|
|
78
|
+
head("Problems to fix", o.problems.length ? `${o.problems.length} open · ${needs} need a person` : null);
|
|
79
|
+
if (!o.problems.length) out.push(` ${green("All steady.")} ${dim("Nothing needs you.")}`);
|
|
80
|
+
for (const p of o.problems.slice(0, 10)) {
|
|
81
|
+
const sev = (SEV[p.severity] ?? dim)(p.severity.padEnd(8));
|
|
82
|
+
out.push(` ${cyan(shortId(p.id))} ${sev} ${clip(p.title, width - 36)}${p.occurrences > 1 ? dim(` ×${p.occurrences}`) : ""}${p.status === "acknowledged" ? dim(" (ack)") : ""}`);
|
|
83
|
+
out.push(` ${dim(`${p.project ?? ""} · last ${ago(p.last_seen_at)}`)}`);
|
|
84
|
+
}
|
|
85
|
+
if (o.problems.length > 10) out.push(dim(` … and ${o.problems.length - 10} more on the dashboard`));
|
|
86
|
+
if (o.problems.length) out.push(dim("\n meksus ack <id> · meksus resolve <id> (the first characters of the id are enough)"));
|
|
87
|
+
return out.join("\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function session() {
|
|
91
|
+
const config = readConfig();
|
|
92
|
+
if (!config?.session) return { error: "Not signed in. Run `meksus login`." };
|
|
93
|
+
const token = await getAccessToken(config);
|
|
94
|
+
if (!token) return { error: "Your sign-in expired. Run `meksus login`." };
|
|
95
|
+
return { config, token };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Appended to `meksus status` (after who you are and which project this repo reports to). */
|
|
99
|
+
export async function printCards({ json = false, all = false } = {}) {
|
|
100
|
+
const s = await session();
|
|
101
|
+
if (s.error) return 1;
|
|
102
|
+
const project = all ? null : readProjectFile();
|
|
103
|
+
const o = await loadOverview(s.config, s.token, project?.id ?? null);
|
|
104
|
+
if (o.error) { console.error(o.error === "signed_out" ? "Your sign-in expired. Run `meksus login`." : "Couldn't load the cards right now. Try again."); return 1; }
|
|
105
|
+
if (json) { console.log(JSON.stringify(o, null, 2)); return 0; }
|
|
106
|
+
console.log(dim(`\nShowing ${o.scope}${project && !all ? " (this repo's .meksus; --all for every project)" : ""}`));
|
|
107
|
+
console.log(renderCards(o, { width: Math.max(60, Math.min(process.stdout.columns ?? 80, 100)) }));
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** `meksus ack <id>` / `meksus resolve <id>`: the id prefix is matched among open problems you can see. */
|
|
112
|
+
export async function incidentCommand(action, ref) {
|
|
113
|
+
if (!ref || !/^[0-9a-f-]{4,36}$/i.test(ref)) { console.error(`Usage: meksus ${action === "acknowledge" ? "ack" : "resolve"} <id> (see \`meksus status\`)`); return 2; }
|
|
114
|
+
const s = await session();
|
|
115
|
+
if (s.error) { console.error(s.error); return 1; }
|
|
116
|
+
const list = await db(s.config, s.token, "incidents?select=id,title,status,project_id&status=neq.resolved&order=last_seen_at.desc&limit=200");
|
|
117
|
+
if (!Array.isArray(list.body)) { console.error("Couldn't load your problems right now. Try again."); return 1; }
|
|
118
|
+
const matches = list.body.filter((i) => i.id.startsWith(ref.toLowerCase()));
|
|
119
|
+
if (!matches.length) { console.error(`No open problem starts with ${ref}. \`meksus status --all\` lists them.`); return 1; }
|
|
120
|
+
if (matches.length > 1) { console.error(`${ref} matches ${matches.length} problems; type more of the id:\n${matches.map((m) => ` ${m.id} ${m.title}`).join("\n")}`); return 1; }
|
|
121
|
+
const i = matches[0];
|
|
122
|
+
const r = await db(s.config, s.token, "rpc/incident_action", { method: "POST", body: JSON.stringify({ p_incident: i.id, p_action: action }) });
|
|
123
|
+
if (r.status >= 300 || !r.body?.ok) { console.error(`Not done: ${r.body?.error ?? r.body?.message ?? `HTTP ${r.status}`}.`); return 1; }
|
|
124
|
+
// Redraw the Discord card(s) for everyone, like the dashboard does. Best effort.
|
|
125
|
+
let synced = false;
|
|
126
|
+
try {
|
|
127
|
+
const res = await fetch(`${apiBase(s.config)}/v1/incidents/${i.id}/discord-sync`, { method: "POST", headers: { Authorization: `Bearer ${s.token}`, "User-Agent": `meksus-cli/${VERSION}` }, signal: AbortSignal.timeout(10_000) });
|
|
128
|
+
synced = res.ok && (await res.json().catch(() => null))?.synced !== false;
|
|
129
|
+
} catch { /* the next action redraws it */ }
|
|
130
|
+
console.log(`${green("✔")} ${action === "acknowledge" ? "Acknowledged" : "Resolved"}: ${i.title}${synced ? dim(" (Discord card updated)") : ""}`);
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
package/src/hooks.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:pat
|
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import { readProjectFile } from "./project.mjs";
|
|
11
11
|
import { record } from "./spool.mjs";
|
|
12
|
+
import { trackTurn } from "./turns.mjs";
|
|
12
13
|
import { taskFrom, taskMode } from "./tasks.mjs";
|
|
13
14
|
|
|
14
15
|
const EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Notification", "Stop", "SessionEnd"];
|
|
@@ -114,6 +115,8 @@ export async function runHook() {
|
|
|
114
115
|
if (task) Object.assign(status, task);
|
|
115
116
|
const file = fileFrom(input, mode);
|
|
116
117
|
if (file) Object.assign(status, file);
|
|
118
|
+
const turn = trackTurn(input, status.session);
|
|
119
|
+
if (turn) status.turn = turn; // the finished turn's size: three numbers
|
|
117
120
|
record(status);
|
|
118
121
|
}
|
|
119
122
|
} catch { /* a monitoring hook must never break the agent */ }
|
|
@@ -121,7 +124,7 @@ export async function runHook() {
|
|
|
121
124
|
}
|
|
122
125
|
|
|
123
126
|
// ── install / uninstall ─────────────────────────────────────────
|
|
124
|
-
function settingsPath(cwd) {
|
|
127
|
+
export function settingsPath(cwd) {
|
|
125
128
|
let d = resolve(cwd);
|
|
126
129
|
for (let i = 0; i < 20; i++) {
|
|
127
130
|
if (existsSync(join(d, ".git"))) break;
|
|
@@ -134,7 +137,7 @@ function settingsPath(cwd) {
|
|
|
134
137
|
|
|
135
138
|
// Reads the settings file, or explains (and changes nothing) when it isn't valid JSON — often two
|
|
136
139
|
// { … } blocks pasted one after the other, which Claude Code can't read either.
|
|
137
|
-
function readSettings(file) {
|
|
140
|
+
export function readSettings(file) {
|
|
138
141
|
if (!existsSync(file)) return {};
|
|
139
142
|
const text = readFileSync(file, "utf8");
|
|
140
143
|
try {
|
|
@@ -151,6 +154,12 @@ function readSettings(file) {
|
|
|
151
154
|
return null;
|
|
152
155
|
}
|
|
153
156
|
|
|
157
|
+
export function writeSettings(file, settings) {
|
|
158
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
159
|
+
writeFileSync(file, `${JSON.stringify(settings, null, 2)}
|
|
160
|
+
`);
|
|
161
|
+
}
|
|
162
|
+
|
|
154
163
|
export function install(cwd = process.cwd()) {
|
|
155
164
|
const file = settingsPath(cwd);
|
|
156
165
|
const settings = readSettings(file);
|
package/src/spool.mjs
CHANGED
|
@@ -72,6 +72,7 @@ export function aggregate(events) {
|
|
|
72
72
|
if (e.exit_code !== undefined && e.exit_code !== null) cur.exit_code = e.exit_code;
|
|
73
73
|
if (e.ended) cur.ended = true;
|
|
74
74
|
if (e.transcript) cur.transcript = e.transcript;
|
|
75
|
+
if (e.turn) cur.turn = e.turn;
|
|
75
76
|
if (e.started_at && e.started_at < cur.started_at) cur.started_at = e.started_at;
|
|
76
77
|
if (typeof e.task === "string") {
|
|
77
78
|
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 });
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Claude plan limits on the M.E.K.S.U.S. dashboard (Rik, 2026-09-26).
|
|
2
|
+
// `meksus statusline` Claude Code's status line command. Reads the status line JSON on stdin and
|
|
3
|
+
// prints one short line ("5h 23% · resets in 2h 14m │ week 41% · Tue 09:00").
|
|
4
|
+
// Keeps ONLY rate_limits (percentages + reset times) in ~/.meksus/limits.json and,
|
|
5
|
+
// when they changed (or every 5 minutes), starts a detached `meksus limits-flush`,
|
|
6
|
+
// at most once a minute (G-1). Never blocks, never breaks the status line.
|
|
7
|
+
// `meksus statusline install [--global] [--replace]` / `uninstall [--global]`
|
|
8
|
+
// <repo>/.claude/settings.local.json, or ~/.claude/settings.json with --global.
|
|
9
|
+
// `meksus limits-flush` internal, detached: one POST /v1/claude-limits as the signed-in user.
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { getAccessToken } from "./auth.mjs";
|
|
16
|
+
import { apiBase, dir, ensureDir, readConfig } from "./config.mjs";
|
|
17
|
+
import { readSettings, settingsPath, writeSettings } from "./hooks.mjs";
|
|
18
|
+
import { VERSION } from "./version.mjs";
|
|
19
|
+
|
|
20
|
+
const CLI = fileURLToPath(new URL("../bin/meksus.mjs", import.meta.url));
|
|
21
|
+
const MARK = "meksus.mjs\" statusline";
|
|
22
|
+
const WINDOWS = ["five_hour", "seven_day", "spend_limit"];
|
|
23
|
+
export const SEND_MIN_MS = 60_000;
|
|
24
|
+
export const SEND_EVERY_MS = 5 * 60_000;
|
|
25
|
+
const limitsFile = () => join(dir(), "limits.json");
|
|
26
|
+
|
|
27
|
+
/** Only the numbers: { five_hour: { used_percentage, resets_at }, … }, or null. */
|
|
28
|
+
export function pickLimits(input) {
|
|
29
|
+
const src = input?.rate_limits;
|
|
30
|
+
if (!src || typeof src !== "object") return null;
|
|
31
|
+
const out = {};
|
|
32
|
+
for (const w of WINDOWS) {
|
|
33
|
+
const v = src[w];
|
|
34
|
+
const pct = Number(v?.used_percentage);
|
|
35
|
+
const at = Number(v?.resets_at);
|
|
36
|
+
if (Number.isFinite(pct) && Number.isFinite(at)) out[w] = { used_percentage: Math.round(pct * 100) / 100, resets_at: Math.round(at) };
|
|
37
|
+
}
|
|
38
|
+
return Object.keys(out).length ? out : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function until(resetsAtS, nowMs = Date.now()) {
|
|
42
|
+
const s = Math.max(0, Math.round(resetsAtS - nowMs / 1000));
|
|
43
|
+
const d = Math.floor(s / 86400), hh = Math.floor((s % 86400) / 3600), mm = Math.floor((s % 3600) / 60);
|
|
44
|
+
return d ? `${d}d ${hh}h` : hh ? `${hh}h ${String(mm).padStart(2, "0")}m` : `${mm}m`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function renderLine(limits, nowMs = Date.now()) {
|
|
48
|
+
if (!limits) return "Claude limits: after the first reply";
|
|
49
|
+
const part = (label, w) => (w ? `${label} ${Math.round(w.used_percentage)}% · resets in ${until(w.resets_at, nowMs)}` : null);
|
|
50
|
+
return [part("5h", limits.five_hour), part("week", limits.seven_day), part("spend", limits.spend_limit)].filter(Boolean).join(" │ ");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const signature = (l) => JSON.stringify(WINDOWS.map((w) => (l?.[w] ? [Math.round(l[w].used_percentage), l[w].resets_at] : null)));
|
|
54
|
+
|
|
55
|
+
/** Should this run start a send? (changed and a minute since the last one, or 5 minutes regardless) */
|
|
56
|
+
export function sendDue(state, limits, nowMs = Date.now()) {
|
|
57
|
+
if (!limits) return false;
|
|
58
|
+
const since = nowMs - (state?.sent_at ?? 0);
|
|
59
|
+
return (signature(limits) !== state?.sent_sig && since >= SEND_MIN_MS) || since >= SEND_EVERY_MS;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readState() { try { return JSON.parse(readFileSync(limitsFile(), "utf8")); } catch { return null; } }
|
|
63
|
+
function writeState(s) { try { ensureDir(); writeFileSync(limitsFile(), JSON.stringify(s)); } catch { /* read-only home: just don't send */ } }
|
|
64
|
+
|
|
65
|
+
export async function runStatusline() {
|
|
66
|
+
let limits = null;
|
|
67
|
+
try {
|
|
68
|
+
const chunks = [];
|
|
69
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
70
|
+
limits = pickLimits(JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"));
|
|
71
|
+
} catch { /* bad input: still print something */ }
|
|
72
|
+
const state = readState();
|
|
73
|
+
const shown = limits ?? state?.limits ?? null;
|
|
74
|
+
process.stdout.write(`${renderLine(shown)}\n`);
|
|
75
|
+
if (limits && sendDue(state, limits) && readConfig()?.session) {
|
|
76
|
+
writeState({ limits, at: Date.now(), sent_at: Date.now(), sent_sig: signature(limits) }); // claim before spawning
|
|
77
|
+
try { spawn(process.execPath, [CLI, "limits-flush"], { detached: true, stdio: "ignore", windowsHide: true, env: process.env }).unref(); } catch { /* next run */ }
|
|
78
|
+
} else if (limits) {
|
|
79
|
+
writeState({ ...(state ?? {}), limits, at: Date.now() });
|
|
80
|
+
}
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function limitsFlush() {
|
|
85
|
+
const state = readState();
|
|
86
|
+
const config = readConfig();
|
|
87
|
+
if (!state?.limits || !config?.session) return 0;
|
|
88
|
+
const token = await getAccessToken(config);
|
|
89
|
+
if (!token) return 0;
|
|
90
|
+
try {
|
|
91
|
+
const res = await fetch(`${apiBase(config)}/v1/claude-limits`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "Content-Type": "application/json", "User-Agent": `meksus-cli/${VERSION}`, Authorization: `Bearer ${token}` },
|
|
94
|
+
body: JSON.stringify({ rate_limits: state.limits }),
|
|
95
|
+
signal: AbortSignal.timeout(8000),
|
|
96
|
+
});
|
|
97
|
+
await res.body?.cancel();
|
|
98
|
+
return res.status;
|
|
99
|
+
} catch { return 0; } // dropped; the next status line run sends again (G-11)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── install / uninstall ─────────────────────────────────────────
|
|
103
|
+
const globalSettings = () => join(homedir(), ".claude", "settings.json");
|
|
104
|
+
|
|
105
|
+
export function statuslineCommand(args) {
|
|
106
|
+
const global = args.includes("--global");
|
|
107
|
+
const file = global ? globalSettings() : settingsPath(process.cwd());
|
|
108
|
+
if (args[0] === "install") {
|
|
109
|
+
const settings = readSettings(file);
|
|
110
|
+
if (!settings) return 1;
|
|
111
|
+
const current = settings.statusLine?.command ?? "";
|
|
112
|
+
if (current && !current.includes(MARK) && !args.includes("--replace")) {
|
|
113
|
+
console.error(`✖ ${file} already has a status line: ${current}`);
|
|
114
|
+
console.error(" Run again with --replace to use the M.E.K.S.U.S. one instead (it shows your Claude plan limits).");
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
settings.statusLine = { type: "command", command: `node "${CLI}" statusline`, padding: 0, refreshInterval: 30 };
|
|
118
|
+
writeSettings(file, settings);
|
|
119
|
+
console.log(`✔ Status line installed in ${file}`);
|
|
120
|
+
console.log(" It shows your Claude plan limits (5-hour and weekly) and sends only those numbers to your M.E.K.S.U.S. dashboard, at most once a minute.");
|
|
121
|
+
console.log(" Claude Code provides them for claude.ai Pro and Max plans, after the first reply in a session.");
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
if (args[0] === "uninstall") {
|
|
125
|
+
const settings = readSettings(file);
|
|
126
|
+
if (!settings) return 1;
|
|
127
|
+
if (!String(settings.statusLine?.command ?? "").includes(MARK)) { console.log(`No M.E.K.S.U.S. status line in ${file}.`); return 0; }
|
|
128
|
+
delete settings.statusLine;
|
|
129
|
+
writeSettings(file, settings);
|
|
130
|
+
console.log(`✔ Status line removed from ${file}`);
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
console.error("Usage: meksus statusline install [--global] [--replace] | uninstall [--global]");
|
|
134
|
+
return 2;
|
|
135
|
+
}
|
package/src/turns.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// How big was the turn Claude just finished? Measured here, on this machine, from the hooks themselves:
|
|
2
|
+
// the time from the prompt (UserPromptSubmit) to Stop, the tool calls in between, and how many distinct
|
|
3
|
+
// files it changed (Edit / Write / MultiEdit / NotebookEdit). Only those three numbers are sent, with the
|
|
4
|
+
// Stop event; file names are kept locally as hashes, only to count them once, and dropped with the turn.
|
|
5
|
+
// The server scores big vs small (packages/shared/src/notices.ts taskSize) for the AI-coder notices.
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { dir, ensureDir } from "./config.mjs";
|
|
10
|
+
|
|
11
|
+
const EDITS = new Set(["Edit", "MultiEdit", "Write", "NotebookEdit"]);
|
|
12
|
+
const KEEP = 50; // sessions remembered at most
|
|
13
|
+
const file = () => join(dir(), "turns.json");
|
|
14
|
+
|
|
15
|
+
function load() { try { return JSON.parse(readFileSync(file(), "utf8")); } catch { return {}; } }
|
|
16
|
+
function save(turns) {
|
|
17
|
+
const entries = Object.entries(turns).sort((a, b) => (b[1].at ?? 0) - (a[1].at ?? 0)).slice(0, KEEP);
|
|
18
|
+
try { ensureDir(); writeFileSync(file(), JSON.stringify(Object.fromEntries(entries))); } catch { /* not measured, not fatal */ }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Updates the session's running turn; on Stop returns { seconds, tool_calls, files }, else null. */
|
|
22
|
+
export function trackTurn(input, session, now = Date.now()) {
|
|
23
|
+
const name = input?.hook_event_name;
|
|
24
|
+
if (!session || !["UserPromptSubmit", "PreToolUse", "Stop"].includes(name)) return null;
|
|
25
|
+
const turns = load();
|
|
26
|
+
let t = turns[session];
|
|
27
|
+
if (name === "UserPromptSubmit" || !t) t = { start: now, tools: 0, files: [], at: now };
|
|
28
|
+
if (name === "PreToolUse") {
|
|
29
|
+
t.tools += 1;
|
|
30
|
+
const target = input.tool_input?.file_path ?? input.tool_input?.notebook_path;
|
|
31
|
+
if (EDITS.has(input.tool_name) && typeof target === "string") {
|
|
32
|
+
const h = createHash("sha256").update(target).digest("hex").slice(0, 16);
|
|
33
|
+
if (!t.files.includes(h) && t.files.length < 500) t.files.push(h);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
t.at = now;
|
|
37
|
+
turns[session] = t;
|
|
38
|
+
save(turns);
|
|
39
|
+
if (name !== "Stop") return null;
|
|
40
|
+
return { seconds: Math.max(0, Math.round((now - t.start) / 1000)), tool_calls: t.tools, files: t.files.length };
|
|
41
|
+
}
|