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/LICENSE +21 -0
- package/README.md +204 -0
- package/bin/claude-wake.js +290 -0
- package/docs/design.md +181 -0
- package/lib/config.js +174 -0
- package/lib/detect.js +223 -0
- package/lib/doctor.js +135 -0
- package/lib/editors.js +87 -0
- package/lib/install.js +315 -0
- package/lib/log.js +43 -0
- package/lib/parse-reset.js +192 -0
- package/lib/send/darwin.js +233 -0
- package/lib/send/index.js +47 -0
- package/lib/send/linux.js +149 -0
- package/lib/send/win32.js +133 -0
- package/lib/state.js +139 -0
- package/lib/watch.js +276 -0
- package/package.json +38 -0
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
|
+
}
|