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/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
- let rotationIndex = 0;
94
- let lastRotationAt = 0;
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
- function maybeAdvanceRotation(rotation, intervalMs) {
108
- if (!Array.isArray(rotation) || rotation.length === 0) return undefined;
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
- // Choose frames + a status-level largeImageText override based on the new
119
- // `presence.byStatus` block when present. Falls back to legacy `p.rotation`
120
- // (which still works for any existing user config that doesn't use byStatus).
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
- if (state.status !== rotationStatus) {
206
- rotationIndex = 0;
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 = maybeAdvanceRotation(safeFrames, intervalMs) || {};
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
- // ── Large-image precedence (single source of truth) ────────────────
227
- // 1. statusAssets[status] "working" gif when working, etc.
228
- // 2. modelAssets[opus|sonnet|haiku|default]
229
- // per-model art (Opus/Sonnet/Haiku),
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. Privacy mode
279
- // suppresses the GitHub button entirely (else clicking it leaks the
280
- // project name we're trying to hide).
281
- const isPrivacyConstrained = state._privacy && state._privacy.visibility !== 'public';
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 = (!isPrivacyConstrained && state.status !== 'stale') ? detectGithubUrl(state.cwd) : null;
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
- function fireStatusSideEffects(resolved) {
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
- const project = humanProject(resolved.cwd) || 'Claude Code';
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
- if ((resolved.status === 'stale' && hideWhenStale) || privacyHidden || pausedUntil) {
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;
@@ -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 { resolveVisibility, listPrivateCwds } from './privacy.js';
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
- check('usage polling', 'pass',
370
- `week ${u.weeklyPct}% · session ${u.sessionPct}% · fetched ${Math.max(0, Math.round((Date.now() - u.fetchedAt) / 60_000))} min ago`);
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
- // Authoritative close signal from the SessionEnd hook trust it instead
830
- // of waiting on staleSessionMin. Any other hook clears the flag, so a
831
- // sibling session staying alive will reset us out of this branch.
832
- if (state.claudeClosed) return staleWipe(state);
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
- const recent = liveSessions[0] || {};
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 = spawnSync('gh', ['--version'], { stdio: 'ignore' });
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 = spawnSync('gh', args, { encoding: 'utf8' });
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 = spawnSync('gh', ['gist', 'edit', gistId, filePath], { encoding: 'utf8' });
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
- const m = cfg.match(/\[remote\s+"origin"\][^[]*?url\s*=\s*([^\r\n]+)/i);
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
- if (toks[1] === 'pr' && toks[2] === 'create') return 'pr';
48
- if (toks[1] === 'issue' && toks[2] === 'create') return 'issue';
49
- if (toks[1] === 'release' && toks[2] === 'create') return 'tag';
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
- const usage = input.tool_response?.usage || input.usage;
215
- if (usage) {
216
- s.tokens.input += usage.input_tokens || 0;
217
- s.tokens.output += usage.output_tokens || 0;
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
- case 'PostCompact': {
259
- // Compaction finished clear the marker and drop to idle. The next
260
- // hook (UserPromptSubmit / PreToolUse) will set the real next state.
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
- processHookEvent(event, parseInput());
298
- process.stdout.write(JSON.stringify({ continue: true }));
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