@asidedev/cli 0.1.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/dist/commands/about.js +38 -0
- package/dist/commands/install.js +60 -0
- package/dist/commands/status.js +148 -0
- package/dist/commands/sync.js +11 -0
- package/dist/commands/uninstall.js +35 -0
- package/dist/commands/wrap.js +157 -0
- package/dist/config.js +32 -0
- package/dist/core/cache.js +25 -0
- package/dist/core/fsutil.js +29 -0
- package/dist/core/identity.js +27 -0
- package/dist/core/lock.js +83 -0
- package/dist/core/queue.js +74 -0
- package/dist/core/render.js +75 -0
- package/dist/core/rotation.js +66 -0
- package/dist/core/state.js +54 -0
- package/dist/core/stdin.js +48 -0
- package/dist/core/syncCore.js +66 -0
- package/dist/core/token.js +16 -0
- package/dist/index.js +58 -0
- package/package.json +40 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { BACKEND_URL, CLI_VERSION } from "../config.js";
|
|
2
|
+
import { peekInstallId } from "../core/identity.js";
|
|
3
|
+
const REPO_URL = "https://github.com/asidedev/aside";
|
|
4
|
+
/** Plain-language transparency disclosure (Section 5.4). */
|
|
5
|
+
export function runAbout() {
|
|
6
|
+
const installId = peekInstallId();
|
|
7
|
+
const lines = [
|
|
8
|
+
`Aside v${CLI_VERSION} — transparency`,
|
|
9
|
+
``,
|
|
10
|
+
`WHAT ASIDE READS`,
|
|
11
|
+
` • Your local curiosity cache and rotation state (~/.aside/).`,
|
|
12
|
+
` • An anonymous install_id generated on your machine${installId ? ` (current: ${installId})` : ""}.`,
|
|
13
|
+
` • From the stdin Claude Code sends to the status line, ONLY two fields:`,
|
|
14
|
+
` - session_id (used only locally, to pin the session's curiosity)`,
|
|
15
|
+
` - model.display_name (used only for formatting)`,
|
|
16
|
+
` Everything else in stdin (cwd, workspace.*, worktree.*, repo.*, pr.*,`,
|
|
17
|
+
` transcript_path, cost.*, context_window.*, environment variables) is`,
|
|
18
|
+
` received and DISCARDED — never written to disk, never sent over the network.`,
|
|
19
|
+
``,
|
|
20
|
+
`WHAT ASIDE SENDS (only on the background sync)`,
|
|
21
|
+
` • install_id, cli_version, and os ('darwin'|'linux'|'win32').`,
|
|
22
|
+
` • Which curiosities were SHOWN (id, whether sponsored). Clicks on`,
|
|
23
|
+
` sponsored items are counted by the server via a redirect — nothing more.`,
|
|
24
|
+
``,
|
|
25
|
+
`WHAT ASIDE NEVER DOES`,
|
|
26
|
+
` • Never reads, stores, or sends: code, prompts, responses, file contents,`,
|
|
27
|
+
` file names, paths, repository metadata, pull request data, transcripts,`,
|
|
28
|
+
` or environment variables.`,
|
|
29
|
+
` • Never opens the file pointed to by transcript_path.`,
|
|
30
|
+
` • Never makes a network request on the 'status' path (never blocks your terminal).`,
|
|
31
|
+
` • Never sends paths/repo/PR.`,
|
|
32
|
+
``,
|
|
33
|
+
`Backend: ${BACKEND_URL}`,
|
|
34
|
+
`Open source: ${REPO_URL}`,
|
|
35
|
+
`Public transparency: ${BACKEND_URL}/about`,
|
|
36
|
+
];
|
|
37
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
38
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { CLAUDE_SETTINGS_PATH } from "../config.js";
|
|
3
|
+
import { readJsonSafe, writeJsonAtomic, fileExists } from "../core/fsutil.js";
|
|
4
|
+
import { getInstallId } from "../core/identity.js";
|
|
5
|
+
import { runSync } from "../core/syncCore.js";
|
|
6
|
+
/** Build the absolute status-line command (item: never use npx — cold start). */
|
|
7
|
+
export function asideStatusCommand() {
|
|
8
|
+
const entry = resolve(process.argv[1] ?? "");
|
|
9
|
+
// Quote both node and the entry path to survive spaces.
|
|
10
|
+
return `"${process.execPath}" "${entry}" status`;
|
|
11
|
+
}
|
|
12
|
+
/** Heuristic: does an existing statusLine command belong to Aside? */
|
|
13
|
+
export function pointsToAside(cmd) {
|
|
14
|
+
if (!cmd)
|
|
15
|
+
return false;
|
|
16
|
+
const c = cmd.toLowerCase();
|
|
17
|
+
return ((c.includes("aside") && c.includes("status")) ||
|
|
18
|
+
c.includes(resolve(process.argv[1] ?? "").toLowerCase()));
|
|
19
|
+
}
|
|
20
|
+
export async function runInstall(opts) {
|
|
21
|
+
const out = (s) => process.stdout.write(s + "\n");
|
|
22
|
+
// 1. Anonymous identity.
|
|
23
|
+
const installId = getInstallId();
|
|
24
|
+
// 2. Best-effort initial sync (user-invoked path: awaiting is fine).
|
|
25
|
+
try {
|
|
26
|
+
await runSync();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
out("⚠ Initial sync failed (offline?). Continuing — the cache fills on the next sync.");
|
|
30
|
+
}
|
|
31
|
+
// 3. Non-destructive merge into settings.json.
|
|
32
|
+
const settings = readJsonSafe(CLAUDE_SETTINGS_PATH, {});
|
|
33
|
+
const existing = settings.statusLine;
|
|
34
|
+
if (existing && existing.command && !pointsToAside(existing.command)) {
|
|
35
|
+
if (!opts.force) {
|
|
36
|
+
out("✋ A status line is already configured and it's NOT Aside's:");
|
|
37
|
+
out(` ${existing.command}`);
|
|
38
|
+
out(" I won't overwrite it without confirmation. Re-run with --force to replace it.");
|
|
39
|
+
// TODO(mel): interactive confirmation prompt instead of requiring --force.
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
out("⚠ Replacing the existing status line (--force).");
|
|
44
|
+
}
|
|
45
|
+
settings.statusLine = {
|
|
46
|
+
type: "command",
|
|
47
|
+
command: asideStatusCommand(),
|
|
48
|
+
};
|
|
49
|
+
if (settings.disableAllHooks === true) {
|
|
50
|
+
out("⚠ 'disableAllHooks: true' is set — the status line will be INACTIVE until you turn it off.");
|
|
51
|
+
}
|
|
52
|
+
if (!fileExists(CLAUDE_SETTINGS_PATH)) {
|
|
53
|
+
out(`ℹ Creating ${CLAUDE_SETTINGS_PATH}.`);
|
|
54
|
+
}
|
|
55
|
+
writeJsonAtomic(CLAUDE_SETTINGS_PATH, settings);
|
|
56
|
+
out("✅ Aside installed.");
|
|
57
|
+
out(` anonymous install_id: ${installId}`);
|
|
58
|
+
out(" A curiosity appears in the status line on your next interaction with Claude Code.");
|
|
59
|
+
out(" See exactly what is read/sent: aside about");
|
|
60
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readStatusInput } from "../core/stdin.js";
|
|
3
|
+
import { readCache, curiosityById } from "../core/cache.js";
|
|
4
|
+
import { readState, writeState, gcPins, } from "../core/state.js";
|
|
5
|
+
import { rotate } from "../core/rotation.js";
|
|
6
|
+
import { withStateLock } from "../core/lock.js";
|
|
7
|
+
import { appendImpression } from "../core/queue.js";
|
|
8
|
+
import { makeClickToken } from "../core/token.js";
|
|
9
|
+
import { renderLine } from "../core/render.js";
|
|
10
|
+
import { getInstallId } from "../core/identity.js";
|
|
11
|
+
import { BACKEND_URL, SLOT_TTL_MS, TURN_GAP_MS, CACHE_STALE_MS, SYNC_MIN_INTERVAL_MS, } from "../config.js";
|
|
12
|
+
function supportsHyperlinks() {
|
|
13
|
+
if (process.env.ASIDE_FORCE_HYPERLINKS === "1")
|
|
14
|
+
return true;
|
|
15
|
+
if (process.env.ASIDE_NO_HYPERLINKS === "1")
|
|
16
|
+
return false;
|
|
17
|
+
const tp = process.env.TERM_PROGRAM;
|
|
18
|
+
if (tp === "iTerm.app" || tp === "WezTerm" || tp === "vscode")
|
|
19
|
+
return true;
|
|
20
|
+
if (process.env.KITTY_WINDOW_ID)
|
|
21
|
+
return true;
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
function columns() {
|
|
25
|
+
const env = Number(process.env.COLUMNS);
|
|
26
|
+
if (Number.isFinite(env) && env > 0)
|
|
27
|
+
return env;
|
|
28
|
+
if (process.stdout.columns && process.stdout.columns > 0) {
|
|
29
|
+
return process.stdout.columns;
|
|
30
|
+
}
|
|
31
|
+
return 80;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Reuse the pinned curiosity only while we're still in the SAME turn — renders
|
|
35
|
+
* cluster within ~300ms around one assistant message, so a gap larger than
|
|
36
|
+
* TURN_GAP_MS means a new prompt → rotate. Also rotate if the slot is very old
|
|
37
|
+
* (hard cap) to cover a single very long turn.
|
|
38
|
+
*/
|
|
39
|
+
function sameTurn(pin, now) {
|
|
40
|
+
if (!pin)
|
|
41
|
+
return false;
|
|
42
|
+
const lastRender = pin.last_render_at ?? pin.slot_started_at;
|
|
43
|
+
return now - lastRender < TURN_GAP_MS && now - pin.slot_started_at < SLOT_TTL_MS;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Core, side-effecting selection done under the state lock. Returns what to
|
|
47
|
+
* render. All disk writes to state happen here (atomic, locked).
|
|
48
|
+
*/
|
|
49
|
+
function decide(cache, sessionId, installId, now) {
|
|
50
|
+
return withStateLock(() => {
|
|
51
|
+
const state = readState();
|
|
52
|
+
gcPins(state, now);
|
|
53
|
+
let pin = state.pins[sessionId];
|
|
54
|
+
let curiosity = null;
|
|
55
|
+
if (sameTurn(pin, now)) {
|
|
56
|
+
// Same turn → keep the curiosity stable across this turn's re-renders.
|
|
57
|
+
curiosity = curiosityById(cache, pin.curiosity_id) ?? null;
|
|
58
|
+
if (curiosity)
|
|
59
|
+
pin.last_render_at = now;
|
|
60
|
+
}
|
|
61
|
+
if (!curiosity) {
|
|
62
|
+
// New turn (or no/expired pin) → rotate to a fresh curiosity.
|
|
63
|
+
curiosity = rotate(cache, state, sessionId, {
|
|
64
|
+
now,
|
|
65
|
+
rng: Math.random,
|
|
66
|
+
});
|
|
67
|
+
pin = state.pins[sessionId];
|
|
68
|
+
}
|
|
69
|
+
if (!curiosity || !pin) {
|
|
70
|
+
writeState(state);
|
|
71
|
+
return { curiosity: null, emitImpression: false, clickToken: null };
|
|
72
|
+
}
|
|
73
|
+
let emit = false;
|
|
74
|
+
if (!pin.impression_emitted) {
|
|
75
|
+
// Generate the self-contained click token for sponsored items so the
|
|
76
|
+
// OSC 8 link works regardless of sync ordering (item 1).
|
|
77
|
+
if (curiosity.is_sponsored && curiosity.click_url) {
|
|
78
|
+
pin.click_token = makeClickToken(curiosity.id, installId);
|
|
79
|
+
}
|
|
80
|
+
pin.impression_emitted = true;
|
|
81
|
+
emit = true;
|
|
82
|
+
}
|
|
83
|
+
state.pins[sessionId] = pin;
|
|
84
|
+
writeState(state);
|
|
85
|
+
return {
|
|
86
|
+
curiosity,
|
|
87
|
+
emitImpression: emit,
|
|
88
|
+
clickToken: pin.click_token,
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function maybeSpawnSync(now) {
|
|
93
|
+
// Read state outside the lock (cheap, best-effort).
|
|
94
|
+
const state = readState();
|
|
95
|
+
const cache = readCache();
|
|
96
|
+
const cacheStale = now - cache.fetched_at > CACHE_STALE_MS;
|
|
97
|
+
const syncRecently = now - state.last_sync_at < SYNC_MIN_INTERVAL_MS;
|
|
98
|
+
if (!cacheStale || syncRecently)
|
|
99
|
+
return;
|
|
100
|
+
withStateLock(() => {
|
|
101
|
+
const s = readState();
|
|
102
|
+
if (now - s.last_sync_at < SYNC_MIN_INTERVAL_MS)
|
|
103
|
+
return;
|
|
104
|
+
s.last_sync_at = now;
|
|
105
|
+
writeState(s);
|
|
106
|
+
});
|
|
107
|
+
try {
|
|
108
|
+
const child = spawn(process.execPath, [process.argv[1] ?? "", "sync"], { detached: true, stdio: "ignore" });
|
|
109
|
+
child.unref();
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
/* never let a spawn failure affect the render */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export function runStatus() {
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
const input = readStatusInput();
|
|
118
|
+
const sessionId = input.session_id ?? "default";
|
|
119
|
+
const cache = readCache();
|
|
120
|
+
const installId = getInstallId();
|
|
121
|
+
// Nothing to show yet (e.g. offline first run): render nothing.
|
|
122
|
+
if (cache.curiosities.length === 0) {
|
|
123
|
+
process.stdout.write("");
|
|
124
|
+
maybeSpawnSync(now);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const decision = decide(cache, sessionId, installId, now);
|
|
128
|
+
if (!decision.curiosity) {
|
|
129
|
+
process.stdout.write(""); // status line disappears
|
|
130
|
+
maybeSpawnSync(now);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (decision.emitImpression) {
|
|
134
|
+
appendImpression({
|
|
135
|
+
curiosity_id: decision.curiosity.id,
|
|
136
|
+
is_sponsored: decision.curiosity.is_sponsored,
|
|
137
|
+
event: "shown",
|
|
138
|
+
click_token: decision.clickToken,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const line = renderLine(decision.curiosity, decision.clickToken, {
|
|
142
|
+
columns: columns(),
|
|
143
|
+
supportsHyperlinks: supportsHyperlinks(),
|
|
144
|
+
backendUrl: BACKEND_URL,
|
|
145
|
+
});
|
|
146
|
+
process.stdout.write(line + "\n");
|
|
147
|
+
maybeSpawnSync(now);
|
|
148
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { runSync } from "../core/syncCore.js";
|
|
2
|
+
/** Internal command, normally spawned detached by `status`. */
|
|
3
|
+
export async function runSyncCommand() {
|
|
4
|
+
try {
|
|
5
|
+
await runSync();
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
// Background process: swallow errors, never surface to the terminal.
|
|
9
|
+
process.exitCode = 0;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { rmSync } from "node:fs";
|
|
2
|
+
import { CLAUDE_SETTINGS_PATH, ASIDE_DIR } from "../config.js";
|
|
3
|
+
import { readJsonSafe, writeJsonAtomic, fileExists } from "../core/fsutil.js";
|
|
4
|
+
import { pointsToAside } from "./install.js";
|
|
5
|
+
export function runUninstall() {
|
|
6
|
+
const out = (s) => process.stdout.write(s + "\n");
|
|
7
|
+
if (fileExists(CLAUDE_SETTINGS_PATH)) {
|
|
8
|
+
const settings = readJsonSafe(CLAUDE_SETTINGS_PATH, {});
|
|
9
|
+
const cmd = settings.statusLine?.command;
|
|
10
|
+
if (settings.statusLine && pointsToAside(cmd)) {
|
|
11
|
+
delete settings.statusLine;
|
|
12
|
+
writeJsonAtomic(CLAUDE_SETTINGS_PATH, settings);
|
|
13
|
+
out("✅ Aside status line removed from settings.json (all other keys preserved).");
|
|
14
|
+
}
|
|
15
|
+
else if (settings.statusLine) {
|
|
16
|
+
out("ℹ The current status line is NOT Aside's — leaving it untouched:");
|
|
17
|
+
out(` ${cmd ?? "(no command)"}`);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
out("ℹ No status line configured in settings.json.");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
out("ℹ settings.json doesn't exist — nothing to remove there.");
|
|
25
|
+
}
|
|
26
|
+
// Remove ~/.aside/ (config, cache, state, queue).
|
|
27
|
+
try {
|
|
28
|
+
rmSync(ASIDE_DIR, { recursive: true, force: true });
|
|
29
|
+
out(`✅ Removed ${ASIDE_DIR}.`);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
out(`⚠ Couldn't remove ${ASIDE_DIR} (delete it manually if needed).`);
|
|
33
|
+
}
|
|
34
|
+
out("Uninstalled. Thanks for using Aside. 👋");
|
|
35
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { readCache, liveCuriosities } from "../core/cache.js";
|
|
3
|
+
import { truncateVisible, visibleWidth } from "../core/render.js";
|
|
4
|
+
/**
|
|
5
|
+
* EXPERIMENTAL terminal wrapper (the "hack" path). Runs `claude` inside a PTY
|
|
6
|
+
* and rewrites its thinking-spinner line so the gerund ("Churned for 5s") is
|
|
7
|
+
* replaced by a developer curiosity that changes on each new prompt.
|
|
8
|
+
*
|
|
9
|
+
* This fights a live TUI, so it is best-effort: it relies on the observed fact
|
|
10
|
+
* that Claude Code redraws the spinner on a single line via carriage return,
|
|
11
|
+
* and only touches lines beginning with a sparkle spinner glyph.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* We detect the spinner by its STATUS TEXT, not by the leading glyph — Claude
|
|
15
|
+
* cycles through glyphs from several families (✦ ✛ ✻ + * …) and chasing them is
|
|
16
|
+
* a losing game. The working line is always a gerund followed by an elapsed-time
|
|
17
|
+
* marker: "Channelling… (7s · ↓ 268 tokens)" or "Baked for 2s". That tail is
|
|
18
|
+
* distinctive enough to key off reliably.
|
|
19
|
+
*
|
|
20
|
+
* Groups: (1) optional leading glyph char, (2) the gerund, (3) the time tail.
|
|
21
|
+
*/
|
|
22
|
+
const SPINNER_RE = /([^\s\r\nA-Za-z\x1b]?)[ \t]*([A-Z][A-Za-z]+(?:…|\.\.\.)?)( for \d+s\b| \(\d+s[^)\r\n]{0,60}\)?)/g;
|
|
23
|
+
/** Pure transform — exported for tests. Rewrites spinner frames in `chunk`. */
|
|
24
|
+
export function rewriteSpinner(chunk, st) {
|
|
25
|
+
if (st.pool.length === 0)
|
|
26
|
+
return chunk;
|
|
27
|
+
SPINNER_RE.lastIndex = 0;
|
|
28
|
+
if (!SPINNER_RE.test(chunk))
|
|
29
|
+
return chunk;
|
|
30
|
+
const now = st.now();
|
|
31
|
+
// New thinking session? (first spinner frame after a quiet gap) → rotate.
|
|
32
|
+
if (st.current === null || now - st.lastSpinnerTs > st.gapMs) {
|
|
33
|
+
st.idx = (st.idx + 1) % st.pool.length;
|
|
34
|
+
st.current = st.pool[st.idx];
|
|
35
|
+
}
|
|
36
|
+
st.lastSpinnerTs = now;
|
|
37
|
+
const display = st.current;
|
|
38
|
+
SPINNER_RE.lastIndex = 0;
|
|
39
|
+
return chunk.replace(SPINNER_RE, (_m, glyph) => {
|
|
40
|
+
// Claude draws its spinner glyph SEPARATELY (e.g. `✻` ESC[3G `Churned…`),
|
|
41
|
+
// so the regex usually captures no glyph (group1 empty) — the gerund text is
|
|
42
|
+
// preceded by a cursor-position escape. In that case DON'T add a glyph (that
|
|
43
|
+
// caused a double-glyph + 1-char shift); just write the curiosity where
|
|
44
|
+
// Claude already positioned the cursor. Cap width so it never wraps, and
|
|
45
|
+
// erase to end-of-line to wipe any leftover from a longer previous frame.
|
|
46
|
+
// Claude positions the cursor right after its glyph (e.g. ESC[3G), so when
|
|
47
|
+
// we captured no glyph we still emit one leading space to sit clear of it.
|
|
48
|
+
const prefix = glyph ? glyph + " " : " ";
|
|
49
|
+
const room = Math.max(8, st.cols - visibleWidth(prefix) - 4);
|
|
50
|
+
const text = truncateVisible(display, room);
|
|
51
|
+
return `${prefix}${text}\x1b[K`;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const MARKER = String.fromCodePoint(0x261e); // ☞ manicule — the "margin note" emblem
|
|
55
|
+
function buildPool() {
|
|
56
|
+
const cache = readCache();
|
|
57
|
+
return liveCuriosities(cache).map((c) => {
|
|
58
|
+
const handle = c.sponsor_handle ? c.sponsor_handle.replace(/^@+/, "") : "aside";
|
|
59
|
+
const body = c.body.replace(/\s+/g, " ").trim();
|
|
60
|
+
return `${MARKER} ${body} — @${handle}`;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** Interactive entry point. */
|
|
64
|
+
export async function runWrap(passthroughArgs) {
|
|
65
|
+
const require = createRequire(import.meta.url);
|
|
66
|
+
let pty;
|
|
67
|
+
try {
|
|
68
|
+
pty = require("node-pty");
|
|
69
|
+
healSpawnHelper(require); // prebuilt spawn-helper often ships without +x
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
process.stderr.write("aside wrap needs the optional 'node-pty' dependency.\n" +
|
|
73
|
+
"Install it: npm i -g node-pty (or reinstall @asidedev/cli)\n");
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const pool = buildPool();
|
|
78
|
+
if (pool.length === 0) {
|
|
79
|
+
process.stderr.write("No curiosities cached yet. Run `aside sync` (or `aside install`) first.\n");
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const claudeBin = process.env.ASIDE_CLAUDE_BIN || "claude";
|
|
84
|
+
const cols = process.stdout.columns || 120;
|
|
85
|
+
const rows = process.stdout.rows || 30;
|
|
86
|
+
const state = {
|
|
87
|
+
current: null,
|
|
88
|
+
lastSpinnerTs: 0,
|
|
89
|
+
idx: -1,
|
|
90
|
+
pool,
|
|
91
|
+
cols,
|
|
92
|
+
now: () => Date.now(),
|
|
93
|
+
gapMs: 700,
|
|
94
|
+
};
|
|
95
|
+
let term;
|
|
96
|
+
try {
|
|
97
|
+
term = pty.spawn(claudeBin, passthroughArgs, {
|
|
98
|
+
name: process.env.TERM || "xterm-256color",
|
|
99
|
+
cols,
|
|
100
|
+
rows,
|
|
101
|
+
cwd: process.cwd(),
|
|
102
|
+
env: process.env,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
process.stderr.write(`aside wrap could not launch '${claudeBin}': ${err.message}\n` +
|
|
107
|
+
"Make sure Claude Code is installed and on your PATH " +
|
|
108
|
+
"(or set ASIDE_CLAUDE_BIN to its full path).\n");
|
|
109
|
+
process.exitCode = 1;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
term.onData((data) => {
|
|
113
|
+
state.cols = process.stdout.columns || state.cols;
|
|
114
|
+
process.stdout.write(rewriteSpinner(data, state));
|
|
115
|
+
});
|
|
116
|
+
const stdin = process.stdin;
|
|
117
|
+
if (stdin.isTTY)
|
|
118
|
+
stdin.setRawMode(true);
|
|
119
|
+
stdin.resume();
|
|
120
|
+
stdin.on("data", (d) => term.write(d.toString("utf8")));
|
|
121
|
+
const onResize = () => term.resize(process.stdout.columns || cols, process.stdout.rows || rows);
|
|
122
|
+
process.stdout.on("resize", onResize);
|
|
123
|
+
term.onExit(({ exitCode }) => {
|
|
124
|
+
if (stdin.isTTY)
|
|
125
|
+
stdin.setRawMode(false);
|
|
126
|
+
stdin.pause();
|
|
127
|
+
process.stdout.removeListener("resize", onResize);
|
|
128
|
+
process.exit(exitCode ?? 0);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* node-pty's prebuilt `spawn-helper` frequently lands without the execute bit
|
|
133
|
+
* (npm extracts it 0644), which makes pty.spawn fail with "posix_spawnp failed".
|
|
134
|
+
* Best-effort: find it next to the loaded node-pty and chmod +x.
|
|
135
|
+
*/
|
|
136
|
+
function healSpawnHelper(require) {
|
|
137
|
+
try {
|
|
138
|
+
// Lazy, optional imports so this never breaks the main path.
|
|
139
|
+
const { dirname, join } = require("node:path");
|
|
140
|
+
const fs = require("node:fs");
|
|
141
|
+
const pkg = require.resolve("node-pty/package.json");
|
|
142
|
+
const root = dirname(pkg);
|
|
143
|
+
for (const arch of ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"]) {
|
|
144
|
+
const helper = join(root, "prebuilds", arch, "spawn-helper");
|
|
145
|
+
try {
|
|
146
|
+
if (fs.existsSync(helper))
|
|
147
|
+
fs.chmodSync(helper, 0o755);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
/* ignore individual failures */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
/* node-pty layout differs or perms denied — nothing we can do, proceed */
|
|
156
|
+
}
|
|
157
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/** Backend base URL. Overridable for local dev / self-hosting. */
|
|
4
|
+
export const BACKEND_URL = process.env.ASIDE_BACKEND_URL?.replace(/\/+$/, "") ||
|
|
5
|
+
"https://asidedev.vercel.app";
|
|
6
|
+
export const ASIDE_DIR = process.env.ASIDE_HOME || join(homedir(), ".aside");
|
|
7
|
+
export const CLAUDE_SETTINGS_PATH = process.env.ASIDE_CLAUDE_SETTINGS || join(homedir(), ".claude", "settings.json");
|
|
8
|
+
export const PATHS = {
|
|
9
|
+
dir: ASIDE_DIR,
|
|
10
|
+
config: join(ASIDE_DIR, "config.json"),
|
|
11
|
+
cache: join(ASIDE_DIR, "cache.json"),
|
|
12
|
+
state: join(ASIDE_DIR, "state.json"),
|
|
13
|
+
queue: join(ASIDE_DIR, "queue.jsonl"),
|
|
14
|
+
/** Lock for read-modify-write on state.json (multi-session safety). */
|
|
15
|
+
stateLock: join(ASIDE_DIR, "state.lock"),
|
|
16
|
+
};
|
|
17
|
+
/** Schema version for on-disk state/cache files. Bump on breaking changes. */
|
|
18
|
+
export const STATE_VERSION = 1;
|
|
19
|
+
// Rotation tuning (Section 8).
|
|
20
|
+
export const DAILY_CAP = 1000; // sanity bound only — the footer rotates per prompt
|
|
21
|
+
export const DISPLAY_PROBABILITY = 1; // always show a curiosity when a new slot is allowed
|
|
22
|
+
export const SPONSORED_RATIO = 0.2; // ~20% of slots are sponsored
|
|
23
|
+
export const RECENT_WINDOW = 30; // anti-repetition window
|
|
24
|
+
// A new prompt/turn is detected by a gap between status-line renders: renders
|
|
25
|
+
// cluster (debounced ~300ms) around each assistant message, with seconds of
|
|
26
|
+
// quiet between turns. A gap longer than this rotates to a fresh curiosity.
|
|
27
|
+
export const TURN_GAP_MS = 2500;
|
|
28
|
+
export const SLOT_TTL_MS = 30 * 60 * 1000; // hard cap: rotate within a very long single turn
|
|
29
|
+
export const PIN_GC_MS = 24 * 60 * 60 * 1000; // drop pins older than 24h
|
|
30
|
+
export const CACHE_STALE_MS = 6 * 60 * 60 * 1000; // refresh cache if older than 6h
|
|
31
|
+
export const SYNC_MIN_INTERVAL_MS = 30 * 60 * 1000; // don't spawn sync more than every 30 min
|
|
32
|
+
export const CLI_VERSION = "0.1.1";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { PATHS, STATE_VERSION } from "../config.js";
|
|
2
|
+
import { readJsonSafe, writeJsonAtomic } from "./fsutil.js";
|
|
3
|
+
const EMPTY = {
|
|
4
|
+
version: STATE_VERSION,
|
|
5
|
+
served_at: null,
|
|
6
|
+
fetched_at: 0,
|
|
7
|
+
curiosities: [],
|
|
8
|
+
};
|
|
9
|
+
export function readCache() {
|
|
10
|
+
const c = readJsonSafe(PATHS.cache, EMPTY);
|
|
11
|
+
// Reinit on version mismatch (Section 14 extended to upgrades).
|
|
12
|
+
if (!c || c.version !== STATE_VERSION || !Array.isArray(c.curiosities)) {
|
|
13
|
+
return { ...EMPTY };
|
|
14
|
+
}
|
|
15
|
+
return c;
|
|
16
|
+
}
|
|
17
|
+
export function writeCache(cache) {
|
|
18
|
+
writeJsonAtomic(PATHS.cache, cache);
|
|
19
|
+
}
|
|
20
|
+
export function liveCuriosities(cache) {
|
|
21
|
+
return cache.curiosities.filter((c) => c.status === "live");
|
|
22
|
+
}
|
|
23
|
+
export function curiosityById(cache, id) {
|
|
24
|
+
return cache.curiosities.find((c) => c.id === id);
|
|
25
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync, existsSync, } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { ASIDE_DIR } from "../config.js";
|
|
4
|
+
export function ensureDir() {
|
|
5
|
+
mkdirSync(ASIDE_DIR, { recursive: true });
|
|
6
|
+
}
|
|
7
|
+
export function readJsonSafe(path, fallback) {
|
|
8
|
+
try {
|
|
9
|
+
const raw = readFileSync(path, "utf8");
|
|
10
|
+
return JSON.parse(raw);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return fallback;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Atomic write: temp file + rename (Section 3.1 / 6.1). */
|
|
17
|
+
export function writeAtomic(path, data) {
|
|
18
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
19
|
+
// Unique-ish temp suffix without Math.random (pid + hrtime).
|
|
20
|
+
const tmp = `${path}.tmp-${process.pid}-${process.hrtime.bigint()}`;
|
|
21
|
+
writeFileSync(tmp, data, "utf8");
|
|
22
|
+
renameSync(tmp, path);
|
|
23
|
+
}
|
|
24
|
+
export function writeJsonAtomic(path, value) {
|
|
25
|
+
writeAtomic(path, JSON.stringify(value, null, 2));
|
|
26
|
+
}
|
|
27
|
+
export function fileExists(path) {
|
|
28
|
+
return existsSync(path);
|
|
29
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { platform } from "node:os";
|
|
3
|
+
import { PATHS } from "../config.js";
|
|
4
|
+
import { readJsonSafe, writeJsonAtomic, ensureDir } from "./fsutil.js";
|
|
5
|
+
/** Read the anonymous install_id, generating + persisting one on first use. */
|
|
6
|
+
export function getInstallId() {
|
|
7
|
+
const cfg = readJsonSafe(PATHS.config, {});
|
|
8
|
+
if (cfg.install_id && typeof cfg.install_id === "string") {
|
|
9
|
+
return cfg.install_id;
|
|
10
|
+
}
|
|
11
|
+
ensureDir();
|
|
12
|
+
const id = randomUUID();
|
|
13
|
+
writeJsonAtomic(PATHS.config, { install_id: id });
|
|
14
|
+
return id;
|
|
15
|
+
}
|
|
16
|
+
/** Read install_id without creating one (returns null if absent). */
|
|
17
|
+
export function peekInstallId() {
|
|
18
|
+
const cfg = readJsonSafe(PATHS.config, {});
|
|
19
|
+
return typeof cfg.install_id === "string" ? cfg.install_id : null;
|
|
20
|
+
}
|
|
21
|
+
/** Normalized OS string — the only platform info ever sent (Section 4.1.4). */
|
|
22
|
+
export function normalizedOs() {
|
|
23
|
+
const p = platform();
|
|
24
|
+
if (p === "darwin" || p === "linux" || p === "win32")
|
|
25
|
+
return p;
|
|
26
|
+
return "linux"; // conservative fallback; never leak the raw value
|
|
27
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { openSync, closeSync, rmSync, readFileSync, statSync, writeSync, } from "node:fs";
|
|
2
|
+
import { PATHS } from "../config.js";
|
|
3
|
+
/**
|
|
4
|
+
* Minimal cross-process advisory lock (item 3 fix: multi-session safety on
|
|
5
|
+
* state.json). Uses O_EXCL create as the lock primitive. Best-effort: if the
|
|
6
|
+
* lock cannot be acquired quickly we proceed anyway rather than blocking the
|
|
7
|
+
* status render — correctness of counters degrades gracefully, the host tool
|
|
8
|
+
* is never delayed.
|
|
9
|
+
*/
|
|
10
|
+
const STALE_MS = 5000; // a held lock older than this is considered abandoned
|
|
11
|
+
const MAX_WAIT_MS = 400; // never block the render for long
|
|
12
|
+
const RETRY_MS = 15;
|
|
13
|
+
function sleepBusy(ms) {
|
|
14
|
+
const end = process.hrtime.bigint() + BigInt(ms) * 1000000n;
|
|
15
|
+
while (process.hrtime.bigint() < end) {
|
|
16
|
+
/* spin briefly; lock contention is rare and short */
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function withStateLock(fn) {
|
|
20
|
+
const path = PATHS.stateLock;
|
|
21
|
+
let fd = null;
|
|
22
|
+
const start = Date.now();
|
|
23
|
+
while (fd === null) {
|
|
24
|
+
try {
|
|
25
|
+
fd = openSync(path, "wx");
|
|
26
|
+
// record holder for stale diagnostics
|
|
27
|
+
try {
|
|
28
|
+
writeSync(fd, String(process.pid));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
/* ignore */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
if (err.code !== "EEXIST") {
|
|
36
|
+
// unexpected error — proceed without lock
|
|
37
|
+
return fn();
|
|
38
|
+
}
|
|
39
|
+
// Lock exists. Break it if stale.
|
|
40
|
+
try {
|
|
41
|
+
const age = Date.now() - statSync(path).mtimeMs;
|
|
42
|
+
if (age > STALE_MS) {
|
|
43
|
+
rmSync(path, { force: true });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* race: lock vanished, retry */
|
|
49
|
+
}
|
|
50
|
+
if (Date.now() - start > MAX_WAIT_MS) {
|
|
51
|
+
// Give up waiting; proceed unlocked rather than block render.
|
|
52
|
+
return fn();
|
|
53
|
+
}
|
|
54
|
+
sleepBusy(RETRY_MS);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
return fn();
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
try {
|
|
62
|
+
closeSync(fd);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
/* ignore */
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
rmSync(path, { force: true });
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* ignore */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Exposed for diagnostics/tests. */
|
|
76
|
+
export function lockHolder() {
|
|
77
|
+
try {
|
|
78
|
+
return readFileSync(PATHS.stateLock, "utf8");
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync, renameSync, rmSync, mkdirSync, } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { PATHS } from "../config.js";
|
|
5
|
+
import { writeAtomic } from "./fsutil.js";
|
|
6
|
+
const SENDING = `${PATHS.queue}.sending`;
|
|
7
|
+
/** Append one impression (only ever "shown" from the client). */
|
|
8
|
+
export function appendImpression(ev) {
|
|
9
|
+
mkdirSync(dirname(PATHS.queue), { recursive: true });
|
|
10
|
+
const entry = { ...ev, client_event_id: randomUUID() };
|
|
11
|
+
appendFileSync(PATHS.queue, JSON.stringify(entry) + "\n", "utf8");
|
|
12
|
+
}
|
|
13
|
+
function parseLines(raw) {
|
|
14
|
+
return raw
|
|
15
|
+
.split("\n")
|
|
16
|
+
.map((l) => l.trim())
|
|
17
|
+
.filter(Boolean)
|
|
18
|
+
.map((l) => {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(l);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
.filter((x) => x !== null);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Atomically take the pending batch for flushing (item 2 fix). Rotates
|
|
30
|
+
* queue.jsonl out of the way so concurrent appends during the network POST are
|
|
31
|
+
* never clobbered. Any leftover .sending batch (from a previous failed flush
|
|
32
|
+
* or crash) is merged in, giving at-least-once delivery.
|
|
33
|
+
*/
|
|
34
|
+
export function takeBatch() {
|
|
35
|
+
let carry = [];
|
|
36
|
+
if (existsSync(SENDING)) {
|
|
37
|
+
carry = parseLines(safeRead(SENDING));
|
|
38
|
+
}
|
|
39
|
+
let fresh = [];
|
|
40
|
+
if (existsSync(PATHS.queue)) {
|
|
41
|
+
const rotated = `${PATHS.queue}.rotate-${process.pid}-${process.hrtime.bigint()}`;
|
|
42
|
+
try {
|
|
43
|
+
renameSync(PATHS.queue, rotated); // new appends create a fresh queue.jsonl
|
|
44
|
+
fresh = parseLines(safeRead(rotated));
|
|
45
|
+
rmSync(rotated, { force: true });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* race: another flush grabbed it; ignore */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const combined = [...carry, ...fresh];
|
|
52
|
+
if (combined.length === 0) {
|
|
53
|
+
if (existsSync(SENDING))
|
|
54
|
+
rmSync(SENDING, { force: true });
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
// Persist the in-flight batch so it survives a crash mid-POST.
|
|
58
|
+
writeAtomic(SENDING, combined.map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
59
|
+
return combined;
|
|
60
|
+
}
|
|
61
|
+
/** Call after a successful POST: discard the in-flight batch. */
|
|
62
|
+
export function flushSucceeded() {
|
|
63
|
+
if (existsSync(SENDING))
|
|
64
|
+
rmSync(SENDING, { force: true });
|
|
65
|
+
}
|
|
66
|
+
/** On failure, the .sending file is left in place; the next takeBatch() merges it. */
|
|
67
|
+
function safeRead(path) {
|
|
68
|
+
try {
|
|
69
|
+
return readFileSync(path, "utf8");
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const ESC = String.fromCharCode(27);
|
|
2
|
+
const DIM = `${ESC}[2m`;
|
|
3
|
+
const RESET = `${ESC}[0m`;
|
|
4
|
+
/** Manicule (☞, U+261E) — points at the curiosity; the "margin note" emblem. */
|
|
5
|
+
const MARKER = String.fromCodePoint(0x261e);
|
|
6
|
+
/** OSC 8 hyperlink wrappers. */
|
|
7
|
+
function osc8(url, label) {
|
|
8
|
+
return `${ESC}]8;;${url}${ESC}\\${label}${ESC}]8;;${ESC}\\`;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Format one status line (Section 9). Truncation is width-aware on the VISIBLE
|
|
12
|
+
* text only (item 6 fix): we never count escape bytes toward width and never
|
|
13
|
+
* cut inside an escape/OSC 8 sequence. The hyperlink/ANSI is applied AFTER the
|
|
14
|
+
* visible text has been truncated.
|
|
15
|
+
*/
|
|
16
|
+
export function renderLine(c, clickToken, opts) {
|
|
17
|
+
const cols = opts.columns > 10 ? opts.columns : 80;
|
|
18
|
+
const handle = c.sponsor_handle ? `@${stripAt(c.sponsor_handle)}` : "@aside";
|
|
19
|
+
// Signature is mandatory on every render (screenshot-distribution engine).
|
|
20
|
+
const signature = ` — ${handle}`;
|
|
21
|
+
const sponsorMark = c.is_sponsored && c.sponsor_handle ? ` · by ${stripAt(c.sponsor_handle)}` : "";
|
|
22
|
+
// Leading manicule points at the curiosity — the "note in the margin" emblem.
|
|
23
|
+
// It travels in every screenshot, for free.
|
|
24
|
+
const head = `${MARKER} `;
|
|
25
|
+
// Reserve room for the marker + signature + sponsor mark; truncate body to fit.
|
|
26
|
+
const tail = signature + sponsorMark;
|
|
27
|
+
const room = Math.max(8, cols - visibleWidth(head) - visibleWidth(tail) - 1);
|
|
28
|
+
const body = truncateVisible(c.body.replace(/\s+/g, " ").trim(), room);
|
|
29
|
+
// Build the visible string, then decorate.
|
|
30
|
+
let line = head + body + tail;
|
|
31
|
+
// Apply OSC 8 only to the signature token, only for sponsored w/ click_url.
|
|
32
|
+
if (c.is_sponsored &&
|
|
33
|
+
c.click_url &&
|
|
34
|
+
clickToken &&
|
|
35
|
+
opts.supportsHyperlinks) {
|
|
36
|
+
const url = `${opts.backendUrl}/api/r/${encodeURIComponent(clickToken)}`;
|
|
37
|
+
const linked = osc8(url, handle);
|
|
38
|
+
// Replace the (already-truncated) plain handle with the linked version.
|
|
39
|
+
line = head + body + ` — ${linked}` + sponsorMark;
|
|
40
|
+
}
|
|
41
|
+
// Subtle dim on the tail for readability; never affects width math.
|
|
42
|
+
return colorizeTail(line, tail);
|
|
43
|
+
}
|
|
44
|
+
function colorizeTail(line, tail) {
|
|
45
|
+
const idx = line.lastIndexOf(tail.trimEnd());
|
|
46
|
+
if (idx < 0)
|
|
47
|
+
return line;
|
|
48
|
+
return line; // keep plain by default; ANSI is optional (MAY) — avoid surprises
|
|
49
|
+
// (Dim styling intentionally left off to keep screenshots clean; see DIM/RESET.)
|
|
50
|
+
}
|
|
51
|
+
function stripAt(h) {
|
|
52
|
+
return h.replace(/^@+/, "");
|
|
53
|
+
}
|
|
54
|
+
/** Visible width = code points outside escape/OSC 8 sequences. */
|
|
55
|
+
export function visibleWidth(s) {
|
|
56
|
+
return stripSequences(s).length;
|
|
57
|
+
}
|
|
58
|
+
/** Remove ANSI CSI and OSC 8 sequences for width measurement. */
|
|
59
|
+
function stripSequences(s) {
|
|
60
|
+
// OSC 8: ESC ] 8 ; ; URL ESC \ (and the closing ESC ] 8 ; ; ESC \)
|
|
61
|
+
let out = s.replace(/\]8;;.*?\\/g, "");
|
|
62
|
+
// CSI: ESC [ ... letter
|
|
63
|
+
out = out.replace(/\[[0-9;]*[A-Za-z]/g, "");
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
/** Truncate a PLAIN string (no escapes) to maxWidth visible chars, adding an ellipsis. */
|
|
67
|
+
export function truncateVisible(plain, maxWidth) {
|
|
68
|
+
const chars = [...plain];
|
|
69
|
+
if (chars.length <= maxWidth)
|
|
70
|
+
return plain;
|
|
71
|
+
if (maxWidth <= 1)
|
|
72
|
+
return chars.slice(0, Math.max(0, maxWidth)).join("");
|
|
73
|
+
return chars.slice(0, maxWidth - 1).join("") + "…";
|
|
74
|
+
}
|
|
75
|
+
export { DIM, RESET };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { DAILY_CAP, DISPLAY_PROBABILITY, SPONSORED_RATIO, RECENT_WINDOW, } from "../config.js";
|
|
2
|
+
import { liveCuriosities } from "./cache.js";
|
|
3
|
+
import { rollDayIfNeeded, gcPins, } from "./state.js";
|
|
4
|
+
/** A sponsored curiosity is eligible only if live (campaign liveness is
|
|
5
|
+
* enforced server-side at feed time, so anything sponsored in the cache is
|
|
6
|
+
* already campaign-live as of the last sync). */
|
|
7
|
+
function isEligibleSponsored(c) {
|
|
8
|
+
return c.is_sponsored && c.status === "live";
|
|
9
|
+
}
|
|
10
|
+
function pick(arr, rng) {
|
|
11
|
+
if (arr.length === 0)
|
|
12
|
+
return undefined;
|
|
13
|
+
return arr[Math.floor(rng() * arr.length)];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Select a curiosity for a NEW slot using local state only (Section 8 / 15.2).
|
|
17
|
+
* Mutates `state` atomically-in-memory (caller persists under lock).
|
|
18
|
+
* Returns the chosen curiosity, or null to render nothing.
|
|
19
|
+
*/
|
|
20
|
+
export function rotate(cache, state, sessionId, deps) {
|
|
21
|
+
const { now, rng } = deps;
|
|
22
|
+
rollDayIfNeeded(state, now);
|
|
23
|
+
gcPins(state, now);
|
|
24
|
+
if (state.slots_today >= DAILY_CAP)
|
|
25
|
+
return null;
|
|
26
|
+
if (rng() > DISPLAY_PROBABILITY)
|
|
27
|
+
return null; // ~1 in 3 attempts shows
|
|
28
|
+
const pool = liveCuriosities(cache).filter((c) => !state.recent.includes(c.id));
|
|
29
|
+
if (pool.length === 0)
|
|
30
|
+
return null;
|
|
31
|
+
const wantSponsored = rng() < SPONSORED_RATIO && state.last_sponsored === false;
|
|
32
|
+
let candidates;
|
|
33
|
+
if (wantSponsored) {
|
|
34
|
+
candidates = pool.filter(isEligibleSponsored);
|
|
35
|
+
if (candidates.length === 0) {
|
|
36
|
+
candidates = pool.filter((c) => !c.is_sponsored);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
candidates = pool.filter((c) => !c.is_sponsored);
|
|
41
|
+
// If we have nothing non-sponsored but the guard allows, fall back to any.
|
|
42
|
+
if (candidates.length === 0 && state.last_sponsored === false) {
|
|
43
|
+
candidates = pool.filter(isEligibleSponsored);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (candidates.length === 0)
|
|
47
|
+
return null;
|
|
48
|
+
const chosen = pick(candidates, rng);
|
|
49
|
+
if (!chosen)
|
|
50
|
+
return null;
|
|
51
|
+
const pin = {
|
|
52
|
+
curiosity_id: chosen.id,
|
|
53
|
+
slot_started_at: now,
|
|
54
|
+
last_render_at: now,
|
|
55
|
+
impression_emitted: false,
|
|
56
|
+
click_token: null,
|
|
57
|
+
};
|
|
58
|
+
state.pins[sessionId] = pin;
|
|
59
|
+
state.slots_today += 1;
|
|
60
|
+
state.recent.push(chosen.id);
|
|
61
|
+
if (state.recent.length > RECENT_WINDOW) {
|
|
62
|
+
state.recent.splice(0, state.recent.length - RECENT_WINDOW);
|
|
63
|
+
}
|
|
64
|
+
state.last_sponsored = chosen.is_sponsored;
|
|
65
|
+
return chosen;
|
|
66
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { PATHS, STATE_VERSION, PIN_GC_MS } from "../config.js";
|
|
2
|
+
import { readJsonSafe, writeJsonAtomic } from "./fsutil.js";
|
|
3
|
+
function empty() {
|
|
4
|
+
return {
|
|
5
|
+
version: STATE_VERSION,
|
|
6
|
+
pins: {},
|
|
7
|
+
day_key: "",
|
|
8
|
+
slots_today: 0,
|
|
9
|
+
recent: [],
|
|
10
|
+
last_sponsored: false,
|
|
11
|
+
last_sync_at: 0,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function readState() {
|
|
15
|
+
const s = readJsonSafe(PATHS.state, empty());
|
|
16
|
+
if (!s || s.version !== STATE_VERSION || typeof s.pins !== "object") {
|
|
17
|
+
return empty();
|
|
18
|
+
}
|
|
19
|
+
// Defensive defaults for partially-written / older files.
|
|
20
|
+
return {
|
|
21
|
+
version: STATE_VERSION,
|
|
22
|
+
pins: s.pins ?? {},
|
|
23
|
+
day_key: s.day_key ?? "",
|
|
24
|
+
slots_today: s.slots_today ?? 0,
|
|
25
|
+
recent: Array.isArray(s.recent) ? s.recent : [],
|
|
26
|
+
last_sponsored: !!s.last_sponsored,
|
|
27
|
+
last_sync_at: s.last_sync_at ?? 0,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function writeState(state) {
|
|
31
|
+
writeJsonAtomic(PATHS.state, state);
|
|
32
|
+
}
|
|
33
|
+
/** Local calendar day key (YYYY-MM-DD), used to reset the daily cap. */
|
|
34
|
+
export function dayKey(nowMs) {
|
|
35
|
+
const d = new Date(nowMs);
|
|
36
|
+
const y = d.getFullYear();
|
|
37
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
38
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
39
|
+
return `${y}-${m}-${day}`;
|
|
40
|
+
}
|
|
41
|
+
/** Drop pins older than PIN_GC_MS so the map cannot grow unbounded (item 5). */
|
|
42
|
+
export function gcPins(state, nowMs) {
|
|
43
|
+
for (const [sid, pin] of Object.entries(state.pins)) {
|
|
44
|
+
if (nowMs - pin.slot_started_at > PIN_GC_MS)
|
|
45
|
+
delete state.pins[sid];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function rollDayIfNeeded(state, nowMs) {
|
|
49
|
+
const key = dayKey(nowMs);
|
|
50
|
+
if (state.day_key !== key) {
|
|
51
|
+
state.day_key = key;
|
|
52
|
+
state.slots_today = 0;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
/**
|
|
3
|
+
* Read the host tool's stdin JSON and extract ONLY the allowlisted fields
|
|
4
|
+
* (Section 5.1 — receive-but-discard). Everything else (cwd, workspace.*,
|
|
5
|
+
* worktree.*, repo.*, pr.*, transcript_path, cost.*, context_window.*, env)
|
|
6
|
+
* is read into memory transiently and never returned, persisted, or sent.
|
|
7
|
+
*
|
|
8
|
+
* MUST tolerate absent / null / partial / malformed JSON without crashing.
|
|
9
|
+
*/
|
|
10
|
+
export function readStatusInput() {
|
|
11
|
+
let raw = "";
|
|
12
|
+
try {
|
|
13
|
+
// fd 0 = stdin. Synchronous, non-blocking-friendly read.
|
|
14
|
+
raw = readFileSync(0, "utf8");
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return { session_id: null };
|
|
18
|
+
}
|
|
19
|
+
if (!raw.trim())
|
|
20
|
+
return { session_id: null };
|
|
21
|
+
let obj;
|
|
22
|
+
try {
|
|
23
|
+
obj = JSON.parse(raw);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return { session_id: null };
|
|
27
|
+
}
|
|
28
|
+
return allowlist(obj);
|
|
29
|
+
}
|
|
30
|
+
/** Pure allowlist extractor — exported for tests. */
|
|
31
|
+
export function allowlist(obj) {
|
|
32
|
+
if (!obj || typeof obj !== "object")
|
|
33
|
+
return { session_id: null };
|
|
34
|
+
const o = obj;
|
|
35
|
+
const session_id = typeof o.session_id === "string" && o.session_id.length > 0
|
|
36
|
+
? o.session_id
|
|
37
|
+
: null;
|
|
38
|
+
let model_display_name = null;
|
|
39
|
+
const model = o.model;
|
|
40
|
+
if (model && typeof model === "object") {
|
|
41
|
+
const dn = model.display_name;
|
|
42
|
+
if (typeof dn === "string")
|
|
43
|
+
model_display_name = dn;
|
|
44
|
+
}
|
|
45
|
+
// Note: we deliberately construct a fresh object containing ONLY these two
|
|
46
|
+
// fields. No path, repo, pr, transcript, cost, or context field is copied.
|
|
47
|
+
return { session_id, model_display_name };
|
|
48
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { BACKEND_URL, CLI_VERSION } from "../config.js";
|
|
2
|
+
import { getInstallId, normalizedOs } from "./identity.js";
|
|
3
|
+
import { readCache, writeCache } from "./cache.js";
|
|
4
|
+
import { STATE_VERSION } from "../config.js";
|
|
5
|
+
import { takeBatch, flushSucceeded } from "./queue.js";
|
|
6
|
+
const FETCH_TIMEOUT_MS = 8000;
|
|
7
|
+
async function httpJson(url, init) {
|
|
8
|
+
const ctrl = new AbortController();
|
|
9
|
+
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
|
10
|
+
try {
|
|
11
|
+
const res = await fetch(url, { ...init, signal: ctrl.signal });
|
|
12
|
+
if (!res.ok)
|
|
13
|
+
return null;
|
|
14
|
+
return (await res.json());
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Background sync (Section 12). NEVER called from the foreground status path.
|
|
25
|
+
* 1. Fetch a FULL snapshot of live curiosities and replace the cache (item 4:
|
|
26
|
+
* snapshot, not incremental — avoids shrinking the pool).
|
|
27
|
+
* 2. Flush the impression queue in one batch; on success discard the in-flight
|
|
28
|
+
* file, on failure keep it for the next run (at-least-once).
|
|
29
|
+
*/
|
|
30
|
+
export async function runSync() {
|
|
31
|
+
const installId = getInstallId();
|
|
32
|
+
const os = normalizedOs();
|
|
33
|
+
// 1. Refresh cache (full snapshot).
|
|
34
|
+
const url = `${BACKEND_URL}/api/curiosities?install_id=${encodeURIComponent(installId)}&cli_version=${encodeURIComponent(CLI_VERSION)}&os=${encodeURIComponent(os)}`;
|
|
35
|
+
const feed = await httpJson(url, { method: "GET" });
|
|
36
|
+
if (feed && Array.isArray(feed.curiosities)) {
|
|
37
|
+
const cache = {
|
|
38
|
+
version: STATE_VERSION,
|
|
39
|
+
served_at: feed.served_at ?? null,
|
|
40
|
+
fetched_at: Date.now(),
|
|
41
|
+
curiosities: feed.curiosities,
|
|
42
|
+
};
|
|
43
|
+
writeCache(cache);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
// Touch fetched_at lightly so we don't hammer a down backend? No — leave
|
|
47
|
+
// fetched_at unchanged so staleness logic can retry next opportunity.
|
|
48
|
+
void readCache();
|
|
49
|
+
}
|
|
50
|
+
// 2. Flush impressions.
|
|
51
|
+
const batch = takeBatch();
|
|
52
|
+
if (batch.length > 0) {
|
|
53
|
+
const body = {
|
|
54
|
+
install_id: installId,
|
|
55
|
+
events: batch,
|
|
56
|
+
};
|
|
57
|
+
const ok = await httpJson(`${BACKEND_URL}/api/impressions`, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: { "content-type": "application/json" },
|
|
60
|
+
body: JSON.stringify(body),
|
|
61
|
+
});
|
|
62
|
+
if (ok)
|
|
63
|
+
flushSucceeded();
|
|
64
|
+
// else: leave .sending in place; next runSync() merges + retries.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Self-contained click token (item 1 fix). The token CARRIES the curiosity_id
|
|
4
|
+
* (and install_id) so the redirect endpoint can resolve the destination
|
|
5
|
+
* WITHOUT depending on the "shown" event having synced first. It is not signed:
|
|
6
|
+
* the client is open-source and cannot hold a server secret, and click counts
|
|
7
|
+
* are explicitly best-effort / not fraud-resistant (Section 5.3 limitation).
|
|
8
|
+
*
|
|
9
|
+
* Format: base64url(`${curiosityId}~${installId}~${nonce}`)
|
|
10
|
+
* The nonce (uuid v4, 122 bits) gives per-shown uniqueness for idempotency.
|
|
11
|
+
*/
|
|
12
|
+
export function makeClickToken(curiosityId, installId) {
|
|
13
|
+
const nonce = randomUUID();
|
|
14
|
+
const payload = `${curiosityId}~${installId}~${nonce}`;
|
|
15
|
+
return Buffer.from(payload, "utf8").toString("base64url");
|
|
16
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { CLI_VERSION } from "./config.js";
|
|
4
|
+
const program = new Command();
|
|
5
|
+
program
|
|
6
|
+
.name("aside")
|
|
7
|
+
.description("Developer microcuriosities in the Claude Code status line")
|
|
8
|
+
.version(CLI_VERSION);
|
|
9
|
+
program
|
|
10
|
+
.command("status")
|
|
11
|
+
.description("Print the current curiosity (invoked by the status line). Local-only, synchronous.")
|
|
12
|
+
.action(async () => {
|
|
13
|
+
const { runStatus } = await import("./commands/status.js");
|
|
14
|
+
runStatus();
|
|
15
|
+
});
|
|
16
|
+
program
|
|
17
|
+
.command("install")
|
|
18
|
+
.description("Register the Aside status line in ~/.claude/settings.json (non-destructive).")
|
|
19
|
+
.option("--force", "overwrite an existing non-Aside status line")
|
|
20
|
+
.action(async (opts) => {
|
|
21
|
+
const { runInstall } = await import("./commands/install.js");
|
|
22
|
+
await runInstall(opts);
|
|
23
|
+
});
|
|
24
|
+
program
|
|
25
|
+
.command("uninstall")
|
|
26
|
+
.description("Remove the Aside status line and ~/.aside/.")
|
|
27
|
+
.action(async () => {
|
|
28
|
+
const { runUninstall } = await import("./commands/uninstall.js");
|
|
29
|
+
runUninstall();
|
|
30
|
+
});
|
|
31
|
+
program
|
|
32
|
+
.command("about")
|
|
33
|
+
.description("Print exactly what Aside reads, sends, and never does.")
|
|
34
|
+
.action(async () => {
|
|
35
|
+
const { runAbout } = await import("./commands/about.js");
|
|
36
|
+
runAbout();
|
|
37
|
+
});
|
|
38
|
+
program
|
|
39
|
+
.command("sync")
|
|
40
|
+
.description("(internal) Refresh the cache and flush impressions. Run detached.")
|
|
41
|
+
.action(async () => {
|
|
42
|
+
const { runSyncCommand } = await import("./commands/sync.js");
|
|
43
|
+
await runSyncCommand();
|
|
44
|
+
});
|
|
45
|
+
program
|
|
46
|
+
.command("wrap", { isDefault: false })
|
|
47
|
+
.description("(experimental) Run `claude` and show a curiosity in the thinking spinner.")
|
|
48
|
+
.allowUnknownOption(true)
|
|
49
|
+
.allowExcessArguments(true)
|
|
50
|
+
.argument("[args...]", "arguments passed through to claude")
|
|
51
|
+
.action(async (args) => {
|
|
52
|
+
const { runWrap } = await import("./commands/wrap.js");
|
|
53
|
+
await runWrap(args ?? []);
|
|
54
|
+
});
|
|
55
|
+
program.parseAsync(process.argv).catch(() => {
|
|
56
|
+
// Never let a CLI error leak a stack trace into a status line render.
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@asidedev/cli",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Aside CLI — renders developer microcuriosities in the Claude Code status line",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"aside": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc -p tsconfig.json",
|
|
14
|
+
"test": "vitest run",
|
|
15
|
+
"test:watch": "vitest"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"commander": "^12.1.0"
|
|
19
|
+
},
|
|
20
|
+
"optionalDependencies": {
|
|
21
|
+
"node-pty": "^1.1.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@aside/shared": "*",
|
|
25
|
+
"@types/node": "^22.0.0",
|
|
26
|
+
"@xterm/headless": "^5.5.0",
|
|
27
|
+
"typescript": "^5.6.0",
|
|
28
|
+
"vitest": "^2.1.0"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/asidedev/aside.git",
|
|
34
|
+
"directory": "packages/cli"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/asidedev/aside#readme",
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
}
|
|
40
|
+
}
|