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