@codizelabs/agent-rewake 0.0.0-stage → 0.2.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.
@@ -0,0 +1,727 @@
1
+ // Agent Rewake's mod for Claude Code. When a usage limit stops this session it asks once, then
2
+ // continues the same session after the reset; it also sends messages scheduled with /rewake,
3
+ // Rewake's one command (the same in every place Rewake runs; see logic.js parseCommand).
4
+ //
5
+ // Everything runs inside this Claude Code process: nothing runs once it exits. A pending continue
6
+ // is kept in $.store and offered again when the session is reopened.
7
+ //
8
+ // $.store keys ($.store is one file shared by every Claude Code process, so two with the same
9
+ // session open both see its record; only the claim decides which one sends):
10
+ // prefs { autoContinue?: 'always' }
11
+ // limit:<id> { state, kind?, resetAt?, fireAt?, createdAt, sentAt?, rehits, attempts, claim? }
12
+ // state: waiting (no reset time yet) | native (Claude Code's own wait) |
13
+ // offered (not answered) | armed | sent
14
+ // claim: { by, at }, the process sending the armed continue right now
15
+ // sched:<id> [{ at, text }]
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.
19
+
20
+ import {
21
+ AFTER_RESET_MS,
22
+ blockedUntil,
23
+ blockingKind,
24
+ CONTINUE_TEXT,
25
+ expired,
26
+ FAR_RESET_MS,
27
+ FEATURES,
28
+ featureOf,
29
+ HOUR,
30
+ helpText,
31
+ MAX_REARMS,
32
+ MAX_REHITS,
33
+ MINUTE,
34
+ mustAsk,
35
+ nativeLikely,
36
+ notHere,
37
+ parseCommand,
38
+ parseWhen,
39
+ personAtPrompt,
40
+ REHIT_WINDOW_MS,
41
+ STALE_MS,
42
+ safeId,
43
+ splitWhen,
44
+ surfaceOf,
45
+ WAITING_EXPIRES_MS,
46
+ WAKE_HORIZON_MS,
47
+ when,
48
+ } from "./logic.js";
49
+
50
+ const TICK_MS = 60_000;
51
+ /** Prompt origins that mean the person (or Claude Code's own auto-continue) moved on. */
52
+ const MOVED_ON = new Set(["composer", "bridge", "sdk", "auto-continuation"]);
53
+ const NEVER = "Don't continue";
54
+ const QUESTION =
55
+ "Continue this session automatically when your usage limit resets? Keep Claude Code open until then; if it's closed, a continue message will be waiting in the prompt for you to send.";
56
+
57
+ let isInteractive = false;
58
+ let surface = null;
59
+ /** How Claude Code was started (`CLAUDE_CODE_ENTRYPOINT`): "cli", "claude-vscode", "sdk-ts"… */
60
+ let entrypoint;
61
+ /** Sessions whose limit is being handled right now (a burst of StopFailures is one limit). */
62
+ const handling = new Set();
63
+ let busy = false;
64
+ /** The person's clock in Rewake's settings: "12h" (default) or "24h". */
65
+ let clock = "12h";
66
+ /** The armed continue's timer in this process: { id, timer }. */
67
+ let armed;
68
+ /** `<stateDir>/hosts/claude-code/sessions`, when the installer said where the state folder is. */
69
+ let mirrorDir;
70
+ /** This process, in a send's claim: two Claude Code processes can have one session open. */
71
+ const ME = Math.random().toString(36).slice(2);
72
+ /** A claim older than this was left by a process that stopped mid-send. */
73
+ const CLAIM_MS = 5 * MINUTE;
74
+ /** Rewake's keep-awake setting: "plugged-in" (default), "always" or "never". */
75
+ let keepAwake = "plugged-in";
76
+ /** Claude Code's PID when this Mac has caffeinate; null where Rewake can't keep it awake. */
77
+ let wakePid;
78
+ /** The running hold: { mode, stream }. */
79
+ let wakeHold;
80
+
81
+ const limitKey = (id) => `limit:${id}`;
82
+ const schedKey = (id) => `sched:${id}`;
83
+ const at = (ms, now) => when(ms, now, clock);
84
+ /** "at 3:05 PM today" or "on Saturday at 3:05 PM". */
85
+ const atWhen = (ms, now) => {
86
+ const t = at(ms, now);
87
+ return /^\d/.test(t) ? `at ${t}` : `on ${t}`;
88
+ };
89
+
90
+ async function loadConfig($) {
91
+ try {
92
+ const cfg = JSON.parse(await $.fs.read(`${$.plugin.root}/rewake.json`));
93
+ if (typeof cfg.stateDir !== "string" || cfg.stateDir === "") return;
94
+ mirrorDir = `${cfg.stateDir}/hosts/claude-code/sessions`;
95
+ try {
96
+ const settings = JSON.parse(await $.fs.read(`${cfg.stateDir}/settings.json`));
97
+ if (settings.clock === "24h" || settings.clock === "12h") clock = settings.clock;
98
+ if (["plugged-in", "always", "never"].includes(settings.keepAwake))
99
+ keepAwake = settings.keepAwake;
100
+ } catch {
101
+ // No settings saved yet: the default clock.
102
+ }
103
+ } catch {
104
+ // Installed without Rewake's CLI (for example from a marketplace): no shared copy.
105
+ }
106
+ }
107
+
108
+ /** Write the record's metadata (never text) where Rewake's CLI reads it. Best effort. */
109
+ async function mirror($, id, ep) {
110
+ if (!mirrorDir || !safeId(id)) return;
111
+ const { state, kind, resetAt, fireAt, createdAt, sentAt, rehits, attempts } = ep ?? {
112
+ state: "none",
113
+ };
114
+ const record = {
115
+ schemaVersion: 1,
116
+ host: "claude-code",
117
+ sessionId: id,
118
+ state,
119
+ kind,
120
+ resetAt,
121
+ fireAt,
122
+ createdAt,
123
+ sentAt,
124
+ rehits,
125
+ attempts,
126
+ updatedAt: await $.clock.now(),
127
+ };
128
+ try {
129
+ await $.fs.write(`${mirrorDir}/${id}.json`, `${JSON.stringify(record)}\n`);
130
+ } catch {
131
+ // A read-only or missing folder must not stop the mod.
132
+ }
133
+ }
134
+
135
+ async function save($, id, ep) {
136
+ await $.store.set(limitKey(id), ep);
137
+ await mirror($, id, ep);
138
+ }
139
+
140
+ async function drop($, id) {
141
+ disarm(id);
142
+ await $.store.delete(limitKey(id));
143
+ await mirror($, id, undefined);
144
+ }
145
+
146
+ function disarm(id) {
147
+ if (armed && (id === undefined || armed.id === id)) {
148
+ armed.timer.cancel();
149
+ armed = undefined;
150
+ }
151
+ }
152
+
153
+ /** One timer for the armed continue, at its fireAt. The 60 s ticker catches up after a sleep. */
154
+ async function arm($, id, ep) {
155
+ disarm();
156
+ const now = await $.clock.now();
157
+ armed = { id, timer: $.clock.after(Math.max(0, ep.fireAt - now), () => void fire($, id)) };
158
+ }
159
+
160
+ /**
161
+ * Keep this Mac from idling to sleep while a continue is due within a few hours: Rewake's own, or
162
+ * Claude Code's (its wait holds nothing while the session sits idle). `caffeinate -w` ties the hold
163
+ * to Claude Code, so it ends with it even after a crash. Returns whether a hold is running.
164
+ */
165
+ async function updateWake($, ep, now) {
166
+ const due = ep?.state === "armed" ? ep.fireAt : ep?.state === "native" ? ep.resetAt : undefined;
167
+ const want =
168
+ keepAwake !== "never" &&
169
+ due !== undefined &&
170
+ due > now - STALE_MS &&
171
+ due - now <= WAKE_HORIZON_MS;
172
+ const mode = keepAwake === "always" ? "-i" : "-s";
173
+ if (wakeHold && (!want || wakeHold.mode !== mode)) {
174
+ void wakeHold.stream.return?.();
175
+ wakeHold = undefined;
176
+ }
177
+ if (!want || wakeHold) return wakeHold !== undefined;
178
+ if (wakePid === undefined) {
179
+ try {
180
+ // The shell's parent is Claude Code; no caffeinate (Linux, Windows) means no hold here.
181
+ const r = await $.process.run(["/bin/sh", "-c", "test -x /usr/bin/caffeinate && echo $PPID"]);
182
+ wakePid = r.exitCode === 0 && /^\d+$/.test(r.stdout.trim()) ? r.stdout.trim() : null;
183
+ } catch {
184
+ wakePid = null;
185
+ }
186
+ }
187
+ if (wakePid === null) return false;
188
+ const stream = $.process.spawn({ argv: ["/usr/bin/caffeinate", mode, "-w", wakePid] });
189
+ const hold = { mode, stream };
190
+ wakeHold = hold;
191
+ void (async () => {
192
+ try {
193
+ for await (const _ of stream) {
194
+ // caffeinate writes nothing; the loop is the hold's life.
195
+ }
196
+ } catch {
197
+ // It couldn't start: no hold.
198
+ }
199
+ if (wakeHold === hold) wakeHold = undefined;
200
+ })();
201
+ return true;
202
+ }
203
+
204
+ async function refreshStatus($, id, now) {
205
+ const ep = await $.store.get(limitKey(id));
206
+ const sched = (await $.store.get(schedKey(id))) ?? [];
207
+ const awake = (await updateWake($, ep, now)) ? " · keeping this Mac awake" : "";
208
+ if (ep?.state === "armed" && ep.fireAt !== undefined) {
209
+ $.ui.status(`Continues at ${at(ep.fireAt, now)}${awake} · /rewake cancel`);
210
+ } else if (ep?.state === "offered") {
211
+ $.ui.status(
212
+ `Usage limit reached · /rewake to continue after the reset${ep.fireAt !== undefined ? ` ${atWhen(ep.fireAt, now)}` : ""}`,
213
+ );
214
+ } else if (sched.length > 0) {
215
+ const next = Math.min(...sched.map((s) => s.at));
216
+ $.ui.status(`${sched.length} scheduled · next at ${at(next, now)} · /rewake list`);
217
+ } else {
218
+ $.ui.status(undefined);
219
+ }
220
+ }
221
+
222
+ /** A limit whose reset time is known: leave it to Claude Code, or ask. */
223
+ async function classify($, id, ep, now) {
224
+ const { autoContinueAtUsageLimit } = await $.settings.read();
225
+ if (
226
+ nativeLikely({
227
+ isInteractive,
228
+ surface,
229
+ setting: autoContinueAtUsageLimit,
230
+ resetAt: ep.resetAt,
231
+ now,
232
+ })
233
+ ) {
234
+ // Claude Code waits and continues by itself; a quota_auto_resume_* notification says how it went.
235
+ await save($, id, { ...ep, state: "native" });
236
+ return;
237
+ }
238
+ await ask($, id, ep, now);
239
+ }
240
+
241
+ async function ask($, id, ep, now) {
242
+ const prefs = (await $.store.get("prefs")) ?? {};
243
+ let state = "armed";
244
+ // A reset more than a day away is always asked about, even with "always".
245
+ if (mustAsk({ autoContinue: prefs.autoContinue, fireAt: ep.fireAt, now, surface })) {
246
+ const yes = `Continue at ${at(ep.fireAt, now)}`;
247
+ const always = `${yes}, and from now on in every session when the reset is within a day`;
248
+ try {
249
+ const answer = await $.ui.ask(QUESTION, {
250
+ options: [yes, always, NEVER],
251
+ header: "Rewake",
252
+ });
253
+ if (answer === always) await $.store.set("prefs", { ...prefs, autoContinue: "always" });
254
+ else if (answer !== yes) state = "declined";
255
+ } catch {
256
+ // Dismissed, or nobody to ask (-p, an SDK host): leave it offered, for /rewake.
257
+ state = "offered";
258
+ }
259
+ }
260
+ if (state === "declined") {
261
+ await drop($, id);
262
+ } else {
263
+ await save($, id, { ...ep, state });
264
+ if (state === "armed") await arm($, id, ep);
265
+ }
266
+ await refreshStatus($, id, await $.clock.now());
267
+ }
268
+
269
+ async function onLimit($, id) {
270
+ // -p runs and other Agent SDK hosts (Zed's Claude adapter among them) have no person at the
271
+ // prompt, and Rewake's ACP add-on already resumes Zed's threads. Claude Code's own editor
272
+ // panels also run through the Agent SDK, but a person is there: they count.
273
+ if (!personAtPrompt({ isInteractive, entrypoint })) return;
274
+ // StopFailure comes in bursts: a second one while the first is still being handled is the same.
275
+ if (handling.has(id)) return;
276
+ handling.add(id);
277
+ try {
278
+ await onLimitOnce($, id);
279
+ } finally {
280
+ handling.delete(id);
281
+ }
282
+ }
283
+
284
+ async function onLimitOnce($, id) {
285
+ const now = await $.clock.now();
286
+ let prev = await $.store.get(limitKey(id));
287
+ if (expired(prev, now)) {
288
+ await drop($, id);
289
+ prev = undefined;
290
+ }
291
+ // StopFailure comes in bursts: one episode at a time.
292
+ if (prev && prev.state !== "sent") return;
293
+ const rehits =
294
+ prev?.state === "sent" && now - prev.sentAt < REHIT_WINDOW_MS ? prev.rehits + 1 : 0;
295
+ const { rateLimits } = await $.session.usage();
296
+ const resetAt = blockedUntil(rateLimits, now);
297
+ if (rehits >= MAX_REHITS) {
298
+ // The limit keeps coming back right after continuing: offer it, don't continue on its own.
299
+ if (resetAt === undefined) return drop($, id);
300
+ await save($, id, {
301
+ state: "offered",
302
+ createdAt: now,
303
+ rehits,
304
+ attempts: 0,
305
+ kind: blockingKind(rateLimits, now),
306
+ resetAt,
307
+ fireAt: resetAt + AFTER_RESET_MS,
308
+ });
309
+ await refreshStatus($, id, now);
310
+ return;
311
+ }
312
+ const ep = { state: "waiting", createdAt: now, rehits, attempts: 0 };
313
+ if (resetAt === undefined) {
314
+ // No reset time yet, or a limit that a wait doesn't fix (credits, a spending cap, an API
315
+ // key's 429): wait for session.measure, then forget it.
316
+ await save($, id, ep);
317
+ return;
318
+ }
319
+ await classify(
320
+ $,
321
+ id,
322
+ { ...ep, kind: blockingKind(rateLimits, now), resetAt, fireAt: resetAt + AFTER_RESET_MS },
323
+ now,
324
+ );
325
+ }
326
+
327
+ async function onMeasure($, rateLimits) {
328
+ const id = await $.session.id();
329
+ const ep = await $.store.get(limitKey(id));
330
+ if (ep?.state !== "waiting") return;
331
+ const now = await $.clock.now();
332
+ if (now - ep.createdAt > WAITING_EXPIRES_MS) return drop($, id);
333
+ const resetAt = blockedUntil(rateLimits, now);
334
+ if (resetAt === undefined) return;
335
+ await classify(
336
+ $,
337
+ id,
338
+ { ...ep, kind: blockingKind(rateLimits, now), resetAt, fireAt: resetAt + AFTER_RESET_MS },
339
+ now,
340
+ );
341
+ }
342
+
343
+ async function onNotification($, e) {
344
+ const id = e.session_id;
345
+ const ep = await $.store.get(limitKey(id));
346
+ if (!ep) return;
347
+ if (
348
+ e.notification_type === "quota_auto_resume_fired" ||
349
+ e.notification_type === "quota_auto_resume_stale"
350
+ ) {
351
+ // Claude Code continued, or waits for Enter after a sleep: nothing for Rewake to do.
352
+ await drop($, id);
353
+ } else if (e.notification_type === "quota_auto_resume_disabled" && ep.state === "native") {
354
+ await ask($, id, ep, await $.clock.now());
355
+ return;
356
+ }
357
+ await refreshStatus($, id, await $.clock.now());
358
+ }
359
+
360
+ async function standDown($) {
361
+ const id = await $.session.id();
362
+ const ep = await $.store.get(limitKey(id));
363
+ if (ep && ep.state !== "sent") await drop($, id);
364
+ await refreshStatus($, id, await $.clock.now());
365
+ }
366
+
367
+ /** Something due long ago (the machine slept): put it in the prompt box instead of sending it. */
368
+ async function offer($, text) {
369
+ await $.prompt.fill({ text });
370
+ $.ui.toast("Rewake: press Enter to send the message waiting in the prompt.");
371
+ }
372
+
373
+ /** The armed continue is due: re-check the limit, then send once. */
374
+ async function fire($, id) {
375
+ if (busy) return;
376
+ busy = true;
377
+ try {
378
+ // A /clear, /resume or /branch moved this process to another conversation: keep the record
379
+ // for when that one is reopened, and send nothing here.
380
+ if ((await $.session.id()) !== id) return disarm(id);
381
+ const ep = await $.store.get(limitKey(id));
382
+ const now = await $.clock.now();
383
+ if (ep?.state !== "armed" || ep.fireAt === undefined || now < ep.fireAt) return;
384
+ if (now - ep.fireAt > STALE_MS) {
385
+ await drop($, id);
386
+ await offer($, CONTINUE_TEXT);
387
+ } else {
388
+ // $.store is one file shared by every Claude Code process: another one with this session
389
+ // open has the same continue armed. Claim it, and go on only if the claim is still ours
390
+ // after the usage check.
391
+ if (ep.claim && ep.claim.by !== ME && now - ep.claim.at < CLAIM_MS) return;
392
+ await $.store.set(limitKey(id), { ...ep, claim: { by: ME, at: now } });
393
+ const still = blockedUntil((await $.session.usage()).rateLimits, now);
394
+ const mine = await $.store.get(limitKey(id));
395
+ if (mine?.state !== "armed" || mine.claim?.by !== ME) return disarm(id);
396
+ // A later window more than a day away is asked about, never waited for silently (rule 2).
397
+ if (still !== undefined && ep.attempts < MAX_REARMS && still - now <= FAR_RESET_MS) {
398
+ // Another window is still used up (a weekly limit behind a 5-hour one): wait for it.
399
+ const next = {
400
+ ...ep,
401
+ resetAt: still,
402
+ fireAt: still + AFTER_RESET_MS,
403
+ attempts: ep.attempts + 1,
404
+ };
405
+ await save($, id, next);
406
+ await arm($, id, next);
407
+ } else if (still !== undefined) {
408
+ // Still limited after the last re-check, or until more than a day from now: offer it for
409
+ // the later reset instead.
410
+ disarm(id);
411
+ await save($, id, {
412
+ ...ep,
413
+ state: "offered",
414
+ resetAt: still,
415
+ fireAt: still + AFTER_RESET_MS,
416
+ attempts: 0,
417
+ });
418
+ } else {
419
+ disarm(id);
420
+ await save($, id, { ...ep, state: "sent", sentAt: now });
421
+ void $.prompt.submit({ text: CONTINUE_TEXT, asUser: true });
422
+ }
423
+ }
424
+ await refreshStatus($, id, now);
425
+ } finally {
426
+ busy = false;
427
+ }
428
+ }
429
+
430
+ /** Every minute: catch up on a continue whose timer slept through its time; send due schedules. */
431
+ async function tick($) {
432
+ const id = await $.session.id();
433
+ const now = await $.clock.now();
434
+ let ep = await $.store.get(limitKey(id));
435
+ if (expired(ep, now)) {
436
+ await drop($, id);
437
+ ep = undefined;
438
+ await refreshStatus($, id, now);
439
+ }
440
+ if (ep?.state === "armed" && ep.fireAt !== undefined && now >= ep.fireAt) return fire($, id);
441
+ await updateWake($, ep, now);
442
+ // Scheduled messages wait while a limit is pending; one per tick.
443
+ if (busy || (ep && ep.state !== "sent")) return;
444
+ const sched = (await $.store.get(schedKey(id))) ?? [];
445
+ const due = sched.find((s) => s.at <= now);
446
+ if (!due) return;
447
+ const rest = sched.filter((s) => s !== due);
448
+ if (rest.length > 0) await $.store.set(schedKey(id), rest);
449
+ else await $.store.delete(schedKey(id));
450
+ if (now - due.at > STALE_MS) await offer($, due.text);
451
+ else void $.prompt.submit({ text: due.text, asUser: true });
452
+ await refreshStatus($, id, now);
453
+ }
454
+
455
+ /** On start or resume: re-arm a pending continue; one that came due while closed is offered, not sent. */
456
+ async function reopen($) {
457
+ const id = await $.session.id();
458
+ const now = await $.clock.now();
459
+ const ep = await $.store.get(limitKey(id));
460
+ if (expired(ep, now)) {
461
+ await drop($, id);
462
+ } else if (ep?.state === "armed" && ep.fireAt !== undefined) {
463
+ if (now >= ep.fireAt) {
464
+ await drop($, id);
465
+ await offer($, CONTINUE_TEXT);
466
+ } else {
467
+ await arm($, id, ep);
468
+ }
469
+ } else if (ep?.state === "native" && ep.fireAt !== undefined) {
470
+ // Claude Code's own wait ended with the process that was waiting: Rewake takes over.
471
+ if (now >= ep.fireAt) {
472
+ await drop($, id);
473
+ await offer($, CONTINUE_TEXT);
474
+ } else {
475
+ await ask($, id, ep, now);
476
+ return;
477
+ }
478
+ }
479
+ await refreshStatus($, id, now);
480
+ }
481
+
482
+ /** Add a scheduled message, kept in time order so `/rewake list` numbers them by time. */
483
+ async function addMessage($, id, when, text) {
484
+ const sched = (await $.store.get(schedKey(id))) ?? [];
485
+ await $.store.set(
486
+ schedKey(id),
487
+ [...sched, { at: when, text }].sort((x, y) => x.at - y.at),
488
+ );
489
+ }
490
+
491
+ /**
492
+ * `/rewake` in a session that isn't waiting at a limit: one question per step, when (presets with
493
+ * their times, or Custom…), then what. Dismissing the first question shows what's scheduled.
494
+ */
495
+ async function scheduleFlow($, id, now) {
496
+ const presets = [
497
+ ["In 30 minutes", 30 * MINUTE],
498
+ ["In 1 hour", HOUR],
499
+ ["In 3 hours", 3 * HOUR],
500
+ ].map(([label, ms]) => ({ label: `${label} (${at(now + ms, now)})`, at: now + ms }));
501
+ const custom = "Custom…";
502
+ let when;
503
+ try {
504
+ const answer = await $.ui.ask("When should Rewake send a message into this session?", {
505
+ options: [...presets.map((p) => p.label), custom],
506
+ header: "Rewake",
507
+ });
508
+ when = presets.find((p) => p.label === answer)?.at;
509
+ if (when === undefined && answer === custom) {
510
+ const typed = await $.ui.ask(
511
+ `When? For example "in 45m", "${clock === "24h" ? "18:00" : "6pm"}" or "tomorrow 9:00".`,
512
+ );
513
+ const p = parseWhen(String(typed).replace(/^\s*at\s+/i, ""), now);
514
+ if (!p.ok) return `Rewake: ${p.error}`;
515
+ when = p.at;
516
+ }
517
+ } catch {
518
+ return `${await listText($, id, now)}\n\nType /rewake help to see what Rewake can do here.`;
519
+ }
520
+ if (when === undefined) return "Nothing was scheduled.";
521
+ let text;
522
+ try {
523
+ text = String(
524
+ await $.ui.ask(`What should Rewake send into this session ${atWhen(when, now)}?`),
525
+ ).trim();
526
+ } catch {
527
+ return "Nothing was scheduled.";
528
+ }
529
+ if (text === "") return "Nothing was scheduled.";
530
+ if (text.startsWith("/")) return 'A scheduled message cannot start with "/".';
531
+ await addMessage($, id, when, text);
532
+ return `Scheduled for ${at(when, now)}.`;
533
+ }
534
+
535
+ /** What `/rewake list` says: the continue, then each scheduled message by number. */
536
+ async function listText($, id, now) {
537
+ const ep = await $.store.get(limitKey(id));
538
+ const sched = (await $.store.get(schedKey(id))) ?? [];
539
+ const lines = [];
540
+ if (ep?.state === "armed" && ep.fireAt !== undefined)
541
+ lines.push(`Continues at ${at(ep.fireAt, now)}. To cancel: /rewake cancel`);
542
+ for (const [i, s] of sched.entries()) lines.push(`${i + 1}. ${at(s.at, now)}: ${s.text}`);
543
+ if (lines.length === 0) lines.push("Nothing scheduled in this session.");
544
+ const prefs = (await $.store.get("prefs")) ?? {};
545
+ if (prefs.autoContinue === "always")
546
+ lines.push(
547
+ "After a usage limit, Rewake continues without asking when the reset is within a day. To be asked each time: /rewake auto off",
548
+ );
549
+ return lines.join("\n");
550
+ }
551
+
552
+ /** Continue after the usage limit: at the reset, or at `when`. */
553
+ async function continueAt($, id, ep, when, now) {
554
+ if (!ep || ep.state === "sent")
555
+ return "This session isn't stopped at a usage limit. To schedule a message: /rewake in 1h <message>";
556
+ if (ep.state === "native" && when === undefined)
557
+ return `Claude Code continues this session by itself${ep.resetAt !== undefined ? ` after the reset ${atWhen(ep.resetAt, now)}` : ""}.`;
558
+ const fireAt = when ?? ep.fireAt;
559
+ if (fireAt === undefined)
560
+ return "Rewake doesn't know when this limit resets yet. Give a time, for example /rewake 3:30pm.";
561
+ if (ep.state === "armed" && when === undefined)
562
+ return `This session already continues at ${at(fireAt, now)}. To cancel: /rewake cancel`;
563
+ const next = { ...ep, state: "armed", fireAt, attempts: 0 };
564
+ await save($, id, next);
565
+ await arm($, id, next);
566
+ return `This session continues at ${at(fireAt, now)}. To cancel: /rewake cancel`;
567
+ }
568
+
569
+ /** `/rewake <args>`: Rewake's one command (logic.js parseCommand), answered without Claude. */
570
+ async function runCommand($, args) {
571
+ const id = await $.session.id();
572
+ const now = await $.clock.now();
573
+ const c = parseCommand(args);
574
+ const ep = await $.store.get(limitKey(id));
575
+ const sched = (await $.store.get(schedKey(id))) ?? [];
576
+ const needs = featureOf(c);
577
+ let text;
578
+ if (needs && !FEATURES.has(needs)) {
579
+ text = notHere(needs);
580
+ } else if (c.kind === "help") {
581
+ text = helpText();
582
+ } else if (c.kind === "home") {
583
+ const waiting = ep && ep.state !== "sent" && ep.state !== "armed";
584
+ text = waiting ? await continueAt($, id, ep, undefined, now) : await scheduleFlow($, id, now);
585
+ } else if (c.kind === "continue" || c.kind === "at") {
586
+ const s = c.kind === "at" ? c.args : (c.when ?? "");
587
+ const p = s ? splitWhen(s, now) : { ok: true, text: "" };
588
+ if (!p.ok) {
589
+ text = `Rewake: ${p.error}`;
590
+ } else if (p.text === "" || c.kind === "continue") {
591
+ text = await continueAt($, id, ep, p.at, now);
592
+ } else if (p.text.startsWith("/")) {
593
+ // $.prompt.submit refuses text that starts with "/" (it would run a command).
594
+ text = 'A scheduled message cannot start with "/".';
595
+ } else {
596
+ await addMessage($, id, p.at, p.text);
597
+ text = `Scheduled for ${at(p.at, now)}.`;
598
+ }
599
+ } else if (c.kind === "list") {
600
+ text = await listText($, id, now);
601
+ } else if (c.kind === "cancel" && c.which === undefined) {
602
+ if (ep && ep.state !== "sent" && ep.fireAt !== undefined) {
603
+ await drop($, id);
604
+ text = `Cancelled the automatic continue at ${at(ep.fireAt, now)}.${sched.length > 0 ? " Your scheduled messages stay." : ""}`;
605
+ } else {
606
+ text = `Nothing is set to continue in this session.${sched.length > 0 ? " To delete a scheduled message: /rewake cancel N, or /rewake cancel all." : ""}`;
607
+ }
608
+ } else if (c.kind === "cancel" && c.which === "all") {
609
+ if (sched.length === 0) {
610
+ text = "Nothing scheduled in this session.";
611
+ } else {
612
+ const one = sched.length === 1;
613
+ const yes = one ? "Delete it" : "Delete them";
614
+ let answer;
615
+ try {
616
+ answer = await $.ui.ask(
617
+ one
618
+ ? "Delete the scheduled message in this session?"
619
+ : `Delete all ${sched.length} scheduled messages in this session?`,
620
+ { options: [yes, one ? "Keep it" : "Keep them"], header: "Rewake" },
621
+ );
622
+ } catch {
623
+ answer = undefined;
624
+ }
625
+ if (answer === yes) {
626
+ await $.store.delete(schedKey(id));
627
+ text = one
628
+ ? "Deleted the scheduled message."
629
+ : `Deleted ${sched.length} scheduled messages.`;
630
+ } else {
631
+ text = one ? "Kept your scheduled message." : "Kept your scheduled messages.";
632
+ }
633
+ }
634
+ } else if (c.kind === "cancel") {
635
+ const n = Number(c.which);
636
+ const s = Number.isInteger(n) && n >= 1 ? sched[n - 1] : undefined;
637
+ if (!s) {
638
+ text = `There's no scheduled message number ${c.which} in this session. Type /rewake list.`;
639
+ } else {
640
+ const rest = sched.filter((x) => x !== s);
641
+ if (rest.length > 0) await $.store.set(schedKey(id), rest);
642
+ else await $.store.delete(schedKey(id));
643
+ text = `Deleted the message scheduled for ${at(s.at, now)}.`;
644
+ }
645
+ } else if (c.kind === "auto") {
646
+ const prefs = (await $.store.get("prefs")) ?? {};
647
+ const { autoContinue: _, ...rest } = prefs;
648
+ if (c.on === true) {
649
+ await $.store.set("prefs", { ...rest, autoContinue: "always" });
650
+ text =
651
+ "From now on, after a usage limit Rewake continues every session without asking when the reset is within a day. To be asked each time: /rewake auto off";
652
+ } else if (c.on === false) {
653
+ await $.store.set("prefs", rest);
654
+ text = "Rewake will ask before continuing after a usage limit.";
655
+ } else {
656
+ text =
657
+ prefs.autoContinue === "always"
658
+ ? "After a usage limit, Rewake continues without asking when the reset is within a day. To be asked each time: /rewake auto off"
659
+ : "After a usage limit, Rewake asks before continuing. To continue without asking: /rewake auto on";
660
+ }
661
+ } else {
662
+ text = helpText();
663
+ }
664
+ await refreshStatus($, id, now);
665
+ return { text };
666
+ }
667
+
668
+ /** Observe-only hooks: if one fails, the event goes on unchanged. */
669
+ const passOn = (_$, e, next) => (next.called ? undefined : next(e));
670
+
671
+ export function register(on) {
672
+ on("session.start", async ($, e, next) => {
673
+ isInteractive = e.isInteractive;
674
+ entrypoint = await $.env.get("CLAUDE_CODE_ENTRYPOINT").catch(() => undefined);
675
+ surface = surfaceOf({ surface: e.surface, entrypoint });
676
+ await loadConfig($);
677
+ $.clock.every(TICK_MS, () => void tick($));
678
+ void reopen($);
679
+ for (const entry of [
680
+ {
681
+ name: "rewake",
682
+ description:
683
+ "Agent Rewake: continue after a usage limit, and schedule messages (/rewake help)",
684
+ argumentHint: "[in 1h <message> | 3:30pm | list | cancel [N|all] | auto on|off | help]",
685
+ },
686
+ ])
687
+ try {
688
+ await $.command.register(entry);
689
+ } catch {
690
+ // The name is taken: everything else still works.
691
+ }
692
+ return next(e);
693
+ });
694
+
695
+ on("classic.SessionStart", ($, e, next) => {
696
+ // An in-process switch (/resume, /clear, /branch): the armed timer belongs to the old one.
697
+ if (e.source === "resume" || e.source === "clear" || e.source === "fork") {
698
+ disarm();
699
+ void reopen($);
700
+ }
701
+ return next(e);
702
+ }).catch(passOn);
703
+
704
+ on("classic.StopFailure", ($, e, next) => {
705
+ // Main conversation only: helper and subagent failures carry agent_id.
706
+ if (e.error === "rate_limit" && e.agent_id === undefined) void onLimit($, e.session_id);
707
+ return next(e);
708
+ }).catch(passOn);
709
+
710
+ on("session.measure", ($, e, next) => {
711
+ if (e.changed.includes("rateLimits")) void onMeasure($, e.rateLimits);
712
+ return next(e);
713
+ }).catch(passOn);
714
+
715
+ on("classic.Notification", ($, e, next) => {
716
+ if (e.notification_type.startsWith("quota_auto_resume_")) void onNotification($, e);
717
+ return next(e);
718
+ }).catch(passOn);
719
+
720
+ on("prompt.submit", ($, e, next) => {
721
+ // Observe only: the person typed, or Claude Code's own auto-continue fired.
722
+ if (MOVED_ON.has(e.origin.kind)) void standDown($);
723
+ return next(e);
724
+ }).catch(passOn);
725
+
726
+ on("command.run", { command: "rewake" }, ($, e) => runCommand($, e.args));
727
+ }