meksus 0.4.0 → 0.6.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 +17 -0
- package/bin/meksus.mjs +13 -4
- package/package.json +1 -1
- package/src/hooks.mjs +48 -19
- package/src/spool.mjs +2 -0
- package/src/statusline.mjs +218 -0
- package/src/turns.mjs +52 -0
package/README.md
CHANGED
|
@@ -29,12 +29,23 @@ The project's exact `<owner/project>` name is shown on its page in the dashboard
|
|
|
29
29
|
|
|
30
30
|
```sh
|
|
31
31
|
meksus hooks install # in the repo: adds Claude Code hooks to .claude/settings.local.json
|
|
32
|
+
meksus hooks install --codex # Codex: once per computer (~/.codex/hooks.json)
|
|
32
33
|
meksus task-lines steps # optional: show the step Claude is on, in one line
|
|
33
34
|
meksus watch --label "build" -- npm run build # any command; a failure opens an incident and alerts Discord
|
|
34
35
|
```
|
|
35
36
|
|
|
36
37
|
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
|
|
|
39
|
+
## Claude plan limits on the dashboard
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
meksus statusline install --global # once per computer (use --replace if you already have a status line)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Then **start Claude Code in a terminal** (`claude`; in VS Code: Terminal → New Terminal) and send it one message. The VS Code chat panel doesn't draw a status line, so it can't pass the numbers on. The bottom of the terminal 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 within a minute. 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.
|
|
46
|
+
|
|
47
|
+
Not showing up? `meksus statusline doctor` checks every step and says which one is missing.
|
|
48
|
+
|
|
38
49
|
## Check
|
|
39
50
|
|
|
40
51
|
```sh
|
|
@@ -53,6 +64,10 @@ With `meksus task-lines` turned on (it's off by default), two more things are se
|
|
|
53
64
|
|
|
54
65
|
The server scrubs anything that looks like a secret.
|
|
55
66
|
|
|
67
|
+
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.
|
|
68
|
+
|
|
69
|
+
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.
|
|
70
|
+
|
|
56
71
|
## Commands
|
|
57
72
|
|
|
58
73
|
| Command | What it does |
|
|
@@ -64,7 +79,9 @@ The server scrubs anything that looks like a secret.
|
|
|
64
79
|
| `meksus project create <name> [--in <parent>] [--use]` | Create a project (owners and admins) |
|
|
65
80
|
| `meksus project use <owner/project>` | Write this repo's `.meksus` |
|
|
66
81
|
| `meksus hooks install` / `uninstall` | Watch Claude Code in this repo |
|
|
82
|
+
| `meksus hooks install --codex` / `uninstall --codex` | Watch Codex on this computer (repositories with a `.meksus` file) |
|
|
67
83
|
| `meksus task-lines off \| steps \| prompt` | Opt-in one-line "working on" |
|
|
84
|
+
| `meksus statusline install [--global]` / `uninstall` | Claude plan limits in the status line and on the dashboard |
|
|
68
85
|
| `meksus watch [--label <name>] -- <cmd>` | Run a command and report how it ends |
|
|
69
86
|
|
|
70
87
|
© Lore Inc. All rights reserved.
|
package/bin/meksus.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { apiBase, dir, readConfig } from "../src/config.mjs";
|
|
|
7
7
|
import { projectCommand, readProjectFile } from "../src/project.mjs";
|
|
8
8
|
import { incidentCommand, printCards } from "../src/cards.mjs";
|
|
9
9
|
import { flush } from "../src/spool.mjs";
|
|
10
|
+
import { limitsFlush, runStatusline, statuslineCommand } from "../src/statusline.mjs";
|
|
10
11
|
import { taskLinesCommand } from "../src/tasks.mjs";
|
|
11
12
|
import { VERSION } from "../src/version.mjs";
|
|
12
13
|
import { watch } from "../src/watch.mjs";
|
|
@@ -21,7 +22,11 @@ const HELP = `M.E.K.S.U.S. CLI ${VERSION}
|
|
|
21
22
|
meksus project create <name> [--in <parent>] [--slug <slug>] [--use]
|
|
22
23
|
meksus project use <owner/project> Write this repo's .meksus file (commit it)
|
|
23
24
|
meksus hooks install | uninstall Watch Claude Code in the current repository
|
|
25
|
+
meksus hooks install --codex | uninstall --codex Watch Codex (once per computer; repos with .meksus)
|
|
24
26
|
meksus task-lines [off | steps | prompt] Show what Claude is working on, in one line (opt-in)
|
|
27
|
+
meksus statusline install [--global] Claude's plan limits (5-hour and weekly) in Claude Code's
|
|
28
|
+
status line and on your M.E.K.S.U.S. dashboard
|
|
29
|
+
meksus statusline doctor Check the plan-limits setup step by step
|
|
25
30
|
meksus watch [--label <name>] -- <cmd> … Run a command and report its status; a failure opens an incident
|
|
26
31
|
|
|
27
32
|
Status only: code, prompts and command output never leave your machine. With task-lines on, the title
|
|
@@ -42,12 +47,16 @@ switch (command) {
|
|
|
42
47
|
case "resolve": code = await incidentCommand("resolve", rest[0]); break;
|
|
43
48
|
case "project": case "projects": code = (await requireAccess()) ? await projectCommand(rest) : 1; break;
|
|
44
49
|
case "hooks":
|
|
45
|
-
if (rest[0] === "install") code = (await requireAccess()) ? install() : 1;
|
|
46
|
-
else if (rest[0] === "uninstall") code = uninstall();
|
|
47
|
-
else { console.error("Usage: meksus hooks install | uninstall"); code = 2; }
|
|
50
|
+
if (rest[0] === "install") code = (await requireAccess()) ? install(process.cwd(), { codex: flag("--codex") }) : 1;
|
|
51
|
+
else if (rest[0] === "uninstall") code = uninstall(process.cwd(), { codex: flag("--codex") });
|
|
52
|
+
else { console.error("Usage: meksus hooks install | uninstall [--codex]"); code = 2; }
|
|
48
53
|
break;
|
|
49
|
-
case "hook": code = await runHook(); break; // called by
|
|
54
|
+
case "hook": code = await runHook(rest[0] === "--agent" && rest[1] === "codex" ? "codex" : "claude-code"); break; // called by the agent, silent
|
|
50
55
|
case "task-lines": code = taskLinesCommand(rest); break;
|
|
56
|
+
case "statusline":
|
|
57
|
+
code = rest.length ? ((rest[0] === "install" && !(await requireAccess())) ? 1 : await statuslineCommand(rest)) : await runStatusline();
|
|
58
|
+
break;
|
|
59
|
+
case "limits-flush": await limitsFlush(); break; // internal, detached
|
|
51
60
|
case "flush": await flush(); break; // internal, detached
|
|
52
61
|
case "watch": code = await watch(rest); break;
|
|
53
62
|
case "--version": case "-v": case "version": console.log(VERSION); break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "meksus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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/hooks.mjs
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
|
-
// Claude Code integration.
|
|
2
|
-
// `meksus hook`
|
|
3
|
-
// line, exits 0 and prints NOTHING (some hooks feed stdout back to
|
|
1
|
+
// Claude Code and Codex integration.
|
|
2
|
+
// `meksus hook [--agent codex]` 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 the agent).
|
|
4
4
|
// `meksus hooks install` adds the hook to <repo>/.claude/settings.local.json (personal, gitignored)
|
|
5
|
-
// `meksus hooks
|
|
5
|
+
// `meksus hooks install --codex` adds it to ~/.codex/hooks.json (Codex's own hooks, same shape; once per
|
|
6
|
+
// computer: repositories without a .meksus file are never reported)
|
|
7
|
+
// `meksus hooks uninstall [--codex]` removes only our entries
|
|
6
8
|
import { createHash } from "node:crypto";
|
|
7
9
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
8
11
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
9
12
|
import { fileURLToPath } from "node:url";
|
|
10
13
|
import { readProjectFile } from "./project.mjs";
|
|
11
14
|
import { record } from "./spool.mjs";
|
|
15
|
+
import { trackTurn } from "./turns.mjs";
|
|
12
16
|
import { taskFrom, taskMode } from "./tasks.mjs";
|
|
13
17
|
|
|
14
18
|
const EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Notification", "Stop", "SessionEnd"];
|
|
19
|
+
// Codex (learn.chatgpt.com/docs/hooks): the same events, "PermissionRequest" instead of "Notification".
|
|
20
|
+
const CODEX_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest", "Stop", "SessionEnd"];
|
|
15
21
|
const TOOL_EVENTS = new Set(["PreToolUse", "PostToolUse"]);
|
|
16
22
|
const CLI = fileURLToPath(new URL("../bin/meksus.mjs", import.meta.url));
|
|
17
23
|
const MARK = "meksus.mjs\" hook";
|
|
@@ -21,7 +27,7 @@ export function sessionKey(agent, id) {
|
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
/** 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()) {
|
|
30
|
+
export function toStatus(input, now = new Date(), agent = "claude-code") {
|
|
25
31
|
const name = input?.hook_event_name;
|
|
26
32
|
if (!name || !input.session_id) return null;
|
|
27
33
|
// PRJ-10/13: status belongs to the project named by the repo's .meksus file; no file → not reported.
|
|
@@ -29,11 +35,13 @@ export function toStatus(input, now = new Date()) {
|
|
|
29
35
|
if (!project) return null;
|
|
30
36
|
const e = {
|
|
31
37
|
project,
|
|
32
|
-
session: sessionKey(
|
|
33
|
-
agent
|
|
38
|
+
session: sessionKey(agent, input.session_id),
|
|
39
|
+
agent,
|
|
34
40
|
label: repoLabel(input.cwd),
|
|
35
41
|
at: now.toISOString(),
|
|
36
|
-
transcript
|
|
42
|
+
// Token usage is read from Claude Code's transcript format; Codex reports its model name instead.
|
|
43
|
+
transcript: agent === "claude-code" && typeof input.transcript_path === "string" ? input.transcript_path : null,
|
|
44
|
+
...(agent === "codex" && typeof input.model === "string" && /^[a-z0-9][a-z0-9.\-]{0,62}$/.test(input.model) ? { model: input.model } : {}),
|
|
37
45
|
};
|
|
38
46
|
switch (name) {
|
|
39
47
|
case "SessionStart": return { ...e, state: "idle", started_at: e.at };
|
|
@@ -42,6 +50,7 @@ export function toStatus(input, now = new Date()) {
|
|
|
42
50
|
case "PostToolUse": return { ...e, state: "working", tool_failure: toolFailed(input.tool_response) };
|
|
43
51
|
case "PostToolUseFailure": return { ...e, state: "working", tool_failure: true };
|
|
44
52
|
case "Notification": return { ...e, state: "waiting" };
|
|
53
|
+
case "PermissionRequest": return { ...e, state: "waiting" }; // Codex asks before running something
|
|
45
54
|
case "Stop": return { ...e, state: "finished" };
|
|
46
55
|
case "SessionEnd": return { ...e, state: "finished", ended: true };
|
|
47
56
|
default: return null;
|
|
@@ -101,12 +110,12 @@ export function repoLabel(cwd) {
|
|
|
101
110
|
return basename(root).slice(0, 80);
|
|
102
111
|
}
|
|
103
112
|
|
|
104
|
-
export async function runHook() {
|
|
113
|
+
export async function runHook(agent = "claude-code") {
|
|
105
114
|
try {
|
|
106
115
|
const chunks = [];
|
|
107
116
|
for await (const c of process.stdin) chunks.push(c);
|
|
108
117
|
const input = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
109
|
-
const status = toStatus(input);
|
|
118
|
+
const status = toStatus(input, new Date(), agent);
|
|
110
119
|
if (status) {
|
|
111
120
|
// Opt-in one-line "working on" from the agent's own step list (`meksus task-lines`).
|
|
112
121
|
const mode = taskMode();
|
|
@@ -114,6 +123,8 @@ export async function runHook() {
|
|
|
114
123
|
if (task) Object.assign(status, task);
|
|
115
124
|
const file = fileFrom(input, mode);
|
|
116
125
|
if (file) Object.assign(status, file);
|
|
126
|
+
const turn = trackTurn(input, status.session);
|
|
127
|
+
if (turn) status.turn = turn; // the finished turn's size: three numbers
|
|
117
128
|
record(status);
|
|
118
129
|
}
|
|
119
130
|
} catch { /* a monitoring hook must never break the agent */ }
|
|
@@ -121,7 +132,7 @@ export async function runHook() {
|
|
|
121
132
|
}
|
|
122
133
|
|
|
123
134
|
// ── install / uninstall ─────────────────────────────────────────
|
|
124
|
-
function settingsPath(cwd) {
|
|
135
|
+
export function settingsPath(cwd) {
|
|
125
136
|
let d = resolve(cwd);
|
|
126
137
|
for (let i = 0; i < 20; i++) {
|
|
127
138
|
if (existsSync(join(d, ".git"))) break;
|
|
@@ -134,7 +145,7 @@ function settingsPath(cwd) {
|
|
|
134
145
|
|
|
135
146
|
// Reads the settings file, or explains (and changes nothing) when it isn't valid JSON — often two
|
|
136
147
|
// { … } blocks pasted one after the other, which Claude Code can't read either.
|
|
137
|
-
function readSettings(file) {
|
|
148
|
+
export function readSettings(file) {
|
|
138
149
|
if (!existsSync(file)) return {};
|
|
139
150
|
const text = readFileSync(file, "utf8");
|
|
140
151
|
try {
|
|
@@ -151,18 +162,36 @@ function readSettings(file) {
|
|
|
151
162
|
return null;
|
|
152
163
|
}
|
|
153
164
|
|
|
154
|
-
export function
|
|
155
|
-
|
|
165
|
+
export function writeSettings(file, settings) {
|
|
166
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
167
|
+
writeFileSync(file, `${JSON.stringify(settings, null, 2)}
|
|
168
|
+
`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export const codexHooksPath = () => join(homedir(), ".codex", "hooks.json");
|
|
172
|
+
|
|
173
|
+
export function install(cwd = process.cwd(), { codex = false } = {}) {
|
|
174
|
+
const file = codex ? codexHooksPath() : settingsPath(cwd);
|
|
156
175
|
const settings = readSettings(file);
|
|
157
176
|
if (!settings) return 1;
|
|
158
177
|
settings.hooks ??= {};
|
|
159
|
-
const command = `node "${CLI}" hook`;
|
|
160
|
-
for (const event of EVENTS) {
|
|
178
|
+
const command = codex ? `node "${CLI}" hook --agent codex` : `node "${CLI}" hook`;
|
|
179
|
+
for (const event of codex ? CODEX_EVENTS : EVENTS) {
|
|
161
180
|
const groups = (settings.hooks[event] ??= []);
|
|
162
181
|
const clean = groups.map((g) => ({ ...g, hooks: (g.hooks ?? []).filter((h) => !String(h.command).includes(MARK)) })).filter((g) => g.hooks.length);
|
|
163
|
-
|
|
182
|
+
// Claude Code's tool matcher "*" means every tool; Codex matchers are regexes, so none = every tool.
|
|
183
|
+
clean.push({ ...(TOOL_EVENTS.has(event) && !codex ? { matcher: "*" } : {}), hooks: [{ type: "command", command, timeout: 10 }] });
|
|
164
184
|
settings.hooks[event] = clean;
|
|
165
185
|
}
|
|
186
|
+
if (codex) {
|
|
187
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
188
|
+
writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`);
|
|
189
|
+
console.log(`✔ Codex hooks installed in ${file}`);
|
|
190
|
+
console.log(" Every Codex session in a repository with a .meksus file now reports: working, needs you, finished.");
|
|
191
|
+
console.log(" Sent: state, timings, tool-call counts and the model name. Never code, prompts or output.");
|
|
192
|
+
console.log(" Start a new Codex session to activate. Hooks are on by default in Codex ([features] hooks).");
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
166
195
|
mkdirSync(dirname(file), { recursive: true });
|
|
167
196
|
writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`);
|
|
168
197
|
console.log(`✔ Claude Code hooks installed in ${file}`);
|
|
@@ -172,8 +201,8 @@ export function install(cwd = process.cwd()) {
|
|
|
172
201
|
return 0;
|
|
173
202
|
}
|
|
174
203
|
|
|
175
|
-
export function uninstall(cwd = process.cwd()) {
|
|
176
|
-
const file = settingsPath(cwd);
|
|
204
|
+
export function uninstall(cwd = process.cwd(), { codex = false } = {}) {
|
|
205
|
+
const file = codex ? codexHooksPath() : settingsPath(cwd);
|
|
177
206
|
if (!existsSync(file)) { console.log("No hooks file here."); return 0; }
|
|
178
207
|
const settings = readSettings(file);
|
|
179
208
|
if (!settings) return 1;
|
package/src/spool.mjs
CHANGED
|
@@ -72,6 +72,8 @@ 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;
|
|
76
|
+
if (e.model) cur.model = e.model; // Codex names its model in every hook call
|
|
75
77
|
if (e.started_at && e.started_at < cur.started_at) cur.started_at = e.started_at;
|
|
76
78
|
if (typeof e.task === "string") {
|
|
77
79
|
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,218 @@
|
|
|
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, with
|
|
5
|
+
// WHEN each Claude Code session last got them from Anthropic (observed_at). Only the
|
|
6
|
+
// newest reading on this computer is sent, with that time, so an idle session's old
|
|
7
|
+
// numbers never overwrite a newer reading (the server keeps the newest too).
|
|
8
|
+
// Sends: numbers changed → at most once a minute; same numbers re-confirmed by newer
|
|
9
|
+
// activity → at most every 5 minutes; nothing new → nothing sent (G-1).
|
|
10
|
+
// Never blocks, never breaks the status line.
|
|
11
|
+
// `meksus statusline install [--global] [--replace]` / `uninstall [--global]`
|
|
12
|
+
// <repo>/.claude/settings.local.json, or ~/.claude/settings.json with --global.
|
|
13
|
+
// `meksus limits-flush` internal, detached: one POST /v1/claude-limits as the signed-in user.
|
|
14
|
+
// `meksus statusline doctor` checks every step (CLI, sign-in, access, the setting, whether Claude Code
|
|
15
|
+
// ran the status line and gave plan limits, the last send) and says what's missing.
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { getAccessToken } from "./auth.mjs";
|
|
22
|
+
import { hasAccess } from "./access.mjs";
|
|
23
|
+
import { apiBase, dir, ensureDir, readConfig } from "./config.mjs";
|
|
24
|
+
import { readSettings, settingsPath, writeSettings } from "./hooks.mjs";
|
|
25
|
+
import { VERSION } from "./version.mjs";
|
|
26
|
+
|
|
27
|
+
const CLI = fileURLToPath(new URL("../bin/meksus.mjs", import.meta.url));
|
|
28
|
+
const MARK = "meksus.mjs\" statusline";
|
|
29
|
+
const WINDOWS = ["five_hour", "seven_day", "spend_limit"];
|
|
30
|
+
export const SEND_MIN_MS = 60_000;
|
|
31
|
+
export const SEND_EVERY_MS = 5 * 60_000;
|
|
32
|
+
const limitsFile = () => join(dir(), "limits.json");
|
|
33
|
+
const seenFile = () => join(dir(), "statusline-seen.json"); // when Claude Code last ran us, and with what (no numbers)
|
|
34
|
+
function readSeen() { try { return JSON.parse(readFileSync(seenFile(), "utf8")); } catch { return {}; } }
|
|
35
|
+
function writeSeen(patch) { try { ensureDir(); writeFileSync(seenFile(), JSON.stringify({ ...readSeen(), ...patch })); } catch { /* diagnostics only */ } }
|
|
36
|
+
|
|
37
|
+
/** Only the numbers: { five_hour: { used_percentage, resets_at }, … }, or null. */
|
|
38
|
+
export function pickLimits(input) {
|
|
39
|
+
const src = input?.rate_limits;
|
|
40
|
+
if (!src || typeof src !== "object") return null;
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const w of WINDOWS) {
|
|
43
|
+
const v = src[w];
|
|
44
|
+
const pct = Number(v?.used_percentage);
|
|
45
|
+
const at = Number(v?.resets_at);
|
|
46
|
+
if (Number.isFinite(pct) && Number.isFinite(at)) out[w] = { used_percentage: Math.round(pct * 100) / 100, resets_at: Math.round(at) };
|
|
47
|
+
}
|
|
48
|
+
return Object.keys(out).length ? out : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function until(resetsAtS, nowMs = Date.now()) {
|
|
52
|
+
const s = Math.max(0, Math.round(resetsAtS - nowMs / 1000));
|
|
53
|
+
const d = Math.floor(s / 86400), hh = Math.floor((s % 86400) / 3600), mm = Math.floor((s % 3600) / 60);
|
|
54
|
+
return d ? `${d}d ${hh}h` : hh ? `${hh}h ${String(mm).padStart(2, "0")}m` : `${mm}m`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function renderLine(limits, nowMs = Date.now()) {
|
|
58
|
+
if (!limits) return "Claude limits: after the first reply";
|
|
59
|
+
const part = (label, w) => (w ? `${label} ${Math.round(w.used_percentage)}% · resets in ${until(w.resets_at, nowMs)}` : null);
|
|
60
|
+
return [part("5h", limits.five_hour), part("week", limits.seven_day), part("spend", limits.spend_limit)].filter(Boolean).join(" │ ");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const signature = (l) => JSON.stringify(WINDOWS.map((w) => (l?.[w] ? [Math.round(l[w].used_percentage), l[w].resets_at] : null)));
|
|
64
|
+
const SESSIONS_MAX = 20;
|
|
65
|
+
const SESSION_TTL_MS = 24 * 3600_000;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Records one status line run. Claude Code hands every session its own last rate_limits (from that session's
|
|
69
|
+
* last API response) and re-runs the status line every 30 s even when idle, so the numbers alone don't say
|
|
70
|
+
* how old they are. Per session: first sight → the transcript's last write (≈ the last reply); numbers
|
|
71
|
+
* changed → now (a reply just arrived); same numbers → re-confirmed at the transcript's last write.
|
|
72
|
+
* The state's reading is the newest across this computer's sessions.
|
|
73
|
+
*/
|
|
74
|
+
export function observe(state, sessionId, limits, transcriptMs, nowMs = Date.now()) {
|
|
75
|
+
const next = { ...(state ?? {}), sessions: { ...(state?.sessions ?? {}) } };
|
|
76
|
+
if (!limits) return next;
|
|
77
|
+
const sid = String(sessionId ?? "unknown").slice(0, 64);
|
|
78
|
+
const sig = signature(limits);
|
|
79
|
+
const mt = Number.isFinite(transcriptMs) ? Math.min(transcriptMs, nowMs) : null;
|
|
80
|
+
const prev = next.sessions[sid];
|
|
81
|
+
const obs = !prev ? (mt ?? nowMs) : prev.sig !== sig ? nowMs : Math.max(prev.obs, mt ?? prev.obs);
|
|
82
|
+
next.sessions[sid] = { sig, obs, seen: nowMs };
|
|
83
|
+
for (const [k, v] of Object.entries(next.sessions)) if (nowMs - (v.seen ?? 0) > SESSION_TTL_MS) delete next.sessions[k];
|
|
84
|
+
const keep = Object.entries(next.sessions).sort((a, b) => b[1].seen - a[1].seen).slice(0, SESSIONS_MAX);
|
|
85
|
+
next.sessions = Object.fromEntries(keep);
|
|
86
|
+
if (!next.observed_at || obs >= next.observed_at) { next.limits = limits; next.observed_at = obs; next.sig = sig; }
|
|
87
|
+
return next;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Should this run start a send? New numbers: a minute since the last try. Same numbers seen again later: 5 minutes. */
|
|
91
|
+
export function sendDue(state, nowMs = Date.now()) {
|
|
92
|
+
if (!state?.limits || !state.observed_at) return false;
|
|
93
|
+
if (state.observed_at <= (state.sent_obs ?? 0)) return false; // nothing newer than what the server has
|
|
94
|
+
const since = nowMs - (state.tried_at ?? 0);
|
|
95
|
+
return state.sig !== state.sent_sig ? since >= SEND_MIN_MS : since >= SEND_EVERY_MS;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readState() { try { return JSON.parse(readFileSync(limitsFile(), "utf8")); } catch { return null; } }
|
|
99
|
+
function writeState(s) { try { ensureDir(); writeFileSync(limitsFile(), JSON.stringify(s)); } catch { /* read-only home: just don't send */ } }
|
|
100
|
+
function mtimeMs(path) { try { return typeof path === "string" && path ? statSync(path).mtimeMs : null; } catch { return null; } }
|
|
101
|
+
|
|
102
|
+
export async function runStatusline() {
|
|
103
|
+
let limits = null;
|
|
104
|
+
let claude = null;
|
|
105
|
+
let sessionId = null;
|
|
106
|
+
let transcript = null;
|
|
107
|
+
try {
|
|
108
|
+
const chunks = [];
|
|
109
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
110
|
+
const input = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
111
|
+
limits = pickLimits(input);
|
|
112
|
+
claude = typeof input.version === "string" ? input.version.slice(0, 20) : null;
|
|
113
|
+
sessionId = input.session_id ?? null;
|
|
114
|
+
transcript = input.transcript_path ?? null;
|
|
115
|
+
} catch { /* bad input: still print something */ }
|
|
116
|
+
writeSeen({ last_run: Date.now(), had_limits: Boolean(limits), claude_version: claude, ...(limits ? { last_limits: Date.now() } : {}) });
|
|
117
|
+
const state = readState();
|
|
118
|
+
process.stdout.write(`${renderLine(limits ?? state?.limits ?? null)}\n`);
|
|
119
|
+
if (!limits) return 0;
|
|
120
|
+
const next = observe(state, sessionId, limits, mtimeMs(transcript));
|
|
121
|
+
if (sendDue(next) && readConfig()?.session) {
|
|
122
|
+
writeState({ ...next, tried_at: Date.now() }); // claim before spawning
|
|
123
|
+
try { spawn(process.execPath, [CLI, "limits-flush"], { detached: true, stdio: "ignore", windowsHide: true, env: process.env }).unref(); } catch { /* next run */ }
|
|
124
|
+
} else {
|
|
125
|
+
writeState(next);
|
|
126
|
+
}
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function limitsFlush() {
|
|
131
|
+
const state = readState();
|
|
132
|
+
const config = readConfig();
|
|
133
|
+
if (!state?.limits || !config?.session) return 0;
|
|
134
|
+
const token = await getAccessToken(config);
|
|
135
|
+
if (!token) return 0;
|
|
136
|
+
const sent = { limits: state.limits, observed_at: state.observed_at ?? Date.now(), sig: state.sig ?? signature(state.limits) };
|
|
137
|
+
try {
|
|
138
|
+
const res = await fetch(`${apiBase(config)}/v1/claude-limits`, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: { "Content-Type": "application/json", "User-Agent": `meksus-cli/${VERSION}`, Authorization: `Bearer ${token}` },
|
|
141
|
+
body: JSON.stringify({ rate_limits: sent.limits, observed_at: sent.observed_at }),
|
|
142
|
+
signal: AbortSignal.timeout(8000),
|
|
143
|
+
});
|
|
144
|
+
await res.body?.cancel();
|
|
145
|
+
writeSeen({ sent_at: Date.now(), sent_status: res.status });
|
|
146
|
+
if (res.ok) { const now = readState() ?? state; writeState({ ...now, sent_obs: Math.max(now.sent_obs ?? 0, sent.observed_at), sent_sig: sent.sig }); }
|
|
147
|
+
return res.status;
|
|
148
|
+
} catch { writeSeen({ sent_at: Date.now(), sent_status: 0 }); return 0; } // dropped; the next status line run sends again (G-11)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── install / uninstall ─────────────────────────────────────────
|
|
152
|
+
const globalSettings = () => join(homedir(), ".claude", "settings.json");
|
|
153
|
+
|
|
154
|
+
export function statuslineCommand(args) {
|
|
155
|
+
const global = args.includes("--global");
|
|
156
|
+
const file = global ? globalSettings() : settingsPath(process.cwd());
|
|
157
|
+
if (args[0] === "install") {
|
|
158
|
+
const settings = readSettings(file);
|
|
159
|
+
if (!settings) return 1;
|
|
160
|
+
const current = settings.statusLine?.command ?? "";
|
|
161
|
+
if (current && !current.includes(MARK) && !args.includes("--replace")) {
|
|
162
|
+
console.error(`✖ ${file} already has a status line: ${current}`);
|
|
163
|
+
console.error(" Run again with --replace to use the M.E.K.S.U.S. one instead (it shows your Claude plan limits).");
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
settings.statusLine = { type: "command", command: `node "${CLI}" statusline`, padding: 0, refreshInterval: 30 };
|
|
167
|
+
writeSettings(file, settings);
|
|
168
|
+
console.log(`✔ Status line installed in ${file}`);
|
|
169
|
+
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.");
|
|
170
|
+
console.log(" Claude Code provides them for claude.ai Pro and Max plans, after the first reply in a session.");
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
if (args[0] === "uninstall") {
|
|
174
|
+
const settings = readSettings(file);
|
|
175
|
+
if (!settings) return 1;
|
|
176
|
+
if (!String(settings.statusLine?.command ?? "").includes(MARK)) { console.log(`No M.E.K.S.U.S. status line in ${file}.`); return 0; }
|
|
177
|
+
delete settings.statusLine;
|
|
178
|
+
writeSettings(file, settings);
|
|
179
|
+
console.log(`✔ Status line removed from ${file}`);
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
if (args[0] === "doctor") return doctor();
|
|
183
|
+
console.error("Usage: meksus statusline install [--global] [--replace] | uninstall [--global] | doctor");
|
|
184
|
+
return 2;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const ago = (ms) => { const s = Math.round((Date.now() - ms) / 1000); return s < 90 ? `${s} s ago` : s < 5400 ? `${Math.round(s / 60)} min ago` : s < 172800 ? `${Math.round(s / 3600)} h ago` : `${Math.round(s / 86400)} days ago`; };
|
|
188
|
+
|
|
189
|
+
/** Walks the whole path and stops at the first missing step, in plain words. */
|
|
190
|
+
async function doctor() {
|
|
191
|
+
const ok = (t) => console.log(`✔ ${t}`);
|
|
192
|
+
const bad = (t, fix) => { console.log(`✖ ${t}`); if (fix) console.log(` → ${fix}`); return 1; };
|
|
193
|
+
ok(`meksus ${VERSION}`);
|
|
194
|
+
const config = readConfig();
|
|
195
|
+
if (!config?.session) return bad("Not signed in to M.E.K.S.U.S. on this computer.", "Run: meksus login");
|
|
196
|
+
ok(`Signed in as ${config.user?.name ?? config.user?.email ?? "you"}`);
|
|
197
|
+
const access = await hasAccess({ fresh: true });
|
|
198
|
+
if (access === false) return bad("This account has no access to M.E.K.S.U.S. (private preview).", "Ask your team's admin to invite you.");
|
|
199
|
+
if (access === true) ok("Access: yes");
|
|
200
|
+
const places = [globalSettings(), settingsPath(process.cwd())].filter((f, i, a) => a.indexOf(f) === i);
|
|
201
|
+
const installed = places.find((f) => { try { return existsSync(f) && String(JSON.parse(readFileSync(f, "utf8")).statusLine?.command ?? "").includes(MARK); } catch { return false; } });
|
|
202
|
+
if (!installed) return bad("Claude Code's status line isn't set to meksus.", "Run: meksus statusline install --global (then start a new Claude Code session)");
|
|
203
|
+
ok(`Status line set in ${installed}`);
|
|
204
|
+
const seen = readSeen();
|
|
205
|
+
if (!seen.last_run) return bad("Claude Code hasn't run the status line on this computer yet.",
|
|
206
|
+
"Start Claude Code in a terminal (type: claude) and send it one message. The VS Code chat panel doesn't draw a status line; use a terminal, e.g. VS Code's Terminal → New Terminal.");
|
|
207
|
+
ok(`Claude Code last ran it ${ago(seen.last_run)}${seen.claude_version ? ` (Claude Code ${seen.claude_version})` : ""}`);
|
|
208
|
+
if (!seen.last_limits) return bad("Claude Code hasn't included plan limits yet.",
|
|
209
|
+
"Send Claude a message in that terminal session. If you signed in to Claude Code with an API key instead of a Pro or Max plan, there are no plan limits to show.");
|
|
210
|
+
ok(`Plan limits last seen ${ago(seen.last_limits)}`);
|
|
211
|
+
if (!seen.sent_at) return bad("Not sent to M.E.K.S.U.S. yet.", "It sends within a minute of the next reply. Run this again in a minute.");
|
|
212
|
+
if (seen.sent_status < 200 || seen.sent_status >= 300) {
|
|
213
|
+
return bad(`The last send failed (${seen.sent_status ? `HTTP ${seen.sent_status}` : "no connection"}, ${ago(seen.sent_at)}).`,
|
|
214
|
+
seen.sent_status === 401 ? "Sign in again: meksus login" : seen.sent_status === 403 ? "This account has no access (private preview)." : "It retries with the next reply. Check your connection.");
|
|
215
|
+
}
|
|
216
|
+
ok(`Sent to M.E.K.S.U.S. ${ago(seen.sent_at)}. Open the dashboard → Agents: the Claude plan limits card is live.`);
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
package/src/turns.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
|
|
13
|
+
/** Files a tool call changes: Claude Code's edit tools name one file_path; Codex's apply_patch lists them
|
|
14
|
+
* in the patch ("*** Update File: src/a.js"). Paths only, used here and never sent. */
|
|
15
|
+
export function editedFiles(input) {
|
|
16
|
+
const target = input?.tool_input?.file_path ?? input?.tool_input?.notebook_path;
|
|
17
|
+
if (EDITS.has(input?.tool_name) && typeof target === "string") return [target];
|
|
18
|
+
if (/apply_patch/i.test(String(input?.tool_name ?? ""))) {
|
|
19
|
+
const text = Object.values(input.tool_input ?? {}).filter((v) => typeof v === "string").join("\n");
|
|
20
|
+
return [...text.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)].map((m) => m[1].trim()).slice(0, 200);
|
|
21
|
+
}
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
const KEEP = 50; // sessions remembered at most
|
|
25
|
+
const file = () => join(dir(), "turns.json");
|
|
26
|
+
|
|
27
|
+
function load() { try { return JSON.parse(readFileSync(file(), "utf8")); } catch { return {}; } }
|
|
28
|
+
function save(turns) {
|
|
29
|
+
const entries = Object.entries(turns).sort((a, b) => (b[1].at ?? 0) - (a[1].at ?? 0)).slice(0, KEEP);
|
|
30
|
+
try { ensureDir(); writeFileSync(file(), JSON.stringify(Object.fromEntries(entries))); } catch { /* not measured, not fatal */ }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Updates the session's running turn; on Stop returns { seconds, tool_calls, files }, else null. */
|
|
34
|
+
export function trackTurn(input, session, now = Date.now()) {
|
|
35
|
+
const name = input?.hook_event_name;
|
|
36
|
+
if (!session || !["UserPromptSubmit", "PreToolUse", "Stop"].includes(name)) return null;
|
|
37
|
+
const turns = load();
|
|
38
|
+
let t = turns[session];
|
|
39
|
+
if (name === "UserPromptSubmit" || !t) t = { start: now, tools: 0, files: [], at: now };
|
|
40
|
+
if (name === "PreToolUse") {
|
|
41
|
+
t.tools += 1;
|
|
42
|
+
for (const target of editedFiles(input)) {
|
|
43
|
+
const h = createHash("sha256").update(target).digest("hex").slice(0, 16);
|
|
44
|
+
if (!t.files.includes(h) && t.files.length < 500) t.files.push(h);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
t.at = now;
|
|
48
|
+
turns[session] = t;
|
|
49
|
+
save(turns);
|
|
50
|
+
if (name !== "Stop") return null;
|
|
51
|
+
return { seconds: Math.max(0, Math.round((now - t.start) / 1000)), tool_calls: t.tools, files: t.files.length };
|
|
52
|
+
}
|