@codizelabs/agent-rewake 0.3.1 → 0.4.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.4.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).
@@ -129,6 +138,167 @@ export function expired(ep, now) {
129
138
  return false;
130
139
  }
131
140
 
141
+ /**
142
+ * Set by Rewake's `fire` on the Claude Code it starts to continue a closed session: that run is
143
+ * Rewake's own, so the plugin stays out of it (it is not a person at a prompt, and it must not
144
+ * touch the session's record while it works).
145
+ */
146
+ export const FIRE_ENV = "AGENT_REWAKE_FIRE";
147
+
148
+ /**
149
+ * The settings variables a continue of a closed session needs as the session had them, and the key
150
+ * variables of which only the names that were set are copied. Kept in step with Rewake's lists
151
+ * (src/hosts/claude-code/host.ts); a test in Rewake's repository fails if they drift apart.
152
+ * A name that looks secret is never copied, whatever this list says.
153
+ */
154
+ export const SETTINGS_VARS = [
155
+ "CLAUDE_CONFIG_DIR",
156
+ "CLAUDE_CODE_PROJECT_DIR_NAME",
157
+ "ANTHROPIC_BASE_URL",
158
+ "ANTHROPIC_MODEL",
159
+ "CLAUDE_CODE_USE_BEDROCK",
160
+ "CLAUDE_CODE_USE_VERTEX",
161
+ "CLAUDE_CODE_USE_FOUNDRY",
162
+ ];
163
+ export const KEY_VARS = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"];
164
+ const SECRET_NAME = /KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL/i;
165
+
166
+ /**
167
+ * What to copy from the session's environment, read with `get(name)`: `env` (a name to its value)
168
+ * and `keysSet` (the key variables that were set, by name only).
169
+ */
170
+ export function sessionVars(get) {
171
+ const env = {};
172
+ for (const name of SETTINGS_VARS) {
173
+ const v = get(name);
174
+ if (typeof v === "string" && v !== "" && !SECRET_NAME.test(name)) env[name] = v;
175
+ }
176
+ const keysSet = KEY_VARS.filter((k) => {
177
+ const v = get(k);
178
+ return typeof v === "string" && v !== "";
179
+ });
180
+ return {
181
+ ...(Object.keys(env).length > 0 && { env }),
182
+ ...(keysSet.length > 0 && { keysSet }),
183
+ };
184
+ }
185
+
186
+ /** States in which this plugin hasn't answered the limit: if Claude Code closes, it stays so. */
187
+ const UNANSWERED = new Set(["armed", "offered", "native"]);
188
+
189
+ /**
190
+ * The limit as Rewake's session records keep it (src/hosts/sessions.ts `SessionLimit`), for the
191
+ * states nobody has answered and with a reset time. Undefined once continued, declined, cancelled,
192
+ * or answered by the person typing: then nothing is left for Rewake to continue.
193
+ */
194
+ export function limitOf(ep) {
195
+ if (!ep || !UNANSWERED.has(ep.state)) return undefined;
196
+ if (typeof ep.resetAt !== "number" || typeof ep.createdAt !== "number") return undefined;
197
+ const kind = ep.kind === "five_hour" ? "session" : isWeekly(ep.kind) ? "weekly" : "other";
198
+ return {
199
+ kind,
200
+ billing: false,
201
+ resetsAt: ep.resetAt,
202
+ seenAt: ep.createdAt,
203
+ confidence: "structured",
204
+ };
205
+ }
206
+
207
+ const isWeekly = (kind) => typeof kind === "string" && kind.startsWith("seven_day");
208
+
209
+ /**
210
+ * The Claude Code process, from the output of
211
+ * `echo "$PPID"; ps -o comm= -p "$PPID"; command -v claude` run in a shell Claude Code started:
212
+ * its id and program name as Rewake's own check reads them (src/util/proc.ts). Undefined when
213
+ * either is missing.
214
+ */
215
+ export function parseProcess(stdout) {
216
+ const [first, comm = ""] = String(stdout).split("\n");
217
+ const pid = Number(first?.trim());
218
+ if (!Number.isInteger(pid) || pid <= 1 || comm.trim() === "") return undefined;
219
+ const name = (comm.trim().split(/[\\/]/).pop() ?? "").replace(/^-/, "");
220
+ return name === "" ? undefined : { pid, name };
221
+ }
222
+
223
+ /**
224
+ * Where `claude` is on the session's own PATH (a timer's PATH is minimal): the third line of the
225
+ * output above. Undefined unless it is an absolute path.
226
+ */
227
+ export function parseProgram(stdout) {
228
+ const line = String(stdout).split("\n")[2]?.trim() ?? "";
229
+ return line.startsWith("/") ? line : undefined;
230
+ }
231
+
232
+ // Keeping the computer awake: the same commands as Rewake's src/util/keep-awake.ts (holdCommand,
233
+ // onMains), copied because the plugin is plain files that import nothing from Rewake.
234
+
235
+ /**
236
+ * Run through the shell (macOS, Linux): Claude Code's PID (the shell's parent), the system name
237
+ * and the hold program, when the computer has one.
238
+ */
239
+ export const WAKE_PROBE =
240
+ 'echo "$PPID"; uname -s; for p in /usr/bin/caffeinate /usr/bin/systemd-inhibit /bin/systemd-inhibit; do [ -x "$p" ] && { echo "$p"; break; }; done';
241
+
242
+ /** Windows has no shell here: PowerShell, whose parent is Claude Code. */
243
+ export const WINDOWS_POWERSHELL = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
244
+ export const WINDOWS_PROBE =
245
+ '(Get-CimInstance Win32_Process -Filter "ProcessId=$PID").ParentProcessId';
246
+
247
+ /** Linux: "mains" unless there is a battery and no mains supply online (keep-awake.ts onMains). */
248
+ export const LINUX_MAINS =
249
+ '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';
250
+
251
+ /** Windows: Win32_Battery.BatteryStatus, or "none" for a desktop (the query in keep-awake.ts). */
252
+ export const WINDOWS_MAINS =
253
+ "$b = Get-CimInstance Win32_Battery; if ($b) { $b.BatteryStatus } else { 'none' }";
254
+
255
+ /** The shell probe's answer: { kind: "darwin" | "linux", pid, program }, or undefined. */
256
+ export function parseWake(stdout) {
257
+ const [pid = "", system, program = ""] = String(stdout)
258
+ .split("\n")
259
+ .map((l) => l.trim());
260
+ if (!/^\d+$/.test(pid)) return undefined;
261
+ if (system === "Darwin" && program === "/usr/bin/caffeinate")
262
+ return { kind: "darwin", pid, program };
263
+ if (system === "Linux" && program.endsWith("/systemd-inhibit"))
264
+ return { kind: "linux", pid, program };
265
+ return undefined;
266
+ }
267
+
268
+ /** Whether a mains check's answer means mains power; unknown counts as mains, as in keep-awake.ts. */
269
+ export function mainsFrom(kind, stdout) {
270
+ const text = String(stdout).trim();
271
+ if (kind === "linux") return text !== "battery";
272
+ if (text === "" || text === "none") return true;
273
+ return text.split(/\s+/).some((n) => ["2", "6", "7", "8", "9"].includes(n));
274
+ }
275
+
276
+ /** The program and arguments that hold the computer awake until process `pid` ends. */
277
+ export function holdArgv(kind, mode, pid, program) {
278
+ if (kind === "darwin") return [program, mode === "always" ? "-i" : "-s", "-w", pid];
279
+ if (kind === "linux")
280
+ return [
281
+ program,
282
+ "--what=idle",
283
+ "--who=Agent Rewake",
284
+ "--why=A planned resume is due",
285
+ "--mode=block",
286
+ "sh",
287
+ "-c",
288
+ 'while kill -0 "$0" 2>/dev/null; do sleep 5; done',
289
+ pid,
290
+ ];
291
+ return [
292
+ WINDOWS_POWERSHELL,
293
+ "-NoProfile",
294
+ "-NonInteractive",
295
+ "-WindowStyle",
296
+ "Hidden",
297
+ "-Command",
298
+ `$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 }`,
299
+ ];
300
+ }
301
+
132
302
  /** "3:05 PM" or "15:05", in the person's clock ("12h" by default, as in Rewake's settings). */
133
303
  function clockTime(d, clock) {
134
304
  const h = d.getHours();
@@ -14,11 +14,15 @@
14
14
  // claim: { by, at }, the process sending the armed continue right now
15
15
  // sched:<id> [{ at, text }]
16
16
  // 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.
17
+ // `agent-rewake doctor` and the schedules page can show it, and so Rewake can continue the session
18
+ // headless if Claude Code closes before the limit is answered (it adds `open`, the Claude Code
19
+ // process, the limit and the session's settings variables: src/hosts/sessions.ts SessionRecord).
20
+ // Installed by `agent-rewake install`, which writes that folder's path into rewake.json beside this
21
+ // plugin.
19
22
 
20
23
  import {
21
24
  AFTER_RESET_MS,
25
+ backoffMs,
22
26
  blockedUntil,
23
27
  blockingKind,
24
28
  CONTINUE_TEXT,
@@ -28,22 +32,34 @@ import {
28
32
  featureOf,
29
33
  HOUR,
30
34
  helpText,
35
+ holdArgv,
36
+ LINUX_MAINS,
37
+ limitOf,
31
38
  MAX_REARMS,
32
39
  MAX_REHITS,
33
40
  MINUTE,
41
+ mainsFrom,
34
42
  mustAsk,
35
43
  nativeLikely,
36
44
  notHere,
37
45
  parseCommand,
46
+ parseProcess,
47
+ parseProgram,
48
+ parseWake,
38
49
  parseWhen,
39
50
  personAtPrompt,
40
51
  REHIT_WINDOW_MS,
41
52
  STALE_MS,
42
53
  safeId,
54
+ sessionVars,
43
55
  splitWhen,
44
56
  surfaceOf,
45
57
  WAITING_EXPIRES_MS,
46
58
  WAKE_HORIZON_MS,
59
+ WAKE_PROBE,
60
+ WINDOWS_MAINS,
61
+ WINDOWS_POWERSHELL,
62
+ WINDOWS_PROBE,
47
63
  when,
48
64
  } from "./logic.js";
49
65
 
@@ -78,15 +94,21 @@ let mirrorDir;
78
94
  let sharedAuto;
79
95
  /** The session's folder, for where Rewake's CLI names it ("in the shop folder"). */
80
96
  let sessionCwd;
97
+ /** This Claude Code process ({ pid, name }) and the session's settings variables, for the copy. */
98
+ let claudeProcess;
99
+ let claudeProgram;
100
+ let claudeVars = {};
101
+ /** Rewake's own headless continue (AGENT_REWAKE_FIRE): the plugin does nothing in it. */
102
+ let inert = false;
81
103
  /** This process, in a send's claim: two Claude Code processes can have one session open. */
82
104
  const ME = Math.random().toString(36).slice(2);
83
105
  /** A claim older than this was left by a process that stopped mid-send. */
84
106
  const CLAIM_MS = 5 * MINUTE;
85
107
  /** Rewake's keep-awake setting: "plugged-in" (default), "always" or "never". */
86
108
  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 }. */
109
+ /** { kind, pid, program }: Claude Code's PID and the hold program (macOS, Linux with systemd-inhibit, Windows); null where there is none. */
110
+ let wakeTarget;
111
+ /** The running hold: { key, stream }. */
90
112
  let wakeHold;
91
113
 
92
114
  const limitKey = (id) => `limit:${id}`;
@@ -117,17 +139,59 @@ async function loadConfig($) {
117
139
  }
118
140
  }
119
141
 
142
+ /** This process and the session's settings variables, for Rewake's copy of the record. */
143
+ async function loadSessionInfo($) {
144
+ if (!mirrorDir) return;
145
+ // $.env.get takes a literal name, so every variable the plugin reads can be listed (logic.js
146
+ // SETTINGS_VARS and KEY_VARS; a test in Rewake's repository compares them with these).
147
+ const read = (get) => get.catch(() => undefined);
148
+ const values = {
149
+ CLAUDE_CONFIG_DIR: await read($.env.get("CLAUDE_CONFIG_DIR")),
150
+ CLAUDE_CODE_PROJECT_DIR_NAME: await read($.env.get("CLAUDE_CODE_PROJECT_DIR_NAME")),
151
+ ANTHROPIC_BASE_URL: await read($.env.get("ANTHROPIC_BASE_URL")),
152
+ ANTHROPIC_MODEL: await read($.env.get("ANTHROPIC_MODEL")),
153
+ CLAUDE_CODE_USE_BEDROCK: await read($.env.get("CLAUDE_CODE_USE_BEDROCK")),
154
+ CLAUDE_CODE_USE_VERTEX: await read($.env.get("CLAUDE_CODE_USE_VERTEX")),
155
+ CLAUDE_CODE_USE_FOUNDRY: await read($.env.get("CLAUDE_CODE_USE_FOUNDRY")),
156
+ ANTHROPIC_API_KEY: await read($.env.get("ANTHROPIC_API_KEY")),
157
+ ANTHROPIC_AUTH_TOKEN: await read($.env.get("ANTHROPIC_AUTH_TOKEN")),
158
+ CLAUDE_CODE_OAUTH_TOKEN: await read($.env.get("CLAUDE_CODE_OAUTH_TOKEN")),
159
+ };
160
+ claudeVars = sessionVars((name) => values[name]);
161
+ try {
162
+ // The shell's parent is Claude Code; no /bin/sh or ps (Windows) means no process to watch.
163
+ const r = await $.process.run([
164
+ "/bin/sh",
165
+ "-c",
166
+ 'echo "$PPID"; ps -o comm= -p "$PPID"; command -v claude',
167
+ ]);
168
+ claudeProcess = r.exitCode === 0 ? parseProcess(r.stdout) : undefined;
169
+ claudeProgram = r.exitCode === 0 ? parseProgram(r.stdout) : undefined;
170
+ } catch {
171
+ claudeProcess = undefined;
172
+ claudeProgram = undefined;
173
+ }
174
+ }
175
+
120
176
  /** Write the record's metadata (never text) where Rewake's CLI reads it. Best effort. */
121
- async function mirror($, id, ep) {
177
+ async function mirror($, id, ep, lastPromptAt) {
122
178
  if (!mirrorDir || !safeId(id)) return;
123
179
  const { state, kind, resetAt, fireAt, createdAt, sentAt, rehits, attempts } = ep ?? {
124
180
  state: "none",
125
181
  };
182
+ const limit = limitOf(ep);
126
183
  const record = {
127
184
  schemaVersion: 1,
128
185
  host: "claude-code",
129
186
  sessionId: id,
130
- ...(typeof sessionCwd === "string" && { cwd: sessionCwd }),
187
+ cwd: typeof sessionCwd === "string" ? sessionCwd : "",
188
+ // Rewake's SessionRecord: open while this process runs (it finds out when the process is gone).
189
+ open: true,
190
+ ...(claudeProcess && { agents: [claudeProcess] }),
191
+ ...(claudeProgram && { program: claudeProgram }),
192
+ ...(limit && { limit }),
193
+ ...(lastPromptAt !== undefined && { lastPromptAt }),
194
+ ...claudeVars,
131
195
  state,
132
196
  kind,
133
197
  resetAt,
@@ -150,10 +214,10 @@ async function save($, id, ep) {
150
214
  await mirror($, id, ep);
151
215
  }
152
216
 
153
- async function drop($, id) {
217
+ async function drop($, id, lastPromptAt) {
154
218
  disarm(id);
155
219
  await $.store.delete(limitKey(id));
156
- await mirror($, id, undefined);
220
+ await mirror($, id, undefined, lastPromptAt);
157
221
  }
158
222
 
159
223
  function disarm(id) {
@@ -171,9 +235,53 @@ async function arm($, id, ep) {
171
235
  }
172
236
 
173
237
  /**
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.
238
+ * Finds Claude Code's PID (the parent of the shell, or of PowerShell on Windows) and the hold
239
+ * program: caffeinate on macOS, systemd-inhibit on Linux, PowerShell on Windows. Null where the
240
+ * computer has none.
241
+ */
242
+ async function findWake($) {
243
+ try {
244
+ const r = await $.process.run(["/bin/sh", "-c", WAKE_PROBE]);
245
+ if (r.exitCode === 0) return parseWake(r.stdout) ?? null;
246
+ } catch {
247
+ // No /bin/sh: Windows.
248
+ }
249
+ try {
250
+ const r = await $.process.run([
251
+ WINDOWS_POWERSHELL,
252
+ "-NoProfile",
253
+ "-NonInteractive",
254
+ "-Command",
255
+ WINDOWS_PROBE,
256
+ ]);
257
+ const pid = r.stdout.trim();
258
+ return r.exitCode === 0 && /^\d+$/.test(pid)
259
+ ? { kind: "win32", pid, program: WINDOWS_POWERSHELL }
260
+ : null;
261
+ } catch {
262
+ return null;
263
+ }
264
+ }
265
+
266
+ /** Whether a Linux or Windows computer is on mains power; unknown counts as mains. */
267
+ async function onMains($, kind) {
268
+ try {
269
+ const r = await $.process.run(
270
+ kind === "linux"
271
+ ? ["/bin/sh", "-c", LINUX_MAINS]
272
+ : [WINDOWS_POWERSHELL, "-NoProfile", "-NonInteractive", "-Command", WINDOWS_MAINS],
273
+ );
274
+ return mainsFrom(kind, r.stdout);
275
+ } catch {
276
+ return true;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Keep this computer from idling to sleep while a continue is due within a few hours: Rewake's own,
282
+ * or Claude Code's (its wait holds nothing while the session sits idle). The hold is tied to Claude
283
+ * Code's process, so it ends with it even after a crash. "plugged-in" holds only on mains power
284
+ * (macOS's `caffeinate -s` does that itself). Returns whether a hold is running.
177
285
  */
178
286
  async function updateWake($, ep, now) {
179
287
  const due = ep?.state === "armed" ? ep.fireAt : ep?.state === "native" ? ep.resetAt : undefined;
@@ -182,34 +290,30 @@ async function updateWake($, ep, now) {
182
290
  due !== undefined &&
183
291
  due > now - STALE_MS &&
184
292
  due - now <= WAKE_HORIZON_MS;
185
- const mode = keepAwake === "always" ? "-i" : "-s";
186
- if (wakeHold && (!want || wakeHold.mode !== mode)) {
293
+ if (want && wakeTarget === undefined) wakeTarget = await findWake($);
294
+ const t = want ? wakeTarget : undefined;
295
+ const hold =
296
+ t && (keepAwake === "always" || t.kind === "darwin" || (await onMains($, t.kind)))
297
+ ? holdArgv(t.kind, keepAwake, t.pid, t.program)
298
+ : undefined;
299
+ const key = hold?.join("\n");
300
+ if (wakeHold && wakeHold.key !== key) {
187
301
  void wakeHold.stream.return?.();
188
302
  wakeHold = undefined;
189
303
  }
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;
304
+ if (!hold || wakeHold) return wakeHold !== undefined;
305
+ const stream = $.process.spawn({ argv: hold });
306
+ const running = { key, stream };
307
+ wakeHold = running;
204
308
  void (async () => {
205
309
  try {
206
310
  for await (const _ of stream) {
207
- // caffeinate writes nothing; the loop is the hold's life.
311
+ // The hold writes nothing; the loop is the hold's life.
208
312
  }
209
313
  } catch {
210
314
  // It couldn't start: no hold.
211
315
  }
212
- if (wakeHold === hold) wakeHold = undefined;
316
+ if (wakeHold === running) wakeHold = undefined;
213
317
  })();
214
318
  return true;
215
319
  }
@@ -217,7 +321,10 @@ async function updateWake($, ep, now) {
217
321
  async function refreshStatus($, id, now) {
218
322
  const ep = await $.store.get(limitKey(id));
219
323
  const sched = (await $.store.get(schedKey(id))) ?? [];
220
- const awake = (await updateWake($, ep, now)) ? " · keeping this Mac awake" : "";
324
+ const holding = await updateWake($, ep, now);
325
+ const awake = holding
326
+ ? ` · keeping this ${wakeTarget?.kind === "darwin" ? "Mac" : "computer"} awake`
327
+ : "";
221
328
  if (ep?.state === "armed" && ep.fireAt !== undefined) {
222
329
  $.ui.status(`Continues at ${at(ep.fireAt, now)}${awake} · /rewake cancel`);
223
330
  } else if (ep?.state === "offered") {
@@ -340,6 +447,22 @@ async function onLimitOnce($, id) {
340
447
  await refreshStatus($, id, now);
341
448
  return;
342
449
  }
450
+ if (resetAt === undefined && rehits > 0) {
451
+ // The limit came back right after Rewake continued, with no new reset time: the first request
452
+ // after the reset was refused (seen at reset + 61 s). The person already said yes, so try
453
+ // again after the usual wait; the re-hit cap above bounds the tries.
454
+ const next = {
455
+ state: "armed",
456
+ createdAt: now,
457
+ rehits,
458
+ attempts: 0,
459
+ fireAt: now + backoffMs(rehits - 1),
460
+ };
461
+ await save($, id, next);
462
+ await arm($, id, next);
463
+ await refreshStatus($, id, now);
464
+ return;
465
+ }
343
466
  const ep = { state: "waiting", createdAt: now, rehits, attempts: 0 };
344
467
  if (resetAt === undefined) {
345
468
  // No reset time yet, or a limit that a wait doesn't fix (credits, a spending cap, an API
@@ -391,7 +514,9 @@ async function onNotification($, e) {
391
514
  async function standDown($) {
392
515
  const id = await $.session.id();
393
516
  const ep = await $.store.get(limitKey(id));
394
- if (ep && ep.state !== "sent") await drop($, id);
517
+ // The person (or Claude Code itself) moved on: also tells Rewake, in case it planned to continue
518
+ // this session after Claude Code was closed.
519
+ if (ep && ep.state !== "sent") await drop($, id, await $.clock.now());
395
520
  await refreshStatus($, id, await $.clock.now());
396
521
  }
397
522
 
@@ -511,6 +636,9 @@ async function reopen($) {
511
636
  return;
512
637
  }
513
638
  }
639
+ // This process has the session open now: Rewake must not continue it headless meanwhile.
640
+ const open = await $.store.get(limitKey(id));
641
+ if (open) await mirror($, id, open);
514
642
  await refreshStatus($, id, now);
515
643
  }
516
644
 
@@ -731,6 +859,8 @@ export function register(on) {
731
859
  isInteractive = e.isInteractive;
732
860
  sessionCwd = e.cwd;
733
861
  entrypoint = await $.env.get("CLAUDE_CODE_ENTRYPOINT").catch(() => undefined);
862
+ inert = Boolean(await $.env.get("AGENT_REWAKE_FIRE").catch(() => undefined));
863
+ if (inert) return next(e);
734
864
  surface = surfaceOf({ surface: e.surface, entrypoint });
735
865
  try {
736
866
  nativeSetting = { value: (await $.settings.read()).autoContinueAtUsageLimit };
@@ -738,6 +868,7 @@ export function register(on) {
738
868
  nativeSetting = undefined;
739
869
  }
740
870
  await loadConfig($);
871
+ await loadSessionInfo($);
741
872
  $.clock.every(TICK_MS, () => void tick($));
742
873
  void reopen($);
743
874
  for (const entry of [
@@ -757,6 +888,7 @@ export function register(on) {
757
888
  });
758
889
 
759
890
  on("classic.SessionStart", ($, e, next) => {
891
+ if (inert) return next(e);
760
892
  // An in-process switch (/resume, /clear, /branch): the armed timer belongs to the old one.
761
893
  if (e.source === "resume" || e.source === "clear" || e.source === "fork") {
762
894
  disarm();
@@ -767,23 +899,24 @@ export function register(on) {
767
899
 
768
900
  on("classic.StopFailure", ($, e, next) => {
769
901
  // 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);
902
+ if (!inert && e.error === "rate_limit" && e.agent_id === undefined)
903
+ void onLimit($, e.session_id);
771
904
  return next(e);
772
905
  }).catch(passOn);
773
906
 
774
907
  on("session.measure", ($, e, next) => {
775
- if (e.changed.includes("rateLimits")) void onMeasure($, e.rateLimits);
908
+ if (!inert && e.changed.includes("rateLimits")) void onMeasure($, e.rateLimits);
776
909
  return next(e);
777
910
  }).catch(passOn);
778
911
 
779
912
  on("classic.Notification", ($, e, next) => {
780
- if (e.notification_type.startsWith("quota_auto_resume_")) void onNotification($, e);
913
+ if (!inert && e.notification_type.startsWith("quota_auto_resume_")) void onNotification($, e);
781
914
  return next(e);
782
915
  }).catch(passOn);
783
916
 
784
917
  on("prompt.submit", ($, e, next) => {
785
918
  // Observe only: the person typed, or Claude Code's own auto-continue fired.
786
- if (MOVED_ON.has(e.origin.kind)) void standDown($);
919
+ if (!inert && MOVED_ON.has(e.origin.kind)) void standDown($);
787
920
  return next(e);
788
921
  }).catch(passOn);
789
922
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codizelabs/agent-rewake",
3
- "version": "0.3.1",
3
+ "version": "0.4.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",