claude-rpc 0.16.2 → 0.17.1
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 +62 -88
- package/src/default-config.js +6 -0
- package/src/doctor.js +21 -3
- 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 +88 -0
- package/src/privacy.js +1 -1
- 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, shouldShowGithubButton } 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;
|
|
@@ -275,12 +238,12 @@ function buildActivity(opts = {}) {
|
|
|
275
238
|
if (typeof config.activityType === 'number') activity.type = config.activityType;
|
|
276
239
|
|
|
277
240
|
// Buttons: static configured set, optionally augmented with a per-project
|
|
278
|
-
// GitHub button when the current cwd has a github origin.
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
|
|
241
|
+
// GitHub button when the current cwd has a github origin. Suppressed under any
|
|
242
|
+
// non-public privacy verdict (else the link leaks the project we're hiding),
|
|
243
|
+
// while stale, and when presence.githubButton is set to false (the explicit
|
|
244
|
+
// off switch — works even without the gh CLI that private-repo detection needs).
|
|
282
245
|
const buttons = Array.isArray(p.buttons) ? p.buttons.slice() : [];
|
|
283
|
-
const gh = (
|
|
246
|
+
const gh = shouldShowGithubButton(p, state) ? detectGithubUrl(state.cwd) : null;
|
|
284
247
|
if (gh && !buttons.some((b) => /github\.com/i.test(b.url || ''))) {
|
|
285
248
|
buttons.unshift({ label: 'View on GitHub →', url: gh });
|
|
286
249
|
}
|
|
@@ -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/default-config.js
CHANGED
|
@@ -204,6 +204,12 @@ export const DEFAULT_CONFIG = {
|
|
|
204
204
|
},
|
|
205
205
|
},
|
|
206
206
|
|
|
207
|
+
// Auto-prepend a "View on GitHub →" button when the cwd is a github repo.
|
|
208
|
+
// Set false to never show it — the privacy-safe off switch for machines
|
|
209
|
+
// without the `gh` CLI, where private repos can't be auto-detected and would
|
|
210
|
+
// otherwise have their link appear on the card.
|
|
211
|
+
githubButton: true,
|
|
212
|
+
|
|
207
213
|
buttons: [
|
|
208
214
|
// The card others see in Discord is the project's main distribution
|
|
209
215
|
// surface — make the button a real call-to-action, not a bare repo link.
|
package/src/doctor.js
CHANGED
|
@@ -15,7 +15,8 @@ import {
|
|
|
15
15
|
CLAUDE_HOME, CLAUDE_PROJECTS, CLAUDE_SETTINGS,
|
|
16
16
|
} from './paths.js';
|
|
17
17
|
import { findLiveSessions } from './scanner.js';
|
|
18
|
-
import {
|
|
18
|
+
import { detectGithubUrl } from './git.js';
|
|
19
|
+
import { resolveVisibility, listPrivateCwds, detectGithubPrivate } from './privacy.js';
|
|
19
20
|
import { readClaudeCredentials, readUsageCache } from './usage.js';
|
|
20
21
|
import { c, check as uiCheck } from './ui.js';
|
|
21
22
|
|
|
@@ -330,6 +331,18 @@ function checkPrivacy(cfg) {
|
|
|
330
331
|
} else {
|
|
331
332
|
check('private-list entries', 'pass', 'none');
|
|
332
333
|
}
|
|
334
|
+
// Private-repo guard. The "View on GitHub →" button URL is read from
|
|
335
|
+
// .git/config (no gh needed), but auto-hiding a PRIVATE repo needs the gh
|
|
336
|
+
// CLI. On a github repo here where the button would show (public verdict,
|
|
337
|
+
// button enabled) and gh can't answer, a private repo's link could leak.
|
|
338
|
+
const ghUrl = detectGithubUrl(cwd);
|
|
339
|
+
if (ghUrl && visibility === 'public' && cfg?.presence?.githubButton !== false
|
|
340
|
+
&& cfg?.privacy?.autoDetectGithubPrivate !== false
|
|
341
|
+
&& detectGithubPrivate(cwd) === null) {
|
|
342
|
+
check('private-repo guard', 'warn',
|
|
343
|
+
'gh CLI unavailable — a private repo here can\'t be auto-detected, so its GitHub link may appear on the card',
|
|
344
|
+
'run `gh auth login`, set presence.githubButton:false, or `claude-rpc private` in private repos');
|
|
345
|
+
}
|
|
333
346
|
} catch (e) {
|
|
334
347
|
check('privacy check', 'warn', `lookup failed: ${e.message}`);
|
|
335
348
|
}
|
|
@@ -366,8 +379,13 @@ function checkUsage(cfg) {
|
|
|
366
379
|
}
|
|
367
380
|
const u = readUsageCache();
|
|
368
381
|
if (u) {
|
|
369
|
-
|
|
370
|
-
|
|
382
|
+
// Either bucket can be null (they come and go between Claude Code
|
|
383
|
+
// releases); interpolating both unconditionally printed a literal `null%`.
|
|
384
|
+
const parts = [];
|
|
385
|
+
if (u.weeklyPct != null) parts.push(`week ${u.weeklyPct}%`);
|
|
386
|
+
if (u.sessionPct != null) parts.push(`session ${u.sessionPct}%`);
|
|
387
|
+
parts.push(`fetched ${Math.max(0, Math.round((Date.now() - u.fetchedAt) / 60_000))} min ago`);
|
|
388
|
+
check('usage polling', 'pass', parts.join(' · '));
|
|
371
389
|
} else {
|
|
372
390
|
check('usage polling', 'warn', 'no fresh usage data yet',
|
|
373
391
|
'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
|