contextspin 0.6.4 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -256
- package/package.json +1 -1
- package/src/cli.js +54 -5
- package/src/config.js +20 -0
- package/src/daemon.js +52 -2
- package/src/inject/statusline.js +99 -3
- package/src/refresh-entry.js +58 -0
package/README.md
CHANGED
|
@@ -1,336 +1,163 @@
|
|
|
1
1
|
# ContextSpin
|
|
2
2
|
|
|
3
|
-
Live context in your Claude Code **status bar** — weather, the top Hacker News story, PRs awaiting your review, CI failures, incidents, meetings — pulled from tools you already run.
|
|
3
|
+
Live context in your Claude Code **status bar** — weather, the top Hacker News story, PRs awaiting your review, CI failures, incidents, meetings — pulled from tools you already run. One-line install, and the bar is never empty.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
curl -fsSL https://raw.githubusercontent.com/mannutech/contextspin/main/install.sh | bash
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Requires Node.js ≥ 18. MIT licensed. The only runtime dependency is [`commander`](https://www.npmjs.com/package/commander).
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
## It does NOT fetch data
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
- **CLI tools** already installed and authenticated on your machine (`gh`, `kubectl`, `aws`, your own scripts…)
|
|
15
|
-
- **HTTP endpoints** you can already reach (internal dashboards, status APIs)
|
|
13
|
+
ContextSpin is a **renderer, not a data layer** — no API clients, no auth flows, no integrations of its own. It aggregates from sources you already have:
|
|
16
14
|
|
|
17
|
-
|
|
15
|
+
- **MCP servers** registered in `~/.claude.json` / `.mcp.json` (stdio only)
|
|
16
|
+
- **CLI tools** already installed and authed (`gh`, `kubectl`, `glab`, your scripts…)
|
|
17
|
+
- **HTTP endpoints** you can already reach
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
It formats whatever they return into one-line snippets and shows the most relevant one. If a tool you have can't reach the data, ContextSpin can't show it — by design.
|
|
20
|
+
|
|
21
|
+
## How it works (daemonless)
|
|
22
|
+
|
|
23
|
+
There is **no background process by default**. The statusline render is the engine — it serves the cached snippet instantly, and only when a source is past its cooldown does it spawn a detached one-shot refresh (lock-guarded so frequent renders never overlap). Nothing runs when you're not in Claude Code, so idle cost is zero.
|
|
20
24
|
|
|
21
25
|
```
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
│ • runs each source, applies filter + format │
|
|
34
|
-
│ • merges / dedups / prioritizes snippets │
|
|
35
|
-
│ • writes ~/.contextspin-cache.json (atomic) │
|
|
36
|
-
└───────────────────────────┬─────────────────────────────┘
|
|
37
|
-
│ read cache
|
|
38
|
-
▼
|
|
39
|
-
┌─────────────────────────────────────────────────────────┐
|
|
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
|
|
26
|
+
Claude Code draws the status bar
|
|
27
|
+
│
|
|
28
|
+
▼
|
|
29
|
+
RENDER (~/.contextspin/statusline.mjs)
|
|
30
|
+
1. read the cache, print one snippet NOW (stale is fine) ──► status bar
|
|
31
|
+
2. if a source is due and no refresh is in flight:
|
|
32
|
+
│
|
|
33
|
+
▼ (detached, non-blocking)
|
|
34
|
+
REFRESH (one-shot, src/refresh-entry.js)
|
|
35
|
+
• run each DUE source, format, merge/dedup/prioritize, record lastRun
|
|
36
|
+
• write ~/.contextspin-cache.json (atomic)
|
|
48
37
|
```
|
|
49
38
|
|
|
50
|
-
|
|
39
|
+
This is *stale-while-revalidate*: the bar is always fast, freshness catches up in the background. A legacy always-on daemon is still available behind `injection.daemonless: false` — only worth it for stdio MCP sources, where a persistent connection beats per-render handshakes.
|
|
51
40
|
|
|
52
41
|
## Install
|
|
53
42
|
|
|
54
|
-
Requires Node.js >= 18 (ContextSpin uses the built-in global `fetch`).
|
|
55
|
-
|
|
56
|
-
**One line — that's it:**
|
|
57
|
-
|
|
58
43
|
```bash
|
|
59
44
|
curl -fsSL https://raw.githubusercontent.com/mannutech/contextspin/main/install.sh | bash
|
|
60
45
|
```
|
|
61
46
|
|
|
62
|
-
This wires a SessionStart hook into `~/.claude/settings.json` (so
|
|
47
|
+
This wires a SessionStart hook into `~/.claude/settings.json` (so it self-heals each session), seeds a no-credentials starter pack (weather, a dad joke, the top HN story), and wires your status bar **non-destructively** (any existing status line is preserved and composed above ours). Restart Claude Code to see it.
|
|
63
48
|
|
|
64
|
-
|
|
49
|
+
`npx contextspin install` does the same. `npx contextspin uninstall` removes everything. `npx contextspin status` shows the current snippets.
|
|
65
50
|
|
|
66
|
-
|
|
67
|
-
<summary>Manual / advanced setup</summary>
|
|
51
|
+
## Sources
|
|
68
52
|
|
|
69
|
-
|
|
70
|
-
npx contextspin setup # create a config (add --yes to skip prompts)
|
|
71
|
-
npx contextspin start # start the background polling daemon
|
|
72
|
-
npx contextspin inject # wire snippets into the status bar
|
|
73
|
-
```
|
|
74
|
-
|
|
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.
|
|
53
|
+
Every source returns a list of records. Each record is optionally `filter`ed, then rendered with `format` using `{{ field }}` templating — dotted/bracketed paths work (`{{ results[0].value }}`), `{{ env.NAME }}` reads an environment variable, unknown fields render empty.
|
|
76
54
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
Check what's happening at any time:
|
|
80
|
-
|
|
81
|
-
```bash
|
|
82
|
-
npx contextspin status
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
## Source types
|
|
86
|
-
|
|
87
|
-
Every source produces a list of records. Each record is run through an optional `filter`, then rendered with `format` using **double-curly-brace** field templating (`{{ field }}`). Inner whitespace is allowed. Dotted and bracketed paths work (`{{ results[0].value }}`), and `{{ env.NAME }}` resolves to an environment variable. Unknown fields render as the empty string.
|
|
88
|
-
|
|
89
|
-
### `mcp` — call a tool on an existing MCP server
|
|
90
|
-
|
|
91
|
-
Calls a tool on a **stdio** MCP server discovered from your Claude config. ContextSpin speaks JSON-RPC 2.0 over the server's stdin/stdout itself — no SDK, no extra dependency.
|
|
55
|
+
**`mcp`** — call a tool on a stdio MCP server discovered from your Claude config (JSON-RPC over stdin/stdout, no SDK):
|
|
92
56
|
|
|
93
57
|
```json
|
|
94
|
-
{
|
|
95
|
-
"
|
|
96
|
-
"tool": "slack_search_public",
|
|
97
|
-
"args": { "query": "mentions:me is:unread" },
|
|
98
|
-
"format": "Slack: {{ text }}",
|
|
99
|
-
"label": "Slack",
|
|
100
|
-
"cooldown": 300,
|
|
101
|
-
"maxSnippets": 2
|
|
102
|
-
}
|
|
58
|
+
{ "type": "mcp", "tool": "slack_search_public", "args": { "query": "mentions:me is:unread" },
|
|
59
|
+
"format": "Slack: {{ text }}", "label": "Slack", "cooldown": 300, "maxSnippets": 2 }
|
|
103
60
|
```
|
|
104
61
|
|
|
105
|
-
|
|
106
|
-
- `server` is optional. If omitted and the tool name doesn't encode it, ContextSpin connects to each stdio server, lists its tools, and uses the first one that exposes the tool.
|
|
107
|
-
- `args` is passed straight through as the tool-call arguments.
|
|
62
|
+
`tool` may be bare or `mcp__<server>__<tool>`; `server` is optional (otherwise the first stdio server exposing the tool is used).
|
|
108
63
|
|
|
109
|
-
|
|
64
|
+
**`cli`** — run a shell command (output parsed as a JSON array/object/primitive, or split into lines):
|
|
110
65
|
|
|
111
66
|
```json
|
|
112
|
-
{
|
|
113
|
-
"
|
|
114
|
-
"command": "gh pr list --review-requested @me --json title,number --limit 3",
|
|
115
|
-
"format": "PR #{{ number }} needs your review: {{ title }}",
|
|
116
|
-
"label": "GitHub",
|
|
117
|
-
"cooldown": 120,
|
|
118
|
-
"maxSnippets": 3
|
|
119
|
-
}
|
|
67
|
+
{ "type": "cli", "command": "gh pr list --review-requested @me --json title,number --limit 3",
|
|
68
|
+
"format": "PR #{{ number }} needs review: {{ title }}", "label": "GitHub", "cooldown": 120, "maxSnippets": 3 }
|
|
120
69
|
```
|
|
121
70
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
- a **JSON array** → each element becomes a record (objects kept as-is; primitives wrapped as `{ value, text }`)
|
|
125
|
-
- a **JSON object** → a single record
|
|
126
|
-
- a **JSON primitive** → `{ value, text }`
|
|
127
|
-
- **anything else** → split into non-empty lines, each becoming `{ text, line, value }`
|
|
128
|
-
|
|
129
|
-
A non-zero exit throws; a configurable timeout (default 15s) protects against hangs.
|
|
130
|
-
|
|
131
|
-
### `http` — fetch a JSON (or text) endpoint
|
|
71
|
+
**`http`** — fetch a JSON or text endpoint:
|
|
132
72
|
|
|
133
73
|
```json
|
|
134
|
-
{
|
|
135
|
-
"type": "http",
|
|
136
|
-
"url": "https://grafana.example.com/api/datasources/proxy/1/query?q=incidents",
|
|
74
|
+
{ "type": "http", "url": "https://grafana.example.com/api/.../query?q=incidents",
|
|
137
75
|
"headers": { "Authorization": "Bearer {{ env.GRAFANA_TOKEN }}" },
|
|
138
|
-
"jq": ".results[0].value",
|
|
139
|
-
"format": "Grafana: {{ value }}",
|
|
140
|
-
"label": "Grafana",
|
|
141
|
-
"cooldown": 30,
|
|
142
|
-
"maxSnippets": 1
|
|
143
|
-
}
|
|
76
|
+
"jq": ".results[0].value", "format": "Grafana: {{ value }}", "label": "Grafana", "cooldown": 30 }
|
|
144
77
|
```
|
|
145
78
|
|
|
146
|
-
|
|
147
|
-
- `method` defaults to `GET`; a `body` object is JSON-stringified with the right content-type.
|
|
148
|
-
- `jq` accepts a **minimal jq subset**: identity `.`, dotted keys (`.a.b`), bracket indexing (`.a[0]`), iteration (`.[]`, `.a[]`), and left-to-right pipes (`a | b`). Unsupported expressions pass the data through unchanged rather than erroring.
|
|
79
|
+
`url` and headers are interpolated (use `{{ env.X }}` for secrets, never hard-code them). `jq` supports a minimal subset: identity, dotted keys, bracket indexing, iteration (`.[]`), and pipes.
|
|
149
80
|
|
|
150
81
|
## Configuration
|
|
151
82
|
|
|
152
|
-
|
|
83
|
+
One JSON file, `~/.contextspin.json` (override with `CONTEXTSPIN_CONFIG`); cache at `~/.contextspin-cache.json` (override with `CONTEXTSPIN_CACHE`).
|
|
153
84
|
|
|
154
85
|
```json
|
|
155
86
|
{
|
|
156
87
|
"sources": [
|
|
157
88
|
{ "type": "cli", "command": "gh pr list --json title --limit 3", "format": "PR: {{ title }}" }
|
|
158
89
|
],
|
|
159
|
-
"injection": {
|
|
160
|
-
|
|
161
|
-
"refresh": 30,
|
|
162
|
-
"maxVisible": 5
|
|
163
|
-
},
|
|
164
|
-
"snippets": {
|
|
165
|
-
"deduplication": true,
|
|
166
|
-
"cooldownAfterShown": 3,
|
|
167
|
-
"priorityOrder": ["incident", "ci", "slack", "calendar", "github", "jira"]
|
|
168
|
-
}
|
|
90
|
+
"injection": { "mode": "statusline", "refresh": 30, "maxVisible": 5 },
|
|
91
|
+
"snippets": { "deduplication": true, "cooldownAfterShown": 3, "priorityOrder": ["incident", "ci", "github"] }
|
|
169
92
|
}
|
|
170
93
|
```
|
|
171
94
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
|
175
|
-
|
|
176
|
-
| `sources` |
|
|
177
|
-
| `sources[].
|
|
178
|
-
| `sources[].
|
|
179
|
-
| `sources[].
|
|
180
|
-
| `sources[].
|
|
181
|
-
| `
|
|
182
|
-
| `
|
|
183
|
-
| `
|
|
184
|
-
| `
|
|
185
|
-
| `
|
|
186
|
-
| `
|
|
187
|
-
| `
|
|
188
|
-
| `
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
| `snippets.cooldownAfterShown` | number | count | `3` | A snippet stops being eligible once shown this many times. |
|
|
192
|
-
| `snippets.priorityOrder` | string[] | source labels | `[]` | Earlier labels sort first (case-insensitive); unlisted sort last. |
|
|
193
|
-
|
|
194
|
-
### Filters
|
|
195
|
-
|
|
196
|
-
`filter` is a **single, safe comparison** — no `eval`, no `Function`. The whole expression is interpolated against the record first, then parsed as `LEFT OP RIGHT` where `OP` is one of `==`, `!=`, `>=`, `<=`, `>`, `<`, or the word `includes`. Both numeric sides compare numerically; otherwise they compare as strings (`==` / `!=` are loose). `includes` is substring containment. With no operator, the result is truthy unless it's empty, `false`, or `0`.
|
|
95
|
+
| Field | Default | Meaning |
|
|
96
|
+
|-------|---------|---------|
|
|
97
|
+
| `sources[].type` | — | `mcp` \| `cli` \| `http` (required) |
|
|
98
|
+
| `sources[].tool` / `command` / `url` | — | Required for `mcp` / `cli` / `http` respectively |
|
|
99
|
+
| `sources[].format` | — | One-line `{{ field }}` template (required) |
|
|
100
|
+
| `sources[].filter` | — | Keep a record only if it passes (see below) |
|
|
101
|
+
| `sources[].label` | derived | Snippet source label (mcp→tool, cli→first token, http→host) |
|
|
102
|
+
| `sources[].cooldown` | `300` | Min seconds between polls of this source |
|
|
103
|
+
| `sources[].maxSnippets` | `2` | Max snippets kept per poll |
|
|
104
|
+
| `injection.refresh` | `30` | Status-bar refresh interval, seconds |
|
|
105
|
+
| `injection.maxVisible` | `5` | Cap on snippets held in the cache |
|
|
106
|
+
| `injection.style` | `true` | Styled box (cyan bars + italic); `false` for plain text |
|
|
107
|
+
| `injection.daemonless` | `true` | Self-refreshing render; `false` for the legacy daemon |
|
|
108
|
+
| `injection.mode` | `statusline` | `statusline` \| `patcher` \| `both` |
|
|
109
|
+
| `snippets.deduplication` | `true` | Drop duplicate-text snippets when merging |
|
|
110
|
+
| `snippets.cooldownAfterShown` | `3` | A snippet stops showing after this many displays |
|
|
111
|
+
| `snippets.priorityOrder` | `[]` | Source labels sorted first (case-insensitive); rest last |
|
|
112
|
+
|
|
113
|
+
**Filters** are a single safe comparison (no `eval`): the expression is interpolated, then parsed as `LEFT OP RIGHT` where `OP` is `==`, `!=`, `>=`, `<=`, `>`, `<`, or `includes`. No `&&`/`||`.
|
|
197
114
|
|
|
198
115
|
```json
|
|
199
116
|
{ "filter": "{{ status }} == failure" }
|
|
200
117
|
```
|
|
201
118
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
## Injection modes
|
|
205
|
-
|
|
206
|
-
### `statusline` (recommended, official)
|
|
207
|
-
|
|
208
|
-
This is the supported path. It uses Claude Code's official [status line](https://code.claude.com/docs/en/statusline) feature, so it survives Claude Code updates.
|
|
209
|
-
|
|
210
|
-
`contextspin inject` (mode `statusline`) will:
|
|
211
|
-
|
|
212
|
-
1. Write `~/.contextspin/statusline-render.js` — a self-contained script that drains stdin (so Claude Code's piped JSON can't cause `EPIPE`), reads the cache, picks the eligible snippet with the lowest `shownCount` (then most recent), increments its count, writes the cache back, and prints that one line. Any error exits cleanly with no output, so it can never break your status bar.
|
|
213
|
-
2. Write `~/.contextspin/statusline.sh` — a `0755` bash wrapper that `exec`s the render script.
|
|
214
|
-
3. Point `statusLine` at that wrapper (refresh in **seconds**), **non-destructively**: any status line you already had is preserved and *composed* — the render script runs your prior command and prints its output **above** the ContextSpin line. Scope-aware: in a project (when `CLAUDE_PROJECT_DIR` is set) it writes the gitignored `<project>/.claude/settings.local.json`, which outranks a repo's tracked `settings.json`, so a project's own status line can't shadow ContextSpin.
|
|
215
|
-
|
|
216
|
-
Reverse it with `contextspin uninject` (this scope) or `contextspin uninstall` (every scope it ever wired, plus the hook and daemon).
|
|
217
|
-
|
|
218
|
-
### `patcher` (EXPERIMENTAL — binary patching)
|
|
219
|
-
|
|
220
|
-
> ⚠️ **Experimental and fragile.** Inspired by [claude-depester](https://github.com/ominiverdi/claude-depester). It rewrites the hard-coded spinner words (`Flibbertigibbeting`, `Discombobulating`, …) inside the Claude Code binary/bundle with your live snippets.
|
|
221
|
-
|
|
222
|
-
Key facts:
|
|
223
|
-
|
|
224
|
-
- It is **length-preserving**: the replacement is padded with spaces so the file size never changes. If your snippets don't fit, words are trimmed until they do.
|
|
225
|
-
- It works on both **text** (`cli.js`) and **compiled binary** installs, located by scanning the usual install paths and keeping only files that actually contain the marker word.
|
|
226
|
-
- A **restart of Claude Code is required** for the patch to take effect.
|
|
227
|
-
- **Claude Code updates overwrite the patch.** The installer also writes a wrapper at `~/.contextspin/cl.sh` that re-applies the patch and then `exec`s `claude`. After every Claude Code update you must re-patch — using that wrapper is the easiest way.
|
|
228
|
-
- On macOS it makes a best-effort `codesign` re-sign after patching.
|
|
229
|
-
|
|
230
|
-
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
|
-
|
|
232
|
-
## Daemon and cache
|
|
233
|
-
|
|
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:
|
|
235
|
-
|
|
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.
|
|
239
|
-
|
|
240
|
-
`stop` / `restart` manage the process; `status` reports whether it's running and lists the current snippets.
|
|
241
|
-
|
|
242
|
-
### Cache file format (`~/.contextspin-cache.json`)
|
|
119
|
+
## Cache
|
|
243
120
|
|
|
244
121
|
```json
|
|
245
122
|
{
|
|
246
123
|
"updatedAt": "2026-06-17T09:00:00.000Z",
|
|
247
124
|
"snippets": [
|
|
248
|
-
{
|
|
249
|
-
"
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
"fetchedAt": "2026-06-17T09:00:00.000Z",
|
|
253
|
-
"shownCount": 0
|
|
254
|
-
}
|
|
255
|
-
]
|
|
125
|
+
{ "text": "CI failing: build on main", "source": "CI", "sourceId": 2,
|
|
126
|
+
"fetchedAt": "2026-06-17T09:00:00.000Z", "shownCount": 0 }
|
|
127
|
+
],
|
|
128
|
+
"meta": { "lastRun": { "2": 1781860451773 } }
|
|
256
129
|
}
|
|
257
130
|
```
|
|
258
131
|
|
|
259
|
-
`shownCount`
|
|
132
|
+
`shownCount` rises each time a snippet is shown; past `cooldownAfterShown` it's retired. `meta.lastRun` maps `sourceId → last poll (ms)` so the refresh honors per-source cooldowns across runs. When the cache is empty or every snippet is retired, the render rotates through built-in defaults (jokes + tips) — so the bar is never blank.
|
|
260
133
|
|
|
261
|
-
## CLI
|
|
134
|
+
## CLI
|
|
262
135
|
|
|
263
136
|
| Command | What it does |
|
|
264
137
|
|---------|--------------|
|
|
265
|
-
| `
|
|
266
|
-
| `
|
|
267
|
-
| `
|
|
268
|
-
| `
|
|
269
|
-
| `
|
|
270
|
-
| `
|
|
271
|
-
| `
|
|
272
|
-
| `
|
|
273
|
-
| `contextspin inject [--mode <m>]` | Install just the injector. `<m>` overrides `injection.mode` (`statusline` / `patcher` / `both`). |
|
|
274
|
-
| `contextspin uninject [--mode <m>]` | Reverse just the injector. |
|
|
275
|
-
| `contextspin` *(no subcommand)* | `setup` if unconfigured, otherwise `start` then `inject`. |
|
|
276
|
-
|
|
277
|
-
## High-impact snippets
|
|
278
|
-
|
|
279
|
-
Three tiers, by how time-sensitive they are.
|
|
280
|
-
|
|
281
|
-
### Tier 1 — time-sensitive (act in minutes)
|
|
282
|
-
|
|
283
|
-
```json
|
|
284
|
-
{ "type": "cli", "command": "gh run list --json status,name,headBranch --limit 5",
|
|
285
|
-
"filter": "{{ status }} == failure",
|
|
286
|
-
"format": "CI failing: {{ name }} on {{ headBranch }}", "label": "CI", "cooldown": 60, "maxSnippets": 2 }
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
```json
|
|
290
|
-
{ "type": "http", "url": "https://grafana.example.com/api/datasources/proxy/1/query?q=incidents",
|
|
291
|
-
"headers": { "Authorization": "Bearer {{ env.GRAFANA_TOKEN }}" },
|
|
292
|
-
"jq": ".results[0].value", "format": "Grafana: {{ value }}", "label": "Grafana", "cooldown": 30, "maxSnippets": 1 }
|
|
293
|
-
```
|
|
138
|
+
| `install` | Wire the self-healing SessionStart hook, create config, wire the statusline (what the curl script runs). |
|
|
139
|
+
| `uninstall` | Remove the hook, restore your prior statusline in **every** scope, stop any daemon. |
|
|
140
|
+
| `status` | Show the engine and cached snippets. |
|
|
141
|
+
| `refresh` | Force a one-shot refresh of all due sources now. |
|
|
142
|
+
| `setup [--yes]` | Create `~/.contextspin.json` (interactive, or detected with `--yes`). |
|
|
143
|
+
| `ensure` | Idempotent create-config + wire-statusline (run by the hook each session). |
|
|
144
|
+
| `inject` / `uninject [--mode <m>]` | Install / reverse just the injector. |
|
|
145
|
+
| `start` / `stop` / `restart` | Manage the legacy daemon (only when `injection.daemonless: false`). |
|
|
294
146
|
|
|
295
|
-
|
|
147
|
+
## Statusline injection
|
|
296
148
|
|
|
297
|
-
|
|
298
|
-
{ "type": "mcp", "tool": "slack_search_public", "args": { "query": "mentions:me is:unread" },
|
|
299
|
-
"format": "Slack: {{ text }}", "label": "Slack", "cooldown": 300, "maxSnippets": 2 }
|
|
300
|
-
```
|
|
149
|
+
Uses Claude Code's official [status line](https://code.claude.com/docs/en/statusline) feature, so it survives updates. The wrapper is **non-destructive and scope-aware**: any status line you already had is composed above the ContextSpin line, and in a project (`CLAUDE_PROJECT_DIR` set) it writes the gitignored `<project>/.claude/settings.local.json` so a repo's own status line can't shadow it. Reverse with `uninject` (this scope) or `uninstall` (everything).
|
|
301
150
|
|
|
302
|
-
|
|
303
|
-
{ "type": "mcp", "tool": "notion-search", "args": { "query": "assigned:me status:open" },
|
|
304
|
-
"format": "Notion: {{ text }}", "label": "Notion", "cooldown": 300, "maxSnippets": 2 }
|
|
305
|
-
```
|
|
306
|
-
|
|
307
|
-
### Tier 3 — work queue (your to-do)
|
|
308
|
-
|
|
309
|
-
```json
|
|
310
|
-
{ "type": "cli", "command": "gh pr list --review-requested @me --json title,number --limit 3",
|
|
311
|
-
"format": "PR #{{ number }} needs your review: {{ title }}", "label": "GitHub", "cooldown": 120, "maxSnippets": 3 }
|
|
312
|
-
```
|
|
151
|
+
There's also an **experimental** `patcher` mode (`injection.mode: "patcher"`) that rewrites Claude Code's hard-coded spinner words in the binary — inspired by [claude-depester](https://github.com/ominiverdi/claude-depester). It's length-preserving and best-effort, but **every Claude Code update overwrites it**, so the statusline is the supported path. Restore with `uninject --mode patcher`.
|
|
313
152
|
|
|
314
153
|
## Limitations
|
|
315
154
|
|
|
316
|
-
- **MCP
|
|
317
|
-
- **OAuth
|
|
318
|
-
- **The status line shows one rotating snippet** at a time, honoring `cooldownAfterShown` so the same item doesn't repeat indefinitely.
|
|
319
|
-
- **The patcher is experimental** and is **overwritten by every Claude Code update**. Treat it as best-effort; the statusline mode is the supported path.
|
|
320
|
-
|
|
321
|
-
## Zero-config defaults (never an empty bar)
|
|
322
|
-
|
|
323
|
-
A fresh install needs no setup:
|
|
324
|
-
|
|
325
|
-
- The config is seeded with a **no-credentials starter pack** — local weather, a dad joke, and the top Hacker News story — so real snippets appear within seconds.
|
|
326
|
-
- When the cache is empty or every snippet is exhausted, the renderer falls back to **built-in defaults** (jokes + "ask `/contextspin`…" tips) that rotate, so the bar is never blank — even offline or before the first poll.
|
|
327
|
-
- A Claude Code **plugin** is also available (the [`mannutech` marketplace](https://github.com/mannutech/claude-plugins)) for those who prefer installing that way — it wraps this same package.
|
|
155
|
+
- **MCP is stdio-only** — discovered from `~/.claude.json` / `.mcp.json`; HTTP/SSE MCP transports aren't supported (use a `cli`/`http` source instead).
|
|
156
|
+
- **OAuth claude.ai connectors aren't reachable** — their tokens live in the OS keychain, out of reach of a standalone process. Use the matching CLI (`gh`, …), an HTTP endpoint, or a local stdio MCP server.
|
|
328
157
|
|
|
329
|
-
##
|
|
158
|
+
## Also available as a plugin
|
|
330
159
|
|
|
331
|
-
|
|
332
|
-
- Claude Code status line docs: https://code.claude.com/docs/en/statusline
|
|
333
|
-
- Claude Code spinner issues: [#10420](https://github.com/anthropics/claude-code/issues/10420), [#13725](https://github.com/anthropics/claude-code/issues/13725), [#22668](https://github.com/anthropics/claude-code/issues/22668), [#27766](https://github.com/anthropics/claude-code/issues/27766), [#27976](https://github.com/anthropics/claude-code/issues/27976)
|
|
160
|
+
A Claude Code plugin wraps this package, via the [`mannutech` marketplace](https://github.com/mannutech/claude-plugins) — for those who prefer installing that way. The curl line above needs neither.
|
|
334
161
|
|
|
335
162
|
## License
|
|
336
163
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "contextspin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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
|
-
|
|
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 (
|
|
336
|
-
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})`);
|
|
337
381
|
} else {
|
|
338
|
-
console.log('
|
|
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
|
|
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.
|
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
|
+
});
|