@ghostlygawd/hangar 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/git.js ADDED
@@ -0,0 +1,96 @@
1
+ import { execFile } from 'node:child_process'
2
+
3
+ /** Run git with args in cwd. Resolves {ok, stdout, stderr, code}; never rejects. */
4
+ export function git(args, cwd) {
5
+ return new Promise((resolve) => {
6
+ execFile('git', args, { cwd, windowsHide: true, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
7
+ resolve({
8
+ ok: !err,
9
+ code: err?.code ?? 0,
10
+ stdout: String(stdout).trimEnd(),
11
+ stderr: String(stderr).trimEnd(),
12
+ })
13
+ })
14
+ })
15
+ }
16
+
17
+ /** True only for the top-level directory of a working tree. */
18
+ export async function isRepoRoot(dir) {
19
+ const r = await git(['rev-parse', '--show-toplevel'], dir)
20
+ if (!r.ok) return false
21
+ return normalize(r.stdout) === normalize(dir)
22
+ }
23
+
24
+ function normalize(p) {
25
+ return p.replace(/[\\/]+$/, '').replace(/\//g, '\\').toLowerCase()
26
+ }
27
+
28
+ /** The repo's default branch: origin/HEAD if known, else main/master if present, else current. */
29
+ export async function defaultBranch(repoDir) {
30
+ const remote = await git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], repoDir)
31
+ if (remote.ok) return remote.stdout.replace(/^origin\//, '')
32
+ for (const candidate of ['main', 'master']) {
33
+ const r = await git(['show-ref', '--verify', '--quiet', `refs/heads/${candidate}`], repoDir)
34
+ if (r.ok) return candidate
35
+ }
36
+ const current = await git(['branch', '--show-current'], repoDir)
37
+ return current.stdout || 'main'
38
+ }
39
+
40
+ export async function currentBranch(dir) {
41
+ const r = await git(['branch', '--show-current'], dir)
42
+ return r.stdout
43
+ }
44
+
45
+ export async function isDirty(dir) {
46
+ const r = await git(['status', '--porcelain'], dir)
47
+ return { dirty: r.ok && r.stdout.length > 0, detail: r.stdout }
48
+ }
49
+
50
+ /** [ahead, behind] of branch relative to base. */
51
+ export async function aheadBehind(repoDir, branch, base) {
52
+ const r = await git(['rev-list', '--left-right', '--count', `${base}...${branch}`], repoDir)
53
+ if (!r.ok) return { ahead: 0, behind: 0 }
54
+ const [behind, ahead] = r.stdout.split(/\s+/).map(Number)
55
+ return { ahead: ahead || 0, behind: behind || 0 }
56
+ }
57
+
58
+ /** Files changed / insertions / deletions of branch vs base (three-dot: since divergence). */
59
+ export async function diffStat(repoDir, branch, base) {
60
+ const r = await git(['diff', '--shortstat', `${base}...${branch}`], repoDir)
61
+ const out = { files: 0, insertions: 0, deletions: 0 }
62
+ if (!r.ok || !r.stdout) return out
63
+ const files = r.stdout.match(/(\d+) files? changed/)
64
+ const ins = r.stdout.match(/(\d+) insertions?/)
65
+ const del = r.stdout.match(/(\d+) deletions?/)
66
+ if (files) out.files = Number(files[1])
67
+ if (ins) out.insertions = Number(ins[1])
68
+ if (del) out.deletions = Number(del[1])
69
+ return out
70
+ }
71
+
72
+ /** True when branch is fully merged into base. */
73
+ export async function isMerged(repoDir, branch, base) {
74
+ const r = await git(['merge-base', '--is-ancestor', branch, base], repoDir)
75
+ return r.ok
76
+ }
77
+
78
+ /** Parse `git worktree list --porcelain` into [{path, branch, head}]. */
79
+ export async function worktrees(repoDir) {
80
+ const r = await git(['worktree', 'list', '--porcelain'], repoDir)
81
+ if (!r.ok) return []
82
+ const out = []
83
+ let entry = null
84
+ for (const line of r.stdout.split('\n')) {
85
+ if (line.startsWith('worktree ')) {
86
+ if (entry) out.push(entry)
87
+ entry = { path: line.slice('worktree '.length), branch: null, head: null }
88
+ } else if (line.startsWith('branch ') && entry) {
89
+ entry.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '')
90
+ } else if (line.startsWith('HEAD ') && entry) {
91
+ entry.head = line.slice('HEAD '.length)
92
+ }
93
+ }
94
+ if (entry) out.push(entry)
95
+ return out
96
+ }
package/lib/launch.js ADDED
@@ -0,0 +1,81 @@
1
+ import { execFile, spawn } from 'node:child_process'
2
+ import { claudeHomeFor } from './paths.js'
3
+
4
+ /** Active windows Hangar opened this run: id → {pid, cwd, profile, startedAt} */
5
+ export const launched = new Map()
6
+ let nextId = 1
7
+
8
+ function hasWindowsTerminal() {
9
+ return new Promise((resolve) => {
10
+ execFile('where.exe', ['wt'], { windowsHide: true }, (err) => resolve(!err))
11
+ })
12
+ }
13
+
14
+ /** Single-quote a string for embedding in a PowerShell single-quoted literal. */
15
+ function psq(s) {
16
+ return `'${String(s).replace(/'/g, "''")}'`
17
+ }
18
+
19
+ /** The interactive PowerShell command that sets the profile, cd's in, and starts claude. */
20
+ export function buildInner({ cwd, profile = 'default', prompt = '' }) {
21
+ const env = profile !== 'default' ? `$env:CLAUDE_CONFIG_DIR=${psq(claudeHomeFor(profile))}; ` : ''
22
+ const claudeArgs = prompt ? ` ${psq(prompt)}` : ''
23
+ return `${env}Set-Location ${psq(cwd)}; claude${claudeArgs}`
24
+ }
25
+
26
+ /**
27
+ * Windows Terminal parses `;` in its own command line as a tab/pane delimiter,
28
+ * so the inner command's semicolons would split into separate tabs — setting
29
+ * CLAUDE_CONFIG_DIR in one and starting claude (under the wrong account) in
30
+ * another. Passing the command as a base64 `-EncodedCommand` blob means wt sees
31
+ * no bare `;` at all. Verified: base64 is [A-Za-z0-9+/=] only.
32
+ */
33
+ export function wtLaunchArgs(cwd, inner) {
34
+ const encoded = Buffer.from(inner, 'utf16le').toString('base64')
35
+ return ['-w', '0', 'nt', '-d', cwd, 'powershell', '-NoExit', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded]
36
+ }
37
+
38
+ /**
39
+ * Open a REAL interactive Claude Code window. Never headless, never SDK:
40
+ * a visible terminal the user can type in, exactly like opening it by hand.
41
+ * Returns {ok, id, pid?} — pid is known on the PowerShell-window path.
42
+ */
43
+ export async function launchSession({ cwd, profile = 'default', prompt = '' }) {
44
+ const inner = buildInner({ cwd, profile, prompt })
45
+
46
+ if (await hasWindowsTerminal()) {
47
+ // wt opens its own window tree; PID of the session shell isn't directly addressable — liveness is best-effort
48
+ const child = spawn('wt', wtLaunchArgs(cwd, inner), {
49
+ detached: true, stdio: 'ignore', windowsHide: false,
50
+ })
51
+ child.unref()
52
+ const id = `w${nextId++}`
53
+ launched.set(id, { id, pid: null, cwd, profile, startedAt: new Date().toISOString(), via: 'wt' })
54
+ return { ok: true, id, pid: null }
55
+ }
56
+
57
+ // Plain PowerShell window via Start-Process -PassThru so we learn the real PID.
58
+ const outer = `$p = Start-Process -FilePath 'powershell' -ArgumentList @('-NoExit','-NoProfile','-ExecutionPolicy','Bypass','-Command',${psq(inner)}) -WorkingDirectory ${psq(cwd)} -PassThru; $p.Id`
59
+ const pid = await new Promise((resolve) => {
60
+ execFile('powershell', ['-NoProfile', '-Command', outer], { windowsHide: true }, (err, stdout) => {
61
+ const n = parseInt(String(stdout).trim(), 10)
62
+ resolve(err || !Number.isFinite(n) ? null : n)
63
+ })
64
+ })
65
+ if (pid === null) return { ok: false, error: 'Failed to open a PowerShell window for the session.' }
66
+ const id = `w${nextId++}`
67
+ launched.set(id, { id, pid, cwd, profile, startedAt: new Date().toISOString(), via: 'powershell' })
68
+ return { ok: true, id, pid }
69
+ }
70
+
71
+ export function liveness() {
72
+ const out = []
73
+ for (const s of launched.values()) {
74
+ let alive = null
75
+ if (s.pid != null) {
76
+ try { process.kill(s.pid, 0); alive = true } catch { alive = false }
77
+ }
78
+ out.push({ ...s, alive })
79
+ }
80
+ return out
81
+ }
package/lib/limits.js ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Limit detection over structured fields ONLY. Message text is never read —
3
+ * a transcript whose *content* talks about rate limits must never trigger.
4
+ * Ported from the proven detector; pure function, no IO.
5
+ *
6
+ * Returns { kind, resetsAt? } or null.
7
+ */
8
+ export function detectLimit(msg) {
9
+ if (!msg || typeof msg !== 'object') return null
10
+
11
+ // 1. explicit rate-limit event with rejected status
12
+ if (msg.type === 'rate_limit_event') {
13
+ const info = msg.rate_limit_info
14
+ if (info && info.status === 'rejected') {
15
+ const kind = info.rateLimitType ? `rate_limit_event:${info.rateLimitType}` : 'rate_limit_event:rejected'
16
+ return { kind, resetsAt: toIso(info.resetsAt) }
17
+ }
18
+ return null
19
+ }
20
+
21
+ // 2. api retry caused by a rate limit (429); 529 overload is transient and never parks
22
+ if (msg.type === 'system' && msg.subtype === 'api_retry') {
23
+ if (msg.error === 'rate_limit' || msg.error_status === 429) {
24
+ return { kind: 'api_retry:rate_limit', resetsAt: undefined }
25
+ }
26
+ return null
27
+ }
28
+
29
+ // 3. assistant turn flagged with a structured rate-limit error
30
+ if (msg.type === 'assistant' && msg.error === 'rate_limit') {
31
+ return { kind: 'assistant:rate_limit', resetsAt: undefined }
32
+ }
33
+
34
+ // 4. terminal result blocked by a limit
35
+ if (
36
+ msg.type === 'result' &&
37
+ msg.terminal_reason === 'blocking_limit' &&
38
+ msg.subtype !== 'success' &&
39
+ msg.is_error === true
40
+ ) {
41
+ return { kind: 'result:blocking_limit', resetsAt: undefined }
42
+ }
43
+
44
+ return null
45
+ }
46
+
47
+ function toIso(value) {
48
+ if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
49
+ const ms = value >= 1e12 ? value : value * 1000
50
+ try {
51
+ return new Date(ms).toISOString()
52
+ } catch {
53
+ return undefined
54
+ }
55
+ }
package/lib/paths.js ADDED
@@ -0,0 +1,40 @@
1
+ import os from 'node:os'
2
+ import path from 'node:path'
3
+
4
+ /** Root for all Hangar state. Never inside a repo, never inside ~/.claude. */
5
+ export const HANGAR_HOME = process.env.HANGAR_HOME || path.join(os.homedir(), '.hangar')
6
+
7
+ export const CONFIG_FILE = path.join(HANGAR_HOME, 'config.json')
8
+ export const REPOS_FILE = path.join(HANGAR_HOME, 'repos.json')
9
+ export const PROFILES_FILE = path.join(HANGAR_HOME, 'profiles.json')
10
+ export const PARKED_FILE = path.join(HANGAR_HOME, 'parked.json')
11
+
12
+ /** Where profile config homes live. The "default" profile is the user's normal ~/.claude. */
13
+ export const PROFILE_HOMES = path.join(HANGAR_HOME, 'profiles')
14
+
15
+ /** The one canonical default config — reused by the server, the CLI, and the tunnel. */
16
+ export const DEFAULT_CONFIG = { port: 4870, bind: '127.0.0.1', token: '', defaultProfile: 'default' }
17
+
18
+ export function defaultClaudeHome() {
19
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
20
+ }
21
+
22
+ /** Config home for a profile name. "default" → the user's normal claude home. */
23
+ export function claudeHomeFor(profileName) {
24
+ if (!profileName || profileName === 'default') return defaultClaudeHome()
25
+ return path.join(PROFILE_HOMES, profileName)
26
+ }
27
+
28
+ /** Claude Code's transcript root for a given config home. */
29
+ export function projectsDirFor(claudeHome) {
30
+ return path.join(claudeHome, 'projects')
31
+ }
32
+
33
+ /**
34
+ * Claude Code names each project's transcript directory by replacing every
35
+ * non-alphanumeric character of the absolute cwd with '-'.
36
+ * Verified against real ~/.claude/projects entries on Windows.
37
+ */
38
+ export function mungeCwd(absPath) {
39
+ return absPath.replace(/[^a-zA-Z0-9]/g, '-')
40
+ }
@@ -0,0 +1,58 @@
1
+ import fs from 'node:fs'
2
+ import { JsonStore } from './store.js'
3
+ import { PROFILES_FILE, PROFILE_HOMES, claudeHomeFor } from './paths.js'
4
+ import path from 'node:path'
5
+
6
+ const SAFE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,31}$/
7
+ // Registry entries must never carry anything credential-shaped.
8
+ const CREDENTIALISH = /credential|token|api[_-]?key|secret|password|auth|bearer/i
9
+
10
+ export const profilesStore = new JsonStore(PROFILES_FILE, { version: 1, profiles: [] })
11
+
12
+ export function validProfileName(name) {
13
+ return SAFE_NAME.test(name) && !name.includes('..') && name !== 'default'
14
+ }
15
+
16
+ /** Reject any entry that smells like a credential made it into the registry. */
17
+ export function validateRegistry(reg) {
18
+ if (!reg || typeof reg !== 'object' || !Array.isArray(reg.profiles)) return 'registry malformed'
19
+ for (const p of reg.profiles) {
20
+ for (const key of Object.keys(p)) {
21
+ if (CREDENTIALISH.test(key)) return `field "${key}" looks credential-like; profiles carry names only`
22
+ }
23
+ if (typeof p.name !== 'string' || !SAFE_NAME.test(p.name)) return `profile name "${p.name}" is not a safe segment`
24
+ }
25
+ return null
26
+ }
27
+
28
+ /** All profiles, always including the implicit default. */
29
+ export function listProfiles() {
30
+ const err = validateRegistry(profilesStore.data)
31
+ const extras = err ? [] : profilesStore.data.profiles
32
+ return [
33
+ { name: 'default', displayName: 'Default (~/.claude)', home: claudeHomeFor('default'), implicit: true },
34
+ ...extras.map((p) => ({ ...p, home: claudeHomeFor(p.name), implicit: false })),
35
+ ]
36
+ }
37
+
38
+ export function addProfile(name, displayName) {
39
+ if (!validProfileName(name)) {
40
+ return { ok: false, status: 400, error: `Invalid profile name "${name}".` }
41
+ }
42
+ if (profilesStore.data.profiles.some((p) => p.name === name)) {
43
+ return { ok: true, existed: true }
44
+ }
45
+ const entry = { name, addedAt: new Date().toISOString() }
46
+ if (displayName) entry.displayName = String(displayName)
47
+ profilesStore.update((d) => d.profiles.push(entry))
48
+ fs.mkdirSync(path.join(PROFILE_HOMES, name), { recursive: true })
49
+ return { ok: true, existed: false, home: claudeHomeFor(name) }
50
+ }
51
+
52
+ export function removeProfile(name) {
53
+ if (name === 'default') return { ok: false, status: 400, error: 'The default profile cannot be removed.' }
54
+ const before = profilesStore.data.profiles.length
55
+ profilesStore.update((d) => { d.profiles = d.profiles.filter((p) => p.name !== name) })
56
+ // the config home on disk is left alone — it may hold a login; removing tracking is not destroying credentials
57
+ return { ok: true, removed: profilesStore.data.profiles.length < before }
58
+ }
package/lib/spaces.js ADDED
@@ -0,0 +1,155 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import {
4
+ git, defaultBranch, isDirty, aheadBehind, diffStat, isMerged, worktrees,
5
+ } from './git.js'
6
+
7
+ export const SPACES_DIR = '.hangar-spaces'
8
+ const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/
9
+
10
+ export function validSpaceName(name) {
11
+ return NAME_RE.test(name) && !name.includes('..')
12
+ }
13
+
14
+ function spacesRoot(repoPath) {
15
+ return path.join(repoPath, SPACES_DIR)
16
+ }
17
+
18
+ export function spacePath(repoPath, name) {
19
+ return path.join(spacesRoot(repoPath), name)
20
+ }
21
+
22
+ /** Keep spaces invisible to the repo's own status without touching .gitignore. */
23
+ function ensureExcluded(repoPath) {
24
+ const excludeFile = path.join(repoPath, '.git', 'info', 'exclude')
25
+ try {
26
+ const current = fs.existsSync(excludeFile) ? fs.readFileSync(excludeFile, 'utf8') : ''
27
+ if (!current.split(/\r?\n/).includes(`${SPACES_DIR}/`)) {
28
+ fs.mkdirSync(path.dirname(excludeFile), { recursive: true })
29
+ fs.appendFileSync(excludeFile, `${current.endsWith('\n') || current === '' ? '' : '\n'}${SPACES_DIR}/\n`)
30
+ }
31
+ } catch {
32
+ // a worktree-style .git file or odd layout — exclusion is cosmetic, never fatal
33
+ }
34
+ }
35
+
36
+ export async function listSpaces(repoPath) {
37
+ const root = spacesRoot(repoPath)
38
+ const [all, base] = await Promise.all([worktrees(repoPath), defaultBranch(repoPath)])
39
+ const mine = all.filter((w) => normalize(w.path).startsWith(normalize(root) + path.sep.toLowerCase()))
40
+ return Promise.all(mine.map(async (w) => {
41
+ const [dirty, ab, stat, merged] = await Promise.all([
42
+ isDirty(w.path),
43
+ w.branch ? aheadBehind(repoPath, w.branch, base) : { ahead: 0, behind: 0 },
44
+ w.branch ? diffStat(repoPath, w.branch, base) : { files: 0, insertions: 0, deletions: 0 },
45
+ w.branch ? isMerged(repoPath, w.branch, base) : false,
46
+ ])
47
+ return {
48
+ name: path.basename(w.path),
49
+ path: w.path,
50
+ branch: w.branch,
51
+ base,
52
+ dirty: dirty.dirty,
53
+ dirtyDetail: dirty.detail,
54
+ ahead: ab.ahead,
55
+ behind: ab.behind,
56
+ diff: stat,
57
+ merged,
58
+ deletable: merged && !dirty.dirty,
59
+ }
60
+ }))
61
+ }
62
+
63
+ export async function createSpace(repoPath, name) {
64
+ if (!validSpaceName(name)) {
65
+ return { ok: false, status: 400, error: `Invalid space name "${name}". Use letters, digits, dot, dash, underscore (max 64).` }
66
+ }
67
+ const target = spacePath(repoPath, name)
68
+ if (fs.existsSync(target)) {
69
+ return { ok: false, status: 409, error: `Space "${name}" already exists.` }
70
+ }
71
+ ensureExcluded(repoPath)
72
+ const base = await defaultBranch(repoPath)
73
+ const branch = `space/${name}`
74
+ const r = await git(['worktree', 'add', target, '-b', branch, base], repoPath)
75
+ if (!r.ok) {
76
+ return { ok: false, status: 500, error: `git worktree add failed: ${r.stderr || r.stdout}` }
77
+ }
78
+ return { ok: true, name, path: target, branch, base }
79
+ }
80
+
81
+ /** True only when `p` resolves strictly inside `root` (after normalization). */
82
+ function isInside(root, p) {
83
+ return normalize(p).startsWith(normalize(root) + '\\')
84
+ }
85
+
86
+ /**
87
+ * The provably-safe rule: delete only when merged into base AND clean.
88
+ * Force is a separate, explicit path that must echo the space name back.
89
+ *
90
+ * Worktree safety is SACRED, so the delete path is validated as strictly as
91
+ * the create path: the name is checked, the resolved target must live inside
92
+ * this repo's .hangar-spaces root, and we refuse to touch the default-branch
93
+ * checkout or anything that escapes the spaces dir.
94
+ */
95
+ export async function deleteSpace(repoPath, name, { force = false, confirm = '' } = {}) {
96
+ if (!validSpaceName(name)) {
97
+ return { ok: false, status: 400, error: `Invalid space name "${name}". Use letters, digits, dot, dash, underscore (max 64).` }
98
+ }
99
+ const root = spacesRoot(repoPath)
100
+ const target = spacePath(repoPath, name)
101
+ // containment: the resolved target must live strictly inside this repo's spaces root
102
+ if (!isInside(root, target)) {
103
+ return { ok: false, status: 400, error: `Refusing to delete "${name}": it resolves outside ${SPACES_DIR}.` }
104
+ }
105
+
106
+ const all = await worktrees(repoPath)
107
+ const entry = all.find((w) => normalize(w.path) === normalize(target))
108
+ if (!entry) return { ok: false, status: 404, error: `Space "${name}" not found.` }
109
+
110
+ // SACRED, belt-and-braces: only ever remove a worktree that physically lives
111
+ // under this repo's .hangar-spaces, never the repo checkout, never the default branch.
112
+ if (!isInside(root, entry.path)) {
113
+ return { ok: false, status: 403, error: `Refusing to remove a worktree outside ${SPACES_DIR}.` }
114
+ }
115
+ if (normalize(entry.path) === normalize(repoPath)) {
116
+ return { ok: false, status: 403, error: 'Refusing to remove the repository checkout itself.' }
117
+ }
118
+
119
+ const base = await defaultBranch(repoPath)
120
+ if (entry.branch && entry.branch === base) {
121
+ return { ok: false, status: 403, error: `Refusing to remove a worktree with the default branch ("${base}") checked out.` }
122
+ }
123
+
124
+ const dirty = await isDirty(target)
125
+ const merged = entry.branch ? await isMerged(repoPath, entry.branch, base) : false
126
+
127
+ if (!force) {
128
+ if (dirty.dirty) {
129
+ return { ok: false, status: 409, error: `Space "${name}" has uncommitted changes:\n${dirty.detail}` }
130
+ }
131
+ if (!entry.branch) {
132
+ return { ok: false, status: 409, error: `Space "${name}" is on a detached HEAD (no branch). Commit to a branch, or force-delete to discard it.` }
133
+ }
134
+ if (!merged) {
135
+ return { ok: false, status: 409, error: `Branch "${entry.branch}" is not merged into ${base}. Merge it first, or force-delete to discard it.` }
136
+ }
137
+ } else if (confirm !== name) {
138
+ return { ok: false, status: 400, error: `Force-delete requires confirm to equal the space name ("${name}").` }
139
+ }
140
+
141
+ const discarded = force ? { dirty: dirty.dirty, unmerged: !merged, dirtyDetail: dirty.detail } : null
142
+ const rm = await git(['worktree', 'remove', force ? '--force' : '--no-force', target], repoPath)
143
+ if (!rm.ok) return { ok: false, status: 500, error: `git worktree remove failed: ${rm.stderr}` }
144
+ let warning = null
145
+ if (entry.branch) {
146
+ const br = await git(['branch', force ? '-D' : '-d', entry.branch], repoPath)
147
+ if (!br.ok) warning = `Space removed, but branch "${entry.branch}" could not be deleted: ${br.stderr || br.stdout}`
148
+ }
149
+ await git(['worktree', 'prune'], repoPath)
150
+ return { ok: true, name, discarded, ...(warning ? { warning } : {}) }
151
+ }
152
+
153
+ function normalize(p) {
154
+ return p.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase()
155
+ }
package/lib/store.js ADDED
@@ -0,0 +1,48 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ /**
5
+ * One JSON file, loaded once, written atomically (temp + rename).
6
+ * Rename retries cover Windows AV/indexer EPERM blips.
7
+ */
8
+ export class JsonStore {
9
+ constructor(filePath, fallback) {
10
+ this.filePath = filePath
11
+ this.fallback = fallback
12
+ this.data = this.#load()
13
+ }
14
+
15
+ #load() {
16
+ try {
17
+ const raw = fs.readFileSync(this.filePath, 'utf8')
18
+ return JSON.parse(raw)
19
+ } catch {
20
+ return structuredClone(this.fallback)
21
+ }
22
+ }
23
+
24
+ save() {
25
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true })
26
+ const tmp = `${this.filePath}.${process.pid}.tmp`
27
+ fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2))
28
+ for (let attempt = 0; ; attempt++) {
29
+ try {
30
+ fs.renameSync(tmp, this.filePath)
31
+ return
32
+ } catch (err) {
33
+ if (attempt >= 4) {
34
+ try { fs.unlinkSync(tmp) } catch { /* best effort */ }
35
+ throw err
36
+ }
37
+ const wait = 25 * 2 ** attempt
38
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, wait)
39
+ }
40
+ }
41
+ }
42
+
43
+ update(fn) {
44
+ fn(this.data)
45
+ this.save()
46
+ return this.data
47
+ }
48
+ }
package/lib/sysinfo.js ADDED
@@ -0,0 +1,78 @@
1
+ import si from 'systeminformation'
2
+
3
+ const HISTORY = 150 // ~5 minutes at 2s
4
+
5
+ /** Samples the machine on a fixed cadence and keeps a short history ring. */
6
+ export class SystemMonitor {
7
+ /**
8
+ * @param {(event: object) => void} emit
9
+ * @param {() => boolean} shouldSample — false skips sampling entirely (nobody watching)
10
+ */
11
+ constructor(emit, shouldSample = () => true) {
12
+ this.emit = emit
13
+ this.shouldSample = shouldSample
14
+ this.history = []
15
+ this.current = null
16
+ this.processes = []
17
+ this.timer = null
18
+ this.procTimer = null
19
+ }
20
+
21
+ start() {
22
+ this.timer = setInterval(() => { if (this.shouldSample()) this.#sample().catch(() => {}) }, 2000)
23
+ // the process sweep is a heavy WMI query — keep it rare and viewer-gated
24
+ this.procTimer = setInterval(() => { if (this.shouldSample()) this.#sampleProcs().catch(() => {}) }, 10000)
25
+ this.#sample().catch(() => {})
26
+ }
27
+
28
+ stop() {
29
+ clearInterval(this.timer)
30
+ clearInterval(this.procTimer)
31
+ }
32
+
33
+ async #sample() {
34
+ const [load, mem, net, disk] = await Promise.all([
35
+ si.currentLoad(),
36
+ si.mem(),
37
+ si.networkStats().catch(() => []),
38
+ si.fsSize().catch(() => []),
39
+ ])
40
+ const point = {
41
+ ts: Date.now(),
42
+ cpu: round(load.currentLoad),
43
+ cores: load.cpus?.map((c) => round(c.load)) ?? [],
44
+ memUsed: mem.active,
45
+ memTotal: mem.total,
46
+ netRx: net[0]?.rx_sec ?? 0,
47
+ netTx: net[0]?.tx_sec ?? 0,
48
+ disks: disk.map((d) => ({ fs: d.fs, used: d.used, size: d.size })),
49
+ }
50
+ this.current = point
51
+ this.history.push({ ts: point.ts, cpu: point.cpu, memUsed: point.memUsed })
52
+ if (this.history.length > HISTORY) this.history.shift()
53
+ this.emit({ type: 'sys', point })
54
+ }
55
+
56
+ async #sampleProcs() {
57
+ const procs = await si.processes()
58
+ const interesting = /^(claude|node|git|powershell|wt|windowsterminal)/i
59
+ this.processes = procs.list
60
+ .filter((p) => interesting.test(p.name))
61
+ .sort((a, b) => b.cpu - a.cpu)
62
+ .slice(0, 25)
63
+ .map((p) => ({ pid: p.pid, name: p.name, cpu: round(p.cpu), memRss: p.memRss, command: clip(p.command, 140) }))
64
+ }
65
+
66
+ snapshot() {
67
+ return { current: this.current, history: this.history, processes: this.processes }
68
+ }
69
+ }
70
+
71
+ function round(n) {
72
+ return Math.round((n ?? 0) * 10) / 10
73
+ }
74
+
75
+ function clip(s, n) {
76
+ s = String(s ?? '')
77
+ return s.length > n ? `${s.slice(0, n)}…` : s
78
+ }