contextspin 0.6.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,39 +14,35 @@ ContextSpin is a **compositor and renderer, not a data layer.** It has no API cl
14
14
  - **CLI tools** already installed and authenticated on your machine (`gh`, `kubectl`, `aws`, your own scripts…)
15
15
  - **HTTP endpoints** you can already reach (internal dashboards, status APIs)
16
16
 
17
- ContextSpin polls those sources on a schedule, formats whatever they return into short one-line snippets, and injects the most relevant one into the Claude Code status bar. If a piece of data isn't reachable by a tool you already have, ContextSpin cannot show it — by design. The only runtime dependency is [`commander`](https://www.npmjs.com/package/commander).
17
+ ContextSpin formats whatever your sources return into short one-line snippets and shows the most relevant one in the Claude Code status bar. If a piece of data isn't reachable by a tool you already have, ContextSpin cannot show it — by design. The only runtime dependency is [`commander`](https://www.npmjs.com/package/commander).
18
18
 
19
- ## Architecture
19
+ ## Architecture (daemonless)
20
+
21
+ There is **no background daemon by default**. The statusline render is the engine: it serves the cached snippet instantly, and only when a source is due does it kick off a detached one-shot refresh (lock-guarded so frequent renders never overlap). Nothing runs when you're not in Claude Code — idle cost is zero.
20
22
 
21
23
  ```
22
- ┌─────────────────────────────────────────────────────────┐
23
- SOURCES (things you already have) │
24
- │ │
25
- │ mcp ──► stdio MCP servers from ~/.claude.json │
26
- │ cli ──► shell commands (gh, kubectl, scripts...) │
27
- │ http ──► HTTP/JSON endpoints you can reach │
28
- └───────────────────────────┬─────────────────────────────┘
29
- │ poll on per-source cooldown
24
+ Claude Code draws the status bar (every refreshInterval)
25
+
30
26
 
31
27
  ┌─────────────────────────────────────────────────────────┐
32
- POLLING DAEMON (detached background process)
33
- runs each source, applies filter + format
34
- merges / dedups / prioritizes snippets │
35
- writes ~/.contextspin-cache.json (atomic)
28
+ RENDER ~/.contextspin/statusline.mjs
29
+ 1. read ~/.contextspin-cache.json
30
+ 2. print one snippet NOW (stale is fine) ───────────────┼──► status bar
31
+ 3. if a source is past its cooldown AND no refresh is
32
+ │ in flight → spawn a detached one-shot refresh: │
36
33
  └───────────────────────────┬─────────────────────────────┘
37
- read cache
34
+ (background, non-blocking)
38
35
 
39
36
  ┌─────────────────────────────────────────────────────────┐
40
- INJECTOR (statusline — non-destructive, composed)
41
- statusline ──► ~/.contextspin/statusline.sh
42
- composed into ~/.claude/settings.json
43
- patcher ──► rewrites spinner words (EXPERIMENTAL)
44
- └───────────────────────────┬─────────────────────────────┘
45
-
46
-
47
- Claude Code status bar shows one snippet
37
+ REFRESH (one-shot) src/refresh-entry.js
38
+ • runs each DUE source (cli / http / mcp), formats
39
+ merges / dedups / prioritizes, records lastRun
40
+ writes ~/.contextspin-cache.json (atomic)
41
+ └─────────────────────────────────────────────────────────┘
48
42
  ```
49
43
 
44
+ This is *stale-while-revalidate*: the bar is always fast (it never waits on the network), and freshness catches up in the background. A legacy always-on **daemon** is still available behind `injection.daemonless: false` — useful only if you poll stdio **MCP** sources, where a persistent connection beats per-render handshakes.
45
+
50
46
  The daemon and the injector are decoupled by the cache file: the daemon writes snippets, the injector reads them. Each runs on its own clock.
51
47
 
52
48
  ## Install
@@ -68,11 +64,11 @@ Prefer to do it yourself? `npx contextspin install` does the same thing. To remo
68
64
 
69
65
  ```bash
70
66
  npx contextspin setup # create a config (add --yes to skip prompts)
71
- npx contextspin start # start the background polling daemon
72
67
  npx contextspin inject # wire snippets into the status bar
68
+ # that's it — the render refreshes itself; no daemon to start
73
69
  ```
74
70
 
75
- Running `npx contextspin` with **no subcommand** is a shortcut: if no config exists it runs `setup`, otherwise it runs `start` followed by `inject` using the mode from your config.
71
+ `npx contextspin install` is the recommended path (it also wires the self-healing SessionStart hook). Running `npx contextspin` with **no subcommand** sets up if unconfigured, otherwise wires + refreshes.
76
72
 
77
73
  </details>
78
74
 
@@ -187,6 +183,7 @@ ContextSpin reads one JSON file: `~/.contextspin.json` (override with the `CONTE
187
183
  | `injection.refresh` | number | seconds | `30` | Daemon poll interval and status-line refresh interval (seconds). |
188
184
  | `injection.maxVisible` | number | count | `5` | Global cap on snippets held in the cache. |
189
185
  | `injection.style` | boolean | — | `true` | Render the line in a styled box (cyan bars + italic). Set `false` for plain text. |
186
+ | `injection.daemonless` | boolean | — | `true` | Render self-refreshes (stale-while-revalidate), no background process. Set `false` for the legacy always-on daemon. |
190
187
  | `snippets.deduplication` | boolean | — | `true` | Drop snippets with duplicate text when merging. |
191
188
  | `snippets.cooldownAfterShown` | number | count | `3` | A snippet stops being eligible once shown this many times. |
192
189
  | `snippets.priorityOrder` | string[] | source labels | `[]` | Earlier labels sort first (case-insensitive); unlisted sort last. |
@@ -229,15 +226,14 @@ Key facts:
229
226
 
230
227
  Restore the originals with `contextspin uninject --mode patcher` (or `inject --mode both` / `uninject --mode both` to do both at once). A backup with the suffix `.contextspin.backup` is created before any install is touched.
231
228
 
232
- ## Daemon and cache
229
+ ## Refresh and cache
233
230
 
234
- `contextspin start` spawns a **detached** background process (the daemon). It writes its PID to `~/.contextspin/daemon.pid` and logs to `~/.contextspin/daemon.log`. The loop:
231
+ By default there is **no background process**. Each time the status bar draws, the render:
235
232
 
236
- 1. For each source whose `cooldown` has elapsed, runs it, applies the filter, formats records, and slices to `maxSnippets`.
237
- 2. Merges the fresh snippets into the existing set: preserves `shownCount` for matching text, optionally dedups, sorts by `priorityOrder` then by recency, and caps to `injection.maxVisible`.
238
- 3. Atomically writes the cache, then sleeps `injection.refresh` seconds.
233
+ 1. Prints the current cached snippet immediately (stale is fine).
234
+ 2. If any source is past its `cooldown` and no refresh is already in flight (a lock at `~/.contextspin/refresh.lock`, TTL 60s), spawns a **detached one-shot refresh**. That refresh runs only the due sources, merges into the existing set (preserves `shownCount` for matching text, optionally dedups, sorts by `priorityOrder` then recency, caps to `injection.maxVisible`), records per-source `lastRun`, and atomically writes the cache.
239
235
 
240
- `stop` / `restart` manage the process; `status` reports whether it's running and lists the current snippets.
236
+ Set `injection.daemonless: false` to use the **legacy daemon** instead: `contextspin start` spawns a detached background process (PID at `~/.contextspin/daemon.pid`, logs at `~/.contextspin/daemon.log`) that polls on `injection.refresh` and writes the same cache. Use this only if you poll stdio MCP sources.
241
237
 
242
238
  ### Cache file format (`~/.contextspin-cache.json`)
243
239
 
@@ -252,27 +248,26 @@ Restore the originals with `contextspin uninject --mode patcher` (or `inject --m
252
248
  "fetchedAt": "2026-06-17T09:00:00.000Z",
253
249
  "shownCount": 0
254
250
  }
255
- ]
251
+ ],
252
+ "meta": { "lastRun": { "2": 1781860451773 } }
256
253
  }
257
254
  ```
258
255
 
259
- `shownCount` is incremented by the status-line renderer each time a snippet is displayed; once it reaches `cooldownAfterShown` the snippet is no longer shown.
256
+ `shownCount` is incremented by the render each time a snippet is displayed; once it reaches `cooldownAfterShown` the snippet is no longer shown. `meta.lastRun` maps `sourceId → last poll (epoch ms)` so the daemonless refresh honors per-source cooldowns across runs.
260
257
 
261
258
  ## CLI commands
262
259
 
263
260
  | Command | What it does |
264
261
  |---------|--------------|
265
- | `contextspin install` | **One-shot install:** wire a self-healing SessionStart hook, create the config, wire the statusline, and start the daemon. (This is what the curl script runs.) |
266
- | `contextspin uninstall` | **Full teardown:** remove the hook, restore your prior statusline in **every** scope it wired, and stop the daemon. |
262
+ | `contextspin install` | **One-shot install:** wire a self-healing SessionStart hook, create the config, and wire the statusline. (This is what the curl script runs.) |
263
+ | `contextspin uninstall` | **Full teardown:** remove the hook, restore your prior statusline in **every** scope it wired, and stop any daemon. |
267
264
  | `contextspin setup [--yes]` | Create `~/.contextspin.json` (interactive, or a detected config with `--yes` / non-TTY). |
268
- | `contextspin start` | Start the detached polling daemon. |
269
- | `contextspin stop` | Stop the daemon. |
270
- | `contextspin restart` | Stop then start. |
271
- | `contextspin status` | Show daemon state and the current cached snippets (source, age, shown count). |
272
- | `contextspin ensure` | Idempotent: create config + wire statusline + start daemon (run by the SessionStart hook each session). |
265
+ | `contextspin status` | Show the engine, and the current cached snippets (source, age, shown count). |
266
+ | `contextspin ensure` | Idempotent: create config + wire statusline (run by the SessionStart hook each session). |
273
267
  | `contextspin inject [--mode <m>]` | Install just the injector. `<m>` overrides `injection.mode` (`statusline` / `patcher` / `both`). |
274
268
  | `contextspin uninject [--mode <m>]` | Reverse just the injector. |
275
- | `contextspin` *(no subcommand)* | `setup` if unconfigured, otherwise `start` then `inject`. |
269
+ | `contextspin start` / `stop` / `restart` | Manage the **legacy daemon** (only when `injection.daemonless: false`). |
270
+ | `contextspin` *(no subcommand)* | `setup` if unconfigured, otherwise wire + refresh. |
276
271
 
277
272
  ## High-impact snippets
278
273
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextspin",
3
- "version": "0.6.4",
3
+ "version": "0.7.0",
4
4
  "description": "Replace Claude Code spinner/statusline text with live org context (meetings, Slack, CI, incidents, PRs) aggregated from your existing MCP servers, CLIs, and HTTP endpoints.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  CONFIG_PATH,
14
14
  STATUSLINE_SH,
15
15
  CLAUDE_SETTINGS_PATH,
16
+ DEFAULT_DAEMONLESS,
16
17
  configExists,
17
18
  loadConfig,
18
19
  saveConfig,
@@ -24,6 +25,7 @@ import {
24
25
  stopDaemon,
25
26
  isDaemonRunning,
26
27
  readCache,
28
+ runRefreshOnce,
27
29
  } from './daemon.js';
28
30
  import {
29
31
  installStatusline,
@@ -264,7 +266,20 @@ async function runEnsure() {
264
266
  if (!wasOurs) did.push('wired statusline');
265
267
  }
266
268
 
267
- if (!isDaemonRunning().running) {
269
+ // DAEMONLESS (default): the render revalidates itself, so no background
270
+ // process is started. Only the legacy daemon engine needs a daemon.
271
+ const daemonless =
272
+ config && config.injection && typeof config.injection.daemonless === 'boolean'
273
+ ? config.injection.daemonless
274
+ : DEFAULT_DAEMONLESS;
275
+ if (daemonless) {
276
+ // Upgrading from the daemon engine? Stop the now-redundant background
277
+ // process so it doesn't linger after the switch.
278
+ if (isDaemonRunning().running) {
279
+ await stopDaemon();
280
+ did.push('stopped legacy daemon');
281
+ }
282
+ } else if (!isDaemonRunning().running) {
268
283
  startDaemonDetached();
269
284
  did.push('started daemon');
270
285
  }
@@ -326,16 +341,45 @@ async function runRestart() {
326
341
  await runStart();
327
342
  }
328
343
 
344
+ /**
345
+ * Force a one-shot refresh now (the daemonless "refresh now"): polls every due
346
+ * source and rewrites the cache. Works regardless of engine.
347
+ * @returns {Promise<void>}
348
+ */
349
+ async function runRefresh() {
350
+ if (!configExists()) {
351
+ printSetupHint();
352
+ process.exit(1);
353
+ return;
354
+ }
355
+ await runRefreshOnce();
356
+ console.log('ContextSpin: refreshed.');
357
+ }
358
+
329
359
  /**
330
360
  * Print the daemon running state plus the current cache contents.
331
361
  * @returns {Promise<void>}
332
362
  */
333
363
  async function runStatus() {
364
+ // Report the active engine. In daemonless mode (the default) there is no
365
+ // background process by design — the render revalidates the cache itself.
366
+ let daemonless = DEFAULT_DAEMONLESS;
367
+ try {
368
+ const cfg = await loadConfig();
369
+ if (cfg && cfg.injection && typeof cfg.injection.daemonless === 'boolean') {
370
+ daemonless = cfg.injection.daemonless;
371
+ }
372
+ } catch {
373
+ // no/invalid config -> assume the default engine
374
+ }
375
+
334
376
  const { running, pid } = isDaemonRunning();
335
- if (running) {
336
- console.log(`Daemon: running (pid ${pid})`);
377
+ if (daemonless) {
378
+ console.log('Engine: daemonless (statusline self-refreshes; no background process)');
379
+ } else if (running) {
380
+ console.log(`Engine: daemon (running, pid ${pid})`);
337
381
  } else {
338
- console.log('Daemon: stopped');
382
+ console.log('Engine: daemon (stopped)');
339
383
  }
340
384
 
341
385
  const cache = await readCache();
@@ -590,9 +634,14 @@ function buildProgram() {
590
634
  .description('Restart the background daemon')
591
635
  .action(action(async () => runRestart()));
592
636
 
637
+ program
638
+ .command('refresh')
639
+ .description('Force a one-shot refresh of all due sources now')
640
+ .action(action(async () => runRefresh()));
641
+
593
642
  program
594
643
  .command('status')
595
- .description('Show daemon state and cached snippets')
644
+ .description('Show the engine and cached snippets')
596
645
  .action(action(async () => runStatus()));
597
646
 
598
647
  program
package/src/config.js CHANGED
@@ -31,6 +31,17 @@ export const PID_PATH = path.join(STATE_DIR, "daemon.pid");
31
31
  /** Path to the daemon log file. */
32
32
  export const LOG_PATH = path.join(STATE_DIR, "daemon.log");
33
33
 
34
+ /**
35
+ * Lock file for the DAEMONLESS engine: the render script triggers a detached
36
+ * one-shot refresh when a source is due, guarded by this lock so frequent
37
+ * renders never spawn overlapping refreshes. Holds a timestamp; stale locks
38
+ * (older than REFRESH_LOCK_TTL_MS) are ignored.
39
+ */
40
+ export const REFRESH_LOCK_PATH = path.join(STATE_DIR, "refresh.lock");
41
+
42
+ /** A refresh lock older than this (ms) is considered stale and overridable. */
43
+ export const REFRESH_LOCK_TTL_MS = 60_000;
44
+
34
45
  /** Path to the generated statusline bash wrapper. */
35
46
  export const STATUSLINE_SH = path.join(STATE_DIR, "statusline.sh");
36
47
 
@@ -137,6 +148,15 @@ export const DEFAULTS = {
137
148
  snippets: { deduplication: true, cooldownAfterShown: 3, priorityOrder: [] },
138
149
  };
139
150
 
151
+ /**
152
+ * Whether the DAEMONLESS engine is the default. When true, no background daemon
153
+ * runs: the statusline render does stale-while-revalidate — it serves the cached
154
+ * snippet instantly and triggers a detached one-shot refresh when a source is
155
+ * due. Idle cost is then zero (nothing runs unless the bar is being drawn).
156
+ * Honored unless a config explicitly sets `injection.daemonless`.
157
+ */
158
+ export const DEFAULT_DAEMONLESS = true;
159
+
140
160
  /** Per-source defaults applied when a field is omitted. */
141
161
  export const SOURCE_DEFAULTS = { cooldown: 300, maxSnippets: 2 };
142
162
 
package/src/daemon.js CHANGED
@@ -25,13 +25,16 @@ export async function readCache() {
25
25
  try {
26
26
  const raw = await fsp.readFile(CACHE_PATH, "utf8");
27
27
  const parsed = JSON.parse(raw);
28
- if (!parsed || typeof parsed !== "object") return { updatedAt: null, snippets: [] };
28
+ if (!parsed || typeof parsed !== "object") return { updatedAt: null, snippets: [], meta: {} };
29
29
  return {
30
30
  updatedAt: parsed.updatedAt ?? null,
31
31
  snippets: Array.isArray(parsed.snippets) ? parsed.snippets : [],
32
+ // `meta.lastRun` maps sourceId -> last poll epoch ms, so the daemonless
33
+ // one-shot refresh can honor per-source cooldowns across separate runs.
34
+ meta: parsed.meta && typeof parsed.meta === "object" ? parsed.meta : {},
32
35
  };
33
36
  } catch {
34
- return { updatedAt: null, snippets: [] };
37
+ return { updatedAt: null, snippets: [], meta: {} };
35
38
  }
36
39
  }
37
40
 
@@ -159,6 +162,53 @@ export async function pollOnce(config, runtime) {
159
162
  return runtime.snippets;
160
163
  }
161
164
 
165
+ /**
166
+ * Run ONE refresh pass for the daemonless engine and exit. Unlike the daemon
167
+ * loop this keeps no in-memory state: it reads the cache (snippets +
168
+ * meta.lastRun), polls only the sources whose cooldown has elapsed, keeps the
169
+ * existing snippets for sources that are not yet due, merges, and writes the
170
+ * cache back (including the updated per-source lastRun). Errors for one source
171
+ * never drop the others' cached snippets.
172
+ *
173
+ * @param {{configPath?: string}} [opts]
174
+ * @returns {Promise<void>}
175
+ */
176
+ export async function runRefreshOnce(opts = {}) {
177
+ const config = await loadConfig(opts.configPath);
178
+ const cache = await readCache();
179
+ const lastRun = cache.meta && typeof cache.meta.lastRun === "object" ? { ...cache.meta.lastRun } : {};
180
+ const now = Date.now();
181
+
182
+ // Group existing cached snippets by source so not-yet-due sources persist.
183
+ const bySource = {};
184
+ for (const s of cache.snippets) {
185
+ if (!s) continue;
186
+ (bySource[s.sourceId] ||= []).push(s);
187
+ }
188
+
189
+ for (const source of config.sources) {
190
+ const last = lastRun[source.id] || 0;
191
+ if (now - last >= source.cooldown * 1000) {
192
+ try {
193
+ bySource[source.id] = await runSource(source, {});
194
+ lastRun[source.id] = Date.now();
195
+ } catch (err) {
196
+ console.error(`source "${source.label}" (#${source.id}) failed: ${err.message}`);
197
+ // keep whatever was cached for this source
198
+ }
199
+ }
200
+ }
201
+
202
+ const flattened = [];
203
+ for (const source of config.sources) {
204
+ const bucket = bySource[source.id];
205
+ if (Array.isArray(bucket)) flattened.push(...bucket);
206
+ }
207
+
208
+ const snippets = mergeSnippets(cache.snippets, flattened, config);
209
+ await writeCache({ updatedAt: nowISO(), snippets, meta: { lastRun } });
210
+ }
211
+
162
212
  /**
163
213
  * Run the daemon poll loop. Writes the PID file, installs signal handlers, and
164
214
  * loops: pollOnce -> writeCache -> wait config.injection.refresh seconds.
@@ -34,7 +34,11 @@ import {
34
34
  CLAUDE_SETTINGS_PATH,
35
35
  DEFAULT_SNIPPETS,
36
36
  WIRED_STATUSLINES_PATH,
37
+ REFRESH_LOCK_PATH,
38
+ REFRESH_LOCK_TTL_MS,
39
+ DEFAULT_DAEMONLESS,
37
40
  } from "../config.js";
41
+ import { fileURLToPath } from "node:url";
38
42
 
39
43
  /**
40
44
  * Build the source text of the Node ESM render script that Claude Code invokes
@@ -70,12 +74,16 @@ import {
70
74
  * @param {string} prevPath - Absolute path to the prev-statusline MAP JSON file.
71
75
  * @returns {string} The ESM source of the render script.
72
76
  */
73
- function buildRenderScript(cachePath, configPath, prevPath) {
77
+ function buildRenderScript(cachePath, configPath, prevPath, opts = {}) {
74
78
  const CACHE = JSON.stringify(cachePath);
75
79
  const CONFIG = JSON.stringify(configPath);
76
80
  const PREV = JSON.stringify(prevPath);
77
81
  const DEFAULTS = JSON.stringify(DEFAULT_SNIPPETS);
78
- return `// contextspin statusline-render.js (generated) composes any prior
82
+ const DAEMONLESS = opts.daemonless ? "true" : "false";
83
+ const REFRESH_ENTRY = JSON.stringify(opts.refreshEntry || "");
84
+ const LOCK = JSON.stringify(opts.lockPath || "");
85
+ const LOCK_TTL = String(typeof opts.lockTtlMs === "number" ? opts.lockTtlMs : 60000);
86
+ return `// contextspin statusline-render.mjs (generated) — composes any prior
79
87
  // statusline (looked up per-project) with one ContextSpin snippet line. MUST
80
88
  // always exit 0 and never lose the prior statusline's output, so the user's
81
89
  // status bar never breaks.
@@ -87,6 +95,14 @@ const CONFIG_PATH = ${CONFIG};
87
95
  const PREV_STATUSLINE_PATH = ${PREV};
88
96
  const DEFAULT_SNIPPETS = ${DEFAULTS};
89
97
 
98
+ // DAEMONLESS engine: when true, this render does stale-while-revalidate — it
99
+ // serves the cached snippet instantly and triggers a detached one-shot refresh
100
+ // when a source is due (lock-guarded so frequent renders never overlap).
101
+ const DAEMONLESS = ${DAEMONLESS};
102
+ const REFRESH_ENTRY = ${REFRESH_ENTRY};
103
+ const REFRESH_LOCK_PATH = ${LOCK};
104
+ const REFRESH_LOCK_TTL_MS = ${LOCK_TTL};
105
+
90
106
  /** Buffer ALL of stdin into a Buffer. Resolves on end/close/error/timeout. */
91
107
  function readStdin() {
92
108
  return new Promise((resolve) => {
@@ -341,6 +357,65 @@ function styleLine(text) {
341
357
  return BAR + "┃" + RESET + " " + BODY + text + RESET + " " + BAR + "┃" + RESET;
342
358
  }
343
359
 
360
+ /**
361
+ * DAEMONLESS stale-while-revalidate: if any source is past its cooldown and no
362
+ * fresh refresh is in flight, spawn a detached one-shot refresh. Never blocks
363
+ * the render (fire-and-forget) and never throws.
364
+ */
365
+ function maybeTriggerRefresh() {
366
+ if (!DAEMONLESS || !REFRESH_ENTRY) return;
367
+ try {
368
+ // Is any source due? (sourceId is the source's index in the config.)
369
+ let cfg;
370
+ try {
371
+ cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
372
+ } catch {
373
+ return;
374
+ }
375
+ const sources = Array.isArray(cfg && cfg.sources) ? cfg.sources : [];
376
+ if (sources.length === 0) return;
377
+
378
+ let lastRun = {};
379
+ try {
380
+ const c = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
381
+ if (c && c.meta && typeof c.meta.lastRun === "object") lastRun = c.meta.lastRun;
382
+ } catch {
383
+ // no cache yet -> everything is due
384
+ }
385
+
386
+ const now = Date.now();
387
+ let due = false;
388
+ for (let i = 0; i < sources.length; i++) {
389
+ const cd = (typeof sources[i].cooldown === "number" ? sources[i].cooldown : 300) * 1000;
390
+ if (now - (lastRun[i] || 0) >= cd) {
391
+ due = true;
392
+ break;
393
+ }
394
+ }
395
+ if (!due) return;
396
+
397
+ // Skip if a fresh refresh is already in flight.
398
+ try {
399
+ const t = Number(fs.readFileSync(REFRESH_LOCK_PATH, "utf8"));
400
+ if (Number.isFinite(t) && now - t < REFRESH_LOCK_TTL_MS) return;
401
+ } catch {
402
+ // no/!readable lock -> proceed
403
+ }
404
+
405
+ const child = spawn(process.execPath, [REFRESH_ENTRY], {
406
+ detached: true,
407
+ stdio: "ignore",
408
+ env: Object.assign({}, process.env, {
409
+ CONTEXTSPIN_CONFIG: CONFIG_PATH,
410
+ CONTEXTSPIN_CACHE: CACHE_PATH,
411
+ }),
412
+ });
413
+ child.unref();
414
+ } catch {
415
+ // never let revalidation break the render
416
+ }
417
+ }
418
+
344
419
  /** Write a string to stdout, awaiting the flush callback. */
345
420
  function writeOut(text) {
346
421
  return new Promise((resolve) => {
@@ -374,6 +449,14 @@ async function main() {
374
449
  line = "";
375
450
  }
376
451
 
452
+ // (b2) DAEMONLESS: kick off a background refresh if anything is due. Detached
453
+ // and non-blocking — the line above is served immediately from cache.
454
+ try {
455
+ maybeTriggerRefresh();
456
+ } catch {
457
+ // ignore
458
+ }
459
+
377
460
  // (c) Compose: prior output, then our line on its own line beneath. We only
378
461
  // insert a separating newline when there is prior output that does not
379
462
  // already end in one, so a lone ContextSpin line stays a single clean line.
@@ -601,7 +684,20 @@ export async function installStatusline(config, opts = {}) {
601
684
  await writePrevMap(map);
602
685
 
603
686
  // (3) Render script (knows the prev-statusline MAP path) + bash wrapper.
604
- const renderSource = buildRenderScript(CACHE_PATH, CONFIG_PATH, PREV_STATUSLINE_PATH);
687
+ // Resolve whether the DAEMONLESS engine is active (config opt-out wins) and the
688
+ // absolute path to the one-shot refresh entry, so the render can revalidate
689
+ // itself with no background daemon.
690
+ const daemonless =
691
+ config && config.injection && typeof config.injection.daemonless === "boolean"
692
+ ? config.injection.daemonless
693
+ : DEFAULT_DAEMONLESS;
694
+ const refreshEntry = fileURLToPath(new URL("../refresh-entry.js", import.meta.url));
695
+ const renderSource = buildRenderScript(CACHE_PATH, CONFIG_PATH, PREV_STATUSLINE_PATH, {
696
+ daemonless,
697
+ refreshEntry,
698
+ lockPath: REFRESH_LOCK_PATH,
699
+ lockTtlMs: REFRESH_LOCK_TTL_MS,
700
+ });
605
701
  await fsp.writeFile(STATUSLINE_JS, renderSource);
606
702
 
607
703
  // Silence stderr so node warnings never reach the status bar.
@@ -0,0 +1,58 @@
1
+ // src/refresh-entry.js — detached one-shot refresh for the DAEMONLESS engine.
2
+ //
3
+ // The statusline render spawns this (fire-and-forget) when a source is due. It
4
+ // is lock-guarded so frequent renders can never spawn overlapping refreshes:
5
+ // it acquires REFRESH_LOCK_PATH atomically (overriding a stale lock), runs one
6
+ // refresh pass, and releases the lock. If the lock is held and fresh, it exits
7
+ // immediately without doing anything.
8
+
9
+ import fs from "node:fs";
10
+ import { REFRESH_LOCK_PATH, REFRESH_LOCK_TTL_MS } from "./config.js";
11
+ import { runRefreshOnce } from "./daemon.js";
12
+
13
+ /**
14
+ * Try to acquire the refresh lock. Uses an exclusive create ("wx"); if the lock
15
+ * exists but is older than the TTL it is treated as stale and overridden.
16
+ * @returns {boolean} true if acquired.
17
+ */
18
+ function acquireLock() {
19
+ try {
20
+ fs.writeFileSync(REFRESH_LOCK_PATH, String(Date.now()), { flag: "wx" });
21
+ return true;
22
+ } catch {
23
+ // Lock exists — override it only if stale.
24
+ try {
25
+ const age = Date.now() - Number(fs.readFileSync(REFRESH_LOCK_PATH, "utf8")) || 0;
26
+ if (age >= REFRESH_LOCK_TTL_MS) {
27
+ fs.writeFileSync(REFRESH_LOCK_PATH, String(Date.now()));
28
+ return true;
29
+ }
30
+ } catch {
31
+ // unreadable lock — leave it; another runner owns it
32
+ }
33
+ return false;
34
+ }
35
+ }
36
+
37
+ /** Release the refresh lock (best-effort). */
38
+ function releaseLock() {
39
+ try {
40
+ fs.rmSync(REFRESH_LOCK_PATH, { force: true });
41
+ } catch {
42
+ // ignore
43
+ }
44
+ }
45
+
46
+ if (!acquireLock()) {
47
+ // A fresh refresh is already in flight; nothing to do.
48
+ process.exit(0);
49
+ }
50
+
51
+ runRefreshOnce({})
52
+ .catch((err) => {
53
+ console.error(`contextspin refresh failed: ${err && err.message ? err.message : err}`);
54
+ })
55
+ .finally(() => {
56
+ releaseLock();
57
+ process.exit(0);
58
+ });