claude-wake 0.1.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.
package/docs/design.md ADDED
@@ -0,0 +1,181 @@
1
+ # claude-wake — Design
2
+
3
+ _Last updated: 2026-07-07_
4
+
5
+ ## Problem
6
+
7
+ When the Claude Code **VS Code extension panel** hits a usage limit ("You've hit your
8
+ session limit · resets 3:30pm"), work stops until a human types "continue" after the
9
+ reset. Overnight or AFK, hours are lost. Existing tools (e.g. `claude-auto-retry`)
10
+ only cover the **terminal TUI** via tmux — the native extension panel is a webview
11
+ that tmux can't see, and there is no built-in auto-resume, no hook, and no IPC to
12
+ inject a message into a running panel.
13
+
14
+ ## Verified foundation (reverse-engineered + live-tested on macOS, 2026-07)
15
+
16
+ 1. **Detection** — the extension writes session transcripts to
17
+ `~/.claude/projects/<enc-cwd>/<sessionId>.jsonl` (same store as the CLI). A real
18
+ limit is one JSONL line with top-level `isApiErrorMessage: true`,
19
+ `apiErrorStatus: 429`, `error: "rate_limit"`, plus `sessionId` and message text
20
+ like `You've hit your session limit · resets 1:20am (America/Los_Angeles)` —
21
+ reset time **with IANA timezone** included.
22
+ 2. **Focus** — `vscode://anthropic.claude-code/open?session=<id>` reveals the exact
23
+ panel among many (verified in extension source: `claude-vscode.primaryEditor.open`).
24
+ The `prompt` query param is **ignored when the panel is already open**, so it
25
+ cannot be used to inject the message — focus only.
26
+ 3. **Send** — the panel's webview input **ignores synthetic keystrokes but accepts a
27
+ paste**: set clipboard → Cmd/Ctrl+V → Return. On macOS the sending process needs
28
+ Accessibility permission; a small compiled `.app` holds that grant so it works
29
+ from a background daemon.
30
+
31
+ All three steps were verified end-to-end in production on 2026-07-07 (two limited
32
+ panels auto-resumed at reset+60s, zero errors).
33
+
34
+ ## Goals / non-goals
35
+
36
+ **Goals**
37
+
38
+ - Auto-resume Claude Code extension panels after a usage-limit reset, unattended.
39
+ - Cross-platform: macOS (reference, fully verified), Linux + Windows (implemented,
40
+ community-verify).
41
+ - `npm i -g claude-wake` → `claude-wake install` and forget. Zero runtime deps.
42
+ - Safe by default: guarded resume message, focus verification before keystrokes,
43
+ strict input validation, no shell interpolation of untrusted data.
44
+
45
+ **Non-goals (v1)**
46
+
47
+ - Terminal TUI sessions (use `claude-auto-retry`).
48
+ - Handoff to other agents (Codex etc.) — possible future plugin surface.
49
+ - Bypassing/queueing around limits — this waits for the official reset, nothing more.
50
+
51
+ ## Architecture
52
+
53
+ ```
54
+ bin/claude-wake.js CLI: watch | install | uninstall | status | doctor | test | logs
55
+ lib/watch.js core loop: scan → schedule → fire (single-instance lock)
56
+ lib/detect.js jsonl tailing + rate-limit entry parsing (pure)
57
+ lib/parse-reset.js tz-aware "resets 3:30pm (America/Los_Angeles)" → epoch (pure)
58
+ lib/state.js offsets / pending / fired episodes, atomic writes (pure-ish)
59
+ lib/config.js defaults + ~/.claude-wake/config.json merge + validation
60
+ lib/editors.js editor presets (vscode / insiders / cursor / windsurf)
61
+ lib/send/index.js platform dispatch
62
+ lib/send/darwin.js pbcopy + compiled KeySender.app (osacompile at setup)
63
+ lib/send/linux.js xclip|wl-copy + xdotool (X11) / wtype (Wayland, experimental)
64
+ lib/send/win32.js PowerShell Set-Clipboard + WScript AppActivate/SendKeys
65
+ lib/install.js launchd / systemd --user / Startup-folder registration
66
+ lib/doctor.js dependency + permission checks with fix instructions
67
+ ```
68
+
69
+ ### Data flow
70
+
71
+ 1. `watch` polls `~/.claude/projects/*/*.jsonl` every `pollSeconds` (20s), reading
72
+ only bytes appended since the stored per-file offset. First run primes offsets to
73
+ EOF (never replays history).
74
+ 2. A line parses as a limit event iff `isApiErrorMessage === true`, a rate-limit
75
+ marker is present (`error === "rate_limit"` or `apiErrorStatus === 429`), AND
76
+ the text carries usage-limit phrasing (transient server 429s say "not your
77
+ usage limit" and are rejected). Extracts `sessionId` (strict UUID check),
78
+ text, timestamp, uuid.
79
+ 3. `parse-reset` turns the text into an epoch using the embedded IANA timezone
80
+ (iterative-offset conversion, DST-safe, pure `Intl`), referenced to the EVENT
81
+ timestamp (not scan time — duplicate lines must parse identically). Fallbacks:
82
+ "try again in N hours/minutes" → relative to the event; nothing parseable →
83
+ event + `fallbackWaitHours`. Dates >8 days out are year-boundary mis-parses
84
+ and resolve backwards (a passed reset fires immediately).
85
+ 4. Dedupe: one fire per limit episode. An episode is identified by proximity —
86
+ same session within ±15 min of a pending/fired fire time — plus the event
87
+ line's `uuid` (session resume copies history into a new sessionId with the
88
+ same uuids). Persisted, so restarts and replays never refire.
89
+ 5. At `reset + marginSeconds`: optionally skip if the session shows HUMAN
90
+ activity after the event (`skipIfResumed`, default on; `isMeta`, tool
91
+ results, and harness-injected tag blocks don't count). Then: open
92
+ `<scheme>://anthropic.claude-code/open?session=<id>` → wait `focusDelayMs` →
93
+ **verify the frontmost app** → only now set the clipboard → paste + Enter.
94
+ Multiple due episodes fire `fireSpacingSeconds` apart.
95
+
96
+ ### The guarded message (default, English, single line — enforced)
97
+
98
+ > "If you were stopped by a usage or token limit, continue from exactly where you
99
+ > left off. If you were not stopped, or the task is already complete, ignore this
100
+ > message."
101
+
102
+ Guarded wording makes stray fires harmless (plan upgraded, limit already reset,
103
+ task finished). Configurable via `message` in config; newlines are stripped
104
+ (a newline would submit early).
105
+
106
+ ## Security posture
107
+
108
+ - **No shell interpolation of transcript-derived data.** Session ids must match
109
+ a strict UUID pattern before any use; message text only ever travels via stdin /
110
+ request files; URIs are built from validated ids + fixed schemes.
111
+ - **JSONL events cannot be forged from conversation content**: detection keys on
112
+ top-level fields of each line; JSON string values cannot contain raw newlines,
113
+ so content cannot fabricate a new line/entry. (The trust boundary is the file
114
+ system: `.jsonl` files under the projects dir are trusted as Claude Code's own
115
+ output — a process that can already write files as the user is out of scope.)
116
+ - **Focus verification before keystrokes**, fail-closed on every verifiable
117
+ platform: macOS checks the frontmost app's **bundle identifier** (process names
118
+ like "Electron" are shared across many apps); X11 requires the active window
119
+ title to end with the editor name; Windows verifies the real foreground window
120
+ PID via `GetForegroundWindow` (AppActivate's return value lies under
121
+ focus-stealing prevention). Wayland has no portable check → sends are refused
122
+ unless `allowUnverifiedPaste: true`. The clipboard is only written after
123
+ verification passes.
124
+ - **The transient-429 trap**: `error:"rate_limit"` also appears for server-side
125
+ throttling ("… not your usage limit"). Detection additionally requires
126
+ usage-limit phrasing, so transient capacity errors never schedule a resume.
127
+ - **Zero runtime dependencies** — nothing to supply-chain.
128
+ - State/config/log files live in `~/.claude-wake/` (0700 dir).
129
+ - Single-instance lock (`watch.lock`, pid + liveness check) prevents double-fires
130
+ from daemon + manual watch.
131
+
132
+ ## Platform notes
133
+
134
+ | | clipboard | focus URI | paste+enter | daemon | perms |
135
+ |---|---|---|---|---|---|
136
+ | macOS | `pbcopy` | `open` | compiled `KeySender.app` (AppleScript, frontmost-verified) | launchd LaunchAgent | Accessibility, once, for KeySender.app |
137
+ | Linux X11 | `xclip`/`xsel` | `xdg-open` | `xdotool` (active-window verified) | systemd --user (graphical-session) | none (X11) |
138
+ | Linux Wayland | `wl-copy` | `xdg-open` | `wtype` (experimental; compositor-dependent) | systemd --user | compositor-dependent |
139
+ | Windows | `Set-Clipboard` | `Start-Process` | `WScript.Shell` AppActivate+SendKeys | Startup-folder VBS (hidden) | none |
140
+
141
+ macOS is the reference implementation (fully live-verified). Linux/Windows are
142
+ implemented to spec and marked **community-verify** in the README; `doctor` checks
143
+ tool availability and prints install hints.
144
+
145
+ ## Config (`~/.claude-wake/config.json`, all optional)
146
+
147
+ | key | default | notes |
148
+ |---|---|---|
149
+ | `message` | guarded English text | single line enforced |
150
+ | `marginSeconds` | 60 | wait after printed reset |
151
+ | `pollSeconds` | 20 | transcript scan interval |
152
+ | `focusDelayMs` | 2500 | URI-open → paste delay (cold starts need more) |
153
+ | `fallbackWaitHours` | 5 | when reset time can't be parsed |
154
+ | `maxEventAgeHours` | 8 | ignore stale events on startup |
155
+ | `fireSpacingSeconds` | 5 | gap between simultaneous resumes |
156
+ | `editor` | `"vscode"` | `vscode` \| `vscode-insiders` \| `cursor` \| `windsurf` |
157
+ | `skipIfResumed` | true | skip if user was active after the limit |
158
+ | `allowUnverifiedPaste` | false | Wayland only: allow blind sends |
159
+ | `claudeDir` | `$CLAUDE_CONFIG_DIR` or `~/.claude` | transcript root |
160
+
161
+ ## Testing
162
+
163
+ - `node --test`: pure modules get unit tests — reset parsing (12h/24h, "Oct 9,
164
+ 10am", DST boundaries, half-hour zones, relative forms), detection (real fixture
165
+ lines, forged-content negative cases), episode dedupe, config validation.
166
+ - `claude-wake test [--session <id>] [--dry-run]` — manual end-to-end fire for
167
+ setup verification.
168
+ - `claude-wake watch --dry-run` — full pipeline, prints instead of sending.
169
+
170
+ ## Known limitations (documented in README)
171
+
172
+ - Limits are **account-wide**: several panels resuming together re-consume quota
173
+ quickly (spacing helps, doesn't solve).
174
+ - An auto-resumed panel continues **autonomous execution** unattended — same trust
175
+ level as leaving Claude Code running.
176
+ - If the user is actively typing at fire time, focus verification aborts rather
177
+ than risk pasting into the wrong window (that episode is skipped, logged).
178
+ - Depends on two stable-ish contracts: the transcript rate-limit line shape and the
179
+ `vscode://anthropic.claude-code/open?session=` handler. Both are version-checked
180
+ realities, not documented APIs; a `doctor` warning + fast patch release is the
181
+ mitigation strategy.
package/lib/config.js ADDED
@@ -0,0 +1,174 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export const HOME = os.homedir();
6
+ export const BASE_DIR = path.join(HOME, ".claude-wake");
7
+ export const CONFIG_FILE = path.join(BASE_DIR, "config.json");
8
+ export const STATE_FILE = path.join(BASE_DIR, "state.json");
9
+ export const LOCK_FILE = path.join(BASE_DIR, "watch.lock");
10
+ export const LOG_FILE = path.join(BASE_DIR, "watcher.log");
11
+ export const PAUSE_FILE = path.join(BASE_DIR, "paused");
12
+
13
+ /** Paused = auto-resume is suspended. While paused the watcher stays alive but
14
+ * does not scan or fire; on resume it re-primes to now (ignores limits that
15
+ * happened during the pause — you paused on purpose). */
16
+ export function isPaused() {
17
+ try {
18
+ return fs.existsSync(PAUSE_FILE);
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ export function setPaused(paused) {
25
+ ensureBaseDir();
26
+ if (paused) {
27
+ fs.writeFileSync(PAUSE_FILE, new Date().toISOString(), { mode: 0o600 });
28
+ } else {
29
+ fs.rmSync(PAUSE_FILE, { force: true });
30
+ }
31
+ }
32
+
33
+ export const DEFAULT_MESSAGE =
34
+ "If you were stopped by a usage or token limit, continue from exactly where " +
35
+ "you left off. If you were not stopped, or the task is already complete, " +
36
+ "ignore this message.";
37
+
38
+ export const DEFAULTS = Object.freeze({
39
+ message: DEFAULT_MESSAGE,
40
+ marginSeconds: 60,
41
+ pollSeconds: 20,
42
+ focusDelayMs: 2500,
43
+ fallbackWaitHours: 5,
44
+ maxEventAgeHours: 8,
45
+ fireSpacingSeconds: 5,
46
+ editor: "vscode",
47
+ skipIfResumed: true,
48
+ // Wayland has no portable "which window is focused" check, so a send there
49
+ // is blind. Fail closed unless the user explicitly opts in.
50
+ allowUnverifiedPaste: false,
51
+ claudeDir: null, // resolved lazily: $CLAUDE_CONFIG_DIR or ~/.claude
52
+ });
53
+
54
+ function clampNumber(value, fallback, min, max) {
55
+ const n = Number(value);
56
+ if (!Number.isFinite(n)) return fallback;
57
+ return Math.min(max, Math.max(min, n));
58
+ }
59
+
60
+ /**
61
+ * Make an arbitrary string safe as a resume message: one line (a newline would
62
+ * submit early), length-capped, and never a leading slash-command / @-mention.
63
+ * Returns "" for non-strings / blank input (→ caller falls back to the default).
64
+ */
65
+ export function sanitizeMessage(s) {
66
+ if (typeof s !== "string") return "";
67
+ // Collapse ANY CR/LF run (incl. a lone \r) to a space — a bare carriage
68
+ // return can submit early when pasted.
69
+ let msg = s
70
+ .replace(/\s*[\r\n]+\s*/g, " ")
71
+ .trim()
72
+ .slice(0, 2000);
73
+ if (/^[/@]/.test(msg)) msg = " " + msg;
74
+ return msg;
75
+ }
76
+
77
+ /** Read config.json as a plain object (or {} if missing/invalid). */
78
+ export function readConfigRaw() {
79
+ try {
80
+ const o = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
81
+ return o && typeof o === "object" && !Array.isArray(o) ? o : {};
82
+ } catch {
83
+ return {};
84
+ }
85
+ }
86
+
87
+ /** Atomically write a config object. */
88
+ export function writeConfigRaw(obj) {
89
+ ensureBaseDir();
90
+ const tmp = CONFIG_FILE + ".tmp";
91
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: 0o600 });
92
+ fs.renameSync(tmp, CONFIG_FILE);
93
+ }
94
+
95
+ /** Merge user config over defaults; invalid values fall back silently-but-logged. */
96
+ export function loadConfig({ warn = () => {}, file = CONFIG_FILE } = {}) {
97
+ let user = {};
98
+ try {
99
+ user = JSON.parse(fs.readFileSync(file, "utf8"));
100
+ if (typeof user !== "object" || user === null || Array.isArray(user)) {
101
+ warn(`config: ${file} is not a JSON object; using defaults`);
102
+ user = {};
103
+ }
104
+ } catch (err) {
105
+ if (err.code !== "ENOENT")
106
+ warn(`config: cannot read ${file}: ${err.message}`);
107
+ }
108
+
109
+ const cfg = { ...DEFAULTS };
110
+
111
+ if (typeof user.message === "string" && user.message.trim()) {
112
+ const msg = sanitizeMessage(user.message);
113
+ if (msg) cfg.message = msg;
114
+ }
115
+ cfg.marginSeconds = clampNumber(
116
+ user.marginSeconds,
117
+ DEFAULTS.marginSeconds,
118
+ 0,
119
+ 3600,
120
+ );
121
+ // Max is bounded well under the lock heartbeat's stale window so the
122
+ // watcher can never starve its own single-instance lock between polls.
123
+ cfg.pollSeconds = clampNumber(user.pollSeconds, DEFAULTS.pollSeconds, 5, 120);
124
+ cfg.focusDelayMs = clampNumber(
125
+ user.focusDelayMs,
126
+ DEFAULTS.focusDelayMs,
127
+ 500,
128
+ 30000,
129
+ );
130
+ cfg.fallbackWaitHours = clampNumber(
131
+ user.fallbackWaitHours,
132
+ DEFAULTS.fallbackWaitHours,
133
+ 0.1,
134
+ 24 * 8,
135
+ );
136
+ cfg.maxEventAgeHours = clampNumber(
137
+ user.maxEventAgeHours,
138
+ DEFAULTS.maxEventAgeHours,
139
+ 0.1,
140
+ 24 * 8,
141
+ );
142
+ // Kept small so a multi-episode fire burst can't outrun the lock heartbeat
143
+ // (a huge spacing + a slow send could exceed the stale window → double-fire).
144
+ cfg.fireSpacingSeconds = clampNumber(
145
+ user.fireSpacingSeconds,
146
+ DEFAULTS.fireSpacingSeconds,
147
+ 0,
148
+ 30,
149
+ );
150
+ if (typeof user.editor === "string") cfg.editor = user.editor.slice(0, 64);
151
+ if (typeof user.skipIfResumed === "boolean")
152
+ cfg.skipIfResumed = user.skipIfResumed;
153
+ if (typeof user.allowUnverifiedPaste === "boolean")
154
+ cfg.allowUnverifiedPaste = user.allowUnverifiedPaste;
155
+ if (typeof user.claudeDir === "string" && user.claudeDir.trim())
156
+ cfg.claudeDir = user.claudeDir;
157
+
158
+ return cfg;
159
+ }
160
+
161
+ /** Transcript root: explicit config > $CLAUDE_CONFIG_DIR > ~/.claude */
162
+ export function resolveClaudeDir(cfg) {
163
+ if (cfg.claudeDir) return cfg.claudeDir;
164
+ if (process.env.CLAUDE_CONFIG_DIR) return process.env.CLAUDE_CONFIG_DIR;
165
+ return path.join(HOME, ".claude");
166
+ }
167
+
168
+ export function projectsDir(cfg) {
169
+ return path.join(resolveClaudeDir(cfg), "projects");
170
+ }
171
+
172
+ export function ensureBaseDir() {
173
+ fs.mkdirSync(BASE_DIR, { recursive: true, mode: 0o700 });
174
+ }
package/lib/detect.js ADDED
@@ -0,0 +1,223 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { isValidSessionId } from "./editors.js";
4
+
5
+ /**
6
+ * Transcript scanning + rate-limit event parsing.
7
+ *
8
+ * A usage-limit event is one JSONL line with TOP-LEVEL `isApiErrorMessage:
9
+ * true`, a rate-limit marker (`error: "rate_limit"` or `apiErrorStatus: 429`),
10
+ * AND limit phrasing in the text. The phrase requirement matters: transient
11
+ * server-side 429s also log `error: "rate_limit"` but say "Server is
12
+ * temporarily limiting requests (not your usage limit)" — resuming those
13
+ * would be a spurious fire (found in hostile review).
14
+ *
15
+ * Conversation content cannot forge these events: detection keys on top-level
16
+ * fields of each line, and JSON string values cannot contain the raw newline
17
+ * needed to fabricate a separate JSONL line.
18
+ */
19
+
20
+ const LIMIT_PHRASE_RE =
21
+ /hit your (?:session |weekly |monthly )?limit|usage limit reached|limit reached|out of extra usage|try again in \d+\s*(?:hour|minute)/i;
22
+ const NOT_USAGE_LIMIT_RE = /not your usage limit/i;
23
+
24
+ const UUID_BASENAME_RE =
25
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/;
26
+
27
+ /** Cap per-file bytes read in one poll (hostile review: unbounded alloc). */
28
+ const MAX_READ_BYTES = 8 * 1024 * 1024;
29
+
30
+ /** Extract plain text from a transcript message object. */
31
+ export function textOfMessage(message) {
32
+ if (!message) return "";
33
+ const c = message.content;
34
+ if (typeof c === "string") return c;
35
+ if (Array.isArray(c)) {
36
+ return c
37
+ .map((b) =>
38
+ b && typeof b === "object" && typeof b.text === "string" ? b.text : "",
39
+ )
40
+ .join(" ");
41
+ }
42
+ return "";
43
+ }
44
+
45
+ /** Parse one JSONL line → rate-limit event or null. */
46
+ export function parseLimitLine(line) {
47
+ // Cheap pre-filter before JSON.parse (hot path: most lines are not errors).
48
+ if (!line.includes('"isApiErrorMessage"')) return null;
49
+ let d;
50
+ try {
51
+ d = JSON.parse(line);
52
+ } catch {
53
+ return null;
54
+ }
55
+ if (d === null || typeof d !== "object" || d.isApiErrorMessage !== true)
56
+ return null;
57
+
58
+ const text = textOfMessage(d.message);
59
+ const marker = d.error === "rate_limit" || d.apiErrorStatus === 429;
60
+ if (!marker) return null;
61
+ if (!LIMIT_PHRASE_RE.test(text) || NOT_USAGE_LIMIT_RE.test(text)) return null;
62
+ if (!isValidSessionId(d.sessionId)) return null;
63
+
64
+ let eventEpochMs = Date.parse(d.timestamp);
65
+ if (!Number.isFinite(eventEpochMs)) eventEpochMs = Date.now();
66
+
67
+ return {
68
+ sessionId: d.sessionId,
69
+ uuid: typeof d.uuid === "string" ? d.uuid : null,
70
+ text,
71
+ eventEpochMs,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * List session transcript files under <claudeDir>/projects.
77
+ * Only UUID-named files (real panel sessions — excludes agent side files);
78
+ * symlinks are skipped. Returns null when the ROOT cannot be read, so a
79
+ * transient readdir failure is distinguishable from "no files" (a `[]` here
80
+ * once wiped all offsets and caused a full-history replay — hostile review).
81
+ */
82
+ export function listTranscripts(projectsRoot) {
83
+ const out = [];
84
+ let dirs;
85
+ try {
86
+ dirs = fs.readdirSync(projectsRoot, { withFileTypes: true });
87
+ } catch {
88
+ return null;
89
+ }
90
+ for (const dir of dirs) {
91
+ if (!dir.isDirectory()) continue;
92
+ const sub = path.join(projectsRoot, dir.name);
93
+ let files;
94
+ try {
95
+ files = fs.readdirSync(sub);
96
+ } catch {
97
+ continue;
98
+ }
99
+ for (const f of files) {
100
+ if (!UUID_BASENAME_RE.test(f)) continue;
101
+ const full = path.join(sub, f);
102
+ try {
103
+ if (!fs.lstatSync(full).isFile()) continue; // skip symlinks & oddities
104
+ } catch {
105
+ continue;
106
+ }
107
+ out.push(full);
108
+ }
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /**
114
+ * Read newly appended bytes of `file` since `offset` (capped per poll).
115
+ * Returns { events, newOffset }. Handles truncation (offset reset to 0).
116
+ *
117
+ * Incomplete trailing lines (no final newline yet) are NOT consumed — the
118
+ * offset stops at the last complete line so a partially-flushed JSON entry is
119
+ * re-read intact on the next poll.
120
+ */
121
+ export function scanFile(file, offset) {
122
+ let size;
123
+ try {
124
+ size = fs.statSync(file).size;
125
+ } catch {
126
+ return { events: [], newOffset: offset };
127
+ }
128
+ let from = offset;
129
+ if (size < from) from = 0; // truncated / rotated
130
+ if (size === from) return { events: [], newOffset: from };
131
+
132
+ let fd;
133
+ try {
134
+ fd = fs.openSync(file, "r");
135
+ } catch {
136
+ return { events: [], newOffset: from };
137
+ }
138
+ let chunk;
139
+ try {
140
+ const len = Math.min(size - from, MAX_READ_BYTES);
141
+ const buf = Buffer.alloc(len);
142
+ const read = fs.readSync(fd, buf, 0, len, from);
143
+ chunk = buf.subarray(0, read).toString("utf8");
144
+ } finally {
145
+ fs.closeSync(fd);
146
+ }
147
+
148
+ const lastNl = chunk.lastIndexOf("\n");
149
+ if (lastNl === -1) {
150
+ // No newline in this window. If we read a FULL cap without one, the line
151
+ // is pathologically large (embedded image / giant tool_result) — advance
152
+ // past it so scanning never wedges and later events are still seen. A
153
+ // short read (< cap) is just an incomplete trailing line still being
154
+ // written — leave the offset so it's re-read intact.
155
+ if (size - from > MAX_READ_BYTES) {
156
+ return { events: [], newOffset: from + Buffer.byteLength(chunk, "utf8") };
157
+ }
158
+ return { events: [], newOffset: from };
159
+ }
160
+ const complete = chunk.slice(0, lastNl);
161
+ const newOffset =
162
+ from + Buffer.byteLength(chunk.slice(0, lastNl + 1), "utf8");
163
+
164
+ const events = [];
165
+ for (const line of complete.split("\n")) {
166
+ const ev = parseLimitLine(line);
167
+ if (ev) events.push({ ...ev, transcriptPath: file });
168
+ }
169
+ return { events, newOffset };
170
+ }
171
+
172
+ /** Auto-injected user-type lines that no human typed (skill loads, hook output …). */
173
+ const INJECTED_TEXT_RE =
174
+ /^\s*<(?:local-command|command-name|command-message|system-reminder|task-notification)/;
175
+
176
+ /**
177
+ * Did the human interact with this session AFTER the limit event?
178
+ * Used by skipIfResumed: if yes, the user already resumed manually — don't fire.
179
+ *
180
+ * Counts only genuine human text: skips `isMeta` lines, tool_result-only
181
+ * entries, and harness-injected tag blocks (all of which are `type:"user"`
182
+ * lines no human typed — hostile review found they silently cancelled
183
+ * legitimate resumes).
184
+ *
185
+ * Reads only the tail (last 256 KB) — enough for anything after a recent event.
186
+ */
187
+ export function sessionResumedAfter(transcriptPath, eventEpochMs) {
188
+ let size, fd;
189
+ try {
190
+ size = fs.statSync(transcriptPath).size;
191
+ fd = fs.openSync(transcriptPath, "r");
192
+ } catch {
193
+ return false;
194
+ }
195
+ let tail;
196
+ try {
197
+ const len = Math.min(size, 256 * 1024);
198
+ const buf = Buffer.alloc(len);
199
+ const read = fs.readSync(fd, buf, 0, len, size - len);
200
+ tail = buf.subarray(0, read).toString("utf8");
201
+ } finally {
202
+ fs.closeSync(fd);
203
+ }
204
+ for (const line of tail.split("\n")) {
205
+ if (!line.includes('"type":"user"') && !line.includes('"type": "user"'))
206
+ continue;
207
+ let d;
208
+ try {
209
+ d = JSON.parse(line);
210
+ } catch {
211
+ continue;
212
+ }
213
+ if (d.type !== "user") continue;
214
+ if (d.isMeta === true) continue;
215
+ const ts = Date.parse(d.timestamp);
216
+ if (!Number.isFinite(ts) || ts <= eventEpochMs) continue;
217
+ const text = textOfMessage(d.message);
218
+ if (!text || !text.trim()) continue; // tool_result blocks have no .text
219
+ if (INJECTED_TEXT_RE.test(text)) continue;
220
+ return true; // real human text after the event
221
+ }
222
+ return false;
223
+ }