unsnooze 1.1.0 → 1.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.
- package/CHANGELOG.md +39 -0
- package/README.md +33 -0
- package/bin/unsnooze.js +14 -0
- package/package.json +1 -1
- package/src/agents/claude.js +1 -0
- package/src/agents/codex.js +2 -4
- package/src/cli.js +2 -1
- package/src/config.js +5 -0
- package/src/hook.js +1 -0
- package/src/install.js +114 -2
- package/src/notify.js +1 -1
- package/src/resumer.js +40 -7
- package/src/settings.js +2 -0
- package/src/state.js +16 -0
- package/src/time-parser.js +73 -10
- package/src/watcher.js +333 -0
- package/src/watchers/claude.js +39 -0
- package/src/watchers/codex.js +106 -0
- package/src/wizard.js +16 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.2.0 — 2026-07-10
|
|
4
|
+
|
|
5
|
+
### GUI surfaces: VS Code extension, desktop apps
|
|
6
|
+
|
|
7
|
+
Sessions running outside a terminal — Claude Code's VS Code extension and
|
|
8
|
+
desktop app, Codex's IDE extension and desktop app — are now guarded too.
|
|
9
|
+
There is no pane to scrape and (for Codex) no hook, so detection tails the
|
|
10
|
+
session files the CLIs already write:
|
|
11
|
+
|
|
12
|
+
- **Claude Code**: rate-limit stops land in `~/.claude/projects` transcripts
|
|
13
|
+
as structured entries (`error:"rate_limit"`, session id, cwd, reset text).
|
|
14
|
+
The new watcher turns them into ledger records; the weekly banner form
|
|
15
|
+
("resets Jul 4 at 12:30am (tz)") now parses, DST-safe.
|
|
16
|
+
- **Codex**: rollouts never persist error events, but every turn's
|
|
17
|
+
`token_count` event carries a `rate_limits` snapshot (`used_percent`,
|
|
18
|
+
`resets_at` epoch). An exhausted window becomes a stop with an exact epoch
|
|
19
|
+
reset — more precise than any scraped banner — and works for every Codex
|
|
20
|
+
surface, since they share `~/.codex/sessions`.
|
|
21
|
+
- **Claude desktop (cowork) sessions** *(experimental, macOS)*: sandboxed
|
|
22
|
+
sessions under `~/Library/Application Support/Claude` are detected, and
|
|
23
|
+
revival exports the session's isolated `CLAUDE_CONFIG_DIR` together with
|
|
24
|
+
`CLAUDE_SECURESTORAGE_CONFIG_DIR=''` so auth resolves through the default
|
|
25
|
+
keychain entry (the sandbox holds no credentials). Verified end-to-end
|
|
26
|
+
against a real desktop session.
|
|
27
|
+
|
|
28
|
+
Revival stays terminal-based: when the limit resets, the session reopens in a
|
|
29
|
+
tmux window via `claude --resume <id>` / `codex resume <id>` — the same
|
|
30
|
+
session file continues, so the conversation stays visible in the GUI's own
|
|
31
|
+
history. Resuming *inside* the GUI panels is not possible today (no IPC/URI
|
|
32
|
+
sends a prompt into them).
|
|
33
|
+
|
|
34
|
+
- **`unsnooze daemon`**: persistent watcher process; `unsnooze install
|
|
35
|
+
--daemon` (or the new wizard step) installs it as a launchd agent (macOS)
|
|
36
|
+
or systemd user unit (Linux) so GUI sessions are watched without a shell.
|
|
37
|
+
- **`guiWatch` setting** (default on) gates the watching; `unsnooze status`
|
|
38
|
+
shows each stop's origin (`cli`, `vscode`, `desktop`, …).
|
|
39
|
+
- Ledger dedupe: transcript records merge with hook/scrape records of the
|
|
40
|
+
same session, so terminal sessions are never double-resumed.
|
|
41
|
+
|
|
3
42
|
## 1.1.0 — 2026-07-10
|
|
4
43
|
|
|
5
44
|
### Per-agent resume messages
|
package/README.md
CHANGED
|
@@ -29,6 +29,7 @@ existing tool solves only a slice of it:
|
|
|
29
29
|
| | **unsnooze** | claude-auto-retry | autoclaude | hydra |
|
|
30
30
|
|---|:---:|:---:|:---:|:---:|
|
|
31
31
|
| Multi-CLI (Claude Code + Codex + Grok) | ✅ | ❌ Claude only | ❌ Claude only | ✅ |
|
|
32
|
+
| GUI sessions (VS Code ext, desktop apps) | ✅ watcher daemon | ❌ | ❌ | ❌ |
|
|
32
33
|
| Waits for reset & resumes the **same** session | ✅ | ✅ | ✅ | ❌ switches provider |
|
|
33
34
|
| All sessions at once (shared ledger + one daemon) | ✅ | ❌ one pane | ✅ | ✅ |
|
|
34
35
|
| Revives sessions whose pane/process is **gone** | ✅ `--resume <id>` | ❌ | ❌ | ❌ |
|
|
@@ -52,6 +53,35 @@ existing tool solves only a slice of it:
|
|
|
52
53
|
fallback. Hit a banner unsnooze missed? Run `unsnooze report` and paste the
|
|
53
54
|
capture into an issue — that's how this adapter gets good.
|
|
54
55
|
|
|
56
|
+
## GUI surfaces (VS Code extension, desktop apps)
|
|
57
|
+
|
|
58
|
+
Terminal sessions are watched through the shell wrapper + tmux. Sessions in
|
|
59
|
+
**Claude Code's VS Code extension / desktop app** and **Codex's IDE
|
|
60
|
+
extension / desktop app** have no pane to scrape — so `unsnooze daemon` tails
|
|
61
|
+
the session files those surfaces already write:
|
|
62
|
+
|
|
63
|
+
- **Claude Code** records every rate-limit stop as a structured entry in its
|
|
64
|
+
`~/.claude/projects/**.jsonl` transcript (session id, cwd, reset time) —
|
|
65
|
+
shared by the CLI and the VS Code extension.
|
|
66
|
+
- **Codex** writes a `rate_limits` snapshot (usage %, **exact epoch reset
|
|
67
|
+
time**) into every rollout under `~/.codex/sessions/` — shared by the CLI,
|
|
68
|
+
IDE extension, and desktop app.
|
|
69
|
+
- **Claude desktop (cowork) sessions** *(experimental, macOS)* run in
|
|
70
|
+
sandboxes under `~/Library/Application Support/Claude`; unsnooze watches
|
|
71
|
+
those too and revives with the session's isolated `CLAUDE_CONFIG_DIR`
|
|
72
|
+
(plus the keychain-scope override that keeps auth working — verified
|
|
73
|
+
against a real desktop session).
|
|
74
|
+
|
|
75
|
+
When the limit resets, the session is revived **in a tmux window** with
|
|
76
|
+
`claude --resume <id>` / `codex resume <id>` — it's the same session file, so
|
|
77
|
+
the continued conversation stays visible in the GUI's own history. (Resuming
|
|
78
|
+
*inside* the GUI panel isn't possible today: no extension/app exposes an
|
|
79
|
+
IPC/URI that can send a prompt.)
|
|
80
|
+
|
|
81
|
+
Enable it in `unsnooze setup` (installs a launchd agent / systemd user unit),
|
|
82
|
+
or run `unsnooze install --daemon` / `unsnooze daemon` yourself. Turn it off
|
|
83
|
+
anytime with `unsnooze config set guiWatch off`.
|
|
84
|
+
|
|
55
85
|
## How it works
|
|
56
86
|
|
|
57
87
|
<div align="center">
|
|
@@ -101,6 +131,8 @@ unsnooze cancel [id|--all] # stop tracking a session
|
|
|
101
131
|
unsnooze config list # settings (see below)
|
|
102
132
|
unsnooze config set <k> <v> # e.g. autoResume off
|
|
103
133
|
unsnooze logs [-f] # what unsnooze has been doing
|
|
134
|
+
unsnooze daemon # persistent GUI-session watcher (usually run
|
|
135
|
+
# by launchd/systemd via `install --daemon`)
|
|
104
136
|
unsnooze report [agent] # capture a pane to report an undetected banner
|
|
105
137
|
unsnooze uninstall [--purge] # remove wrappers + hooks (+ state with --purge)
|
|
106
138
|
unsnooze help # full command list (also -h / --help)
|
|
@@ -116,6 +148,7 @@ unsnooze help # full command list (also -h / --help)
|
|
|
116
148
|
| `autoResume` | `true` | Master switch. Off = stops are still tracked, but nothing is resumed until you run `unsnooze resume-now` or turn it back on. |
|
|
117
149
|
| `menuAutoAnswer` | `true` | May unsnooze answer Claude's limit menu (send keys in your pane)? Off = watch-only. |
|
|
118
150
|
| `notifications` | `true` | Desktop notification on limit detected / session resumed / gave up. |
|
|
151
|
+
| `guiWatch` | `true` | May the daemon watch session files for GUI-surface stops (VS Code extension, desktop apps)? Needs the daemon running (`unsnooze install --daemon`). |
|
|
119
152
|
| `resumeMessage` | *"Continue where you left off…"* | The message sent to wake a session. |
|
|
120
153
|
| `resumeMessages.claude` / `.codex` / `.grok` | `""` | Per-agent override of `resumeMessage`. Empty = use the global message; clear one with `unsnooze config set resumeMessages.claude ""`. |
|
|
121
154
|
| `agents.claude` / `agents.codex` / `agents.grok` | `true` / `true` / `false` | Which CLIs are guarded. |
|
package/bin/unsnooze.js
CHANGED
|
@@ -60,6 +60,17 @@ async function main() {
|
|
|
60
60
|
const { runResumer } = await import('../src/resumer.js');
|
|
61
61
|
return runResumer();
|
|
62
62
|
}
|
|
63
|
+
case 'daemon': {
|
|
64
|
+
// Persistent resumer + transcript watcher: detects and revives limit
|
|
65
|
+
// stops from GUI surfaces (VS Code extension, desktop apps) where no
|
|
66
|
+
// shell wrapper or tmux pane exists. Run via launchd/systemd or a shell.
|
|
67
|
+
const { runResumer } = await import('../src/resumer.js');
|
|
68
|
+
const { createWatcher } = await import('../src/watcher.js');
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
process.on('SIGTERM', () => controller.abort());
|
|
71
|
+
process.on('SIGINT', () => controller.abort());
|
|
72
|
+
return runResumer({ persistent: true, watcher: createWatcher(), signal: controller.signal });
|
|
73
|
+
}
|
|
63
74
|
case 'help':
|
|
64
75
|
case '-h':
|
|
65
76
|
case '--help':
|
|
@@ -73,6 +84,9 @@ Usage:
|
|
|
73
84
|
unsnooze resume-now [id|--all] resume stopped session(s) immediately
|
|
74
85
|
unsnooze cancel [id|--all] stop tracking session(s)
|
|
75
86
|
unsnooze logs [-f] show (or follow) the unsnooze log
|
|
87
|
+
unsnooze daemon persistent watcher for GUI sessions (VS Code
|
|
88
|
+
extension, desktop apps) — no tmux needed to
|
|
89
|
+
detect; revival still opens in tmux
|
|
76
90
|
unsnooze config [list|get|set] view or change settings (toggles, global +
|
|
77
91
|
per-agent resume messages)
|
|
78
92
|
unsnooze setup interactive setup wizard (agents + toggles)
|
package/package.json
CHANGED
package/src/agents/claude.js
CHANGED
|
@@ -23,6 +23,7 @@ export const patterns = {
|
|
|
23
23
|
/resets?\s+in[:\s]\s*\d/i,
|
|
24
24
|
/try again in \d+\s*(?:hours?|minutes?|h|m)/i,
|
|
25
25
|
/resets?\s+(?:on\s+)?(?:mon|tue|wed|thu|fri|sat|sun)/i, // weekly: "resets Tuesday 9am"
|
|
26
|
+
/resets?\s+(?:on\s+)?(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+\d{1,2}\b/i, // weekly: "resets Jul 4 at 12:30am"
|
|
26
27
|
],
|
|
27
28
|
weeklyPatterns: [
|
|
28
29
|
/week(?:ly)?\s+limit/i,
|
package/src/agents/codex.js
CHANGED
|
@@ -11,10 +11,8 @@
|
|
|
11
11
|
// the overload path, never the ledger.
|
|
12
12
|
|
|
13
13
|
import { openSync, readSync, closeSync, readdirSync, statSync } from 'node:fs';
|
|
14
|
-
import { homedir } from 'node:os';
|
|
15
14
|
import { join } from 'node:path';
|
|
16
|
-
|
|
17
|
-
const CODEX_DIR = process.env.UNSNOOZE_CODEX_DIR || join(homedir(), '.codex');
|
|
15
|
+
import { CODEX_DIR } from '../config.js';
|
|
18
16
|
|
|
19
17
|
const LIMIT_ANCHORS = [
|
|
20
18
|
/You've hit your usage limit/i,
|
|
@@ -48,7 +46,7 @@ export const patterns = {
|
|
|
48
46
|
// the first JSONL line carries the session cwd. Conservative: no cwd match →
|
|
49
47
|
// null (the resumer then uses `codex resume --last`, which codex itself scopes
|
|
50
48
|
// to the launch cwd).
|
|
51
|
-
const ROLLOUT_RE = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
|
|
49
|
+
export const ROLLOUT_RE = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
|
|
52
50
|
|
|
53
51
|
function fileHead(path, bytes = 4096) {
|
|
54
52
|
let fd;
|
package/src/cli.js
CHANGED
|
@@ -29,8 +29,9 @@ export function cmdStatus() {
|
|
|
29
29
|
for (const s of sessions.sort((a, b) => (a.resetAt || 0) - (b.resetAt || 0))) {
|
|
30
30
|
const id = s.sessionId ? s.sessionId.slice(0, 8) : '(no id)';
|
|
31
31
|
const reset = s.resetAt ? `${new Date(s.resetAt).toLocaleString()} (${fmtCountdown(s.resetAt - now)})` : '?';
|
|
32
|
+
const origin = s.origin ?? (s.pane ? 'cli' : '?');
|
|
32
33
|
console.log(` [${s.status.toUpperCase().padEnd(9)}] ${id} ${(s.agent || 'claude').padEnd(6)} ${s.limitType?.padEnd(7) ?? 'unknown'} ${s.cwd}`);
|
|
33
|
-
console.log(` pane ${s.pane ?? '-'} · resets ${reset} · attempts ${s.attempts ?? 0}/${MAX_RESUME_ATTEMPTS}${s.lastError ? ` · last error: ${s.lastError}` : ''}`);
|
|
34
|
+
console.log(` pane ${s.pane ?? '-'} · via ${origin} · resets ${reset} · attempts ${s.attempts ?? 0}/${MAX_RESUME_ATTEMPTS}${s.lastError ? ` · last error: ${s.lastError}` : ''}`);
|
|
34
35
|
}
|
|
35
36
|
return 0;
|
|
36
37
|
}
|
package/src/config.js
CHANGED
|
@@ -15,6 +15,10 @@ export const RESUMER_LOCK = join(STATE_DIR, 'resumer.lock');
|
|
|
15
15
|
|
|
16
16
|
export const CLAUDE_DIR = process.env.UNSNOOZE_CLAUDE_DIR || join(homedir(), '.claude');
|
|
17
17
|
export const CLAUDE_SETTINGS = join(CLAUDE_DIR, 'settings.json');
|
|
18
|
+
export const CODEX_DIR = process.env.UNSNOOZE_CODEX_DIR || join(homedir(), '.codex');
|
|
19
|
+
|
|
20
|
+
// Transcript/rollout watcher (GUI detection channel)
|
|
21
|
+
export const WATCH_OFFSETS_FILE = join(STATE_DIR, 'watch-offsets.json');
|
|
18
22
|
|
|
19
23
|
export const TMUX_SESSION_NAME = process.env.UNSNOOZE_TMUX_SESSION || 'unsnooze';
|
|
20
24
|
|
|
@@ -28,6 +32,7 @@ export const VERIFY_DELAY_MS = envInt('UNSNOOZE_VERIFY_DELAY_MS', 20_000);
|
|
|
28
32
|
export const BUSY_DEFER_MS = envInt('UNSNOOZE_BUSY_DEFER_MS', 60_000);
|
|
29
33
|
export const READY_TIMEOUT_MS = envInt('UNSNOOZE_READY_TIMEOUT_MS', 60_000);
|
|
30
34
|
export const EVENT_MARKER_TTL_MS = envInt('UNSNOOZE_EVENT_MARKER_TTL_MS', 120_000);
|
|
35
|
+
export const WATCH_FRESHNESS_MS = envInt('UNSNOOZE_WATCH_FRESHNESS_MS', 15 * 60_000);
|
|
31
36
|
|
|
32
37
|
// Pane scanning
|
|
33
38
|
export const PANE_SCAN_LINES = envInt('UNSNOOZE_PANE_SCAN_LINES', 12);
|
package/src/hook.js
CHANGED
package/src/install.js
CHANGED
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
// - ~/.grok/hooks/unsnooze.json when the grok agent is enabled
|
|
9
9
|
// --settings <path> / --zshrc <path> override targets (used by tests).
|
|
10
10
|
|
|
11
|
-
import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, rmSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, rmSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
12
13
|
import { homedir } from 'node:os';
|
|
13
14
|
import { join, dirname } from 'node:path';
|
|
14
15
|
import { CLAUDE_SETTINGS, STATE_DIR } from './config.js';
|
|
15
16
|
import { getConfig, configFileExists } from './settings.js';
|
|
17
|
+
import { xmlEscape } from './notify.js';
|
|
16
18
|
import { installGrokHooks, uninstallGrokHooks } from './agents/grok.js';
|
|
17
19
|
import { UNSNOOZE_BIN } from './spawn.js';
|
|
18
20
|
|
|
@@ -27,10 +29,11 @@ const LEGACY_FENCES = [
|
|
|
27
29
|
const OLD_FENCE_OPEN = LEGACY_FENCES[0].open;
|
|
28
30
|
|
|
29
31
|
function parseArgs(rest) {
|
|
30
|
-
const opts = { yes: false, settings: CLAUDE_SETTINGS, zshrc: join(homedir(), '.zshrc'), purge: false };
|
|
32
|
+
const opts = { yes: false, settings: CLAUDE_SETTINGS, zshrc: join(homedir(), '.zshrc'), purge: false, daemon: false };
|
|
31
33
|
for (let i = 0; i < rest.length; i++) {
|
|
32
34
|
if (rest[i] === '--yes' || rest[i] === '-y') opts.yes = true;
|
|
33
35
|
else if (rest[i] === '--purge') opts.purge = true;
|
|
36
|
+
else if (rest[i] === '--daemon') opts.daemon = true;
|
|
34
37
|
else if (rest[i] === '--settings') opts.settings = rest[++i];
|
|
35
38
|
else if (rest[i] === '--zshrc') opts.zshrc = rest[++i];
|
|
36
39
|
}
|
|
@@ -124,6 +127,105 @@ export function installZshrcBlock(content, agents = ['claude']) {
|
|
|
124
127
|
return { content: result, oldRemoved };
|
|
125
128
|
}
|
|
126
129
|
|
|
130
|
+
// --- daemon autostart ---
|
|
131
|
+
// GUI sessions (VS Code extension, desktop apps) never pass through the shell
|
|
132
|
+
// wrappers, so their limit stops are only caught while `unsnooze daemon` is
|
|
133
|
+
// alive. Autostart keeps it alive: a launchd user agent on macOS, a systemd
|
|
134
|
+
// user unit on Linux/WSL.
|
|
135
|
+
|
|
136
|
+
export const DAEMON_LABEL = 'com.unsnooze.daemon';
|
|
137
|
+
|
|
138
|
+
// Env overrides keep tests/e2e away from the real LaunchAgents / systemd dirs.
|
|
139
|
+
function autostartDir(platform) {
|
|
140
|
+
if (platform === 'darwin') {
|
|
141
|
+
return process.env.UNSNOOZE_LAUNCH_AGENTS_DIR || join(homedir(), 'Library', 'LaunchAgents');
|
|
142
|
+
}
|
|
143
|
+
return process.env.UNSNOOZE_SYSTEMD_USER_DIR || join(homedir(), '.config', 'systemd', 'user');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function launchdPlist({
|
|
147
|
+
nodeBin = process.execPath, unsnoozeBin = UNSNOOZE_BIN,
|
|
148
|
+
logFile = join(STATE_DIR, 'daemon.log'),
|
|
149
|
+
} = {}) {
|
|
150
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
151
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
152
|
+
<plist version="1.0">
|
|
153
|
+
<dict>
|
|
154
|
+
<key>Label</key><string>${DAEMON_LABEL}</string>
|
|
155
|
+
<key>ProgramArguments</key>
|
|
156
|
+
<array>
|
|
157
|
+
<string>${xmlEscape(nodeBin)}</string>
|
|
158
|
+
<string>${xmlEscape(unsnoozeBin)}</string>
|
|
159
|
+
<string>daemon</string>
|
|
160
|
+
</array>
|
|
161
|
+
<key>RunAtLoad</key><true/>
|
|
162
|
+
<key>KeepAlive</key><true/>
|
|
163
|
+
<key>StandardOutPath</key><string>${xmlEscape(logFile)}</string>
|
|
164
|
+
<key>StandardErrorPath</key><string>${xmlEscape(logFile)}</string>
|
|
165
|
+
</dict>
|
|
166
|
+
</plist>
|
|
167
|
+
`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function systemdUnit({ nodeBin = process.execPath, unsnoozeBin = UNSNOOZE_BIN } = {}) {
|
|
171
|
+
return `[Unit]
|
|
172
|
+
Description=unsnooze daemon — watches GUI AI-coding sessions for limit stops
|
|
173
|
+
|
|
174
|
+
[Service]
|
|
175
|
+
ExecStart="${nodeBin}" "${unsnoozeBin}" daemon
|
|
176
|
+
Restart=on-failure
|
|
177
|
+
RestartSec=10
|
|
178
|
+
|
|
179
|
+
[Install]
|
|
180
|
+
WantedBy=default.target
|
|
181
|
+
`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function defaultActivate(cmd, args) {
|
|
185
|
+
try {
|
|
186
|
+
execFileSync(cmd, args, { stdio: 'ignore' });
|
|
187
|
+
return true;
|
|
188
|
+
} catch {
|
|
189
|
+
return false; // best-effort: the file is in place; a reboot/login loads it
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function installDaemonAutostart({ platform = process.platform, dir = null, activate = defaultActivate } = {}) {
|
|
194
|
+
if (platform === 'darwin') {
|
|
195
|
+
const target = join(dir || autostartDir(platform), `${DAEMON_LABEL}.plist`);
|
|
196
|
+
atomicWrite(target, launchdPlist());
|
|
197
|
+
activate('launchctl', ['unload', target]); // reload cleanly if already loaded
|
|
198
|
+
activate('launchctl', ['load', '-w', target]);
|
|
199
|
+
return target;
|
|
200
|
+
}
|
|
201
|
+
if (platform === 'linux') {
|
|
202
|
+
const target = join(dir || autostartDir(platform), 'unsnooze.service');
|
|
203
|
+
atomicWrite(target, systemdUnit());
|
|
204
|
+
activate('systemctl', ['--user', 'daemon-reload']);
|
|
205
|
+
activate('systemctl', ['--user', 'enable', '--now', 'unsnooze.service']);
|
|
206
|
+
return target;
|
|
207
|
+
}
|
|
208
|
+
return null; // native Windows: no tmux to revive into — daemon unsupported
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function uninstallDaemonAutostart({ platform = process.platform, dir = null, activate = defaultActivate } = {}) {
|
|
212
|
+
if (platform === 'darwin') {
|
|
213
|
+
const target = join(dir || autostartDir(platform), `${DAEMON_LABEL}.plist`);
|
|
214
|
+
if (!existsSync(target)) return null;
|
|
215
|
+
activate('launchctl', ['unload', target]);
|
|
216
|
+
try { unlinkSync(target); } catch { /* already gone */ }
|
|
217
|
+
return target;
|
|
218
|
+
}
|
|
219
|
+
if (platform === 'linux') {
|
|
220
|
+
const target = join(dir || autostartDir(platform), 'unsnooze.service');
|
|
221
|
+
if (!existsSync(target)) return null;
|
|
222
|
+
activate('systemctl', ['--user', 'disable', '--now', 'unsnooze.service']);
|
|
223
|
+
try { unlinkSync(target); } catch { /* already gone */ }
|
|
224
|
+
return target;
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
127
229
|
// --- commands ---
|
|
128
230
|
|
|
129
231
|
export function enabledAgents() {
|
|
@@ -185,6 +287,13 @@ export function cmdInstall(rest, { agents = enabledAgents() } = {}) {
|
|
|
185
287
|
console.log(`unsnooze: wrappers (${agents.join(', ')}) installed in ${rc}${oldRemoved ? ' (legacy wrapper block removed)' : ''}`);
|
|
186
288
|
}
|
|
187
289
|
|
|
290
|
+
// 4. Daemon autostart (GUI-session watching), opt-in via --daemon / wizard.
|
|
291
|
+
if (opts.daemon) {
|
|
292
|
+
const target = installDaemonAutostart();
|
|
293
|
+
if (target) console.log(`unsnooze: daemon autostart installed (${target}) — GUI sessions are watched`);
|
|
294
|
+
else console.log('unsnooze: daemon autostart is not supported on this platform');
|
|
295
|
+
}
|
|
296
|
+
|
|
188
297
|
console.log('\nunsnooze: done. Reload your shell:');
|
|
189
298
|
console.log(' exec $SHELL');
|
|
190
299
|
return 0;
|
|
@@ -211,6 +320,9 @@ export function cmdUninstall(rest) {
|
|
|
211
320
|
}
|
|
212
321
|
}
|
|
213
322
|
|
|
323
|
+
const autostart = uninstallDaemonAutostart();
|
|
324
|
+
if (autostart) console.log(`unsnooze: daemon autostart removed (${autostart})`);
|
|
325
|
+
|
|
214
326
|
if (opts.purge) {
|
|
215
327
|
rmSync(STATE_DIR, { recursive: true, force: true });
|
|
216
328
|
console.log(`unsnooze: state dir ${STATE_DIR} removed`);
|
package/src/notify.js
CHANGED
|
@@ -18,7 +18,7 @@ function appleScriptString(s) {
|
|
|
18
18
|
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function xmlEscape(s) {
|
|
21
|
+
export function xmlEscape(s) {
|
|
22
22
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
23
23
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
24
24
|
}
|
package/src/resumer.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// Exits when no non-terminal records remain; the next limit event respawns it.
|
|
6
6
|
|
|
7
7
|
import { writeFileSync, readFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
8
9
|
import * as realTmux from './tmux.js';
|
|
9
10
|
import {
|
|
10
11
|
RESUMER_LOCK, STATE_DIR, POLL_INTERVAL_MS, STAGGER_MS, VERIFY_DELAY_MS,
|
|
@@ -92,12 +93,21 @@ export async function dispatchOne(rec, { tmux = realTmux, resumeMessage, selfCmd
|
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
// Re-open path: new tmux window in the well-known session, resume by id.
|
|
96
|
+
// Records from sandboxed GUI sessions carry env (e.g. CLAUDE_CONFIG_DIR) the
|
|
97
|
+
// revived CLI needs to find its session store.
|
|
95
98
|
const resume = agent.resumeArgs(rec.sessionId, resumeMessage);
|
|
96
|
-
|
|
99
|
+
// `env K=V cmd`, not a bare `K=V cmd` prefix: the command runs in the
|
|
100
|
+
// user's default shell, and fish/nushell reject POSIX prefix assignments.
|
|
101
|
+
const envPrefix = rec.env
|
|
102
|
+
? 'env ' + Object.entries(rec.env).map(([k, v]) => `${k}=${shellQuote(String(v))}`).join(' ') + ' '
|
|
103
|
+
: '';
|
|
104
|
+
const command = envPrefix + [...selfCmd, '_run', agent.id, ...resume.args].map(shellQuote).join(' ');
|
|
97
105
|
setStatus(key, 'resuming', { lastAttemptAt: Date.now() });
|
|
98
106
|
let newPane;
|
|
99
107
|
try {
|
|
100
|
-
|
|
108
|
+
// cwd can be null on watcher records (unreadable rollout head) — execFile
|
|
109
|
+
// rejects null args, so fall back to the home dir rather than crash-loop.
|
|
110
|
+
newPane = await tmux.newWindow(rec.tmuxSession || TMUX_SESSION_NAME, rec.cwd || homedir(), command);
|
|
101
111
|
} catch (err) {
|
|
102
112
|
setStatus(key, 'stopped', { attempts: (rec.attempts || 0) + 1, lastError: `new-window: ${err.message}` });
|
|
103
113
|
return 'failed';
|
|
@@ -153,20 +163,43 @@ export async function verifyOne(key, { tmux = realTmux } = {}) {
|
|
|
153
163
|
}
|
|
154
164
|
setStatus(key, 'resumed');
|
|
155
165
|
log(`${key}: verified resumed`);
|
|
156
|
-
|
|
166
|
+
const fromGui = rec.origin && rec.origin !== 'cli';
|
|
167
|
+
notify('unsnoozed ✅', `${rec.cwd} is running again${fromGui ? ` (was in ${rec.origin} — revived in tmux)` : ''}`);
|
|
157
168
|
}
|
|
158
169
|
|
|
159
|
-
|
|
160
|
-
|
|
170
|
+
// persistent: never exit on an empty ledger (daemon mode — `unsnooze daemon`,
|
|
171
|
+
// launchd/systemd). watcher: transcript watcher ticked every loop, so GUI
|
|
172
|
+
// sessions are detected without a hook or pane. signal: clean shutdown.
|
|
173
|
+
export async function runResumer({
|
|
174
|
+
tmux = realTmux, pollInterval = POLL_INTERVAL_MS,
|
|
175
|
+
persistent = false, watcher = null, signal = null,
|
|
176
|
+
} = {}) {
|
|
177
|
+
// A transient hook-spawned resumer may hold the lock right now; a daemon
|
|
178
|
+
// outlives it, so wait for the lock instead of dying. The watcher MUST keep
|
|
179
|
+
// ticking during the wait: it only records stops (its own state lock covers
|
|
180
|
+
// that), and a stop left unread past the freshness window is lost for good.
|
|
181
|
+
const tickWatcher = async () => {
|
|
182
|
+
if (!watcher || !getConfig('guiWatch')) return;
|
|
183
|
+
try { await watcher.tick(); } catch (err) { log(`watcher tick failed: ${err.message}`); }
|
|
184
|
+
};
|
|
185
|
+
while (!acquireSingleton()) {
|
|
186
|
+
if (!persistent) { log('another resumer is running — exiting'); return 0; }
|
|
187
|
+
if (signal?.aborted) return 0;
|
|
188
|
+
await tickWatcher();
|
|
189
|
+
log('another resumer holds the lock — daemon waiting');
|
|
190
|
+
await sleep(pollInterval);
|
|
191
|
+
}
|
|
161
192
|
updateState(state => { state.resumerPid = process.pid; });
|
|
162
|
-
log(`resumer started (pid ${process.pid})`);
|
|
193
|
+
log(`resumer started (pid ${process.pid}${persistent ? ', persistent' : ''})`);
|
|
163
194
|
const deferCounts = new Map();
|
|
164
195
|
|
|
165
196
|
try {
|
|
166
197
|
for (;;) {
|
|
198
|
+
if (signal?.aborted) { log('shutdown requested — resumer exiting'); return 0; }
|
|
199
|
+
await tickWatcher();
|
|
167
200
|
const stopped = activeStopped();
|
|
168
201
|
const resuming = Object.values(readState().sessions).filter(s => s.status === 'resuming');
|
|
169
|
-
if (stopped.length === 0 && resuming.length === 0) {
|
|
202
|
+
if (stopped.length === 0 && resuming.length === 0 && !persistent) {
|
|
170
203
|
log('no pending sessions — resumer exiting');
|
|
171
204
|
return 0;
|
|
172
205
|
}
|
package/src/settings.js
CHANGED
|
@@ -16,6 +16,7 @@ export const DEFAULTS = {
|
|
|
16
16
|
autoResume: true, // master switch: dispatch resumes when limits reset
|
|
17
17
|
menuAutoAnswer: true, // may unsnooze drive Claude's limit menu (send keys)?
|
|
18
18
|
notifications: true, // desktop notifications on detect/resume
|
|
19
|
+
guiWatch: true, // daemon watches transcripts/rollouts for GUI-session stops
|
|
19
20
|
resumeMessage: 'Continue where you left off. The session was interrupted by a usage limit which has now reset — pick up the task you were working on and finish it.',
|
|
20
21
|
resumeMessages: { claude: '', codex: '', grok: '' }, // per-agent override; '' = use resumeMessage
|
|
21
22
|
agents: { claude: true, codex: true, grok: false }, // grok is experimental
|
|
@@ -26,6 +27,7 @@ const ENV_NAMES = {
|
|
|
26
27
|
autoResume: 'UNSNOOZE_AUTO_RESUME',
|
|
27
28
|
menuAutoAnswer: 'UNSNOOZE_MENU_AUTO_ANSWER',
|
|
28
29
|
notifications: 'UNSNOOZE_NOTIFICATIONS',
|
|
30
|
+
guiWatch: 'UNSNOOZE_GUI_WATCH',
|
|
29
31
|
resumeMessage: 'UNSNOOZE_RESUME_MESSAGE',
|
|
30
32
|
'resumeMessages.claude': 'UNSNOOZE_RESUME_MESSAGE_CLAUDE',
|
|
31
33
|
'resumeMessages.codex': 'UNSNOOZE_RESUME_MESSAGE_CODEX',
|
package/src/state.js
CHANGED
|
@@ -111,11 +111,27 @@ export function upsertSession(record) {
|
|
|
111
111
|
function findDuplicate(state, record) {
|
|
112
112
|
if (record.sessionId && state.sessions[record.sessionId]) return record.sessionId;
|
|
113
113
|
for (const [key, s] of Object.entries(state.sessions)) {
|
|
114
|
+
// Same sessionId living under a pane-based key (a scrape record that later
|
|
115
|
+
// learned its id through a merge).
|
|
116
|
+
if (record.sessionId && s.sessionId === record.sessionId) return key;
|
|
114
117
|
if (s.pane && s.pane === record.pane
|
|
115
118
|
&& s.status === 'stopped'
|
|
116
119
|
&& Math.abs((s.detectedAt || 0) - record.detectedAt) < DEDUPE_WINDOW_MS) {
|
|
117
120
|
return key;
|
|
118
121
|
}
|
|
122
|
+
// A transcript/hook record with a sessionId matches a scrape record that
|
|
123
|
+
// never learned its id — same agent, same cwd, same detection window.
|
|
124
|
+
// Known trade-off: TWO different sessions of the same agent in the same
|
|
125
|
+
// cwd stopping within the window would wrongly merge — but a pane session
|
|
126
|
+
// writes the very transcript the watcher reads, so same-cwd evidence is
|
|
127
|
+
// almost always the same session, and the alternative (two records) would
|
|
128
|
+
// double-resume it.
|
|
129
|
+
if (record.sessionId && !s.sessionId
|
|
130
|
+
&& s.agent === record.agent && s.cwd && s.cwd === record.cwd
|
|
131
|
+
&& s.status === 'stopped'
|
|
132
|
+
&& Math.abs((s.detectedAt || 0) - record.detectedAt) < DEDUPE_WINDOW_MS) {
|
|
133
|
+
return key;
|
|
134
|
+
}
|
|
119
135
|
}
|
|
120
136
|
return null;
|
|
121
137
|
}
|
package/src/time-parser.js
CHANGED
|
@@ -9,6 +9,9 @@ const RELATIVE_TIME_REGEX = /(?:try again|wait|resets?\s+in)[:\s]\s*(?:for\s+)?(
|
|
|
9
9
|
// "resets Tuesday 3pm" / "resets on Mon" — weekly limits carry a day name.
|
|
10
10
|
const DAY_REGEX = /resets?\s+(?:on\s+)?(mon|tue|wed|thu|fri|sat|sun)[a-z]*/i;
|
|
11
11
|
const DAY_INDEX = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
|
|
12
|
+
// Month-date weekly form (transcript/API error text):
|
|
13
|
+
// "resets Jul 4 at 12:30am (Asia/Calcutta)"
|
|
14
|
+
const RESET_DATE_REGEX = /resets?\s+(?:on\s+)?(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+(\d{1,2})(?:st|nd|rd|th)?(?:,?\s+\d{4})?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(([^)]+)\))?/i;
|
|
12
15
|
|
|
13
16
|
// Codex CLI forms ("try again at …", local time):
|
|
14
17
|
// same day: "or try again at 3:51 PM."
|
|
@@ -50,6 +53,24 @@ export function parseResetTime(text) {
|
|
|
50
53
|
};
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
const resetDateMatch = text.match(RESET_DATE_REGEX);
|
|
57
|
+
if (resetDateMatch) {
|
|
58
|
+
const [, mon, dayOfMonth, hourRaw, minuteRaw, ampmRaw, timezone] = resetDateMatch;
|
|
59
|
+
const ampm = ampmRaw?.toLowerCase() || null;
|
|
60
|
+
let hour = parseInt(hourRaw, 10);
|
|
61
|
+
if (ampm === 'pm' && hour !== 12) hour += 12;
|
|
62
|
+
if (ampm === 'am' && hour === 12) hour = 0;
|
|
63
|
+
return {
|
|
64
|
+
month: MONTH_INDEX[mon.toLowerCase()],
|
|
65
|
+
dayOfMonth: parseInt(dayOfMonth, 10),
|
|
66
|
+
hour,
|
|
67
|
+
minute: minuteRaw ? parseInt(minuteRaw, 10) : 0,
|
|
68
|
+
timezone: timezone || null,
|
|
69
|
+
ambiguous: !ampm && hour >= 1 && hour <= 12,
|
|
70
|
+
day: null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
53
74
|
const dayMatch = text.match(DAY_REGEX);
|
|
54
75
|
const absMatch = text.match(RESET_TIME_REGEX);
|
|
55
76
|
if (absMatch) {
|
|
@@ -120,16 +141,7 @@ export function resetAtMs(parsed, { marginMs = 60_000, fallbackMs = 5 * 3_600_00
|
|
|
120
141
|
// UTC guess until it formats as the desired local h:m. Correction normalized
|
|
121
142
|
// to [-720, +720] minutes to take the minimum-magnitude step (avoids the
|
|
122
143
|
// off-by-a-day bug in high-offset timezones).
|
|
123
|
-
function
|
|
124
|
-
const parts = new Intl.DateTimeFormat('en-US', {
|
|
125
|
-
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour12: false,
|
|
126
|
-
}).formatToParts(now);
|
|
127
|
-
const y = parseInt(parts.find(p => p.type === 'year').value);
|
|
128
|
-
const mo = parseInt(parts.find(p => p.type === 'month').value);
|
|
129
|
-
const d = parseInt(parts.find(p => p.type === 'day').value);
|
|
130
|
-
|
|
131
|
-
const targetStr = `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}T${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00`;
|
|
132
|
-
let candidate = new Date(targetStr + 'Z').getTime();
|
|
144
|
+
function correctWallClock(candidate, h, m) {
|
|
133
145
|
for (let i = 0; i < 3; i++) {
|
|
134
146
|
const fp = new Intl.DateTimeFormat('en-US', {
|
|
135
147
|
timeZone: tz, hour: 'numeric', minute: 'numeric', hour12: false,
|
|
@@ -145,6 +157,18 @@ export function resetAtMs(parsed, { marginMs = 60_000, fallbackMs = 5 * 3_600_00
|
|
|
145
157
|
return candidate;
|
|
146
158
|
}
|
|
147
159
|
|
|
160
|
+
function targetTimestamp(h, m) {
|
|
161
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
162
|
+
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour12: false,
|
|
163
|
+
}).formatToParts(now);
|
|
164
|
+
const y = parseInt(parts.find(p => p.type === 'year').value);
|
|
165
|
+
const mo = parseInt(parts.find(p => p.type === 'month').value);
|
|
166
|
+
const d = parseInt(parts.find(p => p.type === 'day').value);
|
|
167
|
+
|
|
168
|
+
const targetStr = `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}T${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00`;
|
|
169
|
+
return correctWallClock(new Date(targetStr + 'Z').getTime(), h, m);
|
|
170
|
+
}
|
|
171
|
+
|
|
148
172
|
function nextOccurrence(h, m) {
|
|
149
173
|
let t = targetTimestamp(h, m);
|
|
150
174
|
if (t <= now.getTime()) t += 86_400_000;
|
|
@@ -160,6 +184,45 @@ export function resetAtMs(parsed, { marginMs = 60_000, fallbackMs = 5 * 3_600_00
|
|
|
160
184
|
return t;
|
|
161
185
|
}
|
|
162
186
|
|
|
187
|
+
// Month-date form ("resets Jul 4 at 12:30am (tz)"): roll forward from today
|
|
188
|
+
// in the target tz to the named month/day, re-correct the wall-clock (the
|
|
189
|
+
// walk may cross DST). An already-past date means we misread stale text —
|
|
190
|
+
// fall back rather than waiting toward next year.
|
|
191
|
+
if (parsed.month != null) {
|
|
192
|
+
const monthDayAt = t => {
|
|
193
|
+
const fp = new Intl.DateTimeFormat('en-US', { timeZone: tz, month: 'numeric', day: 'numeric' })
|
|
194
|
+
.formatToParts(new Date(t));
|
|
195
|
+
return [
|
|
196
|
+
parseInt(fp.find(p => p.type === 'month').value, 10) - 1,
|
|
197
|
+
parseInt(fp.find(p => p.type === 'day').value, 10),
|
|
198
|
+
];
|
|
199
|
+
};
|
|
200
|
+
const resolve = (h, m) => {
|
|
201
|
+
// Re-correct the wall clock on EVERY day step: a raw 24h jump across a
|
|
202
|
+
// DST transition drifts the local time, and a drifted probe can match
|
|
203
|
+
// the named date at the wrong instant (landing a day late after the
|
|
204
|
+
// final correction). The walk is capped just past the 10-day acceptance
|
|
205
|
+
// window below — longer walks are discarded anyway.
|
|
206
|
+
let t = targetTimestamp(h, m);
|
|
207
|
+
for (let i = 0; i <= 12; i++) {
|
|
208
|
+
const [mo, d] = monthDayAt(t);
|
|
209
|
+
if (mo === parsed.month && d === parsed.dayOfMonth) return t;
|
|
210
|
+
t = correctWallClock(t + 86_400_000, h, m);
|
|
211
|
+
}
|
|
212
|
+
return null; // beyond the weekly window (or an impossible date)
|
|
213
|
+
};
|
|
214
|
+
const candidates = parsed.ambiguous
|
|
215
|
+
? [resolve(parsed.hour, parsed.minute), resolve((parsed.hour + 12) % 24, parsed.minute)]
|
|
216
|
+
: [resolve(parsed.hour, parsed.minute)];
|
|
217
|
+
// The yearless form only ever names a date within the weekly window; a
|
|
218
|
+
// resolution further out means the date already passed and the walk landed
|
|
219
|
+
// on NEXT year's occurrence — that's a misread, not an 11-month wait.
|
|
220
|
+
const maxAhead = now.getTime() + 10 * 86_400_000;
|
|
221
|
+
const future = candidates.filter(t => t != null && t > now.getTime() && t <= maxAhead);
|
|
222
|
+
if (future.length === 0) return { at: now.getTime() + fallbackMs + marginMs, source: 'fallback' };
|
|
223
|
+
return { at: Math.min(...future) + marginMs, source };
|
|
224
|
+
}
|
|
225
|
+
|
|
163
226
|
let at;
|
|
164
227
|
if (parsed.ambiguous) {
|
|
165
228
|
const t1 = nextOccurrence(parsed.hour, parsed.minute);
|
package/src/watcher.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// Session-file watcher — the tmux-free detection channel. Tails the files the
|
|
2
|
+
// agent CLIs already write (Claude Code transcripts, Codex rollouts) for
|
|
3
|
+
// limit-stop evidence, so sessions running in GUI surfaces (VS Code extension,
|
|
4
|
+
// desktop apps) are tracked even though there is no pane to scrape and no
|
|
5
|
+
// shell wrapper in the launch path. Records land in the same ledger with
|
|
6
|
+
// pane-less records the resumer already reopens by session id.
|
|
7
|
+
//
|
|
8
|
+
// Offset semantics: on a cold start (no offsets file) every existing file
|
|
9
|
+
// starts at EOF — history is never replayed as fresh stops. Files that appear
|
|
10
|
+
// later are read from the start (a stop that happened while the daemon was
|
|
11
|
+
// down is still a real stop); the freshness window drops anything old.
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
readdirSync, statSync, readFileSync, writeFileSync, renameSync, mkdirSync,
|
|
15
|
+
openSync, readSync, closeSync, existsSync,
|
|
16
|
+
} from 'node:fs';
|
|
17
|
+
import { join, sep, basename, dirname } from 'node:path';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import {
|
|
20
|
+
CLAUDE_DIR, CODEX_DIR, WATCH_OFFSETS_FILE, WATCH_FRESHNESS_MS,
|
|
21
|
+
RESET_MARGIN_MS, FALLBACK_RESET_MS, TMUX_SESSION_NAME,
|
|
22
|
+
} from './config.js';
|
|
23
|
+
import { parseTranscriptLine } from './watchers/claude.js';
|
|
24
|
+
import { parseRolloutLine, rolloutMeta } from './watchers/codex.js';
|
|
25
|
+
import { ROLLOUT_RE } from './agents/codex.js';
|
|
26
|
+
import { parseResetTime, resetAtMs } from './time-parser.js';
|
|
27
|
+
import { upsertSession, readState, updateState } from './state.js';
|
|
28
|
+
import { getConfig } from './settings.js';
|
|
29
|
+
import { notify } from './notify.js';
|
|
30
|
+
import { makeLogger } from './logger.js';
|
|
31
|
+
|
|
32
|
+
const log = makeLogger('watcher');
|
|
33
|
+
|
|
34
|
+
const MAX_WALK_DEPTH = 8;
|
|
35
|
+
// A file first seen mid-run is read from the start, but never more than this —
|
|
36
|
+
// a giant transcript is history, and the freshness window drops it anyway.
|
|
37
|
+
const MAX_READ_BYTES = 10 * 1024 * 1024;
|
|
38
|
+
|
|
39
|
+
export function claudeSource({ roots }) {
|
|
40
|
+
return {
|
|
41
|
+
agent: 'claude',
|
|
42
|
+
roots,
|
|
43
|
+
enabled: () => getConfig('agents.claude'),
|
|
44
|
+
// Subagent transcripts live under <session>/subagents/ — their limit
|
|
45
|
+
// entries duplicate the parent session's own entry.
|
|
46
|
+
match: p => p.endsWith('.jsonl') && !p.split(sep).includes('subagents'),
|
|
47
|
+
parse(lines) {
|
|
48
|
+
return lines
|
|
49
|
+
.map(parseTranscriptLine)
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
.map(rec => ({
|
|
52
|
+
agent: 'claude',
|
|
53
|
+
sessionId: rec.sessionId,
|
|
54
|
+
cwd: rec.cwd,
|
|
55
|
+
limitType: rec.limitType,
|
|
56
|
+
resetLine: rec.resetLine,
|
|
57
|
+
resetAt: null,
|
|
58
|
+
origin: rec.entrypoint,
|
|
59
|
+
timestampMs: rec.timestampMs,
|
|
60
|
+
}));
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function codexSource({ roots }) {
|
|
66
|
+
return {
|
|
67
|
+
agent: 'codex',
|
|
68
|
+
roots,
|
|
69
|
+
enabled: () => getConfig('agents.codex'),
|
|
70
|
+
match: p => ROLLOUT_RE.test(basename(p)),
|
|
71
|
+
parse(lines, path) {
|
|
72
|
+
const hits = lines.map(parseRolloutLine).filter(Boolean);
|
|
73
|
+
if (hits.length === 0) return [];
|
|
74
|
+
const last = hits[hits.length - 1]; // the latest snapshot governs
|
|
75
|
+
const meta = rolloutMeta(path);
|
|
76
|
+
return [{
|
|
77
|
+
agent: 'codex',
|
|
78
|
+
sessionId: meta.sessionId,
|
|
79
|
+
cwd: meta.cwd,
|
|
80
|
+
limitType: last.limitType,
|
|
81
|
+
resetLine: null,
|
|
82
|
+
resetAt: last.resetAt,
|
|
83
|
+
origin: meta.originator,
|
|
84
|
+
timestampMs: last.timestampMs,
|
|
85
|
+
}];
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Claude desktop (cowork) sessions are sandboxed: each runs against an
|
|
91
|
+
// isolated CLAUDE_CONFIG_DIR at <session>/local_<id>/.claude, so the global
|
|
92
|
+
// hook never fires and the transcripts live outside ~/.claude. Reviving one
|
|
93
|
+
// needs that same CLAUDE_CONFIG_DIR exported — carried on the record as env —
|
|
94
|
+
// plus CLAUDE_SECURESTORAGE_CONFIG_DIR='' so auth stays on the DEFAULT
|
|
95
|
+
// keychain entry: the service name is otherwise derived from the config dir,
|
|
96
|
+
// and the sandbox holds no credentials (verified against a real cowork
|
|
97
|
+
// session: with the pair set, `claude -p --resume <id>` answers; without the
|
|
98
|
+
// override it dies with "Not logged in").
|
|
99
|
+
// Experimental: desktop worktree/VM sessions may still differ.
|
|
100
|
+
export function claudeDesktopSource({ roots }) {
|
|
101
|
+
const base = claudeSource({ roots });
|
|
102
|
+
return {
|
|
103
|
+
...base,
|
|
104
|
+
parse(lines, path) {
|
|
105
|
+
const configDir = configDirFromPath(path);
|
|
106
|
+
return base.parse(lines, path).map(rec => ({
|
|
107
|
+
...rec,
|
|
108
|
+
origin: 'desktop',
|
|
109
|
+
env: configDir
|
|
110
|
+
? { CLAUDE_CONFIG_DIR: configDir, CLAUDE_SECURESTORAGE_CONFIG_DIR: '' }
|
|
111
|
+
: undefined,
|
|
112
|
+
}));
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function configDirFromPath(path) {
|
|
118
|
+
const marker = `${sep}.claude${sep}`;
|
|
119
|
+
const idx = path.indexOf(marker);
|
|
120
|
+
return idx === -1 ? null : path.slice(0, idx + marker.length - 1);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function defaultSources() {
|
|
124
|
+
const sources = [
|
|
125
|
+
claudeSource({ roots: [join(CLAUDE_DIR, 'projects')] }),
|
|
126
|
+
codexSource({ roots: [join(CODEX_DIR, 'sessions')] }),
|
|
127
|
+
];
|
|
128
|
+
if (process.platform === 'darwin') {
|
|
129
|
+
sources.push(claudeDesktopSource({
|
|
130
|
+
roots: [process.env.UNSNOOZE_CLAUDE_DESKTOP_DIR
|
|
131
|
+
|| join(homedir(), 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions')],
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
return sources;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Turn a watcher candidate into a ledger record. No pane key at all — a merge
|
|
138
|
+
// into an existing scrape/hook record must never clobber a live pane id.
|
|
139
|
+
//
|
|
140
|
+
// The same stop is re-emitted whenever the CLI appends another limit line (a
|
|
141
|
+
// GUI auto-retry, or the revived session hitting the still-active limit), so
|
|
142
|
+
// an existing record's lifecycle must be respected: an active record only
|
|
143
|
+
// gets its reset time refreshed — never its status/attempts reset, or the
|
|
144
|
+
// MAX_RESUME_ATTEMPTS cap could never bind — and a cancelled record stays
|
|
145
|
+
// cancelled.
|
|
146
|
+
export function dispatchCandidate(c) {
|
|
147
|
+
const detectedAt = c.timestampMs || Date.now();
|
|
148
|
+
let at, source;
|
|
149
|
+
if (c.resetAt) {
|
|
150
|
+
at = c.resetAt + RESET_MARGIN_MS;
|
|
151
|
+
source = 'absolute';
|
|
152
|
+
} else {
|
|
153
|
+
({ at, source } = resetAtMs(parseResetTime(c.resetLine), {
|
|
154
|
+
marginMs: RESET_MARGIN_MS, fallbackMs: FALLBACK_RESET_MS,
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const existing = c.sessionId
|
|
159
|
+
? Object.values(readState().sessions).find(s => s.sessionId === c.sessionId)
|
|
160
|
+
: null;
|
|
161
|
+
if (existing && existing.status === 'cancelled') return;
|
|
162
|
+
if (existing && (existing.status === 'stopped' || existing.status === 'resuming')) {
|
|
163
|
+
updateState(state => {
|
|
164
|
+
const s = state.sessions[existing.key];
|
|
165
|
+
if (s && (s.status === 'stopped' || s.status === 'resuming')) {
|
|
166
|
+
s.resetAt = at;
|
|
167
|
+
s.resetSource = source;
|
|
168
|
+
if (c.limitType && c.limitType !== 'unknown') s.limitType = c.limitType;
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
log(`refreshed reset for tracked stop: session=${c.sessionId} resetAt=${new Date(at).toISOString()}`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const record = {
|
|
176
|
+
sessionId: c.sessionId || null,
|
|
177
|
+
cwd: c.cwd || null,
|
|
178
|
+
agent: c.agent,
|
|
179
|
+
origin: c.origin || null,
|
|
180
|
+
tmuxSession: TMUX_SESSION_NAME,
|
|
181
|
+
status: 'stopped',
|
|
182
|
+
limitType: c.limitType || 'unknown',
|
|
183
|
+
detectedVia: 'transcript',
|
|
184
|
+
detectedAt,
|
|
185
|
+
resetAt: at,
|
|
186
|
+
resetSource: source,
|
|
187
|
+
attempts: 0,
|
|
188
|
+
lastAttemptAt: null,
|
|
189
|
+
lastError: null,
|
|
190
|
+
};
|
|
191
|
+
if (c.env) record.env = c.env; // e.g. CLAUDE_CONFIG_DIR for sandboxed desktop sessions
|
|
192
|
+
upsertSession(record);
|
|
193
|
+
log(`limit stop via transcript: agent=${c.agent} session=${c.sessionId || '?'} origin=${c.origin || '?'} resetAt=${new Date(at).toISOString()} (${source})`);
|
|
194
|
+
notify('limit hit 😴', `${c.cwd || c.agent}: tracked — resumes when the limit resets`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function loadOffsets(path) {
|
|
198
|
+
try {
|
|
199
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
200
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function readRange(path, from, to) {
|
|
207
|
+
let fd;
|
|
208
|
+
try {
|
|
209
|
+
fd = openSync(path, 'r');
|
|
210
|
+
const len = Math.min(to - from, MAX_READ_BYTES);
|
|
211
|
+
const buf = Buffer.alloc(len);
|
|
212
|
+
let read = 0;
|
|
213
|
+
while (read < len) {
|
|
214
|
+
const n = readSync(fd, buf, read, len - read, from + read);
|
|
215
|
+
if (n <= 0) break;
|
|
216
|
+
read += n;
|
|
217
|
+
}
|
|
218
|
+
return buf.subarray(0, read);
|
|
219
|
+
} catch {
|
|
220
|
+
return Buffer.alloc(0);
|
|
221
|
+
} finally {
|
|
222
|
+
if (fd !== undefined) closeSync(fd);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function createWatcher({
|
|
227
|
+
sources = defaultSources(),
|
|
228
|
+
offsetsPath = WATCH_OFFSETS_FILE,
|
|
229
|
+
freshnessMs = WATCH_FRESHNESS_MS,
|
|
230
|
+
onStop = dispatchCandidate,
|
|
231
|
+
now = Date.now,
|
|
232
|
+
} = {}) {
|
|
233
|
+
let offsets = loadOffsets(offsetsPath);
|
|
234
|
+
let coldStart = offsets === null;
|
|
235
|
+
if (coldStart) offsets = {};
|
|
236
|
+
let dirty = coldStart;
|
|
237
|
+
|
|
238
|
+
function setOffset(path, value) {
|
|
239
|
+
if (offsets[path] !== value) { offsets[path] = value; dirty = true; }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function saveOffsets(seen) {
|
|
243
|
+
// Unseen ≠ gone: a source may be disabled this tick or a root transiently
|
|
244
|
+
// unreadable — wiping those offsets would replay history on re-enable.
|
|
245
|
+
// Only forget files that no longer exist.
|
|
246
|
+
for (const key of Object.keys(offsets)) {
|
|
247
|
+
if (!seen.has(key) && !existsSync(key)) { delete offsets[key]; dirty = true; }
|
|
248
|
+
}
|
|
249
|
+
if (!dirty) return;
|
|
250
|
+
try {
|
|
251
|
+
mkdirSync(dirname(offsetsPath), { recursive: true });
|
|
252
|
+
const tmp = join(dirname(offsetsPath), `.offsets.tmp.${process.pid}`);
|
|
253
|
+
writeFileSync(tmp, JSON.stringify(offsets));
|
|
254
|
+
renameSync(tmp, offsetsPath);
|
|
255
|
+
dirty = false;
|
|
256
|
+
} catch (err) {
|
|
257
|
+
log(`offsets save failed: ${err.message}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Appended complete lines since the last tick, advancing the stored offset.
|
|
262
|
+
// All offset math in bytes (a \n byte can't occur inside a multibyte char).
|
|
263
|
+
function readAppended(path) {
|
|
264
|
+
let size;
|
|
265
|
+
try { size = statSync(path).size; } catch { return null; }
|
|
266
|
+
const known = Object.prototype.hasOwnProperty.call(offsets, path);
|
|
267
|
+
let offset;
|
|
268
|
+
if (!known) {
|
|
269
|
+
if (coldStart) { setOffset(path, size); return null; }
|
|
270
|
+
offset = Math.max(0, size - MAX_READ_BYTES);
|
|
271
|
+
} else {
|
|
272
|
+
offset = offsets[path];
|
|
273
|
+
}
|
|
274
|
+
// Nothing new, or rewritten shorter (rotate/truncate) — pin to EOF.
|
|
275
|
+
if (size <= offset) { setOffset(path, size); return null; }
|
|
276
|
+
const buf = readRange(path, offset, size);
|
|
277
|
+
const lastNl = buf.lastIndexOf(0x0a);
|
|
278
|
+
if (lastNl === -1) { setOffset(path, offset); return null; } // partial line — wait
|
|
279
|
+
setOffset(path, offset + lastNl + 1);
|
|
280
|
+
return buf.subarray(0, lastNl + 1).toString('utf-8').split('\n').filter(l => l.trim());
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function walk(dir, depth, cb) {
|
|
284
|
+
let entries;
|
|
285
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
286
|
+
for (const e of entries) {
|
|
287
|
+
const p = join(dir, e.name);
|
|
288
|
+
if (e.isDirectory()) {
|
|
289
|
+
if (depth < MAX_WALK_DEPTH) walk(p, depth + 1, cb);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (e.isFile()) cb(p);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
async tick() {
|
|
298
|
+
const seen = new Set();
|
|
299
|
+
const candidates = [];
|
|
300
|
+
for (const source of sources) {
|
|
301
|
+
if (source.enabled && !source.enabled()) continue;
|
|
302
|
+
for (const root of source.roots) {
|
|
303
|
+
walk(root, 0, path => {
|
|
304
|
+
if (!source.match(path)) return;
|
|
305
|
+
seen.add(path);
|
|
306
|
+
const lines = readAppended(path);
|
|
307
|
+
if (!lines || lines.length === 0) return;
|
|
308
|
+
try {
|
|
309
|
+
candidates.push(...source.parse(lines, path));
|
|
310
|
+
} catch (err) {
|
|
311
|
+
log(`parse error in ${path}: ${err.message}`);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
coldStart = false;
|
|
317
|
+
saveOffsets(seen);
|
|
318
|
+
// Strict freshness: a candidate without a parseable timestamp cannot be
|
|
319
|
+
// dated, and dispatching it risks reviving a long-finished session (the
|
|
320
|
+
// offsets can legitimately replay old lines after a file reappears).
|
|
321
|
+
// Every real transcript/rollout line carries an ISO timestamp.
|
|
322
|
+
const fresh = [];
|
|
323
|
+
for (const c of candidates) {
|
|
324
|
+
if (Number.isFinite(c.timestampMs) && now() - c.timestampMs <= freshnessMs) fresh.push(c);
|
|
325
|
+
else log(`dropped stale/undatable candidate: agent=${c.agent} session=${c.sessionId || '?'} ts=${c.timestampMs}`);
|
|
326
|
+
}
|
|
327
|
+
for (const c of fresh) {
|
|
328
|
+
try { onStop(c); } catch (err) { log(`onStop failed: ${err.message}`); }
|
|
329
|
+
}
|
|
330
|
+
return fresh.length;
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Claude Code transcript watcher: parses lines appended to session transcript
|
|
2
|
+
// files (~/.claude/projects/<dashed-cwd>/<session-id>.jsonl) into limit-stop
|
|
3
|
+
// candidates. This is the detection channel for GUI surfaces (VS Code
|
|
4
|
+
// extension, desktop app) where no tmux pane exists and hooks may not fire —
|
|
5
|
+
// a rate-limit stop lands in the transcript as a structured API-error entry:
|
|
6
|
+
// { "error":"rate_limit", "isApiErrorMessage":true, "apiErrorStatus":429,
|
|
7
|
+
// "entrypoint":"cli", "cwd":..., "sessionId":...,
|
|
8
|
+
// "message":{"content":[{"type":"text","text":"You've hit your session
|
|
9
|
+
// limit · resets 6:40pm (Asia/Calcutta)"}]} }
|
|
10
|
+
|
|
11
|
+
import { detectLimit } from '../patterns.js';
|
|
12
|
+
import { patterns } from '../agents/claude.js';
|
|
13
|
+
import { PANE_SCAN_LINES } from '../config.js';
|
|
14
|
+
|
|
15
|
+
// One transcript JSONL line → limit-stop candidate or null. Sidechain
|
|
16
|
+
// (subagent) entries are skipped — the resume target is the parent session,
|
|
17
|
+
// whose own entry carries the same error.
|
|
18
|
+
export function parseTranscriptLine(line) {
|
|
19
|
+
if (!line || !line.trim()) return null;
|
|
20
|
+
let entry;
|
|
21
|
+
try { entry = JSON.parse(line); } catch { return null; }
|
|
22
|
+
if (!entry || entry.isApiErrorMessage !== true) return null;
|
|
23
|
+
if (entry.error !== 'rate_limit') return null;
|
|
24
|
+
if (entry.isSidechain) return null;
|
|
25
|
+
|
|
26
|
+
const content = entry.message?.content;
|
|
27
|
+
const text = (Array.isArray(content) && content.find(c => c?.type === 'text')?.text) || '';
|
|
28
|
+
const d = detectLimit(text, PANE_SCAN_LINES, patterns);
|
|
29
|
+
const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
sessionId: entry.sessionId || null,
|
|
33
|
+
cwd: entry.cwd || null,
|
|
34
|
+
entrypoint: entry.entrypoint || null,
|
|
35
|
+
limitType: d.hit ? d.limitType : 'unknown',
|
|
36
|
+
resetLine: d.hit ? d.resetLine : null,
|
|
37
|
+
timestampMs: Number.isFinite(ts) ? ts : null,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Codex rollout watcher: parses lines appended to session rollout files
|
|
2
|
+
// (~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl) into limit-stop candidates.
|
|
3
|
+
//
|
|
4
|
+
// Codex never persists Error/StreamError events to rollouts (confirmed in
|
|
5
|
+
// codex-rs/rollout policy), so the limit banner text is NOT in the file. What
|
|
6
|
+
// IS persisted is a token_count event per turn carrying a rate_limits
|
|
7
|
+
// snapshot — used_percent per window plus an exact resets_at epoch:
|
|
8
|
+
// {"type":"event_msg","payload":{"type":"token_count","rate_limits":{
|
|
9
|
+
// "primary":{"used_percent":100,"window_minutes":300,"resets_at":1778672230},
|
|
10
|
+
// "secondary":{"used_percent":1,"window_minutes":10080,"resets_at":...},
|
|
11
|
+
// "rate_limit_reached_type":null}}}
|
|
12
|
+
// That epoch is more precise than any scraped banner, and rollouts are shared
|
|
13
|
+
// by every Codex surface (CLI, IDE extension, desktop app).
|
|
14
|
+
|
|
15
|
+
import { openSync, readSync, closeSync } from 'node:fs';
|
|
16
|
+
import { basename } from 'node:path';
|
|
17
|
+
import { ROLLOUT_RE } from '../agents/codex.js';
|
|
18
|
+
|
|
19
|
+
const WEEK_MINUTES = 7 * 1440;
|
|
20
|
+
|
|
21
|
+
function windowLimitType(windowMinutes) {
|
|
22
|
+
if (!Number.isFinite(windowMinutes)) return 'unknown';
|
|
23
|
+
return windowMinutes >= WEEK_MINUTES ? 'weekly' : '5h';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// One rollout JSONL line → limit-stop candidate or null. A window binds when
|
|
27
|
+
// its used_percent hits 100 (or rate_limit_reached_type says the model was
|
|
28
|
+
// actually blocked); with several exhausted windows the LATEST reset governs —
|
|
29
|
+
// resuming at the earlier one would immediately re-hit the other limit.
|
|
30
|
+
export function parseRolloutLine(line) {
|
|
31
|
+
if (!line || !line.trim()) return null;
|
|
32
|
+
let entry;
|
|
33
|
+
try { entry = JSON.parse(line); } catch { return null; }
|
|
34
|
+
if (entry?.type !== 'event_msg' || entry.payload?.type !== 'token_count') return null;
|
|
35
|
+
const rl = entry.payload.rate_limits;
|
|
36
|
+
if (!rl || typeof rl !== 'object') return null;
|
|
37
|
+
|
|
38
|
+
const windows = ['primary', 'secondary']
|
|
39
|
+
.map(k => rl[k])
|
|
40
|
+
.filter(w => w && typeof w === 'object');
|
|
41
|
+
let binding = null;
|
|
42
|
+
const exhausted = windows.filter(w => (w.used_percent ?? 0) >= 100);
|
|
43
|
+
if (exhausted.length > 0) {
|
|
44
|
+
binding = exhausted.reduce((a, b) => ((b.resets_at || 0) > (a.resets_at || 0) ? b : a));
|
|
45
|
+
} else if (rl.rate_limit_reached_type) {
|
|
46
|
+
const named = rl[rl.rate_limit_reached_type];
|
|
47
|
+
binding = (named && typeof named === 'object')
|
|
48
|
+
? named
|
|
49
|
+
: windows.reduce((a, b) => ((b.resets_at || 0) > (a?.resets_at || 0) ? b : a), null);
|
|
50
|
+
}
|
|
51
|
+
if (!binding) return null;
|
|
52
|
+
|
|
53
|
+
const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
|
|
54
|
+
return {
|
|
55
|
+
limitType: windowLimitType(binding.window_minutes),
|
|
56
|
+
resetAt: binding.resets_at ? binding.resets_at * 1000 : null,
|
|
57
|
+
reachedType: rl.rate_limit_reached_type || null,
|
|
58
|
+
timestampMs: Number.isFinite(ts) ? ts : null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The session_meta head line can be very long (it embeds the full base
|
|
63
|
+
// instructions) — read in chunks until the first newline.
|
|
64
|
+
function readFirstLine(path, maxBytes = 256 * 1024) {
|
|
65
|
+
let fd;
|
|
66
|
+
try {
|
|
67
|
+
fd = openSync(path, 'r');
|
|
68
|
+
const chunk = Buffer.alloc(16 * 1024);
|
|
69
|
+
let head = '';
|
|
70
|
+
let pos = 0;
|
|
71
|
+
while (pos < maxBytes) {
|
|
72
|
+
const n = readSync(fd, chunk, 0, chunk.length, pos);
|
|
73
|
+
if (n <= 0) break;
|
|
74
|
+
head += chunk.toString('utf-8', 0, n);
|
|
75
|
+
pos += n;
|
|
76
|
+
const nl = head.indexOf('\n');
|
|
77
|
+
if (nl !== -1) return head.slice(0, nl);
|
|
78
|
+
}
|
|
79
|
+
return head;
|
|
80
|
+
} catch {
|
|
81
|
+
return '';
|
|
82
|
+
} finally {
|
|
83
|
+
if (fd !== undefined) closeSync(fd);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Session identity for a rollout file: the session_meta head line when
|
|
88
|
+
// parseable, else the uuid embedded in the filename.
|
|
89
|
+
export function rolloutMeta(path) {
|
|
90
|
+
let sessionId = null;
|
|
91
|
+
let cwd = null;
|
|
92
|
+
let originator = null;
|
|
93
|
+
try {
|
|
94
|
+
const meta = JSON.parse(readFirstLine(path));
|
|
95
|
+
if (meta?.type === 'session_meta') {
|
|
96
|
+
sessionId = meta.payload?.id || null;
|
|
97
|
+
cwd = meta.payload?.cwd || null;
|
|
98
|
+
originator = meta.payload?.originator || null;
|
|
99
|
+
}
|
|
100
|
+
} catch { /* unreadable head — fall back to the filename */ }
|
|
101
|
+
if (!sessionId) {
|
|
102
|
+
const m = basename(path).match(ROLLOUT_RE);
|
|
103
|
+
if (m) sessionId = m[1];
|
|
104
|
+
}
|
|
105
|
+
return { sessionId, cwd, originator };
|
|
106
|
+
}
|
package/src/wizard.js
CHANGED
|
@@ -69,6 +69,20 @@ export async function runWizard() {
|
|
|
69
69
|
});
|
|
70
70
|
if (p.isCancel(notifications)) return cancelled();
|
|
71
71
|
|
|
72
|
+
// GUI watching: only meaningful where an autostart daemon can run.
|
|
73
|
+
let guiWatch = false;
|
|
74
|
+
if (process.platform === 'darwin' || process.platform === 'linux') {
|
|
75
|
+
const answer = await p.confirm({
|
|
76
|
+
message: 'Also guard GUI sessions (Claude Code in VS Code/desktop, Codex app/IDE)?\n'
|
|
77
|
+
+ ' Installs a small background daemon (launchd/systemd) that watches session\n'
|
|
78
|
+
+ ' files for limit stops; revived sessions open in tmux and stay visible in\n'
|
|
79
|
+
+ ' the GUI\'s own history.',
|
|
80
|
+
initialValue: DEFAULTS.guiWatch,
|
|
81
|
+
});
|
|
82
|
+
if (p.isCancel(answer)) return cancelled();
|
|
83
|
+
guiWatch = answer;
|
|
84
|
+
}
|
|
85
|
+
|
|
72
86
|
// Seed message prompts from the existing config so a setup re-run shows —
|
|
73
87
|
// and keeps — values previously set here or via `unsnooze config`.
|
|
74
88
|
const existing = readFileConfig();
|
|
@@ -116,7 +130,7 @@ export async function runWizard() {
|
|
|
116
130
|
// doesn't ask about (e.g. per-agent messages set via `unsnooze config`).
|
|
117
131
|
writeConfig({
|
|
118
132
|
...existing,
|
|
119
|
-
autoResume, menuAutoAnswer, notifications, resumeMessage,
|
|
133
|
+
autoResume, menuAutoAnswer, notifications, guiWatch, resumeMessage,
|
|
120
134
|
resumeMessages: { ...existingMsgs, ...resumeMessages },
|
|
121
135
|
agents: Object.fromEntries(listAgents().map(a => [a.id, agents.includes(a.id)])),
|
|
122
136
|
});
|
|
@@ -124,7 +138,7 @@ export async function runWizard() {
|
|
|
124
138
|
const s = p.spinner();
|
|
125
139
|
s.start('Installing shell wrappers and hooks');
|
|
126
140
|
const { cmdInstall } = await import('./install.js');
|
|
127
|
-
const code = cmdInstall(['--yes']);
|
|
141
|
+
const code = cmdInstall(guiWatch ? ['--yes', '--daemon'] : ['--yes']);
|
|
128
142
|
s.stop(code === 0 ? 'Wrappers and hooks installed' : 'Install hit a problem — see output above');
|
|
129
143
|
|
|
130
144
|
p.outro(code === 0
|