contextspin 0.6.3 → 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 +36 -41
- package/package.json +1 -1
- package/src/cli.js +56 -106
- package/src/config.js +28 -2
- package/src/daemon.js +52 -2
- package/src/inject/hook.js +105 -0
- package/src/inject/statusline.js +99 -3
- package/src/refresh-entry.js +58 -0
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
|
|
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
|
-
|
|
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
|
-
│
|
|
33
|
-
│
|
|
34
|
-
│
|
|
35
|
-
│
|
|
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
|
-
│
|
|
34
|
+
│ (background, non-blocking)
|
|
38
35
|
▼
|
|
39
36
|
┌─────────────────────────────────────────────────────────┐
|
|
40
|
-
│
|
|
41
|
-
│
|
|
42
|
-
│
|
|
43
|
-
│
|
|
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
|
-
|
|
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
|
-
##
|
|
229
|
+
## Refresh and cache
|
|
233
230
|
|
|
234
|
-
|
|
231
|
+
By default there is **no background process**. Each time the status bar draws, the render:
|
|
235
232
|
|
|
236
|
-
1.
|
|
237
|
-
2.
|
|
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
|
-
`
|
|
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
|
|
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
|
|
266
|
-
| `contextspin uninstall` | **Full teardown:** remove the hook, restore your prior statusline in **every** scope it wired, and stop
|
|
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
|
|
269
|
-
| `contextspin
|
|
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`
|
|
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.
|
|
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,12 +25,14 @@ import {
|
|
|
24
25
|
stopDaemon,
|
|
25
26
|
isDaemonRunning,
|
|
26
27
|
readCache,
|
|
28
|
+
runRefreshOnce,
|
|
27
29
|
} from './daemon.js';
|
|
28
30
|
import {
|
|
29
31
|
installStatusline,
|
|
30
32
|
uninstallStatusline,
|
|
31
33
|
uninstallAllStatuslines,
|
|
32
34
|
} from './inject/statusline.js';
|
|
35
|
+
import { addSessionStartHook, removeSessionStartHook } from './inject/hook.js';
|
|
33
36
|
import { installPatcher, restorePatcher } from './inject/patcher.js';
|
|
34
37
|
import { detectSources } from './detect.js';
|
|
35
38
|
|
|
@@ -263,7 +266,20 @@ async function runEnsure() {
|
|
|
263
266
|
if (!wasOurs) did.push('wired statusline');
|
|
264
267
|
}
|
|
265
268
|
|
|
266
|
-
|
|
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) {
|
|
267
283
|
startDaemonDetached();
|
|
268
284
|
did.push('started daemon');
|
|
269
285
|
}
|
|
@@ -325,16 +341,45 @@ async function runRestart() {
|
|
|
325
341
|
await runStart();
|
|
326
342
|
}
|
|
327
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
|
+
|
|
328
359
|
/**
|
|
329
360
|
* Print the daemon running state plus the current cache contents.
|
|
330
361
|
* @returns {Promise<void>}
|
|
331
362
|
*/
|
|
332
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
|
+
|
|
333
376
|
const { running, pid } = isDaemonRunning();
|
|
334
|
-
if (
|
|
335
|
-
console.log(
|
|
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})`);
|
|
336
381
|
} else {
|
|
337
|
-
console.log('
|
|
382
|
+
console.log('Engine: daemon (stopped)');
|
|
338
383
|
}
|
|
339
384
|
|
|
340
385
|
const cache = await readCache();
|
|
@@ -476,106 +521,6 @@ async function runUninject(opts = {}) {
|
|
|
476
521
|
}
|
|
477
522
|
}
|
|
478
523
|
|
|
479
|
-
/**
|
|
480
|
-
* The SessionStart hook command the `install` flow wires into the user settings
|
|
481
|
-
* so ContextSpin self-heals every session (the curl install replicates what the
|
|
482
|
-
* Claude Code plugin's hook does, without the marketplace).
|
|
483
|
-
*
|
|
484
|
-
* The hook is pinned to the EXACT version doing the install (never `@latest` or a
|
|
485
|
-
* range), so a future release never runs on the user's machine without them
|
|
486
|
-
* deliberately re-installing. Runs from a neutral dir so npx never resolves a
|
|
487
|
-
* confused local package (Exit 127).
|
|
488
|
-
* @returns {string}
|
|
489
|
-
*/
|
|
490
|
-
function sessionStartHookCmd() {
|
|
491
|
-
return `cd /tmp && npx --yes contextspin@${readVersion()} ensure >/dev/null 2>&1; exit 0`;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
* Read+parse a JSON file, returning a fallback on any read/parse error.
|
|
496
|
-
* @param {string} filePath
|
|
497
|
-
* @param {*} fallback
|
|
498
|
-
* @returns {*}
|
|
499
|
-
*/
|
|
500
|
-
function readJsonSafeSync(filePath, fallback) {
|
|
501
|
-
try {
|
|
502
|
-
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
503
|
-
} catch {
|
|
504
|
-
return fallback;
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
/**
|
|
509
|
-
* Whether a SessionStart entry already runs ContextSpin (so install is idempotent).
|
|
510
|
-
* @param {*} entry
|
|
511
|
-
* @returns {boolean}
|
|
512
|
-
*/
|
|
513
|
-
function entryRunsContextspin(entry) {
|
|
514
|
-
return !!(
|
|
515
|
-
entry &&
|
|
516
|
-
Array.isArray(entry.hooks) &&
|
|
517
|
-
entry.hooks.some(
|
|
518
|
-
(h) => h && typeof h.command === 'string' && h.command.includes('contextspin'),
|
|
519
|
-
)
|
|
520
|
-
);
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
/**
|
|
524
|
-
* Wire a ContextSpin SessionStart hook into the user ~/.claude/settings.json so
|
|
525
|
-
* the daemon + statusline self-heal every session. JSON-merge (preserves every
|
|
526
|
-
* other key and any existing hooks). Idempotent.
|
|
527
|
-
* @returns {boolean} true if the hook was added (false if already present).
|
|
528
|
-
*/
|
|
529
|
-
function addSessionStartHook() {
|
|
530
|
-
fs.mkdirSync(path.dirname(CLAUDE_SETTINGS_PATH), { recursive: true });
|
|
531
|
-
const settings = readJsonSafeSync(CLAUDE_SETTINGS_PATH, {});
|
|
532
|
-
const obj = settings && typeof settings === 'object' ? settings : {};
|
|
533
|
-
obj.hooks = obj.hooks && typeof obj.hooks === 'object' ? obj.hooks : {};
|
|
534
|
-
const arr = Array.isArray(obj.hooks.SessionStart) ? obj.hooks.SessionStart : [];
|
|
535
|
-
|
|
536
|
-
const desired = sessionStartHookCmd();
|
|
537
|
-
const existing = arr.find(entryRunsContextspin);
|
|
538
|
-
const alreadyCurrent =
|
|
539
|
-
existing &&
|
|
540
|
-
Array.isArray(existing.hooks) &&
|
|
541
|
-
existing.hooks.some((h) => h && h.command === desired);
|
|
542
|
-
// Already exactly this command (same pinned version) — nothing to do.
|
|
543
|
-
if (alreadyCurrent) return false;
|
|
544
|
-
|
|
545
|
-
// Upsert: drop any prior ContextSpin entry (e.g. an older pinned version) and
|
|
546
|
-
// add the current one, so re-installing a newer version re-pins cleanly.
|
|
547
|
-
const others = arr.filter((e) => !entryRunsContextspin(e));
|
|
548
|
-
others.push({
|
|
549
|
-
matcher: '',
|
|
550
|
-
hooks: [{ type: 'command', command: desired, timeout: 15 }],
|
|
551
|
-
});
|
|
552
|
-
obj.hooks.SessionStart = others;
|
|
553
|
-
fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(obj, null, 2));
|
|
554
|
-
return true;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
/**
|
|
558
|
-
* Remove any ContextSpin SessionStart hook from the user settings (best-effort,
|
|
559
|
-
* JSON-merge). Prunes empty containers.
|
|
560
|
-
* @returns {boolean} true if a hook was removed.
|
|
561
|
-
*/
|
|
562
|
-
function removeSessionStartHook() {
|
|
563
|
-
const settings = readJsonSafeSync(CLAUDE_SETTINGS_PATH, null);
|
|
564
|
-
if (!settings || typeof settings !== 'object' || !settings.hooks) return false;
|
|
565
|
-
const arr = Array.isArray(settings.hooks.SessionStart)
|
|
566
|
-
? settings.hooks.SessionStart
|
|
567
|
-
: [];
|
|
568
|
-
const kept = arr.filter((e) => !entryRunsContextspin(e));
|
|
569
|
-
if (kept.length === arr.length) return false;
|
|
570
|
-
if (kept.length > 0) settings.hooks.SessionStart = kept;
|
|
571
|
-
else delete settings.hooks.SessionStart;
|
|
572
|
-
if (settings.hooks && Object.keys(settings.hooks).length === 0) {
|
|
573
|
-
delete settings.hooks;
|
|
574
|
-
}
|
|
575
|
-
fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2));
|
|
576
|
-
return true;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
524
|
/**
|
|
580
525
|
* One-shot install (the `curl | bash` entrypoint): wire the SessionStart hook so
|
|
581
526
|
* ContextSpin self-heals each session, then run ensure (config + statusline +
|
|
@@ -583,7 +528,7 @@ function removeSessionStartHook() {
|
|
|
583
528
|
* @returns {Promise<void>}
|
|
584
529
|
*/
|
|
585
530
|
async function runInstall() {
|
|
586
|
-
const addedHook = addSessionStartHook();
|
|
531
|
+
const addedHook = addSessionStartHook(readVersion());
|
|
587
532
|
await runEnsure();
|
|
588
533
|
console.log('');
|
|
589
534
|
console.log(
|
|
@@ -689,9 +634,14 @@ function buildProgram() {
|
|
|
689
634
|
.description('Restart the background daemon')
|
|
690
635
|
.action(action(async () => runRestart()));
|
|
691
636
|
|
|
637
|
+
program
|
|
638
|
+
.command('refresh')
|
|
639
|
+
.description('Force a one-shot refresh of all due sources now')
|
|
640
|
+
.action(action(async () => runRefresh()));
|
|
641
|
+
|
|
692
642
|
program
|
|
693
643
|
.command('status')
|
|
694
|
-
.description('Show
|
|
644
|
+
.description('Show the engine and cached snippets')
|
|
695
645
|
.action(action(async () => runStatus()));
|
|
696
646
|
|
|
697
647
|
program
|
package/src/config.js
CHANGED
|
@@ -31,11 +31,28 @@ 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
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Path to the generated statusline Node render script. Uses the `.mjs` extension
|
|
50
|
+
* so Node treats it as ESM regardless of version — the script lives in STATE_DIR
|
|
51
|
+
* (no package.json), and Node 18 has no automatic ESM detection, so a plain
|
|
52
|
+
* `.js` would fail to parse its `import` statements ("Cannot use import statement
|
|
53
|
+
* outside a module").
|
|
54
|
+
*/
|
|
55
|
+
export const STATUSLINE_JS = path.join(STATE_DIR, "statusline-render.mjs");
|
|
39
56
|
|
|
40
57
|
/**
|
|
41
58
|
* Path to the recorded prior statusLine commands (captured when we wrap an
|
|
@@ -131,6 +148,15 @@ export const DEFAULTS = {
|
|
|
131
148
|
snippets: { deduplication: true, cooldownAfterShown: 3, priorityOrder: [] },
|
|
132
149
|
};
|
|
133
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
|
+
|
|
134
160
|
/** Per-source defaults applied when a field is omitted. */
|
|
135
161
|
export const SOURCE_DEFAULTS = { cooldown: 300, maxSnippets: 2 };
|
|
136
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.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// src/inject/hook.js — manage ContextSpin's SessionStart hook in the user
|
|
2
|
+
// ~/.claude/settings.json. Used by `contextspin install` / `uninstall` (the curl
|
|
3
|
+
// flow) so ContextSpin self-heals every session without the plugin/marketplace.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { CLAUDE_SETTINGS_PATH } from "../config.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Read+parse a JSON file, returning a fallback on any read/parse error.
|
|
11
|
+
* @param {string} filePath
|
|
12
|
+
* @param {*} fallback
|
|
13
|
+
* @returns {*}
|
|
14
|
+
*/
|
|
15
|
+
function readJsonSafeSync(filePath, fallback) {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
18
|
+
} catch {
|
|
19
|
+
return fallback;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build the SessionStart hook command, pinned to an EXACT version (never
|
|
25
|
+
* `@latest` or a range) so a future release never runs without a deliberate
|
|
26
|
+
* re-install. Runs from a neutral dir so npx can't resolve a confused local
|
|
27
|
+
* package (Exit 127).
|
|
28
|
+
* @param {string} version - The exact package version to pin (e.g. "0.6.3").
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
export function sessionStartHookCmd(version) {
|
|
32
|
+
return `cd /tmp && npx --yes contextspin@${version} ensure >/dev/null 2>&1; exit 0`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether a SessionStart entry already runs ContextSpin (any version).
|
|
37
|
+
* @param {*} entry
|
|
38
|
+
* @returns {boolean}
|
|
39
|
+
*/
|
|
40
|
+
export function entryRunsContextspin(entry) {
|
|
41
|
+
return !!(
|
|
42
|
+
entry &&
|
|
43
|
+
Array.isArray(entry.hooks) &&
|
|
44
|
+
entry.hooks.some(
|
|
45
|
+
(h) => h && typeof h.command === "string" && h.command.includes("contextspin"),
|
|
46
|
+
)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Upsert the ContextSpin SessionStart hook into the user settings, pinned to
|
|
52
|
+
* `version`. JSON-merge (preserves every other key and any non-ours hooks).
|
|
53
|
+
* Drops any prior ContextSpin entry (e.g. an older pinned version) so
|
|
54
|
+
* re-installing a newer version re-pins cleanly. Idempotent when already current.
|
|
55
|
+
* @param {string} version
|
|
56
|
+
* @param {string} [settingsPath=CLAUDE_SETTINGS_PATH]
|
|
57
|
+
* @returns {boolean} true if the file was changed (added or re-pinned).
|
|
58
|
+
*/
|
|
59
|
+
export function addSessionStartHook(version, settingsPath = CLAUDE_SETTINGS_PATH) {
|
|
60
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
61
|
+
const settings = readJsonSafeSync(settingsPath, {});
|
|
62
|
+
const obj = settings && typeof settings === "object" ? settings : {};
|
|
63
|
+
obj.hooks = obj.hooks && typeof obj.hooks === "object" ? obj.hooks : {};
|
|
64
|
+
const arr = Array.isArray(obj.hooks.SessionStart) ? obj.hooks.SessionStart : [];
|
|
65
|
+
|
|
66
|
+
const desired = sessionStartHookCmd(version);
|
|
67
|
+
const existing = arr.find(entryRunsContextspin);
|
|
68
|
+
const alreadyCurrent =
|
|
69
|
+
existing &&
|
|
70
|
+
Array.isArray(existing.hooks) &&
|
|
71
|
+
existing.hooks.some((h) => h && h.command === desired);
|
|
72
|
+
if (alreadyCurrent) return false;
|
|
73
|
+
|
|
74
|
+
const others = arr.filter((e) => !entryRunsContextspin(e));
|
|
75
|
+
others.push({
|
|
76
|
+
matcher: "",
|
|
77
|
+
hooks: [{ type: "command", command: desired, timeout: 15 }],
|
|
78
|
+
});
|
|
79
|
+
obj.hooks.SessionStart = others;
|
|
80
|
+
fs.writeFileSync(settingsPath, JSON.stringify(obj, null, 2));
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Remove any ContextSpin SessionStart hook from the user settings (JSON-merge,
|
|
86
|
+
* best-effort). Prunes empty containers.
|
|
87
|
+
* @param {string} [settingsPath=CLAUDE_SETTINGS_PATH]
|
|
88
|
+
* @returns {boolean} true if a hook was removed.
|
|
89
|
+
*/
|
|
90
|
+
export function removeSessionStartHook(settingsPath = CLAUDE_SETTINGS_PATH) {
|
|
91
|
+
const settings = readJsonSafeSync(settingsPath, null);
|
|
92
|
+
if (!settings || typeof settings !== "object" || !settings.hooks) return false;
|
|
93
|
+
const arr = Array.isArray(settings.hooks.SessionStart)
|
|
94
|
+
? settings.hooks.SessionStart
|
|
95
|
+
: [];
|
|
96
|
+
const kept = arr.filter((e) => !entryRunsContextspin(e));
|
|
97
|
+
if (kept.length === arr.length) return false;
|
|
98
|
+
if (kept.length > 0) settings.hooks.SessionStart = kept;
|
|
99
|
+
else delete settings.hooks.SessionStart;
|
|
100
|
+
if (settings.hooks && Object.keys(settings.hooks).length === 0) {
|
|
101
|
+
delete settings.hooks;
|
|
102
|
+
}
|
|
103
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
104
|
+
return true;
|
|
105
|
+
}
|
package/src/inject/statusline.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
});
|