claude-rpc 0.16.2 → 0.17.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/SECURITY.md +86 -16
- package/package.json +1 -1
- package/src/cli.js +92 -35
- package/src/community.js +7 -5
- package/src/daemon.js +57 -83
- package/src/doctor.js +7 -2
- package/src/format.js +47 -27
- package/src/gist.js +24 -3
- package/src/git.js +10 -1
- package/src/hook.js +36 -26
- package/src/install.js +49 -3
- package/src/mcp.js +10 -4
- package/src/notify.js +17 -0
- package/src/paths.js +15 -4
- package/src/presence.js +75 -0
- package/src/scanner.js +20 -2
- package/src/server/api.js +15 -1
- package/src/server/assets/dashboard.client.js +8 -3
- package/src/server/index.js +19 -1
- package/src/state.js +22 -4
- package/src/tui.js +3 -8
- package/src/version.js +1 -1
package/src/daemon.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { writeFileSync, existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
|
|
2
|
+
import { writeFileSync, readFileSync, existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
|
|
3
3
|
import { basename, dirname } from 'node:path';
|
|
4
4
|
import { Client } from './discord-ipc.js';
|
|
5
|
-
import { readState } from './state.js';
|
|
5
|
+
import { readState, sweepStaleStateTmp } from './state.js';
|
|
6
|
+
import { makeRotationCursor, pickFrames, selectFrame, resolveLargeImageKey } from './presence.js';
|
|
6
7
|
import { buildVars, fillTemplate, framePasses, applyIdle, applyShipped, applyTrigger } from './format.js';
|
|
7
8
|
import { scan, readAggregate, findLiveSessions, readSessionTokens } from './scanner.js';
|
|
8
9
|
import { detectGithubUrl } from './git.js';
|
|
@@ -10,7 +11,7 @@ import { applyPrivacy } from './privacy.js';
|
|
|
10
11
|
import { pauseUntil } from './pause.js';
|
|
11
12
|
import { loadConfig } from './config.js';
|
|
12
13
|
import { migrateConfig } from './install.js';
|
|
13
|
-
import { desktopNotify, postWebhook, shouldWebhook, shouldNotify } from './notify.js';
|
|
14
|
+
import { desktopNotify, postWebhook, shouldWebhook, shouldNotify, sanitizeLabel } from './notify.js';
|
|
14
15
|
import { humanProject } from './format.js';
|
|
15
16
|
import { CONFIG_PATH, STATE_PATH, PID_PATH, LOG_PATH, STATE_DIR, AGGREGATE_PATH, PAUSE_PATH } from './paths.js';
|
|
16
17
|
import { readUsageCache, pollUsage } from './usage.js';
|
|
@@ -90,54 +91,42 @@ let reconnectTimer = null;
|
|
|
90
91
|
const RECONNECT_BASE_MS = 5_000;
|
|
91
92
|
const RECONNECT_CAP_MS = 300_000;
|
|
92
93
|
let reconnectDelayMs = RECONNECT_BASE_MS;
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
// Rotation cursor (index + lastAt + status). selectFrame in presence.js resets
|
|
95
|
+
// it on a status transition — otherwise the cursor carries over from idle's
|
|
96
|
+
// 12-frame rotation into a single-frame working state and back, producing a
|
|
97
|
+
// jarring "blank tick" until modulo aligns.
|
|
98
|
+
const rotationCursor = makeRotationCursor();
|
|
95
99
|
// Stabilizes Discord's elapsed timer: applyIdle can synthesize a sessionStart
|
|
96
100
|
// from a moving transcript mtime, and missing-hook scenarios leave it null —
|
|
97
101
|
// either case would make startTimestamp jump on every rotation.
|
|
98
102
|
let effectiveSessionStart = null;
|
|
99
|
-
// Track which status the rotation cursor belongs to so we can reset it cleanly
|
|
100
|
-
// on a status transition — otherwise the cursor carries over from idle's
|
|
101
|
-
// 7-frame rotation into a single-frame working state and back, producing a
|
|
102
|
-
// jarring "blank tick" until modulo aligns.
|
|
103
|
-
let rotationStatus = null;
|
|
104
103
|
|
|
104
|
+
// Single-instance guard. The CLI checks before spawning, but off-path launches
|
|
105
|
+
// (a login-startup entry racing a manual start; a packaged exe beside a dev
|
|
106
|
+
// run) could start a second daemon that fights over setActivity every ~4s and
|
|
107
|
+
// double-counts the additive community total. If a live daemon already owns the
|
|
108
|
+
// PID file, step aside; a stale PID (owner gone) means take over.
|
|
109
|
+
try {
|
|
110
|
+
if (existsSync(PID_PATH)) {
|
|
111
|
+
const existing = parseInt(readFileSync(PID_PATH, 'utf8'), 10);
|
|
112
|
+
if (existing && existing !== process.pid) {
|
|
113
|
+
let alive = false;
|
|
114
|
+
try { process.kill(existing, 0); alive = true; } catch { /* stale PID — take over */ }
|
|
115
|
+
if (alive) {
|
|
116
|
+
log(`Another daemon (pid ${existing}) is already running — exiting.`);
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch { /* unreadable PID file — fall through and claim it */ }
|
|
105
122
|
writeFileSync(PID_PATH, String(process.pid));
|
|
106
123
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (rotation.length === 1) return rotation[0];
|
|
110
|
-
const now = Date.now();
|
|
111
|
-
if (now - lastRotationAt >= intervalMs) {
|
|
112
|
-
rotationIndex = (rotationIndex + 1) % rotation.length;
|
|
113
|
-
lastRotationAt = now;
|
|
114
|
-
}
|
|
115
|
-
return rotation[rotationIndex % rotation.length];
|
|
116
|
-
}
|
|
124
|
+
// Reclaim any per-pid state tmp files orphaned by a hard-killed writer.
|
|
125
|
+
sweepStaleStateTmp();
|
|
117
126
|
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
// Returns { frames, largeImageTextTpl }. A byStatus entry can be:
|
|
123
|
-
// { details, state, largeImageText, rotation? }
|
|
124
|
-
// If `rotation` is present, the base { details, state } is rendered first
|
|
125
|
-
// and the rotation array cycles after it. Otherwise the entry is a single
|
|
126
|
-
// fixed frame.
|
|
127
|
-
function pickFrames(p, status) {
|
|
128
|
-
const sb = p.byStatus?.[status];
|
|
129
|
-
if (sb) {
|
|
130
|
-
const base = { details: sb.details, state: sb.state, largeImageText: sb.largeImageText };
|
|
131
|
-
const frames = Array.isArray(sb.rotation) && sb.rotation.length
|
|
132
|
-
? [base, ...sb.rotation]
|
|
133
|
-
: [base];
|
|
134
|
-
return { frames, largeImageTextTpl: sb.largeImageText || null };
|
|
135
|
-
}
|
|
136
|
-
if (Array.isArray(p.rotation) && p.rotation.length) {
|
|
137
|
-
return { frames: p.rotation, largeImageTextTpl: null };
|
|
138
|
-
}
|
|
139
|
-
return { frames: [{ details: p.details, state: p.state }], largeImageTextTpl: null };
|
|
140
|
-
}
|
|
127
|
+
// pickFrames / selectFrame / resolveLargeImageKey now live in presence.js (pure
|
|
128
|
+
// + unit-tested). The rotation cursor (rotationCursor) is owned here and passed
|
|
129
|
+
// into selectFrame.
|
|
141
130
|
|
|
142
131
|
// Resolve the raw state file into the final presence state: idle/stale, shipped
|
|
143
132
|
// and trigger overlays, live-session token enrichment, and the privacy verdict.
|
|
@@ -202,18 +191,10 @@ function buildActivity(opts = {}) {
|
|
|
202
191
|
} else {
|
|
203
192
|
({ frames: rawFrames, largeImageTextTpl: statusLIT } = pickFrames(p, state.status));
|
|
204
193
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
lastRotationAt = 0;
|
|
208
|
-
rotationStatus = state.status;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Drop frames whose `requires` vars are empty/zero. Keeps presence tight.
|
|
212
|
-
const frames = rawFrames.filter((f) => framePasses(f, vars));
|
|
213
|
-
const safeFrames = frames.length ? frames : rawFrames.slice(0, 1);
|
|
214
|
-
|
|
194
|
+
// Select the frame for this tick: filter by `requires`, reset the cursor on a
|
|
195
|
+
// status change (starting on the base frame), advance once per intervalMs.
|
|
215
196
|
const intervalMs = Math.max(5000, config.rotationIntervalMs || 12000);
|
|
216
|
-
const frame =
|
|
197
|
+
const frame = selectFrame(rawFrames, vars, state.status, rotationCursor, intervalMs, framePasses, Date.now());
|
|
217
198
|
|
|
218
199
|
const activity = {};
|
|
219
200
|
// Forcing `name` overrides whatever Discord has cached for the app's
|
|
@@ -223,28 +204,10 @@ function buildActivity(opts = {}) {
|
|
|
223
204
|
if (frame.details) activity.details = fillTemplate(frame.details, vars).slice(0, 128);
|
|
224
205
|
if (frame.state) activity.state = fillTemplate(frame.state, vars).slice(0, 128);
|
|
225
206
|
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
// only consulted when statusAssets
|
|
231
|
-
// doesn't match AND state isn't stale.
|
|
232
|
-
// 3. presence.largeImageKey global fallback.
|
|
233
|
-
// smallImageKey separately resolves to the `{statusIcon}` template var
|
|
234
|
-
// (set via config.statusIcons) and is dropped entirely when empty.
|
|
235
|
-
let largeKeyTpl = p.largeImageKey;
|
|
236
|
-
if (config.statusAssets && config.statusAssets[state.status]) {
|
|
237
|
-
largeKeyTpl = config.statusAssets[state.status];
|
|
238
|
-
} else if (config.modelAssets && state.model && state.status !== 'stale') {
|
|
239
|
-
const m = String(state.model).toLowerCase();
|
|
240
|
-
let pick = null;
|
|
241
|
-
if (m.includes('fable')) pick = config.modelAssets.fable;
|
|
242
|
-
else if (m.includes('opus')) pick = config.modelAssets.opus;
|
|
243
|
-
else if (m.includes('sonnet')) pick = config.modelAssets.sonnet;
|
|
244
|
-
else if (m.includes('haiku')) pick = config.modelAssets.haiku;
|
|
245
|
-
if (!pick) pick = config.modelAssets.default;
|
|
246
|
-
if (pick) largeKeyTpl = pick;
|
|
247
|
-
}
|
|
207
|
+
// Large-image precedence (statusAssets[status] > modelAssets[tier] > global)
|
|
208
|
+
// lives in presence.js/resolveLargeImageKey. smallImageKey separately resolves
|
|
209
|
+
// to the `{statusIcon}` template var (config.statusIcons), dropped when empty.
|
|
210
|
+
const largeKeyTpl = resolveLargeImageKey(config, p, state.status, state.model);
|
|
248
211
|
if (largeKeyTpl) activity.largeImageKey = fillTemplate(largeKeyTpl, vars);
|
|
249
212
|
// largeImageText precedence: per-frame override > byStatus entry > global default.
|
|
250
213
|
const largeTextTpl = frame.largeImageText || statusLIT || p.largeImageText;
|
|
@@ -304,13 +267,20 @@ function buildActivity(opts = {}) {
|
|
|
304
267
|
}
|
|
305
268
|
|
|
306
269
|
// Fire desktop-notification + webhook on a status transition (once per change).
|
|
307
|
-
|
|
270
|
+
// When `suppressed` (paused / privacy=hidden), we still advance the transition
|
|
271
|
+
// cursor — so resume doesn't replay a stale notification — but stay silent, or
|
|
272
|
+
// the webhook/toast would leak the project name and defeat the snooze.
|
|
273
|
+
function fireStatusSideEffects(resolved, suppressed = false) {
|
|
308
274
|
const status = resolved.status;
|
|
309
275
|
if (status === lastNotifiedStatus) return;
|
|
310
276
|
const prev = lastNotifiedStatus;
|
|
311
277
|
lastNotifiedStatus = status;
|
|
278
|
+
if (suppressed) return;
|
|
312
279
|
try {
|
|
313
|
-
|
|
280
|
+
// Sanitize the cwd-derived project name before it reaches a notifier or
|
|
281
|
+
// webhook — see sanitizeLabel (closes a win32 PowerShell injection via a
|
|
282
|
+
// maliciously-named directory).
|
|
283
|
+
const project = sanitizeLabel(humanProject(resolved.cwd)) || 'Claude Code';
|
|
314
284
|
if (shouldNotify(config.notify, prev, status)) {
|
|
315
285
|
desktopNotify('Claude Code needs you', `Waiting on you in ${project}`);
|
|
316
286
|
log(`desktop notification raised (status=${status})`);
|
|
@@ -338,17 +308,21 @@ async function pushPresence() {
|
|
|
338
308
|
// for the decision and the frame to disagree.
|
|
339
309
|
const resolved = resolvePresence();
|
|
340
310
|
|
|
341
|
-
// Outbound side-effects on a status TRANSITION (fire once per change):
|
|
342
|
-
// a desktop notification when Claude needs you, and an opt-in webhook POST.
|
|
343
|
-
fireStatusSideEffects(resolved);
|
|
344
|
-
|
|
345
311
|
const hideWhenStale = config.hideWhenStale !== false;
|
|
346
312
|
const privacyHidden = resolved._privacy?.visibility === 'hidden';
|
|
347
313
|
// Global snooze (`claude-rpc pause`) — clears the card while the deadline
|
|
348
314
|
// is in the future. Re-checked every tick, so expiry resumes presence
|
|
349
315
|
// automatically (the 'cleared' stamp differs from the next frame's hash).
|
|
350
316
|
const pausedUntil = pauseUntil();
|
|
351
|
-
|
|
317
|
+
const suppressed = privacyHidden || !!pausedUntil || (resolved.status === 'stale' && hideWhenStale);
|
|
318
|
+
|
|
319
|
+
// Outbound side-effects on a status TRANSITION (fire once per change): a
|
|
320
|
+
// desktop notification when Claude needs you, and an opt-in webhook POST.
|
|
321
|
+
// Suppressed while paused / privacy=hidden — those snooze the card, and a
|
|
322
|
+
// toast or webhook leaking the project name would defeat that.
|
|
323
|
+
fireStatusSideEffects(resolved, suppressed);
|
|
324
|
+
|
|
325
|
+
if (suppressed) {
|
|
352
326
|
const stamp = 'cleared';
|
|
353
327
|
if (lastPayloadHash === stamp) return;
|
|
354
328
|
lastPayloadHash = stamp;
|
package/src/doctor.js
CHANGED
|
@@ -366,8 +366,13 @@ function checkUsage(cfg) {
|
|
|
366
366
|
}
|
|
367
367
|
const u = readUsageCache();
|
|
368
368
|
if (u) {
|
|
369
|
-
|
|
370
|
-
|
|
369
|
+
// Either bucket can be null (they come and go between Claude Code
|
|
370
|
+
// releases); interpolating both unconditionally printed a literal `null%`.
|
|
371
|
+
const parts = [];
|
|
372
|
+
if (u.weeklyPct != null) parts.push(`week ${u.weeklyPct}%`);
|
|
373
|
+
if (u.sessionPct != null) parts.push(`session ${u.sessionPct}%`);
|
|
374
|
+
parts.push(`fetched ${Math.max(0, Math.round((Date.now() - u.fetchedAt) / 60_000))} min ago`);
|
|
375
|
+
check('usage polling', 'pass', parts.join(' · '));
|
|
371
376
|
} else {
|
|
372
377
|
check('usage polling', 'warn', 'no fresh usage data yet',
|
|
373
378
|
'the daemon polls every 10 min while a session is live — or run `claude-rpc usage` for a live fetch');
|
package/src/format.js
CHANGED
|
@@ -450,6 +450,7 @@ export function buildVars(state, config, aggregate) {
|
|
|
450
450
|
if (usage) {
|
|
451
451
|
const bits = [];
|
|
452
452
|
if (usage.sessionPct != null) bits.push(`session ${usage.sessionPct}%`);
|
|
453
|
+
if (usage.weeklyPct != null) bits.push(`weekly ${usage.weeklyPct}%`);
|
|
453
454
|
const day = fmtResetDay(usage.weeklyResetsAt);
|
|
454
455
|
if (day) bits.push(`resets ${day}`);
|
|
455
456
|
usageStateLabel = bits.join(' · ');
|
|
@@ -803,6 +804,29 @@ function staleWipe(state) {
|
|
|
803
804
|
};
|
|
804
805
|
}
|
|
805
806
|
|
|
807
|
+
// A live transcript is being written by a session whose hooks don't feed THIS
|
|
808
|
+
// daemon (a sibling, or a session that out-lived a SessionEnd). The user IS
|
|
809
|
+
// working — adopt the most-recent live session as our 'working' context, zeroing
|
|
810
|
+
// the old session's hook-derived counters since they belong to the quiet one.
|
|
811
|
+
function borrowLiveSession(state, liveSessions, now) {
|
|
812
|
+
const recent = liveSessions[0] || {};
|
|
813
|
+
return {
|
|
814
|
+
...state,
|
|
815
|
+
status: 'working',
|
|
816
|
+
cwd: recent.cwd || state.cwd || '',
|
|
817
|
+
sessionStart: recent.mtime || now,
|
|
818
|
+
lastActivity: recent.mtime || now,
|
|
819
|
+
currentTool: null,
|
|
820
|
+
currentFile: null,
|
|
821
|
+
messages: 0,
|
|
822
|
+
tools: 0,
|
|
823
|
+
filesOpened: [],
|
|
824
|
+
filesEdited: [],
|
|
825
|
+
filesRead: [],
|
|
826
|
+
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
806
830
|
// Apply idle/stale transitions based on lastActivity age. Used by both daemon
|
|
807
831
|
// and the `preview` CLI command so they agree.
|
|
808
832
|
//
|
|
@@ -826,10 +850,28 @@ export function applyIdle(state, cfg = {}) {
|
|
|
826
850
|
// backstop (keeps the card up through short pauses with the terminal open).
|
|
827
851
|
const idleWhenOpen = cfg.idleWhenOpen === true;
|
|
828
852
|
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
|
|
832
|
-
|
|
853
|
+
// Most-recent disk-level activity across ALL transcripts we can see. Computed
|
|
854
|
+
// before the close check so it can defer to a live sibling.
|
|
855
|
+
const mostRecentLiveMs = liveSessions.length
|
|
856
|
+
? Math.max(...liveSessions.map((s) => s.mtime || 0))
|
|
857
|
+
: 0;
|
|
858
|
+
const liveAgeMs = mostRecentLiveMs ? now - mostRecentLiveMs : Infinity;
|
|
859
|
+
|
|
860
|
+
// Authoritative close signal from the SessionEnd hook — trust it instead of
|
|
861
|
+
// waiting on staleSessionMin. BUT state.json is global: a SessionEnd from ONE
|
|
862
|
+
// session must not blank the card while a SIBLING is mid-work. A sibling is
|
|
863
|
+
// alive iff a live transcript in a DIFFERENT cwd than the one that just closed
|
|
864
|
+
// (still in state.cwd) is fresh — adopt it. The just-closed session's own
|
|
865
|
+
// transcript is briefly still fresh, so it must NOT keep the card up; anything
|
|
866
|
+
// but a distinct live sibling wipes. (A's next hook also clears the flag.)
|
|
867
|
+
//
|
|
868
|
+
// Known limitation: the inverse — B's SessionStart resetState briefly showing
|
|
869
|
+
// idle over A's work — isn't fully fixed here (the hook can't see live
|
|
870
|
+
// transcripts); it self-heals on A's next hook. See MEMORY/SECURITY notes.
|
|
871
|
+
if (state.claudeClosed) {
|
|
872
|
+
const sibling = liveSessions.find((s) => s.cwd && s.cwd !== state.cwd && now - (s.mtime || 0) <= staleMs);
|
|
873
|
+
return sibling ? borrowLiveSession(state, [sibling], now) : staleWipe(state);
|
|
874
|
+
}
|
|
833
875
|
|
|
834
876
|
// Notification is a brief status — hold it for ~8s after the hook fires,
|
|
835
877
|
// then fall through to normal idle/stale processing.
|
|
@@ -839,12 +881,6 @@ export function applyIdle(state, cfg = {}) {
|
|
|
839
881
|
state = { ...state, status: 'idle' };
|
|
840
882
|
}
|
|
841
883
|
|
|
842
|
-
// Most-recent disk-level activity across ALL transcripts we can see.
|
|
843
|
-
const mostRecentLiveMs = liveSessions.length
|
|
844
|
-
? Math.max(...liveSessions.map((s) => s.mtime || 0))
|
|
845
|
-
: 0;
|
|
846
|
-
const liveAgeMs = mostRecentLiveMs ? now - mostRecentLiveMs : Infinity;
|
|
847
|
-
|
|
848
884
|
// Truly dormant: no live transcripts AND local state is old → stale.
|
|
849
885
|
if (ageMs > staleMs && liveAgeMs > staleMs) return staleWipe(state);
|
|
850
886
|
|
|
@@ -852,23 +888,7 @@ export function applyIdle(state, cfg = {}) {
|
|
|
852
888
|
// Borrow the most-recent live session as our "active" context, since the
|
|
853
889
|
// user clearly IS working — just not in a session whose hooks feed us.
|
|
854
890
|
if (ageMs > staleMs && liveAgeMs <= staleMs) {
|
|
855
|
-
|
|
856
|
-
return {
|
|
857
|
-
...state,
|
|
858
|
-
status: 'working',
|
|
859
|
-
cwd: recent.cwd || state.cwd || '',
|
|
860
|
-
sessionStart: recent.mtime || now,
|
|
861
|
-
lastActivity: recent.mtime || now,
|
|
862
|
-
// Hook-derived per-session counters belong to the OLD session — zero them.
|
|
863
|
-
currentTool: null,
|
|
864
|
-
currentFile: null,
|
|
865
|
-
messages: 0,
|
|
866
|
-
tools: 0,
|
|
867
|
-
filesOpened: [],
|
|
868
|
-
filesEdited: [],
|
|
869
|
-
filesRead: [],
|
|
870
|
-
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
871
|
-
};
|
|
891
|
+
return borrowLiveSession(state, liveSessions, now);
|
|
872
892
|
}
|
|
873
893
|
|
|
874
894
|
// Local state is fresh.
|
package/src/gist.js
CHANGED
|
@@ -17,6 +17,25 @@ import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
|
17
17
|
import { tmpdir } from 'node:os';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
|
|
20
|
+
// `gh` on Windows is gh.cmd/gh.exe — a bare 'gh' spawn (no shell) won't resolve
|
|
21
|
+
// the extension, so detection and publish silently fell through to the REST
|
|
22
|
+
// path (which then needs GH_TOKEN). Run via the shell on win32 so PATHEXT
|
|
23
|
+
// resolves it. shell:true does NOT auto-quote, and our args include a
|
|
24
|
+
// space-bearing --desc, so quote anything with whitespace/quotes ourselves.
|
|
25
|
+
const WIN = process.platform === 'win32';
|
|
26
|
+
function ghQuote(a) {
|
|
27
|
+
return /[\s"]/.test(a) ? `"${String(a).replace(/"/g, '""')}"` : a;
|
|
28
|
+
}
|
|
29
|
+
function gh(args, opts = {}) {
|
|
30
|
+
return WIN
|
|
31
|
+
? spawnSync('gh', args.map(ghQuote), { ...opts, shell: true })
|
|
32
|
+
: spawnSync('gh', args, opts);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Bare fetch has no total timeout; a stalled GitHub endpoint would hang the
|
|
36
|
+
// publish forever. 10s is plenty for a gist round-trip.
|
|
37
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
38
|
+
|
|
20
39
|
// Extract { owner, id } from a "https://gist.github.com/<user>/<hash>"
|
|
21
40
|
// URL. Returns null on no match — callers throw with the raw output so
|
|
22
41
|
// debugging an unparseable gh response is straightforward.
|
|
@@ -43,7 +62,7 @@ export function gistMarkdown({ owner, id, filename, label = 'Claude' }) {
|
|
|
43
62
|
|
|
44
63
|
export function hasGh() {
|
|
45
64
|
try {
|
|
46
|
-
const r =
|
|
65
|
+
const r = gh(['--version'], { stdio: 'ignore' });
|
|
47
66
|
return r.status === 0;
|
|
48
67
|
} catch {
|
|
49
68
|
// gh missing entirely → spawn throws on some platforms instead of
|
|
@@ -55,7 +74,7 @@ export function hasGh() {
|
|
|
55
74
|
function ghCreate(filePath, description, isPublic) {
|
|
56
75
|
const args = ['gist', 'create', filePath, '--desc', description];
|
|
57
76
|
if (isPublic) args.push('--public');
|
|
58
|
-
const r =
|
|
77
|
+
const r = gh(args, { encoding: 'utf8' });
|
|
59
78
|
if (r.status !== 0) {
|
|
60
79
|
throw new Error(`gh gist create failed: ${(r.stderr || r.stdout || '').trim()}`);
|
|
61
80
|
}
|
|
@@ -69,7 +88,7 @@ function ghCreate(filePath, description, isPublic) {
|
|
|
69
88
|
}
|
|
70
89
|
|
|
71
90
|
function ghEdit(gistId, filePath) {
|
|
72
|
-
const r =
|
|
91
|
+
const r = gh(['gist', 'edit', gistId, filePath], { encoding: 'utf8' });
|
|
73
92
|
if (r.status !== 0) {
|
|
74
93
|
throw new Error(`gh gist edit failed: ${(r.stderr || r.stdout || '').trim()}`);
|
|
75
94
|
}
|
|
@@ -89,6 +108,7 @@ async function restCreate({ svg, filename, description, isPublic, token }) {
|
|
|
89
108
|
public: !!isPublic,
|
|
90
109
|
files: { [filename]: { content: svg } },
|
|
91
110
|
}),
|
|
111
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
92
112
|
});
|
|
93
113
|
if (!res.ok) {
|
|
94
114
|
const body = await res.text().catch(() => '');
|
|
@@ -108,6 +128,7 @@ async function restEdit({ svg, filename, gistId, token }) {
|
|
|
108
128
|
'Content-Type': 'application/json',
|
|
109
129
|
},
|
|
110
130
|
body: JSON.stringify({ files: { [filename]: { content: svg } } }),
|
|
131
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
111
132
|
});
|
|
112
133
|
if (!res.ok) {
|
|
113
134
|
const body = await res.text().catch(() => '');
|
package/src/git.js
CHANGED
|
@@ -60,7 +60,16 @@ function readGitInfo(cwd) {
|
|
|
60
60
|
// origin URL → github URL + repo name.
|
|
61
61
|
try {
|
|
62
62
|
const cfg = readFileSync(join(commonGitDir(gitDir), 'config'), 'utf8');
|
|
63
|
-
|
|
63
|
+
// Isolate the [remote "origin"] section (everything up to the next section
|
|
64
|
+
// header) and read its url= line. The old single regex used `[^[]*?`
|
|
65
|
+
// between the header and url=, so any stray `[` in between — e.g. a
|
|
66
|
+
// bracketed comment above the url — aborted the match and silently dropped
|
|
67
|
+
// GitHub detection.
|
|
68
|
+
const oi = cfg.search(/\[remote\s+"origin"\]/i);
|
|
69
|
+
const rest = oi === -1 ? '' : cfg.slice(oi);
|
|
70
|
+
const nextSec = rest ? rest.slice(1).search(/\n[ \t]*\[/) : -1;
|
|
71
|
+
const section = nextSec === -1 ? rest : rest.slice(0, nextSec + 1);
|
|
72
|
+
const m = section.match(/^[ \t]*url\s*=\s*(.+)$/mi);
|
|
64
73
|
if (m) {
|
|
65
74
|
const raw = m[1].trim();
|
|
66
75
|
const ssh = raw.match(/^git@github\.com:([^\s]+?)(?:\.git)?$/i);
|
package/src/hook.js
CHANGED
|
@@ -36,6 +36,21 @@ function gitSubcommand(args) {
|
|
|
36
36
|
return null;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// First two gh positionals (noun, verb), skipping global flags so the canonical
|
|
40
|
+
// targeted form `gh -R owner/repo pr create` still classifies. -R/--repo take a
|
|
41
|
+
// value; other globals here don't precede a create, so skipping the rest is safe.
|
|
42
|
+
function ghSubcommand(args) {
|
|
43
|
+
const pos = [];
|
|
44
|
+
for (let i = 0; i < args.length; i++) {
|
|
45
|
+
const a = args[i];
|
|
46
|
+
if (a === '-R' || a === '--repo') { i++; continue; } // flag that takes a value
|
|
47
|
+
if (a.startsWith('-')) continue;
|
|
48
|
+
pos.push(a.toLowerCase());
|
|
49
|
+
if (pos.length === 2) break;
|
|
50
|
+
}
|
|
51
|
+
return { noun: pos[0], verb: pos[1] };
|
|
52
|
+
}
|
|
53
|
+
|
|
39
54
|
function shipKindForSegment(seg) {
|
|
40
55
|
const toks = tokenizeSegment(seg);
|
|
41
56
|
if (!toks.length) return null;
|
|
@@ -44,9 +59,10 @@ function shipKindForSegment(seg) {
|
|
|
44
59
|
if (sub === 'push') return 'push';
|
|
45
60
|
if (sub === 'commit') return 'commit';
|
|
46
61
|
} else if (toks[0] === 'gh') {
|
|
47
|
-
|
|
48
|
-
if (
|
|
49
|
-
if (
|
|
62
|
+
const { noun, verb } = ghSubcommand(toks.slice(1));
|
|
63
|
+
if (noun === 'pr' && verb === 'create') return 'pr';
|
|
64
|
+
if (noun === 'issue' && verb === 'create') return 'issue';
|
|
65
|
+
if (noun === 'release' && verb === 'create') return 'tag';
|
|
50
66
|
}
|
|
51
67
|
return null;
|
|
52
68
|
}
|
|
@@ -211,13 +227,10 @@ export function processHookEvent(event, input = {}) {
|
|
|
211
227
|
s.justShippedSubject = shipSubject;
|
|
212
228
|
s.justShippedBranch = shipBranch;
|
|
213
229
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
s.tokens.cacheRead += usage.cache_read_input_tokens || 0;
|
|
219
|
-
s.tokens.cacheWrite += usage.cache_creation_input_tokens || 0;
|
|
220
|
-
}
|
|
230
|
+
// NOTE: PostToolUse tool_response carries no model usage, so this
|
|
231
|
+
// never fired (the daemon overrides state.tokens from the transcript —
|
|
232
|
+
// the documented single source of truth). Removed to kill a latent
|
|
233
|
+
// double-count if a future Claude Code version did attach usage here.
|
|
221
234
|
return s;
|
|
222
235
|
});
|
|
223
236
|
break;
|
|
@@ -255,20 +268,9 @@ export function processHookEvent(event, input = {}) {
|
|
|
255
268
|
appendEvent({ type: 'precompact', ts: now, trigger: input.trigger || input.matcher || null, cwd: input.cwd || null });
|
|
256
269
|
break;
|
|
257
270
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
updateState((s) => {
|
|
262
|
-
s.status = 'idle';
|
|
263
|
-
s.compactStartedAt = null;
|
|
264
|
-
s.compactTrigger = null;
|
|
265
|
-
s.lastActivity = now;
|
|
266
|
-
s.claudeClosed = false;
|
|
267
|
-
return s;
|
|
268
|
-
});
|
|
269
|
-
appendEvent({ type: 'postcompact', ts: now, cwd: input.cwd || null });
|
|
270
|
-
break;
|
|
271
|
-
}
|
|
271
|
+
// No PostCompact case: Claude Code has no such event. Post-compaction
|
|
272
|
+
// arrives as SessionStart (source:'compact'), whose resetState clears the
|
|
273
|
+
// compacting marker — so the `compacting` state ends without a handler.
|
|
272
274
|
case 'SessionEnd': {
|
|
273
275
|
// Authoritative "Claude Code is gone" signal — don't wait on the
|
|
274
276
|
// staleSessionMin timeout. applyIdle short-circuits to stale when it
|
|
@@ -293,9 +295,17 @@ export function processHookEvent(event, input = {}) {
|
|
|
293
295
|
}
|
|
294
296
|
|
|
295
297
|
// Stdin-driven CLI form: read JSON event payload from stdin, dispatch, ack.
|
|
298
|
+
// The `{continue:true}` ack is a documented contract (SECURITY.md) — Claude
|
|
299
|
+
// Code reads it on every hook — so a state-write failure (full/unwritable
|
|
300
|
+
// tmpdir) must never stop us from emitting it. Dispatch is best-effort; the
|
|
301
|
+
// ack always goes out.
|
|
296
302
|
export function runHookCli(event) {
|
|
297
|
-
|
|
298
|
-
|
|
303
|
+
try {
|
|
304
|
+
processHookEvent(event, parseInput());
|
|
305
|
+
} catch { /* presence is best-effort; never break the user's turn */ }
|
|
306
|
+
finally {
|
|
307
|
+
try { process.stdout.write(JSON.stringify({ continue: true })); } catch { /* stdout closed */ }
|
|
308
|
+
}
|
|
299
309
|
}
|
|
300
310
|
|
|
301
311
|
// Run directly when invoked as `node src/hook.js <event>`. Detection is based
|
package/src/install.js
CHANGED
|
@@ -54,7 +54,7 @@ function noop(fact) { noopFacts.push(fact); }
|
|
|
54
54
|
|
|
55
55
|
const EVENTS = [
|
|
56
56
|
'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse',
|
|
57
|
-
'Stop', 'SubagentStop', 'Notification', 'SessionEnd',
|
|
57
|
+
'Stop', 'SubagentStop', 'Notification', 'SessionEnd', 'PreCompact',
|
|
58
58
|
];
|
|
59
59
|
|
|
60
60
|
function readJson(p, fb) {
|
|
@@ -135,15 +135,29 @@ function regCommand(args) {
|
|
|
135
135
|
});
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
const STARTUP_VBS = join(CANONICAL_INSTALL_DIR, 'claude-rpc-daemon.vbs');
|
|
139
|
+
|
|
138
140
|
export async function addStartupEntry(exePath) {
|
|
141
|
+
// The packaged exe is a console-subsystem node.exe, so a bare Run-key entry
|
|
142
|
+
// (`"<exe>" daemon`) makes Explorer pop a console window at every login that
|
|
143
|
+
// persists for the daemon's whole (weeks-long) life — closing it kills the
|
|
144
|
+
// daemon. Launch through a tiny .vbs shim via wscript (window style 0) so the
|
|
145
|
+
// unattended startup path is windowless, like every other launch path. We
|
|
146
|
+
// avoid schtasks deliberately — SECURITY.md advertises "no scheduled task".
|
|
147
|
+
let runCmd = `"${exePath}" daemon`;
|
|
148
|
+
try {
|
|
149
|
+
mkdirSync(CANONICAL_INSTALL_DIR, { recursive: true });
|
|
150
|
+
writeFileSync(STARTUP_VBS, `CreateObject("WScript.Shell").Run """${exePath}"" daemon", 0, False\r\n`);
|
|
151
|
+
runCmd = `wscript.exe "${STARTUP_VBS}"`;
|
|
152
|
+
} catch { /* couldn't write the shim — fall back to the direct (windowed) entry */ }
|
|
139
153
|
await regCommand([
|
|
140
154
|
'add', STARTUP_KEY,
|
|
141
155
|
'/v', STARTUP_VALUE,
|
|
142
156
|
'/t', 'REG_SZ',
|
|
143
|
-
'/d',
|
|
157
|
+
'/d', runCmd,
|
|
144
158
|
'/f',
|
|
145
159
|
]);
|
|
146
|
-
if (runDirty) step(SYM_OK, 'startup entry', `HKCU\\…\\Run\\${STARTUP_VALUE} — daemon starts at login`);
|
|
160
|
+
if (runDirty) step(SYM_OK, 'startup entry', `HKCU\\…\\Run\\${STARTUP_VALUE} — daemon starts at login (windowless)`);
|
|
147
161
|
else noop('startup entry present');
|
|
148
162
|
}
|
|
149
163
|
|
|
@@ -154,6 +168,7 @@ export async function removeStartupEntry() {
|
|
|
154
168
|
} catch {
|
|
155
169
|
// Already absent — fine.
|
|
156
170
|
}
|
|
171
|
+
try { unlinkSync(STARTUP_VBS); } catch { /* shim absent — fine */ }
|
|
157
172
|
}
|
|
158
173
|
|
|
159
174
|
function samePath(a, b) {
|
|
@@ -395,6 +410,37 @@ export function migrateConfig({ silent = false } = {}) {
|
|
|
395
410
|
if (changed) added.push('presence.buttons[] → CTA');
|
|
396
411
|
}
|
|
397
412
|
|
|
413
|
+
// Frame reconciliation. byStatus.<status>.rotation arrays are seeded once and
|
|
414
|
+
// never reconciled (arrays REPLACE on merge), so default frames shipped in
|
|
415
|
+
// later versions — the v0.16 usage / cost / churn / goal / budget frames —
|
|
416
|
+
// never reach an existing user just by bumping the package. For each status
|
|
417
|
+
// whose rotation is still default-derived (every frame the user has is a
|
|
418
|
+
// current default frame — they haven't added their own), append the default
|
|
419
|
+
// frames they're missing, in default order. A frame's `requires` signature is
|
|
420
|
+
// its stable identity, so a text tweak to an existing frame doesn't block it;
|
|
421
|
+
// anyone who added a custom frame is left entirely alone.
|
|
422
|
+
const frameId = (f) => (Array.isArray(f?.requires) && f.requires.length)
|
|
423
|
+
? 'r:' + [...f.requires].map(String).sort().join('|')
|
|
424
|
+
: 't:' + (f?.details ?? '') + '' + (f?.state ?? '');
|
|
425
|
+
const dflBy = DEFAULT_CONFIG.presence?.byStatus || {};
|
|
426
|
+
const usrBy = cfg.presence.byStatus || {};
|
|
427
|
+
let framesAdded = 0;
|
|
428
|
+
for (const status of Object.keys(dflBy)) {
|
|
429
|
+
const dRot = dflBy[status]?.rotation;
|
|
430
|
+
const uEntry = usrBy[status];
|
|
431
|
+
const uRot = uEntry?.rotation;
|
|
432
|
+
if (!Array.isArray(dRot) || !dRot.length || !Array.isArray(uRot) || !uRot.length) continue;
|
|
433
|
+
const dIds = new Set(dRot.map(frameId));
|
|
434
|
+
if (!uRot.every((f) => dIds.has(frameId(f)))) continue; // user customized — hands off
|
|
435
|
+
const uIds = new Set(uRot.map(frameId));
|
|
436
|
+
const missing = dRot.filter((f) => !uIds.has(frameId(f)));
|
|
437
|
+
if (missing.length) {
|
|
438
|
+
uEntry.rotation = [...uRot, ...missing.map((f) => JSON.parse(JSON.stringify(f)))];
|
|
439
|
+
framesAdded += missing.length;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (framesAdded) added.push(`+${framesAdded} default rotation frame${framesAdded === 1 ? '' : 's'}`);
|
|
443
|
+
|
|
398
444
|
if (added.length === 0) return false;
|
|
399
445
|
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
|
400
446
|
if (!silent) dirtyStep(SYM_OK, 'config migrated', `added: ${added.join(', ')}`);
|
package/src/mcp.js
CHANGED
|
@@ -8,9 +8,6 @@
|
|
|
8
8
|
// without standing up the transport.
|
|
9
9
|
|
|
10
10
|
import { readAggregate, dayKey } from './scanner.js';
|
|
11
|
-
import { buildVars } from './format.js';
|
|
12
|
-
import { readState } from './state.js';
|
|
13
|
-
import { loadConfig } from './config.js';
|
|
14
11
|
import { VERSION } from './version.js';
|
|
15
12
|
|
|
16
13
|
function fmtH(ms) { const h = (ms || 0) / 3_600_000; return h < 1 ? `${Math.round(h * 60)}m` : `${h.toFixed(1)}h`; }
|
|
@@ -99,7 +96,11 @@ export function toolList() {
|
|
|
99
96
|
export function callTool(name, getAgg = readAggregate) {
|
|
100
97
|
const t = TOOLS[name];
|
|
101
98
|
if (!t) throw new Error(`unknown tool: ${name}`);
|
|
102
|
-
const agg = getAgg()
|
|
99
|
+
const agg = getAgg();
|
|
100
|
+
// No aggregate at all (fresh install / standalone MCP / scanner never ran):
|
|
101
|
+
// a `|| {}` here would render all-zeros, indistinguishable from a genuinely
|
|
102
|
+
// idle day. Surface the same hint every other surface uses instead.
|
|
103
|
+
if (agg == null) return 'No stats yet — run `claude-rpc scan` to build your history.';
|
|
103
104
|
return t.handler(agg);
|
|
104
105
|
}
|
|
105
106
|
|
|
@@ -130,6 +131,11 @@ export function runMcpServer({ input = process.stdin, output = process.stdout }
|
|
|
130
131
|
if (method === 'tools/list') return reply(id, { tools: toolList() });
|
|
131
132
|
if (method === 'tools/call') {
|
|
132
133
|
const name = params?.name;
|
|
134
|
+
// "No such tool" is a protocol error, not a tool that ran and failed —
|
|
135
|
+
// validate before dispatch so we don't conflate the two (or leak the
|
|
136
|
+
// literal "undefined" when params.name is omitted).
|
|
137
|
+
if (!name) return replyErr(id, -32602, 'missing tool name');
|
|
138
|
+
if (!TOOLS[name]) return replyErr(id, -32602, `unknown tool: ${name}`);
|
|
133
139
|
try {
|
|
134
140
|
const text = callTool(name);
|
|
135
141
|
return reply(id, { content: [{ type: 'text', text }], isError: false });
|