@cullumco/cadence 0.1.3 → 0.1.5

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
@@ -4,15 +4,18 @@ import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { getMusicSignal } from "./providers/music.js";
6
6
  import { getSelfReportSignal } from "./providers/selfreport.js";
7
- import { getAmbientSignal } from "./providers/ambient.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) {
17
20
  try {
18
21
  const text = (await readFile(STATE_FILE, "utf-8")).trim();
@@ -37,21 +40,27 @@ async function cmdClear() {
37
40
  // or null when there's nothing to say. Shared by `test` and the bare command.
38
41
  async function buildPreview() {
39
42
  const signals = [];
40
- const [music, report, ambient, git, overrides] = await Promise.all([
41
- getMusicSignal().catch(() => null),
43
+ const providers = await loadProviders();
44
+ const [music, report, environment, git, esoteric, overrides] = await Promise.all([
45
+ getMusicSignal(providers).catch(() => null),
42
46
  getSelfReportSignal().catch(() => null),
43
- getAmbientSignal(new Date()).catch(() => null),
47
+ getEnvironmentSignal(new Date(), {
48
+ focusedAppEnabled: providerEnabled(providers, "focusedApp"),
49
+ }).catch(() => null),
44
50
  getGitSignal(process.cwd()).catch(() => null),
51
+ getEsotericSignal(providers).catch(() => null),
45
52
  loadOverrides(),
46
53
  ]);
47
54
  if (music)
48
55
  signals.push(music);
49
56
  if (report)
50
57
  signals.push(report);
51
- if (ambient)
52
- signals.push(ambient);
58
+ if (environment)
59
+ signals.push(environment);
53
60
  if (git)
54
61
  signals.push(git);
62
+ if (esoteric)
63
+ signals.push(esoteric);
55
64
  if (signals.length === 0 && Object.keys(overrides).length === 0)
56
65
  return null;
57
66
  const state = { signals, capturedAt: Date.now() };
@@ -62,7 +71,7 @@ async function buildPreview() {
62
71
  async function cmdTest() {
63
72
  const block = await buildPreview();
64
73
  if (!block) {
65
- console.log(' (no signals — play something, set: cadence state "...", or pin a dial: cadence set pace fast)');
74
+ console.log(' (no signals — play something, set: cadence report "...", or pin a dial: cadence set pace fast)');
66
75
  return;
67
76
  }
68
77
  console.log("\n" + block + "\n");
@@ -70,16 +79,170 @@ async function cmdTest() {
70
79
  // The legibility view: every signal Cadence can read — live value, or the
71
80
  // reason it's absent. Unlike `test`, this never goes silent.
72
81
  async function cmdSignals() {
73
- const [music, report, ambient, git] = await Promise.all([
82
+ const [music, report, environment, git, providers] = await Promise.all([
74
83
  getMusicSignal().catch(() => null),
75
84
  getSelfReportSignal().catch(() => null),
76
- getAmbientSignal(new Date()).catch(() => null),
85
+ getEnvironmentSignal(new Date()).catch(() => null),
77
86
  getGitSignal(process.cwd()).catch(() => null),
87
+ loadProviders(),
78
88
  ]);
79
89
  console.log("\n" +
80
- renderSignalsTable({ music, report, ambient, git, now: Date.now(), platform: process.platform }) +
90
+ renderSignalsTable({
91
+ music,
92
+ report,
93
+ environment,
94
+ git,
95
+ providers,
96
+ now: Date.now(),
97
+ platform: process.platform,
98
+ }) +
81
99
  "\n");
82
100
  }
101
+ // ── opt-in provider registry: the consent layer ────────────────────────────
102
+ async function loadCfg() {
103
+ try {
104
+ return JSON.parse(await readFile(CONFIG_FILE, "utf-8"));
105
+ }
106
+ catch {
107
+ return {};
108
+ }
109
+ }
110
+ async function saveCfg(cfg) {
111
+ await mkdir(CADENCE_DIR, { recursive: true });
112
+ await writeFile(CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf-8");
113
+ }
114
+ function cfgProviders(cfg) {
115
+ return cfg["providers"] && typeof cfg["providers"] === "object"
116
+ ? cfg["providers"]
117
+ : {};
118
+ }
119
+ function knownProvider(name) {
120
+ return name != null && name in OPT_IN_PROVIDERS;
121
+ }
122
+ function listProviders() {
123
+ console.log(" opt-in signals (off until you enable them):");
124
+ for (const [name, desc] of Object.entries(OPT_IN_PROVIDERS)) {
125
+ console.log(` ${name.padEnd(14)} ${desc}`);
126
+ }
127
+ }
128
+ async function cmdEnable(args) {
129
+ const [name, ...valueParts] = args;
130
+ if (!name) {
131
+ console.log(" usage: cadence enable <signal> [value] e.g. cadence enable typingTempo");
132
+ listProviders();
133
+ return;
134
+ }
135
+ if (!knownProvider(name)) {
136
+ console.error(` unknown signal "${name}".`);
137
+ if (name === "spotify")
138
+ console.error(" spotify takes credentials — run: cadence spotify");
139
+ listProviders();
140
+ process.exit(1);
141
+ }
142
+ const cfg = await loadCfg();
143
+ const providers = cfgProviders(cfg);
144
+ providers[name] = valueParts.length ? valueParts.join(" ") : true;
145
+ cfg["providers"] = providers;
146
+ await saveCfg(cfg);
147
+ console.log(` enabled ${name}${valueParts.length ? ` = "${valueParts.join(" ")}"` : ""}`);
148
+ }
149
+ // The whole-product kill switch. State (pins, opt-ins, self-report) survives a
150
+ // pause untouched — resume picks up exactly where you left off.
151
+ async function cmdPause() {
152
+ const cfg = await loadCfg();
153
+ cfg["paused"] = true;
154
+ await saveCfg(cfg);
155
+ console.log(" cadence paused — prompts go through untouched. resume: cadence resume");
156
+ }
157
+ async function cmdResume() {
158
+ const cfg = await loadCfg();
159
+ delete cfg["paused"];
160
+ await saveCfg(cfg);
161
+ console.log(" cadence resumed — reading the room again. preview: cadence test");
162
+ }
163
+ async function cmdDisable(args) {
164
+ const [name] = args;
165
+ if (!name) {
166
+ console.log(" usage: cadence disable <signal>");
167
+ listProviders();
168
+ return;
169
+ }
170
+ const cfg = await loadCfg();
171
+ const providers = cfgProviders(cfg);
172
+ delete providers[name];
173
+ cfg["providers"] = providers;
174
+ await saveCfg(cfg);
175
+ console.log(` disabled ${name}`);
176
+ }
177
+ // Spotify is the cross-platform music source — opt-in, browser-authorized via
178
+ // PKCE (no client secret to keep). Bring your own Spotify app client id (or
179
+ // bake one in below / via env for a zero-config experience).
180
+ const DEFAULT_SPOTIFY_CLIENT_ID = ""; // register a "Cadence" app and set this to ship zero-config
181
+ const SPOTIFY_HELP = ` cadence spotify — link Spotify as a cross-platform now-playing source
182
+
183
+ macOS already reads Spotify.app / Music.app with zero setup. This is for
184
+ Linux / Windows (or anyone not scripting the desktop app).
185
+
186
+ One-time setup, then we handle the token dance for you:
187
+ 1. Create an app at https://developer.spotify.com/dashboard
188
+ 2. Add this redirect URI to it: ${REDIRECT_URI}
189
+ 3. cadence spotify connect <clientId> (opens your browser once)
190
+
191
+ Then it's just another music signal — vibe still comes from MusicBrainz.
192
+ Advanced (skip the browser): cadence spotify <clientId> <refreshToken>
193
+ Turn it off: cadence spotify off`;
194
+ async function cmdSpotifyConnect(clientIdArg) {
195
+ const clientId = clientIdArg || process.env["CADENCE_SPOTIFY_CLIENT_ID"] || DEFAULT_SPOTIFY_CLIENT_ID;
196
+ if (!clientId) {
197
+ console.log(" cadence spotify connect needs a Spotify app client id.\n");
198
+ console.log(SPOTIFY_HELP);
199
+ return;
200
+ }
201
+ if (!process.stdin.isTTY) {
202
+ console.log(" spotify connect is interactive — run it in a terminal.");
203
+ return;
204
+ }
205
+ try {
206
+ const refreshToken = await connectSpotify(clientId, (m) => console.log(m));
207
+ const cfg = await loadCfg();
208
+ const providers = cfgProviders(cfg);
209
+ providers["spotify"] = { clientId, refreshToken };
210
+ cfg["providers"] = providers;
211
+ await saveCfg(cfg);
212
+ console.log(" ✓ Spotify linked — currently-playing is now a cross-platform signal");
213
+ }
214
+ catch (e) {
215
+ console.error(` couldn't link Spotify: ${e instanceof Error ? e.message : String(e)}`);
216
+ process.exit(1);
217
+ }
218
+ }
219
+ async function cmdSpotify(args) {
220
+ const [first, second, third] = args;
221
+ if (first === "connect")
222
+ return cmdSpotifyConnect(second);
223
+ if (first === "off") {
224
+ const cfg = await loadCfg();
225
+ const providers = cfgProviders(cfg);
226
+ delete providers["spotify"];
227
+ cfg["providers"] = providers;
228
+ await saveCfg(cfg);
229
+ console.log(" unlinked Spotify");
230
+ return;
231
+ }
232
+ // advanced manual path: clientId + refreshToken (+ optional secret)
233
+ if (first && second) {
234
+ const cfg = await loadCfg();
235
+ const providers = cfgProviders(cfg);
236
+ providers["spotify"] = third
237
+ ? { clientId: first, refreshToken: second, clientSecret: third }
238
+ : { clientId: first, refreshToken: second };
239
+ cfg["providers"] = providers;
240
+ await saveCfg(cfg);
241
+ console.log(" Spotify linked — currently-playing is now a cross-platform signal");
242
+ return;
243
+ }
244
+ console.log(SPOTIFY_HELP);
245
+ }
83
246
  const LEVELS = ["low", "medium", "high"];
84
247
  async function cmdSet(args) {
85
248
  const [dial, value] = args;
@@ -189,18 +352,23 @@ async function hasUserInput() {
189
352
  return state.trim().length > 0 || Object.keys(cfg).length > 0;
190
353
  }
191
354
  const INPUTS_FOOTER = ` where you can input:
192
- cadence state "..." how you are right now (4h TTL)
355
+ cadence report "..." how you are right now (2h TTL)
193
356
  cadence set <dial> <level> pin a dial: ${DIALS.join(", ")}
194
357
  cadence set-location <lat> <lon> opt into weather
195
358
  cadence start interactive setup
196
359
  cadence help everything else`;
197
360
  // Bare \`cadence\`: live status + where to input — not a help dump.
198
361
  async function cmdRoot() {
362
+ if (await isPaused()) {
363
+ console.log("\n cadence is paused — prompts go through untouched.");
364
+ console.log(" resume: cadence resume\n");
365
+ return;
366
+ }
199
367
  if (!(await hasUserInput())) {
200
368
  console.log("\n cadence — agents that read the room");
201
369
  console.log(" It hasn't heard from you yet. Fastest start:\n");
202
370
  console.log(' cadence start guided setup (~30s)');
203
- console.log(' cadence state "ship mode" or just say how you are\n');
371
+ console.log(' cadence report "ship mode" or just say how you are\n');
204
372
  return;
205
373
  }
206
374
  const block = await buildPreview();
@@ -215,7 +383,7 @@ async function cmdRoot() {
215
383
  // Guided first run: three prompts, every one skippable, nothing destructive.
216
384
  async function cmdStart() {
217
385
  if (!process.stdin.isTTY) {
218
- console.log(' cadence start is interactive — run it in a terminal, or use: cadence state "..."');
386
+ console.log(' cadence start is interactive — run it in a terminal, or use: cadence report "..."');
219
387
  return;
220
388
  }
221
389
  const { createInterface } = await import("node:readline/promises");
@@ -228,10 +396,10 @@ async function cmdStart() {
228
396
  if (state) {
229
397
  await mkdir(CADENCE_DIR, { recursive: true });
230
398
  await writeFile(STATE_FILE, state, "utf-8");
231
- console.log(' ✓ set — expires after 4h; update anytime: cadence state "..."\n');
399
+ console.log(' ✓ set — expires after 2h; update anytime: cadence report "..."\n');
232
400
  }
233
401
  else {
234
- console.log(' skipped — later: cadence state "..."\n');
402
+ console.log(' skipped — later: cadence report "..."\n');
235
403
  }
236
404
  // 2 ── dial pins: overrides, so only offered, never pushed
237
405
  console.log(` 2/3 Pin any dials? Pins override inference until unset.`);
@@ -279,12 +447,15 @@ const HELP = `
279
447
 
280
448
  daily:
281
449
  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
450
+ cadence start guided setup (self-report, dials, weather — all skippable)
451
+ cadence report "..." set your self-report (e.g. "two beers, ship mode")
452
+ cadence report print current self-report
453
+ ("cadence state" still works as an alias)
454
+ cadence clear clear self-report
286
455
  cadence test preview what the hook would inject right now
287
456
  cadence signals every signal — live value, or why it's absent
457
+ cadence pause silence all hooks (state survives untouched)
458
+ cadence resume start reading the room again
288
459
 
289
460
  dials (your determination — pinned dials override inference):
290
461
  cadence dials show the mixing board and what's pinned
@@ -293,16 +464,28 @@ const HELP = `
293
464
  dials: pace, tone, posture, proactivity
294
465
  (env also works: CADENCE_PACE=fast)
295
466
 
296
- ambient (time & day are automatic; weather is opt-in):
467
+ environment (time & day are automatic; weather is opt-in):
297
468
  cadence set-location <lat> <lon> [name] turn on weather for your area
469
+
470
+ opt-in signals (off until you turn them on — as much as you're willing to give):
471
+ cadence enable <signal> [value] turn an opt-in signal on (e.g. typingTempo)
472
+ cadence disable <signal> turn it back off
473
+ see them all: cadence signals
474
+
475
+ music (macOS reads Spotify.app / Music.app automatically):
476
+ cadence spotify connect <id> link Spotify (cross-platform, opens browser)
477
+ cadence spotify off unlink it
298
478
  `;
299
479
  async function main() {
300
480
  const [cmd, ...rest] = process.argv.slice(2);
301
481
  switch (cmd) {
302
482
  case "start":
303
483
  return cmdStart();
304
- case "state":
305
- return cmdState(rest);
484
+ case "report":
485
+ return cmdReport(rest);
486
+ case "state": // deprecated alias for `report` — kept for alpha installs
487
+ console.error(' note: "cadence state" is now "cadence report" (alias kept for now)');
488
+ return cmdReport(rest);
306
489
  case "clear":
307
490
  return cmdClear();
308
491
  case "test":
@@ -317,6 +500,16 @@ async function main() {
317
500
  return cmdDials();
318
501
  case "set-location":
319
502
  return cmdLocation(rest);
503
+ case "pause":
504
+ return cmdPause();
505
+ case "resume":
506
+ return cmdResume();
507
+ case "enable":
508
+ return cmdEnable(rest);
509
+ case "disable":
510
+ return cmdDisable(rest);
511
+ case "spotify":
512
+ return cmdSpotify(rest);
320
513
  case undefined:
321
514
  return cmdRoot(); // live status + inputs, not the help dump
322
515
  case "help":
package/dist/config.js ADDED
@@ -0,0 +1,76 @@
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
+ moon: "current moon phase (offline) → esoteric flavor",
28
+ horoscope: "daily horoscope for your sign, e.g. `cadence enable horoscope leo`",
29
+ };
30
+ export async function loadConfig() {
31
+ try {
32
+ const raw = JSON.parse(await readFile(CONFIG_FILE, "utf-8"));
33
+ return raw && typeof raw === "object" ? raw : {};
34
+ }
35
+ catch {
36
+ return {}; // no/garbled config → empty, never throw
37
+ }
38
+ }
39
+ /** The opt-in registry, or `{}` if the user hasn't opted into anything. */
40
+ export function readProviders(cfg) {
41
+ const p = cfg["providers"];
42
+ return p && typeof p === "object" ? p : {};
43
+ }
44
+ export async function loadProviders() {
45
+ return readProviders(await loadConfig());
46
+ }
47
+ /* A provider is enabled when its key carries a truthy value: `true`, a config
48
+ * object (`{ ics: "…" }`), or a setting string (`"leo"`). `false`, `null`,
49
+ * empty string, or an empty object all read as "off" — tri-state honesty, so
50
+ * `"horoscope": ""` doesn't silently count as consent. */
51
+ export function providerEnabled(providers, name) {
52
+ const v = providers[name];
53
+ if (v == null || v === false || v === "")
54
+ return false;
55
+ if (typeof v === "object")
56
+ return Object.keys(v).length > 0;
57
+ return Boolean(v);
58
+ }
59
+ /** The raw setting for a provider (e.g. the horoscope sign, the calendar ics
60
+ * object), or undefined when it's off. Lets a provider read its own config
61
+ * without re-parsing the file. */
62
+ export function providerSetting(providers, name) {
63
+ return providerEnabled(providers, name) ? providers[name] : undefined;
64
+ }
65
+ /* ── pause: the whole-product kill switch ──────────────────────────────────
66
+ * An ambient layer that injects into every prompt needs a visible, instant
67
+ * off switch. `cadence pause` sets `"paused": true`; every hook checks it
68
+ * FIRST and exits silently — no signals read, no subprocesses spawned, no
69
+ * block injected. State (pins, opt-ins, self-report) is preserved untouched,
70
+ * so `cadence resume` picks up exactly where you left off. */
71
+ export function readPaused(cfg) {
72
+ return cfg["paused"] === true;
73
+ }
74
+ export async function isPaused() {
75
+ return readPaused(await loadConfig());
76
+ }
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,53 @@ 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(), { focusedAppEnabled: providerEnabled(providers, "focusedApp") }),
35
39
  getGitSignal(cwd),
36
- getActivitySignal(prompt),
40
+ getActivitySignal(prompt, Date.now(), { tempoEnabled }),
41
+ getIntentSignal(prompt),
42
+ getEsotericSignal(providers),
37
43
  ]);
38
44
  const signals = [];
39
45
  if (music.status === "fulfilled" && music.value)
40
46
  signals.push(music.value);
41
47
  if (report.status === "fulfilled" && report.value)
42
48
  signals.push(report.value);
43
- if (ambient.status === "fulfilled" && ambient.value)
44
- signals.push(ambient.value);
49
+ if (environment.status === "fulfilled" && environment.value)
50
+ signals.push(environment.value);
45
51
  if (git.status === "fulfilled" && git.value)
46
52
  signals.push(git.value);
47
53
  if (activity.status === "fulfilled" && activity.value)
48
54
  signals.push(activity.value);
55
+ if (intent.status === "fulfilled" && intent.value)
56
+ signals.push(intent.value);
57
+ if (esoteric.status === "fulfilled" && esoteric.value)
58
+ signals.push(esoteric.value);
49
59
  return signals;
50
60
  }
51
61
  async function main() {
62
+ // Paused = the user asked for silence. Check FIRST: no signals read, no
63
+ // subprocesses spawned, nothing injected. `cadence resume` turns it back on.
64
+ if (await isPaused())
65
+ process.exit(0);
52
66
  const { cwd, prompt } = await readStdin();
53
67
  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(),
68
+ // Pins + the opt-in registry are tiny local reads; load them first so signal
69
+ // collection knows which opt-in providers to run, then race only the
70
+ // subprocess-heavy collection against the budget.
71
+ const [overrides, providers] = await Promise.all([loadOverrides(), loadProviders()]);
72
+ const signals = await Promise.race([
73
+ collectSignals(projectDir, prompt, providers),
74
+ // unref: the losing timer must not hold the process open after the
75
+ // race settles — Claude Code waits on our EXIT, not our output.
76
+ new Promise((resolve) => setTimeout(() => {
77
+ debug("hook", `signal collection exceeded ${TOTAL_BUDGET_MS}ms budget — injecting without signals`);
78
+ resolve([]);
79
+ }, TOTAL_BUDGET_MS).unref()),
63
80
  ]);
64
81
  // Nothing to say: no signals AND no pinned dials.
65
82
  if (signals.length === 0 && Object.keys(overrides).length === 0) {
@@ -70,12 +87,14 @@ async function main() {
70
87
  const reframe = buildReframe(cadence);
71
88
  const stateWithCadence = { ...state, cadence, pinned, reframe };
72
89
  const block = render(stateWithCadence);
90
+ // Exit in the write callback (stdout to a pipe can flush async): a straggling
91
+ // provider subprocess must never keep the user's prompt waiting on our exit.
73
92
  process.stdout.write(JSON.stringify({
74
93
  hookSpecificOutput: {
75
94
  hookEventName: "UserPromptSubmit",
76
95
  additionalContext: block,
77
96
  },
78
- }));
97
+ }), () => process.exit(0));
79
98
  }
80
99
  main().catch((err) => {
81
100
  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.