claude-rpc 0.7.3 → 0.8.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/README.md +4 -0
- package/SECURITY.md +169 -0
- package/package.json +3 -2
- package/src/daemon.js +54 -0
- package/src/default-config.js +7 -1
- package/src/doctor.js +21 -10
- package/src/format.js +20 -12
- package/src/version.js +1 -1
package/README.md
CHANGED
|
@@ -43,6 +43,8 @@ claude-rpc start
|
|
|
43
43
|
|
|
44
44
|
That's the whole pitch. Open Claude Code in any project — the daemon picks it up within a second. Something looks wrong? `claude-rpc doctor`.
|
|
45
45
|
|
|
46
|
+
> `setup` registers a Windows startup entry and wires hooks into Claude Code's `settings.json`, and the daemon reports anonymous totals by default. All of it is reversible (`claude-rpc uninstall`, `community off`) and fully documented in [`SECURITY.md`](SECURITY.md) — read it first if you want to know exactly what runs.
|
|
47
|
+
|
|
46
48
|
The Discord *desktop* app must be running. The browser client doesn't expose the local IPC bridge that Rich Presence uses.
|
|
47
49
|
|
|
48
50
|
<details>
|
|
@@ -142,6 +144,8 @@ claude-rpc community report # one-shot manual flush (testing)
|
|
|
142
144
|
|
|
143
145
|
Each report sends only: a `sessionsDelta`, a `tokensDelta`, the claude-rpc version, OS family (`linux`/`darwin`/`win32`), and the anonymous UUID v4. No prompts, paths, models, repos, costs, usernames, or hostnames — the Worker's [`validateReport`](worker/src/index.js) is the schema of record. The full Worker source is in this repo so the privacy claim is auditable.
|
|
144
146
|
|
|
147
|
+
For a complete account of the sensitive things claude-rpc does — startup persistence, hook injection, every outbound request, and the exact telemetry payload — see [`SECURITY.md`](SECURITY.md). It's also the reference for supply-chain scanner findings (Socket.dev et al.): the flagged persistence and hook-injection behaviors are inherent to the tool and documented there.
|
|
148
|
+
|
|
145
149
|
## three pieces, glued by json files
|
|
146
150
|
|
|
147
151
|
```
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Security & behavior disclosure
|
|
2
|
+
|
|
3
|
+
claude-rpc is a Discord Rich Presence daemon for Claude Code. To do its job it
|
|
4
|
+
has to (a) register itself to start on login and (b) wire commands into Claude
|
|
5
|
+
Code's hook system. Both are legitimate, documented behaviors — but they are
|
|
6
|
+
also patterns that automated supply-chain scanners (e.g. Socket.dev) flag,
|
|
7
|
+
because malware persists and injects the same way. This document is the
|
|
8
|
+
audit trail: every sensitive thing the package does, where in the source it
|
|
9
|
+
lives, why it's there, its blast radius, and how to reverse or disable it.
|
|
10
|
+
|
|
11
|
+
Everything below is verifiable against the published source — there is no
|
|
12
|
+
minified bundle, no obfuscation, no `eval`/`new Function`, and no remote code
|
|
13
|
+
fetch-and-execute anywhere in `src/`.
|
|
14
|
+
|
|
15
|
+
## TL;DR for reviewers
|
|
16
|
+
|
|
17
|
+
| Behavior | Where | Scope | Reversible? |
|
|
18
|
+
| --- | --- | --- | --- |
|
|
19
|
+
| Startup persistence | `src/install.js` → `addStartupEntry` | `HKCU` Run key, current user, no admin | Yes — `claude-rpc uninstall` / `removeStartupEntry` |
|
|
20
|
+
| Hook injection | `src/install.js` → `installHooks` | Only into Claude Code's own `settings.json`, only our own commands | Yes — `uninstallHooks` removes exactly what it added |
|
|
21
|
+
| Outbound network | `src/community.js`, `src/gist.js`, `default-config.js` asset URLs | Anonymous counters + (opt-in) gist publish + GIF assets | Telemetry: `community off`. Gist: only on explicit `badge --gist`. |
|
|
22
|
+
| Local subprocess | `reg.exe`, `git`, `gh` | Static args, no shell interpolation of untrusted input | n/a |
|
|
23
|
+
|
|
24
|
+
No credential access, no filesystem scanning outside `~/.claude-rpc` and Claude
|
|
25
|
+
Code transcripts, no keylogging, no clipboard access, no AV/EDR evasion.
|
|
26
|
+
|
|
27
|
+
## 1. Startup persistence (Windows Run key)
|
|
28
|
+
|
|
29
|
+
**Source:** `src/install.js`, `addStartupEntry` / `removeStartupEntry`.
|
|
30
|
+
|
|
31
|
+
On Windows, `setup` writes one value:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
HKCU\Software\Microsoft\Windows\CurrentVersion\Run
|
|
35
|
+
ClaudeRPC = "<path-to-exe>" daemon
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- **Why:** the Discord presence is driven by a long-lived daemon. If it doesn't
|
|
39
|
+
restart on login, your presence silently stops working after every reboot.
|
|
40
|
+
- **Scope:** `HKCU` (current user) only. No admin elevation, no `HKLM`, no
|
|
41
|
+
service install, no scheduled task.
|
|
42
|
+
- **Reverse it:** `claude-rpc uninstall` deletes the value. You can also delete
|
|
43
|
+
it by hand in `regedit` or with
|
|
44
|
+
`reg delete "HKCU\...\Run" /v ClaudeRPC /f`.
|
|
45
|
+
- The value points at the canonical install path (`%LOCALAPPDATA%`-class dir),
|
|
46
|
+
not at wherever you happened to run the installer from — see
|
|
47
|
+
`ensureCanonicalExe`.
|
|
48
|
+
|
|
49
|
+
Non-Windows platforms get **no** persistence registration (`install()` warns
|
|
50
|
+
and skips it).
|
|
51
|
+
|
|
52
|
+
## 2. Hook injection into Claude Code
|
|
53
|
+
|
|
54
|
+
**Source:** `src/install.js`, `installHooks` / `uninstallHooks`.
|
|
55
|
+
|
|
56
|
+
`setup` adds command hooks to Claude Code's `settings.json` for eight lifecycle
|
|
57
|
+
events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`,
|
|
58
|
+
`Stop`, `SubagentStop`, `Notification`, `SessionEnd`. Each entry looks like:
|
|
59
|
+
|
|
60
|
+
```jsonc
|
|
61
|
+
{ "matcher": "", "hooks": [{ "type": "command", "command": "\"<exe>\" hook PostToolUse" }] }
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- **Why:** this *is* the integration. Claude Code's hook system is the
|
|
65
|
+
supported, documented way for a tool to observe session lifecycle. The hook
|
|
66
|
+
reads the event JSON on stdin, updates `~/.claude-rpc/state.json`, and prints
|
|
67
|
+
`{"continue":true}`. It never blocks, rewrites, or vetoes a tool call — see
|
|
68
|
+
`src/hook.js`, `processHookEvent`.
|
|
69
|
+
- **What the hook reads:** tool name, file path of the active tool, token usage
|
|
70
|
+
counters, and `git push`/`git commit` detection (for the "just shipped"
|
|
71
|
+
card). It writes only to local state/log files. It does not read file
|
|
72
|
+
*contents*, prompts, or responses beyond the usage counters Claude provides.
|
|
73
|
+
- **Scope:** the installer only ever touches entries whose command matches
|
|
74
|
+
`isOurHookCommand` (contains `claude-rpc` or `hook.js`). It will not modify,
|
|
75
|
+
reorder, or delete anyone else's hooks.
|
|
76
|
+
- **Reverse it:** `claude-rpc uninstall` (or `uninstallHooks`) strips exactly
|
|
77
|
+
the entries it added and leaves the rest of `settings.json` intact.
|
|
78
|
+
|
|
79
|
+
## 3. Outbound network
|
|
80
|
+
|
|
81
|
+
There are three distinct network behaviors. Two are optional; one is cosmetic.
|
|
82
|
+
|
|
83
|
+
### 3a. Community totals (telemetry) — ON by default for fresh installs
|
|
84
|
+
|
|
85
|
+
**Source:** `src/community.js`; endpoint in `src/default-config.js`
|
|
86
|
+
(`community.endpoint`); receiving end is the full Worker source in
|
|
87
|
+
[`worker/src/index.js`](worker/src/index.js).
|
|
88
|
+
|
|
89
|
+
A fresh install mints an anonymous UUID v4 and the daemon POSTs to
|
|
90
|
+
`https://claude-rpc-totals.claude-rpc.workers.dev/report` every 30 minutes.
|
|
91
|
+
The **complete** payload (see `buildPayload`, enforced by the Worker's
|
|
92
|
+
`validateReport`) is:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"instanceId": "<random UUID v4>",
|
|
97
|
+
"sessionsDelta": 3,
|
|
98
|
+
"tokensDelta": 142000,
|
|
99
|
+
"version": "0.7.3",
|
|
100
|
+
"osFamily": "win32",
|
|
101
|
+
"ts": 1716500000000
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
What is **not** sent, ever: prompts, responses, file paths, file contents,
|
|
106
|
+
project/repo names, models, cost figures, usernames, hostnames, IP (beyond what
|
|
107
|
+
any HTTP request inherently exposes to Cloudflare's edge), or absolute counter
|
|
108
|
+
values — only forward deltas since the last accepted report.
|
|
109
|
+
|
|
110
|
+
- **Opt out any time:** `claude-rpc community off`.
|
|
111
|
+
- **Upgraders are protected:** anyone upgrading from a pre-v0.7 config is
|
|
112
|
+
written `community.enabled: false`; re-enabling requires the explicit consent
|
|
113
|
+
flow `claude-rpc community on`, which prints the payload schema first.
|
|
114
|
+
- **Why on by default:** the live badges in the README aggregate these counters.
|
|
115
|
+
The trade-off is disclosed here, in the README, and at install time.
|
|
116
|
+
- **Auditable:** the Worker persists only two running integers and a 30-day
|
|
117
|
+
`seen:<instanceId>` dedup marker. The source is in this repo.
|
|
118
|
+
|
|
119
|
+
### 3b. Gist badge publishing — only on explicit command
|
|
120
|
+
|
|
121
|
+
**Source:** `src/gist.js`. Runs **only** when you run `claude-rpc badge --gist`.
|
|
122
|
+
Publishes a badge SVG to *your own* GitHub gist via the `gh` CLI or a
|
|
123
|
+
`GH_TOKEN` you supply with `gist` scope. Hits `api.github.com` /
|
|
124
|
+
`gist.github.com`. Never runs unattended, never on install, never from the
|
|
125
|
+
daemon.
|
|
126
|
+
|
|
127
|
+
### 3c. Presence GIF assets — Discord-side only
|
|
128
|
+
|
|
129
|
+
`default-config.js` references `https://cdn.qualit.ly/clawd-*.gif`. These URLs
|
|
130
|
+
are handed to Discord as image keys; **Discord's** client fetches them to render
|
|
131
|
+
the card. The daemon itself doesn't download them. Swap them for your own URLs
|
|
132
|
+
in `config.json` if you prefer.
|
|
133
|
+
|
|
134
|
+
## 4. Local subprocesses
|
|
135
|
+
|
|
136
|
+
All `child_process` use is static-argument and visible:
|
|
137
|
+
|
|
138
|
+
- `reg.exe add/delete` — the Run key above (`src/install.js`).
|
|
139
|
+
- `git` — read last commit subject / branch for the "just shipped" card
|
|
140
|
+
(`src/git.js`).
|
|
141
|
+
- `gh repo view --json isPrivate` — auto-hide GitHub-private repos from the card
|
|
142
|
+
(`src/privacy.js`); 1.5s timeout, silent skip if `gh` is absent.
|
|
143
|
+
- `gh gist` — only under 3b above.
|
|
144
|
+
|
|
145
|
+
No subprocess interpolates untrusted/remote input into a shell. The one
|
|
146
|
+
`shell: true` call (`verifyHookPipe`) uses only static, trusted args and is
|
|
147
|
+
documented inline as such.
|
|
148
|
+
|
|
149
|
+
## 5. What it stores locally
|
|
150
|
+
|
|
151
|
+
Under `~/.claude-rpc/` (a.k.a. `%APPDATA%\claude-rpc\` on Windows):
|
|
152
|
+
`config.json`, `state.json`, `aggregate.json`, `events.jsonl` (rotated at 5 MB),
|
|
153
|
+
`private-list.json`, `community-cursor.json`. The scanner also reads Claude Code
|
|
154
|
+
transcript files to build aggregates. None of this leaves your machine except
|
|
155
|
+
the minimal telemetry in 3a.
|
|
156
|
+
|
|
157
|
+
## 6. Privacy controls
|
|
158
|
+
|
|
159
|
+
Independent of telemetry, the Discord card has a privacy valve (`src/privacy.js`):
|
|
160
|
+
per-project `.claude-rpc.json`, a runtime private-list, config glob patterns, and
|
|
161
|
+
`gh`-based auto-hide of private repos. Levels: `public` / `name-only` / `hidden`.
|
|
162
|
+
Local dashboards and aggregates are never redacted — privacy is a one-way valve
|
|
163
|
+
from local state to Discord only.
|
|
164
|
+
|
|
165
|
+
## Reporting a vulnerability
|
|
166
|
+
|
|
167
|
+
Open an issue at https://github.com/rar-file/claude-rpc/issues, or for anything
|
|
168
|
+
sensitive email c.archer.simmons@gmail.com. Please include version
|
|
169
|
+
(`claude-rpc --version`), OS, and repro steps.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-rpc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Discord Rich Presence for Claude Code — live model, project, tokens, and lifetime stats driven by Claude Code's hook system.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"src",
|
|
58
58
|
"config.example.json",
|
|
59
59
|
"LICENSE",
|
|
60
|
-
"README.md"
|
|
60
|
+
"README.md",
|
|
61
|
+
"SECURITY.md"
|
|
61
62
|
]
|
|
62
63
|
}
|
package/src/daemon.js
CHANGED
|
@@ -282,9 +282,33 @@ async function pushPresence() {
|
|
|
282
282
|
log('Presence updated:', activity.details || '-', '|', activity.state || '-');
|
|
283
283
|
} catch (e) {
|
|
284
284
|
log('setActivity failed:', e.message, '|', e.stack?.split('\n').slice(0, 3).join(' | '));
|
|
285
|
+
// A failed setActivity usually means the IPC pipe died WITHOUT the client
|
|
286
|
+
// emitting a 'disconnected' event (Discord restart, socket reset, OS
|
|
287
|
+
// sleep). Left alone, `connected` stays true and the daemon goes silently
|
|
288
|
+
// dark forever. Tear the client down and force a backoff reconnect so we
|
|
289
|
+
// self-heal. Guarded to connection-shaped errors so a one-off API hiccup
|
|
290
|
+
// doesn't needlessly bounce a healthy socket.
|
|
291
|
+
if (isConnectionError(e)) {
|
|
292
|
+
log('setActivity error looks connection-level — forcing reconnect');
|
|
293
|
+
connected = false;
|
|
294
|
+
lastPayloadHash = '';
|
|
295
|
+
try { client?.destroy(); } catch { /* already gone */ }
|
|
296
|
+
scheduleReconnect('setActivity failed');
|
|
297
|
+
}
|
|
285
298
|
}
|
|
286
299
|
}
|
|
287
300
|
|
|
301
|
+
// Heuristic: does this error indicate the IPC transport itself is dead
|
|
302
|
+
// (vs. a transient/application-level failure)? Matches the common broken-pipe
|
|
303
|
+
// / closed-socket shapes from @xhayper/discord-rpc and the underlying net
|
|
304
|
+
// socket so we only force a reconnect when the connection is actually gone.
|
|
305
|
+
function isConnectionError(e) {
|
|
306
|
+
const code = (e && e.code) || '';
|
|
307
|
+
if (['EPIPE', 'ECONNRESET', 'ENOENT', 'ECONNREFUSED', 'ERR_STREAM_WRITE_AFTER_END'].includes(code)) return true;
|
|
308
|
+
const m = String((e && e.message) || '').toLowerCase();
|
|
309
|
+
return /closed|reset|broken pipe|not connected|disconnect|write after end|socket|econnreset|epipe|connection/.test(m);
|
|
310
|
+
}
|
|
311
|
+
|
|
288
312
|
async function connect() {
|
|
289
313
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
290
314
|
client = new Client({ clientId: config.clientId, transport: { type: 'ipc' } });
|
|
@@ -454,6 +478,36 @@ function refreshLiveSessions() {
|
|
|
454
478
|
refreshLiveSessions();
|
|
455
479
|
setInterval(refreshLiveSessions, 30_000);
|
|
456
480
|
|
|
481
|
+
// ── Connection watchdog (auto-heal) ──────────────────────────────────────────
|
|
482
|
+
// The reconnect path is event-driven (the 'disconnected' handler + login
|
|
483
|
+
// failures + setActivity errors). But a connection can rot in ways that emit
|
|
484
|
+
// no event at all: a half-open client where `connected` is still true but the
|
|
485
|
+
// user handle is gone, or a state where we're down with no retry in flight.
|
|
486
|
+
// This periodic check guarantees the daemon always converges back to a live
|
|
487
|
+
// connection instead of silently staying dark — the single most common
|
|
488
|
+
// "it just stopped showing up" failure users hit.
|
|
489
|
+
const HEALTH_CHECK_MS = 30_000;
|
|
490
|
+
setInterval(() => {
|
|
491
|
+
try {
|
|
492
|
+
// Half-open: flag says connected but there's no usable user handle.
|
|
493
|
+
if (connected && !client?.user) {
|
|
494
|
+
log('Watchdog: connected but no user handle — forcing reconnect');
|
|
495
|
+
connected = false;
|
|
496
|
+
try { client?.destroy(); } catch { /* already gone */ }
|
|
497
|
+
scheduleReconnect('watchdog: half-open');
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
// Down with nothing scheduled to bring us back. scheduleReconnect is a
|
|
501
|
+
// no-op when a timer is already pending, so this can't stack retries.
|
|
502
|
+
if (!connected && !reconnectTimer) {
|
|
503
|
+
log('Watchdog: disconnected with no reconnect pending — forcing reconnect');
|
|
504
|
+
scheduleReconnect('watchdog: no retry pending');
|
|
505
|
+
}
|
|
506
|
+
} catch (e) {
|
|
507
|
+
log('Watchdog tick failed:', e.message);
|
|
508
|
+
}
|
|
509
|
+
}, HEALTH_CHECK_MS);
|
|
510
|
+
|
|
457
511
|
// Community-totals flush. Disabled by default; turns on via
|
|
458
512
|
// `claude-rpc community on`. Best-effort — flushCommunity swallows every
|
|
459
513
|
// failure mode, so a flaky endpoint or no network just means the deltas
|
package/src/default-config.js
CHANGED
|
@@ -21,6 +21,12 @@ export const DEFAULT_CONFIG = {
|
|
|
21
21
|
// when Claude isn't open, the Discord presence should disappear quickly.
|
|
22
22
|
// The SessionEnd hook short-circuits this — see hook.js + format.applyIdle.
|
|
23
23
|
staleSessionMin: 5,
|
|
24
|
+
// When the Claude Code session is still open but its transcript has gone
|
|
25
|
+
// quiet (you paused, stepped away briefly), show 'idle' rather than
|
|
26
|
+
// clearing the card. Only an authoritative SessionEnd or the full
|
|
27
|
+
// staleSessionMin dormancy window drops to stale. Set false to restore the
|
|
28
|
+
// old behavior (clear ~90-120s after the last transcript write).
|
|
29
|
+
idleWhenOpen: true,
|
|
24
30
|
// When true, the daemon CLEARS Discord activity entirely once the state
|
|
25
31
|
// goes stale — your profile shows nothing instead of an "Away" frame.
|
|
26
32
|
hideWhenStale: true,
|
|
@@ -119,7 +125,7 @@ export const DEFAULT_CONFIG = {
|
|
|
119
125
|
},
|
|
120
126
|
|
|
121
127
|
buttons: [
|
|
122
|
-
{ label: "Claude Code", url: "https://
|
|
128
|
+
{ label: "Claude Code", url: "https://github.com/rar-file/claude-rpc" },
|
|
123
129
|
],
|
|
124
130
|
},
|
|
125
131
|
statusIcons: {
|
package/src/doctor.js
CHANGED
|
@@ -228,19 +228,30 @@ function checkDaemonLog() {
|
|
|
228
228
|
}
|
|
229
229
|
const ageMin = (Date.now() - st.mtimeMs) / 60_000;
|
|
230
230
|
const sizeKB = (st.size / 1024).toFixed(1);
|
|
231
|
-
//
|
|
232
|
-
|
|
231
|
+
// Infer live IPC state from the log. The daemon connects once and stays
|
|
232
|
+
// connected without re-logging, so grepping only for "connected" produces
|
|
233
|
+
// a false warning on a long-lived daemon. Instead: "Discord RPC connected",
|
|
234
|
+
// "Presence updated", and "Presence cleared" all imply a live connection —
|
|
235
|
+
// the latter two only log *after* the daemon's `connected` guard passes and
|
|
236
|
+
// a setActivity/clearActivity succeeds (see daemon.js). A later "retry in
|
|
237
|
+
// Ns" / "login failed" / "disconnected" line means it dropped. Whichever
|
|
238
|
+
// happened most recently wins.
|
|
239
|
+
let ipc = 'unknown';
|
|
233
240
|
try {
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
241
|
+
for (const line of readFileSync(LOG_PATH, 'utf8').split('\n').slice(-80)) {
|
|
242
|
+
if (/Discord RPC connected|Presence updated|Presence cleared/i.test(line)) ipc = 'up';
|
|
243
|
+
else if (/retry in \d+s|login failed|Discord disconnected/i.test(line)) ipc = 'down';
|
|
244
|
+
}
|
|
245
|
+
} catch { /* log unreadable — ipc stays 'unknown', warn renders */ }
|
|
246
|
+
if (ipc === 'up') {
|
|
238
247
|
check('discord IPC connection', 'pass',
|
|
239
|
-
|
|
240
|
-
} else {
|
|
241
|
-
check('discord IPC connection', 'warn',
|
|
242
|
-
`log shows no recent "connected" line`,
|
|
248
|
+
`connected · ${sizeKB} KB log · last write ${ageMin.toFixed(1)} min ago`);
|
|
249
|
+
} else if (ipc === 'down') {
|
|
250
|
+
check('discord IPC connection', 'warn', 'daemon is reconnecting to Discord',
|
|
243
251
|
'is the discord desktop client running? rpc only works via desktop, not browser');
|
|
252
|
+
} else {
|
|
253
|
+
check('discord IPC connection', 'warn', 'no connection activity in the log yet',
|
|
254
|
+
'start the daemon with discord desktop running');
|
|
244
255
|
}
|
|
245
256
|
}
|
|
246
257
|
|
package/src/format.js
CHANGED
|
@@ -682,6 +682,12 @@ export function applyIdle(state, cfg = {}) {
|
|
|
682
682
|
const idleMs = (cfg.idleThresholdSec || 60) * 1000;
|
|
683
683
|
const staleMs = Math.max(60_000, (cfg.staleSessionMin || 5) * 60 * 1000);
|
|
684
684
|
const notificationMs = (cfg.notificationWindowSec || 8) * 1000;
|
|
685
|
+
// When the Claude Code session is still open (no authoritative SessionEnd)
|
|
686
|
+
// but its transcript has gone quiet, prefer 'idle' over clearing the card.
|
|
687
|
+
// Only an explicit close (claudeClosed) or the staleMs dormancy backstop
|
|
688
|
+
// drops us to stale. Default on; set idleWhenOpen:false to restore the old
|
|
689
|
+
// aggressive ~90-120s clear when no transcript is being written.
|
|
690
|
+
const idleWhenOpen = cfg.idleWhenOpen !== false;
|
|
685
691
|
|
|
686
692
|
// Authoritative close signal from the SessionEnd hook — trust it instead
|
|
687
693
|
// of waiting on staleSessionMin. Any other hook clears the flag, so a
|
|
@@ -730,24 +736,26 @@ export function applyIdle(state, cfg = {}) {
|
|
|
730
736
|
|
|
731
737
|
// Local state is fresh.
|
|
732
738
|
if (state.status === 'idle') {
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
// (
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
|
|
739
|
+
// No transcripts being written anywhere on disk — Claude Code may have
|
|
740
|
+
// closed without a SessionEnd hook (force-quit, OS sleep, crash). With
|
|
741
|
+
// idleWhenOpen (default) we keep showing 'idle': the session hasn't been
|
|
742
|
+
// authoritatively closed and the staleMs dormancy backstop above will
|
|
743
|
+
// still clear it if Claude is truly gone. Set idleWhenOpen:false to go
|
|
744
|
+
// straight to stale here — clears Discord ~90-120s after the last write,
|
|
745
|
+
// at the cost of dropping the card whenever you pause for a couple
|
|
746
|
+
// minutes with the session still open.
|
|
747
|
+
if (liveSessions.length === 0 && !idleWhenOpen) return staleWipe(state);
|
|
741
748
|
return state;
|
|
742
749
|
}
|
|
743
750
|
if (ageMs > idleMs) {
|
|
744
751
|
// Hook channel is quiet, but a live transcript was modified recently?
|
|
745
752
|
// Keep "working" instead of dropping to "idle".
|
|
746
753
|
if (liveAgeMs <= idleMs) return state;
|
|
747
|
-
// Hooks quiet AND no live transcripts
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
|
|
754
|
+
// Hooks quiet AND no live transcripts. With idleWhenOpen (default) the
|
|
755
|
+
// session is treated as open-but-paused and drops to idle; the staleMs
|
|
756
|
+
// backstop above clears it if Claude is actually gone. Set
|
|
757
|
+
// idleWhenOpen:false to go straight to stale here (old behavior).
|
|
758
|
+
if (liveSessions.length === 0 && !idleWhenOpen) return staleWipe(state);
|
|
751
759
|
// Going idle — wipe "current activity" indicators so rotation frames
|
|
752
760
|
// gated on filesEdited / currentFile / currentTool stop showing stale
|
|
753
761
|
// active-session data. Keep the session counters (messages/tools/tokens)
|