@cullumco/cadence 0.1.4 → 0.1.6

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/dist/cli.js CHANGED
@@ -3,55 +3,77 @@ import { writeFile, mkdir, readFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { getMusicSignal } from "./providers/music.js";
6
- import { getSelfReportSignal } from "./providers/selfreport.js";
7
- import { getAmbientSignal } from "./providers/ambient.js";
6
+ import { getSelfReportSignal, STALE_AFTER_MS } from "./providers/selfreport.js";
7
+ import { getEnvironmentSignal } from "./providers/environment.js";
8
8
  import { getGitSignal } from "./providers/git.js";
9
+ import { getEsotericSignal } from "./providers/esoteric.js";
9
10
  import { deriveCadence, buildReframe, loadOverrides, applyOverrides, resolveDialLevel, DIALS, DIAL_WORDS, } from "./cadence.js";
10
11
  import { render } from "./inject.js";
11
12
  import { renderSignalsTable } from "./signals-view.js";
13
+ import { loadProviders, providerEnabled, isPaused, OPT_IN_PROVIDERS } from "./config.js";
14
+ import { connectSpotify, REDIRECT_URI } from "./spotify-auth.js";
12
15
  const CADENCE_DIR = join(homedir(), ".cadence");
13
16
  const STATE_FILE = join(CADENCE_DIR, "state.txt");
14
17
  const CONFIG_FILE = join(CADENCE_DIR, "config.json");
15
- async function cmdState(args) {
18
+ async function cmdReport(args) {
16
19
  if (args.length === 0) {
20
+ // Same TTL the hook applies — printing an expired report as if it were
21
+ // live would contradict what actually gets injected.
22
+ const report = await getSelfReportSignal();
23
+ if (report) {
24
+ const rem = Math.max(0, STALE_AFTER_MS - (Date.now() - report.setAt));
25
+ const h = Math.floor(rem / 3_600_000);
26
+ const m = Math.floor((rem % 3_600_000) / 60_000);
27
+ console.log(`${report.text} (${h > 0 ? `${h}h${String(m).padStart(2, "0")}m` : `${m}m`} left)`);
28
+ return;
29
+ }
17
30
  try {
18
- const text = (await readFile(STATE_FILE, "utf-8")).trim();
19
- console.log(text || "(no state set)");
31
+ const stale = (await readFile(STATE_FILE, "utf-8")).trim();
32
+ console.log(stale
33
+ ? `(last self-report expired — refresh: cadence report "...")`
34
+ : "(no self-report set)");
20
35
  }
21
36
  catch {
22
- console.log("(no state set)");
37
+ console.log("(no self-report set)");
23
38
  }
24
39
  return;
25
40
  }
26
41
  const text = args.join(" ");
27
42
  await mkdir(CADENCE_DIR, { recursive: true });
28
43
  await writeFile(STATE_FILE, text, "utf-8");
29
- console.log(` state set: "${text}"`);
44
+ console.log(` self-report set: "${text}"`);
30
45
  }
31
46
  async function cmdClear() {
32
47
  await mkdir(CADENCE_DIR, { recursive: true });
33
48
  await writeFile(STATE_FILE, "", "utf-8");
34
- console.log(" state cleared");
49
+ console.log(" self-report cleared");
35
50
  }
36
51
  // Collects live signals and renders the exact block the hook would inject,
37
52
  // or null when there's nothing to say. Shared by `test` and the bare command.
38
53
  async function buildPreview() {
39
54
  const signals = [];
40
- const [music, report, ambient, git, overrides] = await Promise.all([
41
- getMusicSignal().catch(() => null),
55
+ const providers = await loadProviders();
56
+ const [music, report, environment, git, esoteric, overrides] = await Promise.all([
57
+ getMusicSignal(providers).catch(() => null),
42
58
  getSelfReportSignal().catch(() => null),
43
- getAmbientSignal(new Date()).catch(() => null),
59
+ getEnvironmentSignal(new Date(), {
60
+ focusedAppEnabled: providerEnabled(providers, "focusedApp"),
61
+ wifiEnabled: providerEnabled(providers, "wifi"),
62
+ }).catch(() => null),
44
63
  getGitSignal(process.cwd()).catch(() => null),
64
+ getEsotericSignal(providers).catch(() => null),
45
65
  loadOverrides(),
46
66
  ]);
47
67
  if (music)
48
68
  signals.push(music);
49
69
  if (report)
50
70
  signals.push(report);
51
- if (ambient)
52
- signals.push(ambient);
71
+ if (environment)
72
+ signals.push(environment);
53
73
  if (git)
54
74
  signals.push(git);
75
+ if (esoteric)
76
+ signals.push(esoteric);
55
77
  if (signals.length === 0 && Object.keys(overrides).length === 0)
56
78
  return null;
57
79
  const state = { signals, capturedAt: Date.now() };
@@ -62,7 +84,7 @@ async function buildPreview() {
62
84
  async function cmdTest() {
63
85
  const block = await buildPreview();
64
86
  if (!block) {
65
- console.log(' (no signals — play something, set: cadence state "...", or pin a dial: cadence set pace fast)');
87
+ console.log(' (no signals — play something, set: cadence report "...", or pin a dial: cadence set pace fast)');
66
88
  return;
67
89
  }
68
90
  console.log("\n" + block + "\n");
@@ -70,16 +92,173 @@ async function cmdTest() {
70
92
  // The legibility view: every signal Cadence can read — live value, or the
71
93
  // reason it's absent. Unlike `test`, this never goes silent.
72
94
  async function cmdSignals() {
73
- const [music, report, ambient, git] = await Promise.all([
95
+ const providers = await loadProviders();
96
+ const [music, report, environment, git] = await Promise.all([
74
97
  getMusicSignal().catch(() => null),
75
98
  getSelfReportSignal().catch(() => null),
76
- getAmbientSignal(new Date()).catch(() => null),
99
+ getEnvironmentSignal(new Date(), {
100
+ focusedAppEnabled: providerEnabled(providers, "focusedApp"),
101
+ wifiEnabled: providerEnabled(providers, "wifi"),
102
+ }).catch(() => null),
77
103
  getGitSignal(process.cwd()).catch(() => null),
78
104
  ]);
79
105
  console.log("\n" +
80
- renderSignalsTable({ music, report, ambient, git, now: Date.now(), platform: process.platform }) +
106
+ renderSignalsTable({
107
+ music,
108
+ report,
109
+ environment,
110
+ git,
111
+ providers,
112
+ now: Date.now(),
113
+ platform: process.platform,
114
+ }) +
81
115
  "\n");
82
116
  }
117
+ // ── opt-in provider registry: the consent layer ────────────────────────────
118
+ async function loadCfg() {
119
+ try {
120
+ return JSON.parse(await readFile(CONFIG_FILE, "utf-8"));
121
+ }
122
+ catch {
123
+ return {};
124
+ }
125
+ }
126
+ async function saveCfg(cfg) {
127
+ await mkdir(CADENCE_DIR, { recursive: true });
128
+ await writeFile(CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf-8");
129
+ }
130
+ function cfgProviders(cfg) {
131
+ return cfg["providers"] && typeof cfg["providers"] === "object"
132
+ ? cfg["providers"]
133
+ : {};
134
+ }
135
+ function knownProvider(name) {
136
+ return name != null && name in OPT_IN_PROVIDERS;
137
+ }
138
+ function listProviders() {
139
+ console.log(" opt-in signals (off until you enable them):");
140
+ for (const [name, desc] of Object.entries(OPT_IN_PROVIDERS)) {
141
+ console.log(` ${name.padEnd(14)} ${desc}`);
142
+ }
143
+ }
144
+ async function cmdEnable(args) {
145
+ const [name, ...valueParts] = args;
146
+ if (!name) {
147
+ console.log(" usage: cadence enable <signal> [value] e.g. cadence enable typingTempo");
148
+ listProviders();
149
+ return;
150
+ }
151
+ if (!knownProvider(name)) {
152
+ console.error(` unknown signal "${name}".`);
153
+ if (name === "spotify")
154
+ console.error(" spotify takes credentials — run: cadence spotify");
155
+ listProviders();
156
+ process.exit(1);
157
+ }
158
+ const cfg = await loadCfg();
159
+ const providers = cfgProviders(cfg);
160
+ providers[name] = valueParts.length ? valueParts.join(" ") : true;
161
+ cfg["providers"] = providers;
162
+ await saveCfg(cfg);
163
+ console.log(` enabled ${name}${valueParts.length ? ` = "${valueParts.join(" ")}"` : ""}`);
164
+ }
165
+ // The whole-product kill switch. State (pins, opt-ins, self-report) survives a
166
+ // pause untouched — resume picks up exactly where you left off.
167
+ async function cmdPause() {
168
+ const cfg = await loadCfg();
169
+ cfg["paused"] = true;
170
+ await saveCfg(cfg);
171
+ console.log(" cadence paused — prompts go through untouched. resume: cadence resume");
172
+ }
173
+ async function cmdResume() {
174
+ const cfg = await loadCfg();
175
+ delete cfg["paused"];
176
+ await saveCfg(cfg);
177
+ console.log(" cadence resumed — reading the room again. preview: cadence test");
178
+ }
179
+ async function cmdDisable(args) {
180
+ const [name] = args;
181
+ if (!name) {
182
+ console.log(" usage: cadence disable <signal>");
183
+ listProviders();
184
+ return;
185
+ }
186
+ const cfg = await loadCfg();
187
+ const providers = cfgProviders(cfg);
188
+ delete providers[name];
189
+ cfg["providers"] = providers;
190
+ await saveCfg(cfg);
191
+ console.log(` disabled ${name}`);
192
+ }
193
+ // Spotify is the cross-platform music source — opt-in, browser-authorized via
194
+ // PKCE (no client secret to keep). Bring your own Spotify app client id (or
195
+ // bake one in below / via env for a zero-config experience).
196
+ const DEFAULT_SPOTIFY_CLIENT_ID = ""; // register a "Cadence" app and set this to ship zero-config
197
+ const SPOTIFY_HELP = ` cadence spotify — link Spotify as a cross-platform now-playing source
198
+
199
+ macOS already reads Spotify.app / Music.app with zero setup. This is for
200
+ Linux / Windows (or anyone not scripting the desktop app).
201
+
202
+ One-time setup, then we handle the token dance for you:
203
+ 1. Create an app at https://developer.spotify.com/dashboard
204
+ 2. Add this redirect URI to it: ${REDIRECT_URI}
205
+ 3. cadence spotify connect <clientId> (opens your browser once)
206
+
207
+ Then it's just another music signal — vibe still comes from MusicBrainz.
208
+ Advanced (skip the browser): cadence spotify <clientId> <refreshToken>
209
+ Turn it off: cadence spotify off`;
210
+ async function cmdSpotifyConnect(clientIdArg) {
211
+ const clientId = clientIdArg || process.env["CADENCE_SPOTIFY_CLIENT_ID"] || DEFAULT_SPOTIFY_CLIENT_ID;
212
+ if (!clientId) {
213
+ console.log(" cadence spotify connect needs a Spotify app client id.\n");
214
+ console.log(SPOTIFY_HELP);
215
+ return;
216
+ }
217
+ if (!process.stdin.isTTY) {
218
+ console.log(" spotify connect is interactive — run it in a terminal.");
219
+ return;
220
+ }
221
+ try {
222
+ const refreshToken = await connectSpotify(clientId, (m) => console.log(m));
223
+ const cfg = await loadCfg();
224
+ const providers = cfgProviders(cfg);
225
+ providers["spotify"] = { clientId, refreshToken };
226
+ cfg["providers"] = providers;
227
+ await saveCfg(cfg);
228
+ console.log(" ✓ Spotify linked — currently-playing is now a cross-platform signal");
229
+ }
230
+ catch (e) {
231
+ console.error(` couldn't link Spotify: ${e instanceof Error ? e.message : String(e)}`);
232
+ process.exit(1);
233
+ }
234
+ }
235
+ async function cmdSpotify(args) {
236
+ const [first, second, third] = args;
237
+ if (first === "connect")
238
+ return cmdSpotifyConnect(second);
239
+ if (first === "off") {
240
+ const cfg = await loadCfg();
241
+ const providers = cfgProviders(cfg);
242
+ delete providers["spotify"];
243
+ cfg["providers"] = providers;
244
+ await saveCfg(cfg);
245
+ console.log(" unlinked Spotify");
246
+ return;
247
+ }
248
+ // advanced manual path: clientId + refreshToken (+ optional secret)
249
+ if (first && second) {
250
+ const cfg = await loadCfg();
251
+ const providers = cfgProviders(cfg);
252
+ providers["spotify"] = third
253
+ ? { clientId: first, refreshToken: second, clientSecret: third }
254
+ : { clientId: first, refreshToken: second };
255
+ cfg["providers"] = providers;
256
+ await saveCfg(cfg);
257
+ console.log(" Spotify linked — currently-playing is now a cross-platform signal");
258
+ return;
259
+ }
260
+ console.log(SPOTIFY_HELP);
261
+ }
83
262
  const LEVELS = ["low", "medium", "high"];
84
263
  async function cmdSet(args) {
85
264
  const [dial, value] = args;
@@ -189,18 +368,23 @@ async function hasUserInput() {
189
368
  return state.trim().length > 0 || Object.keys(cfg).length > 0;
190
369
  }
191
370
  const INPUTS_FOOTER = ` where you can input:
192
- cadence state "..." how you are right now (4h TTL)
371
+ cadence report "..." how you are right now (2h TTL)
193
372
  cadence set <dial> <level> pin a dial: ${DIALS.join(", ")}
194
373
  cadence set-location <lat> <lon> opt into weather
195
374
  cadence start interactive setup
196
375
  cadence help everything else`;
197
376
  // Bare \`cadence\`: live status + where to input — not a help dump.
198
377
  async function cmdRoot() {
378
+ if (await isPaused()) {
379
+ console.log("\n cadence is paused — prompts go through untouched.");
380
+ console.log(" resume: cadence resume\n");
381
+ return;
382
+ }
199
383
  if (!(await hasUserInput())) {
200
384
  console.log("\n cadence — agents that read the room");
201
385
  console.log(" It hasn't heard from you yet. Fastest start:\n");
202
386
  console.log(' cadence start guided setup (~30s)');
203
- console.log(' cadence state "ship mode" or just say how you are\n');
387
+ console.log(' cadence report "ship mode" or just say how you are\n');
204
388
  return;
205
389
  }
206
390
  const block = await buildPreview();
@@ -215,7 +399,7 @@ async function cmdRoot() {
215
399
  // Guided first run: three prompts, every one skippable, nothing destructive.
216
400
  async function cmdStart() {
217
401
  if (!process.stdin.isTTY) {
218
- console.log(' cadence start is interactive — run it in a terminal, or use: cadence state "..."');
402
+ console.log(' cadence start is interactive — run it in a terminal, or use: cadence report "..."');
219
403
  return;
220
404
  }
221
405
  const { createInterface } = await import("node:readline/promises");
@@ -228,10 +412,10 @@ async function cmdStart() {
228
412
  if (state) {
229
413
  await mkdir(CADENCE_DIR, { recursive: true });
230
414
  await writeFile(STATE_FILE, state, "utf-8");
231
- console.log(' ✓ set — expires after 4h; update anytime: cadence state "..."\n');
415
+ console.log(' ✓ set — expires after 2h; update anytime: cadence report "..."\n');
232
416
  }
233
417
  else {
234
- console.log(' skipped — later: cadence state "..."\n');
418
+ console.log(' skipped — later: cadence report "..."\n');
235
419
  }
236
420
  // 2 ── dial pins: overrides, so only offered, never pushed
237
421
  console.log(` 2/3 Pin any dials? Pins override inference until unset.`);
@@ -279,12 +463,15 @@ const HELP = `
279
463
 
280
464
  daily:
281
465
  cadence live status + where to input
282
- cadence start guided setup (state, dials, weather — all skippable)
283
- cadence state "..." set self-reported state (e.g. "two beers, ship mode")
284
- cadence state print current self-reported state
285
- cadence clear clear self-reported state
466
+ cadence start guided setup (self-report, dials, weather — all skippable)
467
+ cadence report "..." set your self-report (e.g. "two beers, ship mode")
468
+ cadence report print current self-report
469
+ ("cadence state" still works as an alias)
470
+ cadence clear clear self-report
286
471
  cadence test preview what the hook would inject right now
287
472
  cadence signals every signal — live value, or why it's absent
473
+ cadence pause silence all hooks (state survives untouched)
474
+ cadence resume start reading the room again
288
475
 
289
476
  dials (your determination — pinned dials override inference):
290
477
  cadence dials show the mixing board and what's pinned
@@ -293,16 +480,28 @@ const HELP = `
293
480
  dials: pace, tone, posture, proactivity
294
481
  (env also works: CADENCE_PACE=fast)
295
482
 
296
- ambient (time & day are automatic; weather is opt-in):
483
+ environment (time & day are automatic; weather is opt-in):
297
484
  cadence set-location <lat> <lon> [name] turn on weather for your area
485
+
486
+ opt-in signals (off until you turn them on — as much as you're willing to give):
487
+ cadence enable <signal> [value] turn an opt-in signal on (e.g. typingTempo)
488
+ cadence disable <signal> turn it back off
489
+ see them all: cadence signals
490
+
491
+ music (macOS reads Spotify.app / Music.app automatically):
492
+ cadence spotify connect <id> link Spotify (cross-platform, opens browser)
493
+ cadence spotify off unlink it
298
494
  `;
299
495
  async function main() {
300
496
  const [cmd, ...rest] = process.argv.slice(2);
301
497
  switch (cmd) {
302
498
  case "start":
303
499
  return cmdStart();
304
- case "state":
305
- return cmdState(rest);
500
+ case "report":
501
+ return cmdReport(rest);
502
+ case "state": // deprecated alias for `report` — kept for alpha installs
503
+ console.error(' note: "cadence state" is now "cadence report" (alias kept for now)');
504
+ return cmdReport(rest);
306
505
  case "clear":
307
506
  return cmdClear();
308
507
  case "test":
@@ -317,6 +516,16 @@ async function main() {
317
516
  return cmdDials();
318
517
  case "set-location":
319
518
  return cmdLocation(rest);
519
+ case "pause":
520
+ return cmdPause();
521
+ case "resume":
522
+ return cmdResume();
523
+ case "enable":
524
+ return cmdEnable(rest);
525
+ case "disable":
526
+ return cmdDisable(rest);
527
+ case "spotify":
528
+ return cmdSpotify(rest);
320
529
  case undefined:
321
530
  return cmdRoot(); // live status + inputs, not the help dump
322
531
  case "help":
package/dist/config.js ADDED
@@ -0,0 +1,77 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ /* ─────────────────────────────────────────────────────────────────────────
5
+ * Config + the opt-in provider registry.
6
+ *
7
+ * `~/.cadence/config.json` already held pinned dials and a weather location.
8
+ * It now also holds a `providers` block — the consent registry. Signals that
9
+ * read something the user might not want shared (their calendar, the app
10
+ * they're in, an esoteric feed) stay OFF until the user names them here:
11
+ *
12
+ * { "providers": { "typingTempo": true, "focusedApp": true,
13
+ * "calendar": { "ics": "https://…" }, "horoscope": "leo" } }
14
+ *
15
+ * The rule is "as many signals as the user is willing to give": nothing
16
+ * privacy-adjacent fires on inference alone. Always resolves — a missing or
17
+ * garbled file reads as "nothing opted in," never throws.
18
+ * ───────────────────────────────────────────────────────────────────────── */
19
+ const CONFIG_FILE = join(homedir(), ".cadence", "config.json");
20
+ /* The opt-in signals the user can turn on, and a one-line description for the
21
+ * CLI. Adding a row here is what makes `cadence enable <name>` accept it — the
22
+ * single source of truth shared by the CLI, the signals view, and the
23
+ * providers. Grow this as opt-in providers land. */
24
+ export const OPT_IN_PROVIDERS = {
25
+ typingTempo: "prompt rhythm — rapid-fire vs. one long considered prompt → pace",
26
+ focusedApp: "frontmost non-terminal app (macOS) → flavor in the context line",
27
+ wifi: "wifi network name (macOS) → place context, home vs. office vs. café",
28
+ moon: "current moon phase (offline) → esoteric flavor",
29
+ horoscope: "daily horoscope for your sign, e.g. `cadence enable horoscope leo`",
30
+ };
31
+ export async function loadConfig() {
32
+ try {
33
+ const raw = JSON.parse(await readFile(CONFIG_FILE, "utf-8"));
34
+ return raw && typeof raw === "object" ? raw : {};
35
+ }
36
+ catch {
37
+ return {}; // no/garbled config → empty, never throw
38
+ }
39
+ }
40
+ /** The opt-in registry, or `{}` if the user hasn't opted into anything. */
41
+ export function readProviders(cfg) {
42
+ const p = cfg["providers"];
43
+ return p && typeof p === "object" ? p : {};
44
+ }
45
+ export async function loadProviders() {
46
+ return readProviders(await loadConfig());
47
+ }
48
+ /* A provider is enabled when its key carries a truthy value: `true`, a config
49
+ * object (`{ ics: "…" }`), or a setting string (`"leo"`). `false`, `null`,
50
+ * empty string, or an empty object all read as "off" — tri-state honesty, so
51
+ * `"horoscope": ""` doesn't silently count as consent. */
52
+ export function providerEnabled(providers, name) {
53
+ const v = providers[name];
54
+ if (v == null || v === false || v === "")
55
+ return false;
56
+ if (typeof v === "object")
57
+ return Object.keys(v).length > 0;
58
+ return Boolean(v);
59
+ }
60
+ /** The raw setting for a provider (e.g. the horoscope sign, the calendar ics
61
+ * object), or undefined when it's off. Lets a provider read its own config
62
+ * without re-parsing the file. */
63
+ export function providerSetting(providers, name) {
64
+ return providerEnabled(providers, name) ? providers[name] : undefined;
65
+ }
66
+ /* ── pause: the whole-product kill switch ──────────────────────────────────
67
+ * An ambient layer that injects into every prompt needs a visible, instant
68
+ * off switch. `cadence pause` sets `"paused": true`; every hook checks it
69
+ * FIRST and exits silently — no signals read, no subprocesses spawned, no
70
+ * block injected. State (pins, opt-ins, self-report) is preserved untouched,
71
+ * so `cadence resume` picks up exactly where you left off. */
72
+ export function readPaused(cfg) {
73
+ return cfg["paused"] === true;
74
+ }
75
+ export async function isPaused() {
76
+ return readPaused(await loadConfig());
77
+ }
package/dist/hook.js CHANGED
@@ -1,10 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { getMusicSignal } from "./providers/music.js";
3
3
  import { getSelfReportSignal } from "./providers/selfreport.js";
4
- import { getAmbientSignal } from "./providers/ambient.js";
4
+ import { getEnvironmentSignal } from "./providers/environment.js";
5
5
  import { getGitSignal } from "./providers/git.js";
6
6
  import { getActivitySignal } from "./providers/activity.js";
7
+ import { getIntentSignal } from "./providers/intent.js";
8
+ import { getEsotericSignal } from "./providers/esoteric.js";
7
9
  import { deriveCadence, buildReframe, loadOverrides, applyOverrides } from "./cadence.js";
10
+ import { loadProviders, providerEnabled, isPaused } from "./config.js";
8
11
  import { render } from "./inject.js";
9
12
  import { debug } from "./debug.js";
10
13
  const TOTAL_BUDGET_MS = 1500;
@@ -27,39 +30,56 @@ async function readStdin() {
27
30
  return {};
28
31
  }
29
32
  }
30
- async function collectSignals(cwd, prompt) {
31
- const [music, report, ambient, git, activity] = await Promise.allSettled([
32
- getMusicSignal(),
33
+ async function collectSignals(cwd, prompt, providers) {
34
+ const tempoEnabled = providerEnabled(providers, "typingTempo");
35
+ const [music, report, environment, git, activity, intent, esoteric] = await Promise.allSettled([
36
+ getMusicSignal(providers),
33
37
  getSelfReportSignal(),
34
- getAmbientSignal(new Date()),
38
+ getEnvironmentSignal(new Date(), {
39
+ focusedAppEnabled: providerEnabled(providers, "focusedApp"),
40
+ wifiEnabled: providerEnabled(providers, "wifi"),
41
+ }),
35
42
  getGitSignal(cwd),
36
- getActivitySignal(prompt),
43
+ getActivitySignal(prompt, Date.now(), { tempoEnabled }),
44
+ getIntentSignal(prompt),
45
+ getEsotericSignal(providers),
37
46
  ]);
38
47
  const signals = [];
39
48
  if (music.status === "fulfilled" && music.value)
40
49
  signals.push(music.value);
41
50
  if (report.status === "fulfilled" && report.value)
42
51
  signals.push(report.value);
43
- if (ambient.status === "fulfilled" && ambient.value)
44
- signals.push(ambient.value);
52
+ if (environment.status === "fulfilled" && environment.value)
53
+ signals.push(environment.value);
45
54
  if (git.status === "fulfilled" && git.value)
46
55
  signals.push(git.value);
47
56
  if (activity.status === "fulfilled" && activity.value)
48
57
  signals.push(activity.value);
58
+ if (intent.status === "fulfilled" && intent.value)
59
+ signals.push(intent.value);
60
+ if (esoteric.status === "fulfilled" && esoteric.value)
61
+ signals.push(esoteric.value);
49
62
  return signals;
50
63
  }
51
64
  async function main() {
65
+ // Paused = the user asked for silence. Check FIRST: no signals read, no
66
+ // subprocesses spawned, nothing injected. `cadence resume` turns it back on.
67
+ if (await isPaused())
68
+ process.exit(0);
52
69
  const { cwd, prompt } = await readStdin();
53
70
  const projectDir = cwd ?? process.cwd();
54
- const [signals, overrides] = await Promise.all([
55
- Promise.race([
56
- collectSignals(projectDir, prompt),
57
- new Promise((resolve) => setTimeout(() => {
58
- debug("hook", `signal collection exceeded ${TOTAL_BUDGET_MS}ms budget — injecting without signals`);
59
- resolve([]);
60
- }, TOTAL_BUDGET_MS)),
61
- ]),
62
- loadOverrides(),
71
+ // Pins + the opt-in registry are tiny local reads; load them first so signal
72
+ // collection knows which opt-in providers to run, then race only the
73
+ // subprocess-heavy collection against the budget.
74
+ const [overrides, providers] = await Promise.all([loadOverrides(), loadProviders()]);
75
+ const signals = await Promise.race([
76
+ collectSignals(projectDir, prompt, providers),
77
+ // unref: the losing timer must not hold the process open after the
78
+ // race settles — Claude Code waits on our EXIT, not our output.
79
+ new Promise((resolve) => setTimeout(() => {
80
+ debug("hook", `signal collection exceeded ${TOTAL_BUDGET_MS}ms budget — injecting without signals`);
81
+ resolve([]);
82
+ }, TOTAL_BUDGET_MS).unref()),
63
83
  ]);
64
84
  // Nothing to say: no signals AND no pinned dials.
65
85
  if (signals.length === 0 && Object.keys(overrides).length === 0) {
@@ -70,12 +90,14 @@ async function main() {
70
90
  const reframe = buildReframe(cadence);
71
91
  const stateWithCadence = { ...state, cadence, pinned, reframe };
72
92
  const block = render(stateWithCadence);
93
+ // Exit in the write callback (stdout to a pipe can flush async): a straggling
94
+ // provider subprocess must never keep the user's prompt waiting on our exit.
73
95
  process.stdout.write(JSON.stringify({
74
96
  hookSpecificOutput: {
75
97
  hookEventName: "UserPromptSubmit",
76
98
  additionalContext: block,
77
99
  },
78
- }));
100
+ }), () => process.exit(0));
79
101
  }
80
102
  main().catch((err) => {
81
103
  const msg = err instanceof Error ? err.message : String(err);
package/dist/inject.js CHANGED
@@ -28,10 +28,16 @@ function renderGit(g) {
28
28
  ].filter(Boolean);
29
29
  return ` git: ${parts.join(", ")}`;
30
30
  }
31
+ function renderIntent(i) {
32
+ if (!i.kind)
33
+ return [];
34
+ return [` intent: ${i.kind} (read from your prompt)`];
35
+ }
31
36
  function renderActivity(a) {
32
37
  const parts = [
33
38
  a.minSinceLastPrompt != null ? `min_since_prompt=${a.minSinceLastPrompt}` : null,
34
39
  a.promptLength != null ? `prompt_len=${a.promptLength}` : null,
40
+ a.tempo ? `tempo=${a.tempo}` : null,
35
41
  ].filter(Boolean);
36
42
  return ` activity: { ${parts.join(" ")} }`;
37
43
  }
@@ -42,7 +48,7 @@ function renderPlace(p) {
42
48
  ].filter(Boolean);
43
49
  return ` place: { ${parts.join(" ")} }`;
44
50
  }
45
- function renderAmbient(a) {
51
+ function renderEnvironment(a) {
46
52
  // Human-readable atmosphere line, e.g.
47
53
  // "friday late night, rainy, unplugged 8%, dark mode, on Home-wifi, up 14h"
48
54
  const parts = [
@@ -52,6 +58,7 @@ function renderAmbient(a) {
52
58
  ? `unplugged${a.batteryPct != null ? ` ${a.batteryPct}%` : ""}`
53
59
  : null,
54
60
  a.focus === true ? "focus on" : null,
61
+ a.focusedApp ? `in ${quote(a.focusedApp)}` : null,
55
62
  a.darkMode === true ? "dark mode" : null,
56
63
  a.displays != null && a.displays > 1 ? `${a.displays} displays` : null,
57
64
  a.network ? `on ${quote(a.network)}` : null,
@@ -60,6 +67,13 @@ function renderAmbient(a) {
60
67
  ].filter(Boolean);
61
68
  return ` context: ${parts.join(", ")}`;
62
69
  }
70
+ function renderEsoteric(e) {
71
+ const parts = [
72
+ e.moonPhase ? `moon ${e.moonPhase}` : null,
73
+ e.horoscope ? `${e.sign ?? "horoscope"}: ${quote(e.horoscope)}` : null,
74
+ ].filter(Boolean);
75
+ return parts.length ? [` esoteric: ${parts.join(" · ")}`] : [];
76
+ }
63
77
  function renderCadence(c, pinned) {
64
78
  const dials = Object.keys(DIAL_WORDS)
65
79
  .map((d) => {
@@ -78,12 +92,16 @@ export function render(state) {
78
92
  lines.push(renderReport(sig));
79
93
  else if (sig.source === "git")
80
94
  lines.push(renderGit(sig));
95
+ else if (sig.source === "intent")
96
+ lines.push(...renderIntent(sig));
81
97
  else if (sig.source === "activity")
82
98
  lines.push(renderActivity(sig));
83
99
  else if (sig.source === "place")
84
100
  lines.push(renderPlace(sig));
85
- else if (sig.source === "ambient")
86
- lines.push(renderAmbient(sig));
101
+ else if (sig.source === "environment")
102
+ lines.push(renderEnvironment(sig));
103
+ else if (sig.source === "esoteric")
104
+ lines.push(...renderEsoteric(sig));
87
105
  }
88
106
  // Render even with zero signals if the user pinned dials — a hand-set board
89
107
  // is itself a signal worth injecting.