@codizelabs/agent-rewake 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rewake",
3
- "version": "0.3.1",
3
+ "version": "0.5.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",
@@ -22,6 +22,15 @@ export const MAX_REARMS = 4;
22
22
  export const REHIT_WINDOW_MS = 15 * MINUTE;
23
23
  /** Re-hits in a row before Rewake stops continuing this session (Claude Code's own cap is 2). */
24
24
  export const MAX_REHITS = 2;
25
+ /** Waits when the limit is back and no new reset time is known: 2, 5, 10, then 20 minutes. */
26
+ const BACKOFF_MS = [2, 5, 10, 20].map((m) => m * MINUTE);
27
+
28
+ /** How long to wait before trying again after `rearms` tries (Rewake's backoffMs, src/core/resume.ts). */
29
+ export function backoffMs(rearms) {
30
+ const i = Math.max(0, Math.min(BACKOFF_MS.length - 1, Math.floor(rearms)));
31
+ return BACKOFF_MS[i];
32
+ }
33
+
25
34
  /**
26
35
  * Sent as the person's message, so it says who sent it (Rewake's AUTO_LABEL, src/addon.ts):
27
36
  * automation never speaks as the person (zed-launch A-5).
@@ -67,6 +76,104 @@ export function blockingKind(rateLimits, now) {
67
76
  )?.kind;
68
77
  }
69
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
+
70
177
  /**
71
178
  * Claude Code's own panels, by `CLAUDE_CODE_ENTRYPOINT`: the VS Code extension (also in Cursor and
72
179
  * other VS Code builds) and the desktop app. They run Claude Code through the Agent SDK, so the
@@ -129,6 +236,167 @@ export function expired(ep, now) {
129
236
  return false;
130
237
  }
131
238
 
239
+ /**
240
+ * Set by Rewake's `fire` on the Claude Code it starts to continue a closed session: that run is
241
+ * Rewake's own, so the plugin stays out of it (it is not a person at a prompt, and it must not
242
+ * touch the session's record while it works).
243
+ */
244
+ export const FIRE_ENV = "AGENT_REWAKE_FIRE";
245
+
246
+ /**
247
+ * The settings variables a continue of a closed session needs as the session had them, and the key
248
+ * variables of which only the names that were set are copied. Kept in step with Rewake's lists
249
+ * (src/hosts/claude-code/host.ts); a test in Rewake's repository fails if they drift apart.
250
+ * A name that looks secret is never copied, whatever this list says.
251
+ */
252
+ export const SETTINGS_VARS = [
253
+ "CLAUDE_CONFIG_DIR",
254
+ "CLAUDE_CODE_PROJECT_DIR_NAME",
255
+ "ANTHROPIC_BASE_URL",
256
+ "ANTHROPIC_MODEL",
257
+ "CLAUDE_CODE_USE_BEDROCK",
258
+ "CLAUDE_CODE_USE_VERTEX",
259
+ "CLAUDE_CODE_USE_FOUNDRY",
260
+ ];
261
+ export const KEY_VARS = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"];
262
+ const SECRET_NAME = /KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL/i;
263
+
264
+ /**
265
+ * What to copy from the session's environment, read with `get(name)`: `env` (a name to its value)
266
+ * and `keysSet` (the key variables that were set, by name only).
267
+ */
268
+ export function sessionVars(get) {
269
+ const env = {};
270
+ for (const name of SETTINGS_VARS) {
271
+ const v = get(name);
272
+ if (typeof v === "string" && v !== "" && !SECRET_NAME.test(name)) env[name] = v;
273
+ }
274
+ const keysSet = KEY_VARS.filter((k) => {
275
+ const v = get(k);
276
+ return typeof v === "string" && v !== "";
277
+ });
278
+ return {
279
+ ...(Object.keys(env).length > 0 && { env }),
280
+ ...(keysSet.length > 0 && { keysSet }),
281
+ };
282
+ }
283
+
284
+ /** States in which this plugin hasn't answered the limit: if Claude Code closes, it stays so. */
285
+ const UNANSWERED = new Set(["armed", "offered", "native"]);
286
+
287
+ /**
288
+ * The limit as Rewake's session records keep it (src/hosts/sessions.ts `SessionLimit`), for the
289
+ * states nobody has answered and with a reset time. Undefined once continued, declined, cancelled,
290
+ * or answered by the person typing: then nothing is left for Rewake to continue.
291
+ */
292
+ export function limitOf(ep) {
293
+ if (!ep || !UNANSWERED.has(ep.state)) return undefined;
294
+ if (typeof ep.resetAt !== "number" || typeof ep.createdAt !== "number") return undefined;
295
+ const kind = ep.kind === "five_hour" ? "session" : isWeekly(ep.kind) ? "weekly" : "other";
296
+ return {
297
+ kind,
298
+ billing: false,
299
+ resetsAt: ep.resetAt,
300
+ seenAt: ep.createdAt,
301
+ confidence: "structured",
302
+ };
303
+ }
304
+
305
+ const isWeekly = (kind) => typeof kind === "string" && kind.startsWith("seven_day");
306
+
307
+ /**
308
+ * The Claude Code process, from the output of
309
+ * `echo "$PPID"; ps -o comm= -p "$PPID"; command -v claude` run in a shell Claude Code started:
310
+ * its id and program name as Rewake's own check reads them (src/util/proc.ts). Undefined when
311
+ * either is missing.
312
+ */
313
+ export function parseProcess(stdout) {
314
+ const [first, comm = ""] = String(stdout).split("\n");
315
+ const pid = Number(first?.trim());
316
+ if (!Number.isInteger(pid) || pid <= 1 || comm.trim() === "") return undefined;
317
+ const name = (comm.trim().split(/[\\/]/).pop() ?? "").replace(/^-/, "");
318
+ return name === "" ? undefined : { pid, name };
319
+ }
320
+
321
+ /**
322
+ * Where `claude` is on the session's own PATH (a timer's PATH is minimal): the third line of the
323
+ * output above. Undefined unless it is an absolute path.
324
+ */
325
+ export function parseProgram(stdout) {
326
+ const line = String(stdout).split("\n")[2]?.trim() ?? "";
327
+ return line.startsWith("/") ? line : undefined;
328
+ }
329
+
330
+ // Keeping the computer awake: the same commands as Rewake's src/util/keep-awake.ts (holdCommand,
331
+ // onMains), copied because the plugin is plain files that import nothing from Rewake.
332
+
333
+ /**
334
+ * Run through the shell (macOS, Linux): Claude Code's PID (the shell's parent), the system name
335
+ * and the hold program, when the computer has one.
336
+ */
337
+ export const WAKE_PROBE =
338
+ 'echo "$PPID"; uname -s; for p in /usr/bin/caffeinate /usr/bin/systemd-inhibit /bin/systemd-inhibit; do [ -x "$p" ] && { echo "$p"; break; }; done';
339
+
340
+ /** Windows has no shell here: PowerShell, whose parent is Claude Code. */
341
+ export const WINDOWS_POWERSHELL = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
342
+ export const WINDOWS_PROBE =
343
+ '(Get-CimInstance Win32_Process -Filter "ProcessId=$PID").ParentProcessId';
344
+
345
+ /** Linux: "mains" unless there is a battery and no mains supply online (keep-awake.ts onMains). */
346
+ export const LINUX_MAINS =
347
+ 'b=0; m=0; for d in /sys/class/power_supply/*; do t=$(cat "$d/type" 2>/dev/null); [ "$t" = Battery ] && b=1; [ "$t" = Mains ] && [ "$(cat "$d/online" 2>/dev/null)" = 1 ] && m=1; done; if [ $b = 0 ] || [ $m = 1 ]; then echo mains; else echo battery; fi';
348
+
349
+ /** Windows: Win32_Battery.BatteryStatus, or "none" for a desktop (the query in keep-awake.ts). */
350
+ export const WINDOWS_MAINS =
351
+ "$b = Get-CimInstance Win32_Battery; if ($b) { $b.BatteryStatus } else { 'none' }";
352
+
353
+ /** The shell probe's answer: { kind: "darwin" | "linux", pid, program }, or undefined. */
354
+ export function parseWake(stdout) {
355
+ const [pid = "", system, program = ""] = String(stdout)
356
+ .split("\n")
357
+ .map((l) => l.trim());
358
+ if (!/^\d+$/.test(pid)) return undefined;
359
+ if (system === "Darwin" && program === "/usr/bin/caffeinate")
360
+ return { kind: "darwin", pid, program };
361
+ if (system === "Linux" && program.endsWith("/systemd-inhibit"))
362
+ return { kind: "linux", pid, program };
363
+ return undefined;
364
+ }
365
+
366
+ /** Whether a mains check's answer means mains power; unknown counts as mains, as in keep-awake.ts. */
367
+ export function mainsFrom(kind, stdout) {
368
+ const text = String(stdout).trim();
369
+ if (kind === "linux") return text !== "battery";
370
+ if (text === "" || text === "none") return true;
371
+ return text.split(/\s+/).some((n) => ["2", "6", "7", "8", "9"].includes(n));
372
+ }
373
+
374
+ /** The program and arguments that hold the computer awake until process `pid` ends. */
375
+ export function holdArgv(kind, mode, pid, program) {
376
+ if (kind === "darwin") return [program, mode === "always" ? "-i" : "-s", "-w", pid];
377
+ if (kind === "linux")
378
+ return [
379
+ program,
380
+ "--what=idle",
381
+ "--who=Agent Rewake",
382
+ "--why=A planned resume is due",
383
+ "--mode=block",
384
+ "sh",
385
+ "-c",
386
+ 'while kill -0 "$0" 2>/dev/null; do sleep 5; done',
387
+ pid,
388
+ ];
389
+ return [
390
+ WINDOWS_POWERSHELL,
391
+ "-NoProfile",
392
+ "-NonInteractive",
393
+ "-WindowStyle",
394
+ "Hidden",
395
+ "-Command",
396
+ `$t = Add-Type -MemberDefinition '[DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint f);' -Name W -Namespace R -PassThru; $null = $t::SetThreadExecutionState(0x80000001); while (Get-Process -Id ${pid} -ErrorAction SilentlyContinue) { Start-Sleep -Seconds 5 }`,
397
+ ];
398
+ }
399
+
132
400
  /** "3:05 PM" or "15:05", in the person's clock ("12h" by default, as in Rewake's settings). */
133
401
  function clockTime(d, clock) {
134
402
  const h = d.getHours();