@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.
@@ -13,12 +13,20 @@
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
- // `agent-rewake doctor` and the schedules page can show it. Installed by `agent-rewake install`,
18
- // which writes that folder's path into rewake.json beside this plugin.
21
+ // `agent-rewake doctor` and the schedules page can show it, and so Rewake can continue the session
22
+ // headless if Claude Code closes before the limit is answered (it adds `open`, the Claude Code
23
+ // process, the limit and the session's settings variables: src/hosts/sessions.ts SessionRecord).
24
+ // Installed by `agent-rewake install`, which writes that folder's path into rewake.json beside this
25
+ // plugin.
19
26
 
20
27
  import {
21
28
  AFTER_RESET_MS,
29
+ backoffMs,
22
30
  blockedUntil,
23
31
  blockingKind,
24
32
  CONTINUE_TEXT,
@@ -28,22 +36,37 @@ import {
28
36
  featureOf,
29
37
  HOUR,
30
38
  helpText,
39
+ holdArgv,
40
+ LINUX_MAINS,
41
+ levelText,
42
+ limitOf,
31
43
  MAX_REARMS,
32
44
  MAX_REHITS,
33
45
  MINUTE,
46
+ mainsFrom,
34
47
  mustAsk,
35
48
  nativeLikely,
36
49
  notHere,
50
+ paceText,
37
51
  parseCommand,
52
+ parseProcess,
53
+ parseProgram,
54
+ parseWake,
38
55
  parseWhen,
39
56
  personAtPrompt,
40
57
  REHIT_WINDOW_MS,
41
58
  STALE_MS,
42
59
  safeId,
60
+ sessionVars,
43
61
  splitWhen,
44
62
  surfaceOf,
63
+ usageStep,
45
64
  WAITING_EXPIRES_MS,
46
65
  WAKE_HORIZON_MS,
66
+ WAKE_PROBE,
67
+ WINDOWS_MAINS,
68
+ WINDOWS_POWERSHELL,
69
+ WINDOWS_PROBE,
47
70
  when,
48
71
  } from "./logic.js";
49
72
 
@@ -78,15 +101,25 @@ let mirrorDir;
78
101
  let sharedAuto;
79
102
  /** The session's folder, for where Rewake's CLI names it ("in the shop folder"). */
80
103
  let sessionCwd;
104
+ /** This Claude Code process ({ pid, name }) and the session's settings variables, for the copy. */
105
+ let claudeProcess;
106
+ let claudeProgram;
107
+ let claudeVars = {};
108
+ /** Rewake's own headless continue (AGENT_REWAKE_FIRE): the plugin does nothing in it. */
109
+ let inert = false;
81
110
  /** This process, in a send's claim: two Claude Code processes can have one session open. */
82
111
  const ME = Math.random().toString(36).slice(2);
83
112
  /** A claim older than this was left by a process that stopped mid-send. */
84
113
  const CLAIM_MS = 5 * MINUTE;
85
114
  /** Rewake's keep-awake setting: "plugged-in" (default), "always" or "never". */
86
115
  let keepAwake = "plugged-in";
87
- /** Claude Code's PID when this Mac has caffeinate; null where Rewake can't keep it awake. */
88
- let wakePid;
89
- /** The running hold: { mode, stream }. */
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 = "";
120
+ /** { kind, pid, program }: Claude Code's PID and the hold program (macOS, Linux with systemd-inhibit, Windows); null where there is none. */
121
+ let wakeTarget;
122
+ /** The running hold: { key, stream }. */
90
123
  let wakeHold;
91
124
 
92
125
  const limitKey = (id) => `limit:${id}`;
@@ -109,6 +142,8 @@ async function loadConfig($) {
109
142
  if (["on", "off", "ask"].includes(settings.newThreads)) sharedAuto = settings.newThreads;
110
143
  if (["plugged-in", "always", "never"].includes(settings.keepAwake))
111
144
  keepAwake = settings.keepAwake;
145
+ if (settings.usageWarnings === "on" || settings.usageWarnings === "off")
146
+ usageWarnings = settings.usageWarnings;
112
147
  } catch {
113
148
  // No settings saved yet: the default clock.
114
149
  }
@@ -117,17 +152,59 @@ async function loadConfig($) {
117
152
  }
118
153
  }
119
154
 
155
+ /** This process and the session's settings variables, for Rewake's copy of the record. */
156
+ async function loadSessionInfo($) {
157
+ if (!mirrorDir) return;
158
+ // $.env.get takes a literal name, so every variable the plugin reads can be listed (logic.js
159
+ // SETTINGS_VARS and KEY_VARS; a test in Rewake's repository compares them with these).
160
+ const read = (get) => get.catch(() => undefined);
161
+ const values = {
162
+ CLAUDE_CONFIG_DIR: configDir, // read once, at session start
163
+ CLAUDE_CODE_PROJECT_DIR_NAME: await read($.env.get("CLAUDE_CODE_PROJECT_DIR_NAME")),
164
+ ANTHROPIC_BASE_URL: await read($.env.get("ANTHROPIC_BASE_URL")),
165
+ ANTHROPIC_MODEL: await read($.env.get("ANTHROPIC_MODEL")),
166
+ CLAUDE_CODE_USE_BEDROCK: await read($.env.get("CLAUDE_CODE_USE_BEDROCK")),
167
+ CLAUDE_CODE_USE_VERTEX: await read($.env.get("CLAUDE_CODE_USE_VERTEX")),
168
+ CLAUDE_CODE_USE_FOUNDRY: await read($.env.get("CLAUDE_CODE_USE_FOUNDRY")),
169
+ ANTHROPIC_API_KEY: await read($.env.get("ANTHROPIC_API_KEY")),
170
+ ANTHROPIC_AUTH_TOKEN: await read($.env.get("ANTHROPIC_AUTH_TOKEN")),
171
+ CLAUDE_CODE_OAUTH_TOKEN: await read($.env.get("CLAUDE_CODE_OAUTH_TOKEN")),
172
+ };
173
+ claudeVars = sessionVars((name) => values[name]);
174
+ try {
175
+ // The shell's parent is Claude Code; no /bin/sh or ps (Windows) means no process to watch.
176
+ const r = await $.process.run([
177
+ "/bin/sh",
178
+ "-c",
179
+ 'echo "$PPID"; ps -o comm= -p "$PPID"; command -v claude',
180
+ ]);
181
+ claudeProcess = r.exitCode === 0 ? parseProcess(r.stdout) : undefined;
182
+ claudeProgram = r.exitCode === 0 ? parseProgram(r.stdout) : undefined;
183
+ } catch {
184
+ claudeProcess = undefined;
185
+ claudeProgram = undefined;
186
+ }
187
+ }
188
+
120
189
  /** Write the record's metadata (never text) where Rewake's CLI reads it. Best effort. */
121
- async function mirror($, id, ep) {
190
+ async function mirror($, id, ep, lastPromptAt) {
122
191
  if (!mirrorDir || !safeId(id)) return;
123
192
  const { state, kind, resetAt, fireAt, createdAt, sentAt, rehits, attempts } = ep ?? {
124
193
  state: "none",
125
194
  };
195
+ const limit = limitOf(ep);
126
196
  const record = {
127
197
  schemaVersion: 1,
128
198
  host: "claude-code",
129
199
  sessionId: id,
130
- ...(typeof sessionCwd === "string" && { cwd: sessionCwd }),
200
+ cwd: typeof sessionCwd === "string" ? sessionCwd : "",
201
+ // Rewake's SessionRecord: open while this process runs (it finds out when the process is gone).
202
+ open: true,
203
+ ...(claudeProcess && { agents: [claudeProcess] }),
204
+ ...(claudeProgram && { program: claudeProgram }),
205
+ ...(limit && { limit }),
206
+ ...(lastPromptAt !== undefined && { lastPromptAt }),
207
+ ...claudeVars,
131
208
  state,
132
209
  kind,
133
210
  resetAt,
@@ -150,10 +227,10 @@ async function save($, id, ep) {
150
227
  await mirror($, id, ep);
151
228
  }
152
229
 
153
- async function drop($, id) {
230
+ async function drop($, id, lastPromptAt) {
154
231
  disarm(id);
155
232
  await $.store.delete(limitKey(id));
156
- await mirror($, id, undefined);
233
+ await mirror($, id, undefined, lastPromptAt);
157
234
  }
158
235
 
159
236
  function disarm(id) {
@@ -171,9 +248,53 @@ async function arm($, id, ep) {
171
248
  }
172
249
 
173
250
  /**
174
- * Keep this Mac from idling to sleep while a continue is due within a few hours: Rewake's own, or
175
- * Claude Code's (its wait holds nothing while the session sits idle). `caffeinate -w` ties the hold
176
- * to Claude Code, so it ends with it even after a crash. Returns whether a hold is running.
251
+ * Finds Claude Code's PID (the parent of the shell, or of PowerShell on Windows) and the hold
252
+ * program: caffeinate on macOS, systemd-inhibit on Linux, PowerShell on Windows. Null where the
253
+ * computer has none.
254
+ */
255
+ async function findWake($) {
256
+ try {
257
+ const r = await $.process.run(["/bin/sh", "-c", WAKE_PROBE]);
258
+ if (r.exitCode === 0) return parseWake(r.stdout) ?? null;
259
+ } catch {
260
+ // No /bin/sh: Windows.
261
+ }
262
+ try {
263
+ const r = await $.process.run([
264
+ WINDOWS_POWERSHELL,
265
+ "-NoProfile",
266
+ "-NonInteractive",
267
+ "-Command",
268
+ WINDOWS_PROBE,
269
+ ]);
270
+ const pid = r.stdout.trim();
271
+ return r.exitCode === 0 && /^\d+$/.test(pid)
272
+ ? { kind: "win32", pid, program: WINDOWS_POWERSHELL }
273
+ : null;
274
+ } catch {
275
+ return null;
276
+ }
277
+ }
278
+
279
+ /** Whether a Linux or Windows computer is on mains power; unknown counts as mains. */
280
+ async function onMains($, kind) {
281
+ try {
282
+ const r = await $.process.run(
283
+ kind === "linux"
284
+ ? ["/bin/sh", "-c", LINUX_MAINS]
285
+ : [WINDOWS_POWERSHELL, "-NoProfile", "-NonInteractive", "-Command", WINDOWS_MAINS],
286
+ );
287
+ return mainsFrom(kind, r.stdout);
288
+ } catch {
289
+ return true;
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Keep this computer from idling to sleep while a continue is due within a few hours: Rewake's own,
295
+ * or Claude Code's (its wait holds nothing while the session sits idle). The hold is tied to Claude
296
+ * Code's process, so it ends with it even after a crash. "plugged-in" holds only on mains power
297
+ * (macOS's `caffeinate -s` does that itself). Returns whether a hold is running.
177
298
  */
178
299
  async function updateWake($, ep, now) {
179
300
  const due = ep?.state === "armed" ? ep.fireAt : ep?.state === "native" ? ep.resetAt : undefined;
@@ -182,34 +303,30 @@ async function updateWake($, ep, now) {
182
303
  due !== undefined &&
183
304
  due > now - STALE_MS &&
184
305
  due - now <= WAKE_HORIZON_MS;
185
- const mode = keepAwake === "always" ? "-i" : "-s";
186
- if (wakeHold && (!want || wakeHold.mode !== mode)) {
306
+ if (want && wakeTarget === undefined) wakeTarget = await findWake($);
307
+ const t = want ? wakeTarget : undefined;
308
+ const hold =
309
+ t && (keepAwake === "always" || t.kind === "darwin" || (await onMains($, t.kind)))
310
+ ? holdArgv(t.kind, keepAwake, t.pid, t.program)
311
+ : undefined;
312
+ const key = hold?.join("\n");
313
+ if (wakeHold && wakeHold.key !== key) {
187
314
  void wakeHold.stream.return?.();
188
315
  wakeHold = undefined;
189
316
  }
190
- if (!want || wakeHold) return wakeHold !== undefined;
191
- if (wakePid === undefined) {
192
- try {
193
- // The shell's parent is Claude Code; no caffeinate (Linux, Windows) means no hold here.
194
- const r = await $.process.run(["/bin/sh", "-c", "test -x /usr/bin/caffeinate && echo $PPID"]);
195
- wakePid = r.exitCode === 0 && /^\d+$/.test(r.stdout.trim()) ? r.stdout.trim() : null;
196
- } catch {
197
- wakePid = null;
198
- }
199
- }
200
- if (wakePid === null) return false;
201
- const stream = $.process.spawn({ argv: ["/usr/bin/caffeinate", mode, "-w", wakePid] });
202
- const hold = { mode, stream };
203
- wakeHold = hold;
317
+ if (!hold || wakeHold) return wakeHold !== undefined;
318
+ const stream = $.process.spawn({ argv: hold });
319
+ const running = { key, stream };
320
+ wakeHold = running;
204
321
  void (async () => {
205
322
  try {
206
323
  for await (const _ of stream) {
207
- // caffeinate writes nothing; the loop is the hold's life.
324
+ // The hold writes nothing; the loop is the hold's life.
208
325
  }
209
326
  } catch {
210
327
  // It couldn't start: no hold.
211
328
  }
212
- if (wakeHold === hold) wakeHold = undefined;
329
+ if (wakeHold === running) wakeHold = undefined;
213
330
  })();
214
331
  return true;
215
332
  }
@@ -217,7 +334,10 @@ async function updateWake($, ep, now) {
217
334
  async function refreshStatus($, id, now) {
218
335
  const ep = await $.store.get(limitKey(id));
219
336
  const sched = (await $.store.get(schedKey(id))) ?? [];
220
- const awake = (await updateWake($, ep, now)) ? " · keeping this Mac awake" : "";
337
+ const holding = await updateWake($, ep, now);
338
+ const awake = holding
339
+ ? ` · keeping this ${wakeTarget?.kind === "darwin" ? "Mac" : "computer"} awake`
340
+ : "";
221
341
  if (ep?.state === "armed" && ep.fireAt !== undefined) {
222
342
  $.ui.status(`Continues at ${at(ep.fireAt, now)}${awake} · /rewake cancel`);
223
343
  } else if (ep?.state === "offered") {
@@ -340,6 +460,22 @@ async function onLimitOnce($, id) {
340
460
  await refreshStatus($, id, now);
341
461
  return;
342
462
  }
463
+ if (resetAt === undefined && rehits > 0) {
464
+ // The limit came back right after Rewake continued, with no new reset time: the first request
465
+ // after the reset was refused (seen at reset + 61 s). The person already said yes, so try
466
+ // again after the usual wait; the re-hit cap above bounds the tries.
467
+ const next = {
468
+ state: "armed",
469
+ createdAt: now,
470
+ rehits,
471
+ attempts: 0,
472
+ fireAt: now + backoffMs(rehits - 1),
473
+ };
474
+ await save($, id, next);
475
+ await arm($, id, next);
476
+ await refreshStatus($, id, now);
477
+ return;
478
+ }
343
479
  const ep = { state: "waiting", createdAt: now, rehits, attempts: 0 };
344
480
  if (resetAt === undefined) {
345
481
  // No reset time yet, or a limit that a wait doesn't fix (credits, a spending cap, an API
@@ -371,6 +507,26 @@ async function onMeasure($, rateLimits) {
371
507
  );
372
508
  }
373
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
+
374
530
  async function onNotification($, e) {
375
531
  const id = e.session_id;
376
532
  const ep = await $.store.get(limitKey(id));
@@ -391,7 +547,9 @@ async function onNotification($, e) {
391
547
  async function standDown($) {
392
548
  const id = await $.session.id();
393
549
  const ep = await $.store.get(limitKey(id));
394
- if (ep && ep.state !== "sent") await drop($, id);
550
+ // The person (or Claude Code itself) moved on: also tells Rewake, in case it planned to continue
551
+ // this session after Claude Code was closed.
552
+ if (ep && ep.state !== "sent") await drop($, id, await $.clock.now());
395
553
  await refreshStatus($, id, await $.clock.now());
396
554
  }
397
555
 
@@ -511,6 +669,9 @@ async function reopen($) {
511
669
  return;
512
670
  }
513
671
  }
672
+ // This process has the session open now: Rewake must not continue it headless meanwhile.
673
+ const open = await $.store.get(limitKey(id));
674
+ if (open) await mirror($, id, open);
514
675
  await refreshStatus($, id, now);
515
676
  }
516
677
 
@@ -731,6 +892,9 @@ export function register(on) {
731
892
  isInteractive = e.isInteractive;
732
893
  sessionCwd = e.cwd;
733
894
  entrypoint = await $.env.get("CLAUDE_CODE_ENTRYPOINT").catch(() => undefined);
895
+ configDir = (await $.env.get("CLAUDE_CONFIG_DIR").catch(() => undefined)) ?? "";
896
+ inert = Boolean(await $.env.get("AGENT_REWAKE_FIRE").catch(() => undefined));
897
+ if (inert) return next(e);
734
898
  surface = surfaceOf({ surface: e.surface, entrypoint });
735
899
  try {
736
900
  nativeSetting = { value: (await $.settings.read()).autoContinueAtUsageLimit };
@@ -738,6 +902,7 @@ export function register(on) {
738
902
  nativeSetting = undefined;
739
903
  }
740
904
  await loadConfig($);
905
+ await loadSessionInfo($);
741
906
  $.clock.every(TICK_MS, () => void tick($));
742
907
  void reopen($);
743
908
  for (const entry of [
@@ -757,6 +922,7 @@ export function register(on) {
757
922
  });
758
923
 
759
924
  on("classic.SessionStart", ($, e, next) => {
925
+ if (inert) return next(e);
760
926
  // An in-process switch (/resume, /clear, /branch): the armed timer belongs to the old one.
761
927
  if (e.source === "resume" || e.source === "clear" || e.source === "fork") {
762
928
  disarm();
@@ -767,23 +933,27 @@ export function register(on) {
767
933
 
768
934
  on("classic.StopFailure", ($, e, next) => {
769
935
  // Main conversation only: helper and subagent failures carry agent_id.
770
- if (e.error === "rate_limit" && e.agent_id === undefined) void onLimit($, e.session_id);
936
+ if (!inert && e.error === "rate_limit" && e.agent_id === undefined)
937
+ void onLimit($, e.session_id);
771
938
  return next(e);
772
939
  }).catch(passOn);
773
940
 
774
941
  on("session.measure", ($, e, next) => {
775
- if (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
+ }
776
946
  return next(e);
777
947
  }).catch(passOn);
778
948
 
779
949
  on("classic.Notification", ($, e, next) => {
780
- if (e.notification_type.startsWith("quota_auto_resume_")) void onNotification($, e);
950
+ if (!inert && e.notification_type.startsWith("quota_auto_resume_")) void onNotification($, e);
781
951
  return next(e);
782
952
  }).catch(passOn);
783
953
 
784
954
  on("prompt.submit", ($, e, next) => {
785
955
  // Observe only: the person typed, or Claude Code's own auto-continue fired.
786
- if (MOVED_ON.has(e.origin.kind)) void standDown($);
956
+ if (!inert && MOVED_ON.has(e.origin.kind)) void standDown($);
787
957
  return next(e);
788
958
  }).catch(passOn);
789
959
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codizelabs/agent-rewake",
3
- "version": "0.3.1",
3
+ "version": "0.5.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",