@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.
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }