@codizelabs/agent-rewake 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rewake",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Continues this session when your usage limit resets, and sends messages you schedule into it.",
5
5
  "author": {
6
6
  "name": "Codize Labs",
@@ -76,6 +76,104 @@ export function blockingKind(rateLimits, now) {
76
76
  )?.kind;
77
77
  }
78
78
 
79
+ // ---- Warnings before a limit: Claude Code's own usage figures, no network, no sign-in.
80
+
81
+ /** Percent used at which a window is warned about, lowest first. */
82
+ const WARN_AT = [80, 95];
83
+ /** Samples kept per window, and the least time they must span before a pace is trusted. */
84
+ const SAMPLE_CAP = 12;
85
+ const MIN_SPAN_MS = 10 * MINUTE;
86
+ /** Only readings this recent give the pace. */
87
+ const RECENT_MS = HOUR;
88
+ /** A pace warning comes when the projected limit is this near. */
89
+ const PACE_HORIZON_MS = 45 * MINUTE;
90
+ /** Two reset times closer than this are the same window (the figures can differ by a moment). */
91
+ const SAME_WINDOW_MS = 30 * MINUTE;
92
+
93
+ /**
94
+ * Milliseconds until the window reaches 100, from its samples ({ t, p }: time, percent used), or
95
+ * undefined when the pace is unknown: the figure has not moved since the newest sample (so idle
96
+ * time never counts as a pace), fewer than two samples from the last hour, those less than 10
97
+ * minutes apart, or no rise.
98
+ */
99
+ export function timeToLimit(samples, now) {
100
+ const recent = samples.filter((s) => s.t >= now - RECENT_MS);
101
+ const first = recent[0];
102
+ const last = recent.at(-1);
103
+ if (!first || !last || last.t !== now || last.t - first.t < MIN_SPAN_MS) return undefined;
104
+ const rate = (last.p - first.p) / (last.t - first.t);
105
+ return rate > 0 ? (100 - last.p) / rate : undefined;
106
+ }
107
+
108
+ /** The estimate as a range of whole minutes in fives, about 0.7x to 1.4x: [20, 40]. */
109
+ export function paceRange(ms) {
110
+ const five = (x) => Math.max(5, Math.round(x / (5 * MINUTE)) * 5);
111
+ const lo = five(ms * 0.7);
112
+ return [lo, Math.max(lo + 5, five(ms * 1.4))];
113
+ }
114
+
115
+ /**
116
+ * One window's reading, given what was kept for it (`prev`: { resetsAt, samples, high, pace },
117
+ * or undefined). Returns undefined when there is nothing to track (a spending cap, no reset ahead,
118
+ * a full window: the limit flow takes over), else { next, level?, pace? }: the state to keep, the
119
+ * threshold newly crossed (only the highest if it jumped past both) and the pace range to warn
120
+ * with. A threshold is warned once per window, the pace once per window and never together with
121
+ * a threshold (it waits for the next reading).
122
+ */
123
+ export function usageStep(prev, w, now) {
124
+ const reset = typeof w.resetsAt === "string" ? Date.parse(w.resetsAt) : Number.NaN;
125
+ const p = w.percentUsed;
126
+ if (BILLING_KINDS.has(w.kind) || !Number.isFinite(reset) || reset <= now) return undefined;
127
+ if (!(p >= 0 && p < 100)) return undefined;
128
+ const same = prev && Math.abs(prev.resetsAt - reset) < SAME_WINDOW_MS;
129
+ const base = same ? prev : { resetsAt: reset, samples: [], high: 0, pace: false };
130
+ const last = base.samples.at(-1);
131
+ const samples =
132
+ last?.p === p ? base.samples : [...(last && p < last.p ? [] : base.samples), { t: now, p }];
133
+ const kept = samples.slice(-SAMPLE_CAP);
134
+ const level = WARN_AT.filter((l) => p >= l && l > base.high).pop();
135
+ const ms = level === undefined && !base.pace ? timeToLimit(kept, now) : undefined;
136
+ const pace =
137
+ ms !== undefined && ms <= PACE_HORIZON_MS && now + ms < reset ? paceRange(ms) : undefined;
138
+ return {
139
+ next: {
140
+ resetsAt: reset, // the latest, so a slowly drifting time can't make the window look new
141
+ samples: kept,
142
+ high: level ?? base.high,
143
+ pace: base.pace || pace !== undefined,
144
+ },
145
+ level,
146
+ pace,
147
+ };
148
+ }
149
+
150
+ /** "5-hour limit", "7-day limit", "7-day opus limit", "some kind limit". */
151
+ export function limitName(kind) {
152
+ const m = /^seven_day_(.+)$/.exec(kind);
153
+ const name =
154
+ kind === "five_hour"
155
+ ? "5-hour"
156
+ : kind === "seven_day"
157
+ ? "7-day"
158
+ : m
159
+ ? `7-day ${m[1].replace(/_/g, " ")}`
160
+ : String(kind).replace(/_/g, " ");
161
+ return `${name} limit`;
162
+ }
163
+
164
+ /** `reset` is the reset time as words: "at 3:00 AM today", "on Friday at 9:00 AM". */
165
+ export function levelText(kind, percent, reset) {
166
+ const head = `${limitName(kind)}: ${Math.floor(percent)}% used, as of Claude Code's last reply. It resets ${reset}.`;
167
+ // Compacting is a model call too, so it is only hinted at below 95%.
168
+ return percent >= 95
169
+ ? `${head} A good moment to wrap up or save what you are doing.`
170
+ : `${head} If you are in the middle of a task, /compact now can make the rest last longer (compacting also uses some).`;
171
+ }
172
+
173
+ export function paceText(kind, [lo, hi], reset) {
174
+ return `At this pace you reach the ${limitName(kind)} in about ${lo} to ${hi} minutes, before it resets ${reset}.`;
175
+ }
176
+
79
177
  /**
80
178
  * Claude Code's own panels, by `CLAUDE_CODE_ENTRYPOINT`: the VS Code extension (also in Cursor and
81
179
  * other VS Code builds) and the desktop app. They run Claude Code through the Agent SDK, so the
@@ -13,6 +13,10 @@
13
13
  // offered (not answered) | armed | sent
14
14
  // claim: { by, at }, the process sending the armed continue right now
15
15
  // sched:<id> [{ at, text }]
16
+ // usage:<dir> { <kind>: { resetsAt, samples: [{ t, p }], high, pace } }, what the warnings
17
+ // before a limit have said. The figures are the account's, not the session's, so
18
+ // the key is the config dir (CLAUDE_CONFIG_DIR, which is one sign-in): a restart or
19
+ // another session of the same account doesn't repeat a warning.
16
20
  // A copy of each limit record (no message text) goes to Rewake's shared state folder, so
17
21
  // `agent-rewake doctor` and the schedules page can show it, and so Rewake can continue the session
18
22
  // headless if Claude Code closes before the limit is answered (it adds `open`, the Claude Code
@@ -34,6 +38,7 @@ import {
34
38
  helpText,
35
39
  holdArgv,
36
40
  LINUX_MAINS,
41
+ levelText,
37
42
  limitOf,
38
43
  MAX_REARMS,
39
44
  MAX_REHITS,
@@ -42,6 +47,7 @@ import {
42
47
  mustAsk,
43
48
  nativeLikely,
44
49
  notHere,
50
+ paceText,
45
51
  parseCommand,
46
52
  parseProcess,
47
53
  parseProgram,
@@ -54,6 +60,7 @@ import {
54
60
  sessionVars,
55
61
  splitWhen,
56
62
  surfaceOf,
63
+ usageStep,
57
64
  WAITING_EXPIRES_MS,
58
65
  WAKE_HORIZON_MS,
59
66
  WAKE_PROBE,
@@ -106,6 +113,10 @@ const ME = Math.random().toString(36).slice(2);
106
113
  const CLAIM_MS = 5 * MINUTE;
107
114
  /** Rewake's keep-awake setting: "plugged-in" (default), "always" or "never". */
108
115
  let keepAwake = "plugged-in";
116
+ /** Rewake's usage-warnings setting: "on" (default) or "off". */
117
+ let usageWarnings = "on";
118
+ /** CLAUDE_CONFIG_DIR, which names the account's warnings in $.store ("" for the default). */
119
+ let configDir = "";
109
120
  /** { kind, pid, program }: Claude Code's PID and the hold program (macOS, Linux with systemd-inhibit, Windows); null where there is none. */
110
121
  let wakeTarget;
111
122
  /** The running hold: { key, stream }. */
@@ -131,6 +142,8 @@ async function loadConfig($) {
131
142
  if (["on", "off", "ask"].includes(settings.newThreads)) sharedAuto = settings.newThreads;
132
143
  if (["plugged-in", "always", "never"].includes(settings.keepAwake))
133
144
  keepAwake = settings.keepAwake;
145
+ if (settings.usageWarnings === "on" || settings.usageWarnings === "off")
146
+ usageWarnings = settings.usageWarnings;
134
147
  } catch {
135
148
  // No settings saved yet: the default clock.
136
149
  }
@@ -146,7 +159,7 @@ async function loadSessionInfo($) {
146
159
  // SETTINGS_VARS and KEY_VARS; a test in Rewake's repository compares them with these).
147
160
  const read = (get) => get.catch(() => undefined);
148
161
  const values = {
149
- CLAUDE_CONFIG_DIR: await read($.env.get("CLAUDE_CONFIG_DIR")),
162
+ CLAUDE_CONFIG_DIR: configDir, // read once, at session start
150
163
  CLAUDE_CODE_PROJECT_DIR_NAME: await read($.env.get("CLAUDE_CODE_PROJECT_DIR_NAME")),
151
164
  ANTHROPIC_BASE_URL: await read($.env.get("ANTHROPIC_BASE_URL")),
152
165
  ANTHROPIC_MODEL: await read($.env.get("ANTHROPIC_MODEL")),
@@ -494,6 +507,26 @@ async function onMeasure($, rateLimits) {
494
507
  );
495
508
  }
496
509
 
510
+ /** Toasts at 80% and 95% of a usage window, and when the pace would reach the limit soon. */
511
+ async function warnUsage($, rateLimits) {
512
+ if (usageWarnings === "off" || !personAtPrompt({ isInteractive, entrypoint })) return;
513
+ const now = await $.clock.now();
514
+ const key = `usage:${configDir}`;
515
+ const state = (await $.store.get(key)) ?? {};
516
+ const toasts = [];
517
+ for (const w of rateLimits ?? []) {
518
+ const step = w && usageStep(state[w.kind], w, now);
519
+ if (!step) continue;
520
+ state[w.kind] = step.next;
521
+ const reset = atWhen(step.next.resetsAt, now);
522
+ if (step.level !== undefined) toasts.push(levelText(w.kind, w.percentUsed, reset));
523
+ else if (step.pace) toasts.push(paceText(w.kind, step.pace, reset));
524
+ }
525
+ // Saved before showing: a failure while showing must not make the same warning come back.
526
+ await $.store.set(key, state);
527
+ for (const text of toasts) $.ui.toast(text);
528
+ }
529
+
497
530
  async function onNotification($, e) {
498
531
  const id = e.session_id;
499
532
  const ep = await $.store.get(limitKey(id));
@@ -859,6 +892,7 @@ export function register(on) {
859
892
  isInteractive = e.isInteractive;
860
893
  sessionCwd = e.cwd;
861
894
  entrypoint = await $.env.get("CLAUDE_CODE_ENTRYPOINT").catch(() => undefined);
895
+ configDir = (await $.env.get("CLAUDE_CONFIG_DIR").catch(() => undefined)) ?? "";
862
896
  inert = Boolean(await $.env.get("AGENT_REWAKE_FIRE").catch(() => undefined));
863
897
  if (inert) return next(e);
864
898
  surface = surfaceOf({ surface: e.surface, entrypoint });
@@ -905,7 +939,10 @@ export function register(on) {
905
939
  }).catch(passOn);
906
940
 
907
941
  on("session.measure", ($, e, next) => {
908
- if (!inert && e.changed.includes("rateLimits")) void onMeasure($, e.rateLimits);
942
+ if (!inert && e.changed.includes("rateLimits")) {
943
+ void onMeasure($, e.rateLimits);
944
+ void warnUsage($, e.rateLimits).catch(() => undefined);
945
+ }
909
946
  return next(e);
910
947
  }).catch(passOn);
911
948
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codizelabs/agent-rewake",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Auto-resume Claude Code, Codex, Gemini CLI, GitHub Copilot, Grok or any Zed agent when its usage limit resets, in the same session, and schedule messages into your agent threads.",
5
5
  "keywords": [
6
6
  "zed",