@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/CHANGELOG.md +101 -0
- package/LICENSE +202 -0
- package/README.md +86 -0
- package/bin/hangar.js +156 -0
- package/lib/doctor.js +68 -0
- package/lib/git.js +96 -0
- package/lib/launch.js +81 -0
- package/lib/limits.js +55 -0
- package/lib/paths.js +40 -0
- package/lib/profiles.js +58 -0
- package/lib/spaces.js +155 -0
- package/lib/store.js +48 -0
- package/lib/sysinfo.js +78 -0
- package/lib/transcripts.js +266 -0
- package/package.json +56 -0
- package/server/bus.js +60 -0
- package/server/gate.js +50 -0
- package/server/index.js +67 -0
- package/server/routes.js +166 -0
- package/web/dist/assets/index-D593hYue.js +49 -0
- package/web/dist/assets/index-Dv9Rj8Ur.css +1 -0
- package/web/dist/index.html +20 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import chokidar from 'chokidar'
|
|
4
|
+
import { projectsDirFor, mungeCwd } from './paths.js'
|
|
5
|
+
import { detectLimit } from './limits.js'
|
|
6
|
+
|
|
7
|
+
const ACTIVE_MS = 90_000
|
|
8
|
+
const IDLE_MS = 30 * 60_000
|
|
9
|
+
const RECENT_PARSE_MS = 48 * 3_600_000
|
|
10
|
+
const FEED_CAP = 1000
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Watches Claude Code transcript JSONL files across all profile homes and
|
|
14
|
+
* keeps an in-memory view of every session. Pure file reads — observing a
|
|
15
|
+
* session costs zero tokens by construction.
|
|
16
|
+
*/
|
|
17
|
+
export class TranscriptWatcher {
|
|
18
|
+
/**
|
|
19
|
+
* @param {() => Array<{name: string, home: string}>} getProfiles
|
|
20
|
+
* @param {(event: object) => void} emit
|
|
21
|
+
* @param {(profile: string, hit: {kind: string, resetsAt?: string}) => void} onLimit
|
|
22
|
+
*/
|
|
23
|
+
constructor(getProfiles, emit, onLimit) {
|
|
24
|
+
this.getProfiles = getProfiles
|
|
25
|
+
this.emit = emit
|
|
26
|
+
this.onLimit = onLimit
|
|
27
|
+
this.sessions = new Map() // sessionId → session view
|
|
28
|
+
this.offsets = new Map() // file → byte offset already parsed
|
|
29
|
+
this.fileProfile = new Map() // file → profile name
|
|
30
|
+
this.watcher = null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
start() {
|
|
34
|
+
const roots = []
|
|
35
|
+
for (const p of this.getProfiles()) {
|
|
36
|
+
const dir = projectsDirFor(p.home)
|
|
37
|
+
if (fs.existsSync(dir)) roots.push({ dir, profile: p.name })
|
|
38
|
+
}
|
|
39
|
+
const paths = roots.map((r) => r.dir)
|
|
40
|
+
this.roots = roots
|
|
41
|
+
if (paths.length === 0) return
|
|
42
|
+
this.watcher = chokidar.watch(paths, {
|
|
43
|
+
ignoreInitial: false,
|
|
44
|
+
depth: 2,
|
|
45
|
+
awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 },
|
|
46
|
+
})
|
|
47
|
+
this.watcher.on('add', (f) => this.#onFile(f, true))
|
|
48
|
+
this.watcher.on('change', (f) => this.#onFile(f, false))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
stop() {
|
|
52
|
+
this.watcher?.close()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#profileFor(file) {
|
|
56
|
+
if (this.fileProfile.has(file)) return this.fileProfile.get(file)
|
|
57
|
+
const hit = this.roots.find((r) => file.toLowerCase().startsWith(r.dir.toLowerCase()))
|
|
58
|
+
const name = hit ? hit.profile : 'default'
|
|
59
|
+
this.fileProfile.set(file, name)
|
|
60
|
+
return name
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#onFile(file, isAdd) {
|
|
64
|
+
if (!file.endsWith('.jsonl')) return
|
|
65
|
+
let st
|
|
66
|
+
try { st = fs.statSync(file) } catch { return }
|
|
67
|
+
// History older than the recent window parses lazily (on demand), not at startup.
|
|
68
|
+
if (isAdd && Date.now() - st.mtimeMs > RECENT_PARSE_MS) {
|
|
69
|
+
this.#registerLazy(file, st)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
this.#consume(file)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#registerLazy(file, st) {
|
|
76
|
+
const sessionId = path.basename(file, '.jsonl')
|
|
77
|
+
if (this.sessions.has(sessionId)) return
|
|
78
|
+
this.sessions.set(sessionId, this.#blankSession(sessionId, file, st.mtimeMs, { lazy: true }))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Parse any unread bytes of a transcript file. */
|
|
82
|
+
#consume(file) {
|
|
83
|
+
let fd
|
|
84
|
+
try { fd = fs.openSync(file, 'r') } catch { return }
|
|
85
|
+
try {
|
|
86
|
+
const size = fs.fstatSync(fd).size
|
|
87
|
+
const from = this.offsets.get(file) ?? 0
|
|
88
|
+
if (size <= from) return
|
|
89
|
+
const buf = Buffer.alloc(size - from)
|
|
90
|
+
fs.readSync(fd, buf, 0, buf.length, from)
|
|
91
|
+
const text = buf.toString('utf8')
|
|
92
|
+
const lastNl = text.lastIndexOf('\n')
|
|
93
|
+
if (lastNl === -1) return // torn single line — wait for more
|
|
94
|
+
this.offsets.set(file, from + Buffer.byteLength(text.slice(0, lastNl + 1)))
|
|
95
|
+
for (const line of text.slice(0, lastNl).split('\n')) {
|
|
96
|
+
if (line.trim()) this.#line(file, line)
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
fs.closeSync(fd)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Force-parse a lazy session's full history (used when the UI opens it). */
|
|
104
|
+
hydrate(sessionId) {
|
|
105
|
+
const s = this.sessions.get(sessionId)
|
|
106
|
+
if (!s || !s.lazy) return
|
|
107
|
+
s.lazy = false
|
|
108
|
+
this.#consume(s.file)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#blankSession(sessionId, file, mtimeMs, extra = {}) {
|
|
112
|
+
return {
|
|
113
|
+
id: sessionId,
|
|
114
|
+
file,
|
|
115
|
+
profile: this.#profileFor(file),
|
|
116
|
+
cwd: null,
|
|
117
|
+
gitBranch: null,
|
|
118
|
+
firstTs: null,
|
|
119
|
+
lastTs: mtimeMs ? new Date(mtimeMs).toISOString() : null,
|
|
120
|
+
entries: [],
|
|
121
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 },
|
|
122
|
+
lastContext: null,
|
|
123
|
+
model: null,
|
|
124
|
+
...extra,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#line(file, line) {
|
|
129
|
+
let obj
|
|
130
|
+
try { obj = JSON.parse(line) } catch { return }
|
|
131
|
+
|
|
132
|
+
const limit = detectLimit(obj)
|
|
133
|
+
if (limit) this.onLimit(this.#profileFor(file), limit)
|
|
134
|
+
|
|
135
|
+
const sessionId = obj.sessionId || path.basename(file, '.jsonl')
|
|
136
|
+
let s = this.sessions.get(sessionId)
|
|
137
|
+
if (!s) {
|
|
138
|
+
s = this.#blankSession(sessionId, file, null)
|
|
139
|
+
this.sessions.set(sessionId, s)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (obj.cwd && !s.cwd) s.cwd = obj.cwd
|
|
143
|
+
if (obj.gitBranch) s.gitBranch = obj.gitBranch
|
|
144
|
+
if (obj.timestamp) {
|
|
145
|
+
if (!s.firstTs) s.firstTs = obj.timestamp
|
|
146
|
+
s.lastTs = obj.timestamp
|
|
147
|
+
}
|
|
148
|
+
if (obj.isSidechain) return // subagent side-channels stay out of the main feed
|
|
149
|
+
|
|
150
|
+
const entry = toEntry(obj)
|
|
151
|
+
if (!entry) return
|
|
152
|
+
entry.ts = obj.timestamp || new Date().toISOString()
|
|
153
|
+
|
|
154
|
+
if (entry.usage) {
|
|
155
|
+
s.usage.input += entry.usage.input_tokens || 0
|
|
156
|
+
s.usage.output += entry.usage.output_tokens || 0
|
|
157
|
+
s.usage.cacheRead += entry.usage.cache_read_input_tokens || 0
|
|
158
|
+
s.usage.cacheCreation += entry.usage.cache_creation_input_tokens || 0
|
|
159
|
+
s.lastContext =
|
|
160
|
+
(entry.usage.input_tokens || 0) +
|
|
161
|
+
(entry.usage.cache_read_input_tokens || 0) +
|
|
162
|
+
(entry.usage.cache_creation_input_tokens || 0)
|
|
163
|
+
delete entry.usage
|
|
164
|
+
}
|
|
165
|
+
if (entry.model) s.model = entry.model
|
|
166
|
+
|
|
167
|
+
s.entries.push(entry)
|
|
168
|
+
if (s.entries.length > FEED_CAP) s.entries.splice(0, s.entries.length - FEED_CAP)
|
|
169
|
+
this.emit({ type: 'message', sessionId, profile: s.profile, cwd: s.cwd, entry })
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Lightweight session summaries for the board/activity views. */
|
|
173
|
+
list() {
|
|
174
|
+
const now = Date.now()
|
|
175
|
+
return [...this.sessions.values()].map((s) => ({
|
|
176
|
+
id: s.id,
|
|
177
|
+
profile: s.profile,
|
|
178
|
+
cwd: s.cwd,
|
|
179
|
+
gitBranch: s.gitBranch,
|
|
180
|
+
firstTs: s.firstTs,
|
|
181
|
+
lastTs: s.lastTs,
|
|
182
|
+
state: stateOf(s.lastTs, now),
|
|
183
|
+
usage: s.usage,
|
|
184
|
+
lastContext: s.lastContext,
|
|
185
|
+
model: s.model,
|
|
186
|
+
entryCount: s.entries.length,
|
|
187
|
+
lazy: !!s.lazy,
|
|
188
|
+
lastEntry: s.entries[s.entries.length - 1] ?? null,
|
|
189
|
+
}))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
feed(sessionId, from = 0) {
|
|
193
|
+
this.hydrate(sessionId)
|
|
194
|
+
const s = this.sessions.get(sessionId)
|
|
195
|
+
if (!s) return null
|
|
196
|
+
return {
|
|
197
|
+
id: s.id,
|
|
198
|
+
profile: s.profile,
|
|
199
|
+
cwd: s.cwd,
|
|
200
|
+
state: stateOf(s.lastTs, Date.now()),
|
|
201
|
+
usage: s.usage,
|
|
202
|
+
lastContext: s.lastContext,
|
|
203
|
+
model: s.model,
|
|
204
|
+
total: s.entries.length,
|
|
205
|
+
entries: s.entries.slice(Math.max(0, from)),
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function stateOf(lastTs, now) {
|
|
211
|
+
if (!lastTs) return 'ended'
|
|
212
|
+
const age = now - Date.parse(lastTs)
|
|
213
|
+
if (age < ACTIVE_MS) return 'active'
|
|
214
|
+
if (age < IDLE_MS) return 'idle'
|
|
215
|
+
return 'ended'
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Map one transcript object to a feed entry, or null for non-display lines. */
|
|
219
|
+
function toEntry(obj) {
|
|
220
|
+
if (obj.type === 'user' && obj.message?.role === 'user') {
|
|
221
|
+
const c = obj.message.content
|
|
222
|
+
if (typeof c === 'string') {
|
|
223
|
+
if (c.startsWith('<local-command') || c.startsWith('<command-name>')) return null
|
|
224
|
+
return { kind: 'user', text: clip(c, 2000) }
|
|
225
|
+
}
|
|
226
|
+
if (Array.isArray(c)) {
|
|
227
|
+
const results = c.filter((b) => b.type === 'tool_result')
|
|
228
|
+
if (results.length) return { kind: 'tool_result', count: results.length }
|
|
229
|
+
const text = c.filter((b) => b.type === 'text').map((b) => b.text).join('\n')
|
|
230
|
+
return text ? { kind: 'user', text: clip(text, 2000) } : null
|
|
231
|
+
}
|
|
232
|
+
return null
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (obj.type === 'assistant' && obj.message) {
|
|
236
|
+
const blocks = Array.isArray(obj.message.content) ? obj.message.content : []
|
|
237
|
+
const text = blocks.filter((b) => b.type === 'text').map((b) => b.text).join('\n')
|
|
238
|
+
const tools = blocks
|
|
239
|
+
.filter((b) => b.type === 'tool_use')
|
|
240
|
+
.map((b) => ({ name: b.name, hint: toolHint(b.name, b.input) }))
|
|
241
|
+
if (!text && tools.length === 0) return null
|
|
242
|
+
return {
|
|
243
|
+
kind: 'assistant',
|
|
244
|
+
text: clip(text, 2000),
|
|
245
|
+
tools,
|
|
246
|
+
usage: obj.message.usage,
|
|
247
|
+
model: obj.message.model,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return null
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function toolHint(name, input) {
|
|
255
|
+
if (!input || typeof input !== 'object') return ''
|
|
256
|
+
const v =
|
|
257
|
+
input.file_path || input.path || input.command || input.pattern ||
|
|
258
|
+
input.query || input.url || input.description || input.prompt || ''
|
|
259
|
+
return clip(String(v).split('\n')[0], 120)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function clip(s, n) {
|
|
263
|
+
return s.length > n ? `${s.slice(0, n)}…` : s
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export { mungeCwd }
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ghostlygawd/hangar",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A free, open-source cockpit for parallel Claude Code on Windows — isolated worktree spaces, real interactive sessions, multi-account, zero-token observability.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"hangar": "bin/hangar.js"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=18"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin/",
|
|
15
|
+
"lib/",
|
|
16
|
+
"server/",
|
|
17
|
+
"web/dist/",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"CHANGELOG.md"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"start": "node bin/hangar.js",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"web:dev": "npm --prefix web run dev",
|
|
26
|
+
"web:build": "npm --prefix web run build",
|
|
27
|
+
"prepublishOnly": "npm run web:build"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@fastify/static": "^8.0.0",
|
|
31
|
+
"chokidar": "^4.0.0",
|
|
32
|
+
"fastify": "^5.0.0",
|
|
33
|
+
"qrcode-terminal": "^0.12.0",
|
|
34
|
+
"systeminformation": "^5.23.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"vitest": "^3.0.0"
|
|
38
|
+
},
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/GhostlyGawd/hangar.git"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://ghostlygawd.github.io/hangar",
|
|
44
|
+
"bugs": {
|
|
45
|
+
"url": "https://github.com/GhostlyGawd/hangar/issues"
|
|
46
|
+
},
|
|
47
|
+
"keywords": [
|
|
48
|
+
"claude",
|
|
49
|
+
"claude-code",
|
|
50
|
+
"worktree",
|
|
51
|
+
"parallel",
|
|
52
|
+
"agents",
|
|
53
|
+
"windows",
|
|
54
|
+
"cockpit"
|
|
55
|
+
]
|
|
56
|
+
}
|
package/server/bus.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Tiny SSE fan-out. Subscribers are raw HTTP responses. */
|
|
2
|
+
export class Bus {
|
|
3
|
+
constructor() {
|
|
4
|
+
this.clients = new Set()
|
|
5
|
+
this.heartbeats = new Map()
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
publish(event) {
|
|
9
|
+
const frame = `data: ${JSON.stringify(event)}\n\n`
|
|
10
|
+
for (const res of this.clients) this.#write(res, frame)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Write to one client; a dead/broken socket is dropped, never thrown — one
|
|
14
|
+
* stale tab must not starve every other client of events. */
|
|
15
|
+
#write(res, frame) {
|
|
16
|
+
if (res.writableEnded || res.destroyed) { this.#drop(res); return }
|
|
17
|
+
try {
|
|
18
|
+
res.write(frame)
|
|
19
|
+
} catch {
|
|
20
|
+
this.#drop(res)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#drop(res) {
|
|
25
|
+
const hb = this.heartbeats.get(res)
|
|
26
|
+
if (hb) { clearInterval(hb); this.heartbeats.delete(res) }
|
|
27
|
+
this.clients.delete(res)
|
|
28
|
+
try { res.destroy() } catch { /* best effort */ }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Attach a fastify reply as an SSE stream. */
|
|
32
|
+
attach(reply) {
|
|
33
|
+
// hand the response lifecycle to us — without this Fastify 5 also tries to
|
|
34
|
+
// send its own reply on the already-written socket (FST_ERR_REP_ALREADY_SENT).
|
|
35
|
+
reply.hijack()
|
|
36
|
+
const res = reply.raw
|
|
37
|
+
res.writeHead(200, {
|
|
38
|
+
'content-type': 'text/event-stream',
|
|
39
|
+
'cache-control': 'no-cache',
|
|
40
|
+
connection: 'keep-alive',
|
|
41
|
+
'x-accel-buffering': 'no',
|
|
42
|
+
})
|
|
43
|
+
res.write(': hangar\n\n')
|
|
44
|
+
this.clients.add(res)
|
|
45
|
+
const heartbeat = setInterval(() => this.#write(res, ': hb\n\n'), 15000)
|
|
46
|
+
this.heartbeats.set(res, heartbeat)
|
|
47
|
+
res.on('close', () => this.#drop(res))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Clear heartbeats and end every stream so the server can shut down cleanly. */
|
|
51
|
+
close() {
|
|
52
|
+
for (const res of this.clients) {
|
|
53
|
+
const hb = this.heartbeats.get(res)
|
|
54
|
+
if (hb) clearInterval(hb)
|
|
55
|
+
try { res.end() } catch { /* best effort */ }
|
|
56
|
+
}
|
|
57
|
+
this.heartbeats.clear()
|
|
58
|
+
this.clients.clear()
|
|
59
|
+
}
|
|
60
|
+
}
|
package/server/gate.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
function timingSafeTokenEqual(candidate, expected) {
|
|
4
|
+
if (typeof candidate !== 'string') return false
|
|
5
|
+
const a = Buffer.from(candidate)
|
|
6
|
+
const b = Buffer.from(expected)
|
|
7
|
+
if (a.length !== b.length) return false
|
|
8
|
+
return crypto.timingSafeEqual(a, b)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function cookieValue(header, name) {
|
|
12
|
+
if (!header) return undefined
|
|
13
|
+
for (const part of header.split(';')) {
|
|
14
|
+
const eq = part.indexOf('=')
|
|
15
|
+
if (eq === -1) continue
|
|
16
|
+
if (part.slice(0, eq).trim() === name) return decodeURIComponent(part.slice(eq + 1).trim())
|
|
17
|
+
}
|
|
18
|
+
return undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Token gate. Off when no token is configured (localhost-only use).
|
|
23
|
+
* When on: EVERYTHING is gated except /api/health — the API, the live SSE
|
|
24
|
+
* stream, AND the dashboard shell + assets. Auth comes from ?token=,
|
|
25
|
+
* Authorization: Bearer, or a session cookie; the compare is timing-safe.
|
|
26
|
+
*
|
|
27
|
+
* The cookie is what lets a phone work: it opens the tunnel link once with
|
|
28
|
+
* ?token=…, which we validate and echo back as an HttpOnly cookie, so the
|
|
29
|
+
* follow-up asset/document requests (which carry no query) still pass.
|
|
30
|
+
*/
|
|
31
|
+
export function registerGate(app, getToken) {
|
|
32
|
+
app.addHook('preHandler', async (request, reply) => {
|
|
33
|
+
const expected = getToken()
|
|
34
|
+
if (!expected) return
|
|
35
|
+
const route = request.routeOptions?.url ?? request.url
|
|
36
|
+
if (route === '/api/health') return
|
|
37
|
+
const query = request.query ?? {}
|
|
38
|
+
const auth = request.headers.authorization
|
|
39
|
+
const fromRequest = query.token ?? (auth?.startsWith('Bearer ') ? auth.slice(7) : undefined)
|
|
40
|
+
const cookie = cookieValue(request.headers.cookie, 'hangar_token')
|
|
41
|
+
const candidate = fromRequest ?? cookie
|
|
42
|
+
if (!timingSafeTokenEqual(candidate, expected)) {
|
|
43
|
+
return reply.code(401).send({ error: 'unauthorized' })
|
|
44
|
+
}
|
|
45
|
+
// establish a session so subsequent no-query asset/document loads pass
|
|
46
|
+
if (fromRequest !== undefined && cookie !== expected) {
|
|
47
|
+
reply.header('set-cookie', `hangar_token=${encodeURIComponent(expected)}; Path=/; HttpOnly; SameSite=Strict`)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
}
|
package/server/index.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import Fastify from 'fastify'
|
|
5
|
+
import fastifyStatic from '@fastify/static'
|
|
6
|
+
import { JsonStore } from '../lib/store.js'
|
|
7
|
+
import { CONFIG_FILE, REPOS_FILE, PARKED_FILE, DEFAULT_CONFIG } from '../lib/paths.js'
|
|
8
|
+
import { listProfiles } from '../lib/profiles.js'
|
|
9
|
+
import { TranscriptWatcher } from '../lib/transcripts.js'
|
|
10
|
+
import { SystemMonitor } from '../lib/sysinfo.js'
|
|
11
|
+
import { Bus } from './bus.js'
|
|
12
|
+
import { registerGate } from './gate.js'
|
|
13
|
+
import { registerRoutes } from './routes.js'
|
|
14
|
+
|
|
15
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
const require = createRequire(import.meta.url)
|
|
17
|
+
const { version } = require('../package.json')
|
|
18
|
+
|
|
19
|
+
export async function startServer(overrides = {}) {
|
|
20
|
+
const config = new JsonStore(CONFIG_FILE, DEFAULT_CONFIG)
|
|
21
|
+
const repos = new JsonStore(REPOS_FILE, { repos: [] })
|
|
22
|
+
const parked = new JsonStore(PARKED_FILE, {})
|
|
23
|
+
|
|
24
|
+
const bind = overrides.bind ?? config.data.bind
|
|
25
|
+
const port = overrides.port ?? config.data.port
|
|
26
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1'])
|
|
27
|
+
const lanExposed = !LOOPBACK.has(bind)
|
|
28
|
+
if (lanExposed && !config.data.token && !overrides.token) {
|
|
29
|
+
throw new Error('Refusing to bind beyond localhost without a token. Set one: hangar config token <value>')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const bus = new Bus()
|
|
33
|
+
|
|
34
|
+
const transcripts = new TranscriptWatcher(
|
|
35
|
+
() => listProfiles(),
|
|
36
|
+
(event) => bus.publish(event),
|
|
37
|
+
(profile, hit) => {
|
|
38
|
+
const existing = parked.data[profile]
|
|
39
|
+
if (existing && existing.reason === hit.kind) return
|
|
40
|
+
parked.update((d) => {
|
|
41
|
+
d[profile] = { profile, limitedAt: new Date().toISOString(), reason: hit.kind, resetAt: hit.resetsAt }
|
|
42
|
+
})
|
|
43
|
+
bus.publish({ type: 'parked', parked: parked.data })
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
transcripts.start()
|
|
47
|
+
|
|
48
|
+
const sysmon = new SystemMonitor((event) => bus.publish(event), () => bus.clients.size > 0)
|
|
49
|
+
sysmon.start()
|
|
50
|
+
|
|
51
|
+
const app = Fastify({ logger: false })
|
|
52
|
+
registerGate(app, () => overrides.token ?? config.data.token)
|
|
53
|
+
registerRoutes(app, { repos, parked, config, transcripts, sysmon, bus, version })
|
|
54
|
+
|
|
55
|
+
app.register(fastifyStatic, {
|
|
56
|
+
root: path.join(here, '..', 'web', 'dist'),
|
|
57
|
+
prefix: '/',
|
|
58
|
+
})
|
|
59
|
+
app.setNotFoundHandler((req, reply) => {
|
|
60
|
+
// SPA fallback for client-side routes; API misses stay JSON 404s
|
|
61
|
+
if (req.raw.url?.startsWith('/api/')) return reply.code(404).send({ error: 'not found' })
|
|
62
|
+
return reply.sendFile('index.html')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
await app.listen({ port, host: bind })
|
|
66
|
+
return { app, port, bind, config, stop: async () => { transcripts.stop(); sysmon.stop(); bus.close(); await app.close() } }
|
|
67
|
+
}
|
package/server/routes.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import { isRepoRoot, currentBranch } from '../lib/git.js'
|
|
3
|
+
import { listSpaces, createSpace, deleteSpace, spacePath } from '../lib/spaces.js'
|
|
4
|
+
import { listProfiles, addProfile, removeProfile } from '../lib/profiles.js'
|
|
5
|
+
import { launchSession, liveness } from '../lib/launch.js'
|
|
6
|
+
|
|
7
|
+
export function registerRoutes(app, ctx) {
|
|
8
|
+
const { repos, parked, config, transcripts, sysmon } = ctx
|
|
9
|
+
|
|
10
|
+
app.get('/api/health', async () => ({ ok: true, name: 'hangar', version: ctx.version }))
|
|
11
|
+
|
|
12
|
+
// ----- full snapshot the UI boots from -----
|
|
13
|
+
app.get('/api/state', async () => {
|
|
14
|
+
const out = await Promise.all(repos.data.repos.map(async (r) => {
|
|
15
|
+
const [spaces, branch] = await Promise.all([
|
|
16
|
+
listSpaces(r.path).catch(() => []),
|
|
17
|
+
currentBranch(r.path).catch(() => null),
|
|
18
|
+
])
|
|
19
|
+
return { ...r, branch, spaces }
|
|
20
|
+
}))
|
|
21
|
+
return {
|
|
22
|
+
repos: out,
|
|
23
|
+
profiles: listProfiles().map(({ home, ...p }) => ({ ...p })),
|
|
24
|
+
parked: parked.data,
|
|
25
|
+
sessions: attachSessions(transcripts.list(), repos.data.repos),
|
|
26
|
+
windows: liveness(),
|
|
27
|
+
config: { port: config.data.port, bind: config.data.bind, tokenSet: !!config.data.token },
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// ----- repos -----
|
|
32
|
+
app.post('/api/repos', async (req, reply) => {
|
|
33
|
+
// store canonical Windows form so cwd-prefix joins are reliable
|
|
34
|
+
const p = String(req.body?.path ?? '').trim().replace(/\//g, '\\').replace(/\\+$/, '')
|
|
35
|
+
if (!p) return reply.code(400).send({ error: 'path is required' })
|
|
36
|
+
if (!(await isRepoRoot(p))) {
|
|
37
|
+
return reply.code(400).send({ error: `"${p}" is not the top level of a git repository.` })
|
|
38
|
+
}
|
|
39
|
+
const existing = repos.data.repos.find((r) => samePath(r.path, p))
|
|
40
|
+
if (existing) return existing
|
|
41
|
+
const entry = { id: crypto.randomUUID().slice(0, 8), name: p.replace(/[\\/]+$/, '').split(/[\\/]/).pop(), path: p, addedAt: new Date().toISOString() }
|
|
42
|
+
repos.update((d) => d.repos.push(entry))
|
|
43
|
+
return entry
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
app.delete('/api/repos/:id', async (req, reply) => {
|
|
47
|
+
const before = repos.data.repos.length
|
|
48
|
+
repos.update((d) => { d.repos = d.repos.filter((r) => r.id !== req.params.id) })
|
|
49
|
+
if (repos.data.repos.length === before) return reply.code(404).send({ error: 'unknown repo' })
|
|
50
|
+
return { ok: true }
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// ----- spaces -----
|
|
54
|
+
app.post('/api/repos/:id/spaces', async (req, reply) => {
|
|
55
|
+
const repo = repos.data.repos.find((r) => r.id === req.params.id)
|
|
56
|
+
if (!repo) return reply.code(404).send({ error: 'unknown repo' })
|
|
57
|
+
const res = await createSpace(repo.path, String(req.body?.name ?? ''))
|
|
58
|
+
if (!res.ok) return reply.code(res.status).send({ error: res.error })
|
|
59
|
+
ctx.bus.publish({ type: 'spaces' })
|
|
60
|
+
return res
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
app.delete('/api/repos/:id/spaces/:name', async (req, reply) => {
|
|
64
|
+
const repo = repos.data.repos.find((r) => r.id === req.params.id)
|
|
65
|
+
if (!repo) return reply.code(404).send({ error: 'unknown repo' })
|
|
66
|
+
const res = await deleteSpace(repo.path, req.params.name, {
|
|
67
|
+
force: req.query?.force === '1',
|
|
68
|
+
confirm: req.query?.confirm ?? '',
|
|
69
|
+
})
|
|
70
|
+
if (!res.ok) return reply.code(res.status).send({ error: res.error })
|
|
71
|
+
ctx.bus.publish({ type: 'spaces' })
|
|
72
|
+
return res
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// ----- launch (real interactive windows only) -----
|
|
76
|
+
app.post('/api/launch', async (req, reply) => {
|
|
77
|
+
const { repoId, space, profile = 'default', prompt = '' } = req.body ?? {}
|
|
78
|
+
const repo = repos.data.repos.find((r) => r.id === repoId)
|
|
79
|
+
if (!repo) return reply.code(404).send({ error: 'unknown repo' })
|
|
80
|
+
const cwd = space ? spacePath(repo.path, String(space)) : repo.path
|
|
81
|
+
const known = listProfiles().some((p) => p.name === profile)
|
|
82
|
+
if (!known) return reply.code(400).send({ error: `unknown profile "${profile}"` })
|
|
83
|
+
const res = await launchSession({ cwd, profile, prompt: String(prompt ?? '') })
|
|
84
|
+
if (!res.ok) return reply.code(500).send({ error: res.error })
|
|
85
|
+
ctx.bus.publish({ type: 'windows' })
|
|
86
|
+
return res
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// ----- sessions -----
|
|
90
|
+
app.get('/api/sessions', async () => attachSessions(transcripts.list(), repos.data.repos))
|
|
91
|
+
|
|
92
|
+
app.get('/api/sessions/:id', async (req, reply) => {
|
|
93
|
+
const from = Number(req.query?.from ?? 0)
|
|
94
|
+
const feed = transcripts.feed(req.params.id, Number.isFinite(from) ? from : 0)
|
|
95
|
+
if (!feed) return reply.code(404).send({ error: 'unknown session' })
|
|
96
|
+
return feed
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// ----- profiles -----
|
|
100
|
+
app.get('/api/profiles', async () => ({
|
|
101
|
+
profiles: listProfiles().map(({ home, ...p }) => p),
|
|
102
|
+
parked: parked.data,
|
|
103
|
+
}))
|
|
104
|
+
|
|
105
|
+
app.post('/api/profiles', async (req, reply) => {
|
|
106
|
+
const res = addProfile(String(req.body?.name ?? ''), req.body?.displayName)
|
|
107
|
+
if (!res.ok) return reply.code(res.status).send({ error: res.error })
|
|
108
|
+
return res
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
app.delete('/api/profiles/:name', async (req, reply) => {
|
|
112
|
+
const res = removeProfile(req.params.name)
|
|
113
|
+
if (!res.ok) return reply.code(res.status).send({ error: res.error })
|
|
114
|
+
return res
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
app.post('/api/parked/clear', async (req) => {
|
|
118
|
+
const name = String(req.body?.profile ?? '')
|
|
119
|
+
parked.update((d) => { delete d[name] })
|
|
120
|
+
ctx.bus.publish({ type: 'parked', parked: parked.data })
|
|
121
|
+
return { ok: true }
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// ----- system -----
|
|
125
|
+
app.get('/api/system', async () => sysmon.snapshot())
|
|
126
|
+
|
|
127
|
+
// ----- config -----
|
|
128
|
+
app.put('/api/config', async (req) => {
|
|
129
|
+
const allowed = ['port', 'bind', 'token', 'defaultProfile']
|
|
130
|
+
config.update((d) => {
|
|
131
|
+
for (const k of allowed) {
|
|
132
|
+
if (k in (req.body ?? {})) d[k] = req.body[k]
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
return { ok: true, restartNeeded: ['port', 'bind'].some((k) => k in (req.body ?? {})) }
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// ----- live events -----
|
|
139
|
+
app.get('/api/events', (req, reply) => {
|
|
140
|
+
ctx.bus.attach(reply)
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Join sessions to tracked repos/spaces by cwd prefix (slash-direction agnostic). */
|
|
145
|
+
function attachSessions(sessions, repoList) {
|
|
146
|
+
const norm = (p) => p.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase()
|
|
147
|
+
return sessions
|
|
148
|
+
.map((s) => {
|
|
149
|
+
if (!s.cwd) return { ...s, repoId: null, space: null }
|
|
150
|
+
const cwd = norm(s.cwd)
|
|
151
|
+
const repo = repoList.find((r) => {
|
|
152
|
+
const rp = norm(r.path)
|
|
153
|
+
return cwd === rp || cwd.startsWith(rp + '\\')
|
|
154
|
+
})
|
|
155
|
+
if (!repo) return null // sessions outside tracked repos stay out of the cockpit
|
|
156
|
+
const rest = cwd.slice(norm(repo.path).length).replace(/^\\/, '')
|
|
157
|
+
const m = rest.match(/^\.hangar-spaces\\([^\\]+)/)
|
|
158
|
+
return { ...s, repoId: repo.id, repoName: repo.name, space: m ? m[1] : null }
|
|
159
|
+
})
|
|
160
|
+
.filter(Boolean)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function samePath(a, b) {
|
|
164
|
+
const n = (x) => x.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase()
|
|
165
|
+
return n(a) === n(b)
|
|
166
|
+
}
|