@xerktech/claude-hud 0.1.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.
Files changed (60) hide show
  1. package/README.md +50 -0
  2. package/dist/_broker/attached.js +78 -0
  3. package/dist/_broker/attached.js.map +1 -0
  4. package/dist/_broker/audio-codec.js +82 -0
  5. package/dist/_broker/audio-codec.js.map +1 -0
  6. package/dist/_broker/audio-session.js +81 -0
  7. package/dist/_broker/audio-session.js.map +1 -0
  8. package/dist/_broker/auth.js +32 -0
  9. package/dist/_broker/auth.js.map +1 -0
  10. package/dist/_broker/bus.js +76 -0
  11. package/dist/_broker/bus.js.map +1 -0
  12. package/dist/_broker/cert.js +72 -0
  13. package/dist/_broker/cert.js.map +1 -0
  14. package/dist/_broker/claude.js +160 -0
  15. package/dist/_broker/claude.js.map +1 -0
  16. package/dist/_broker/cli.js +134 -0
  17. package/dist/_broker/cli.js.map +1 -0
  18. package/dist/_broker/cors.js +48 -0
  19. package/dist/_broker/cors.js.map +1 -0
  20. package/dist/_broker/hooks.js +196 -0
  21. package/dist/_broker/hooks.js.map +1 -0
  22. package/dist/_broker/index.js +48 -0
  23. package/dist/_broker/index.js.map +1 -0
  24. package/dist/_broker/intent-dispatcher.js +86 -0
  25. package/dist/_broker/intent-dispatcher.js.map +1 -0
  26. package/dist/_broker/intent.js +127 -0
  27. package/dist/_broker/intent.js.map +1 -0
  28. package/dist/_broker/jsonl-tail.js +185 -0
  29. package/dist/_broker/jsonl-tail.js.map +1 -0
  30. package/dist/_broker/mdns.js +41 -0
  31. package/dist/_broker/mdns.js.map +1 -0
  32. package/dist/_broker/projects.js +161 -0
  33. package/dist/_broker/projects.js.map +1 -0
  34. package/dist/_broker/qr.js +11 -0
  35. package/dist/_broker/qr.js.map +1 -0
  36. package/dist/_broker/routes.js +379 -0
  37. package/dist/_broker/routes.js.map +1 -0
  38. package/dist/_broker/server.js +325 -0
  39. package/dist/_broker/server.js.map +1 -0
  40. package/dist/_broker/sessions-store.js +50 -0
  41. package/dist/_broker/sessions-store.js.map +1 -0
  42. package/dist/_broker/sessions.js +792 -0
  43. package/dist/_broker/sessions.js.map +1 -0
  44. package/dist/_broker/store.js +79 -0
  45. package/dist/_broker/store.js.map +1 -0
  46. package/dist/_broker/stt.js +93 -0
  47. package/dist/_broker/stt.js.map +1 -0
  48. package/dist/broker-bin.js +37 -0
  49. package/dist/broker-bin.js.map +1 -0
  50. package/dist/broker-lifecycle.js +186 -0
  51. package/dist/broker-lifecycle.js.map +1 -0
  52. package/dist/index.js +189 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/paths.js +17 -0
  55. package/dist/paths.js.map +1 -0
  56. package/dist/wrapper/index.js +263 -0
  57. package/dist/wrapper/index.js.map +1 -0
  58. package/dist/wrapper/slug.js +5 -0
  59. package/dist/wrapper/slug.js.map +1 -0
  60. package/package.json +70 -0
@@ -0,0 +1,792 @@
1
+ import { LineSplitter, defaultSpawnClaude, encodeUserTurn, extractAssistantText, parseStreamJsonLine, } from "./claude.js";
2
+ import { JsonlTailer } from "./jsonl-tail.js";
3
+ const DEFAULT_IDLE_ARCHIVE_MS = 30 * 60 * 1000;
4
+ const DEFAULT_SIGKILL_GRACE_MS = 2_000;
5
+ export class SessionManager {
6
+ sessions = new Map();
7
+ focusedId;
8
+ bus;
9
+ spawn;
10
+ now;
11
+ defaultCwd;
12
+ idleArchiveMs;
13
+ sigkillGraceMs;
14
+ setTimer;
15
+ clearTimer;
16
+ onTerminate;
17
+ getHookEnv;
18
+ attachedHub;
19
+ tailerFactory;
20
+ nextNumericId = 1;
21
+ idleInterval;
22
+ constructor(opts) {
23
+ this.bus = opts.bus;
24
+ this.spawn = opts.spawn ?? defaultSpawnClaude;
25
+ this.now = opts.now ?? (() => Date.now());
26
+ this.defaultCwd = opts.defaultCwd ?? process.cwd();
27
+ this.idleArchiveMs = opts.idleArchiveMs ?? DEFAULT_IDLE_ARCHIVE_MS;
28
+ this.sigkillGraceMs = opts.sigkillGraceMs ?? DEFAULT_SIGKILL_GRACE_MS;
29
+ this.setTimer = opts.setTimer ?? ((cb, d) => setTimeout(cb, d));
30
+ this.clearTimer = opts.clearTimer ?? (t => clearTimeout(t));
31
+ this.onTerminate = opts.onTerminate;
32
+ this.getHookEnv = opts.getHookEnv;
33
+ this.attachedHub = opts.attachedHub;
34
+ this.tailerFactory =
35
+ opts.tailerFactory ??
36
+ (factoryOpts => new JsonlTailer({
37
+ filePath: factoryOpts.filePath,
38
+ onEvent: factoryOpts.onEvent,
39
+ onError: factoryOpts.onError,
40
+ }));
41
+ }
42
+ /** Late-binding setter so `startServer` can wire the hub after construction. */
43
+ setAttachedHub(hub) {
44
+ this.attachedHub = hub;
45
+ }
46
+ /**
47
+ * Update the lazy hook-env lookup. Called by `startServer` once the
48
+ * listener is bound and the URL is known. Without this, every spawn would
49
+ * either need to wait on the listener or ship without the env vars.
50
+ */
51
+ setHookEnv(getHookEnv) {
52
+ this.getHookEnv = getHookEnv;
53
+ }
54
+ /**
55
+ * Construct the env-var bundle for one session's spawned `claude`. The
56
+ * bundled `broker/scripts/claude-hook.mjs` reads these to call back into
57
+ * the broker for permission decisions.
58
+ */
59
+ buildHookEnv(sessionId) {
60
+ const cfg = this.getHookEnv?.();
61
+ if (!cfg)
62
+ return undefined;
63
+ const env = {
64
+ BROKER_HOOKS_URL: cfg.url,
65
+ BROKER_HOOKS_TOKEN: cfg.token,
66
+ BROKER_SESSION_ID: sessionId,
67
+ };
68
+ if (cfg.insecure)
69
+ env.BROKER_HOOKS_INSECURE = '1';
70
+ return env;
71
+ }
72
+ list() {
73
+ return [...this.sessions.values()].map(toPublic);
74
+ }
75
+ active() {
76
+ return this.list().filter(s => s.status !== 'archived' && s.status !== 'exited' && s.status !== 'error');
77
+ }
78
+ get(id) {
79
+ const s = this.sessions.get(id);
80
+ return s ? toPublic(s) : undefined;
81
+ }
82
+ /**
83
+ * Find the most recently active non-archived/exited session for a given
84
+ * project. Used by the voice intent dispatcher to resolve `focus <project>`
85
+ * and `end session in <project>` to a concrete session id.
86
+ */
87
+ findActiveByProject(projectId) {
88
+ const active = [...this.sessions.values()]
89
+ .filter(s => s.projectId === projectId &&
90
+ s.status !== 'archived' &&
91
+ s.status !== 'exited' &&
92
+ s.status !== 'error')
93
+ .sort((a, b) => b.lastActivityAt - a.lastActivityAt);
94
+ return active[0] ? toPublic(active[0]) : undefined;
95
+ }
96
+ getFocusedId() {
97
+ return this.focusedId;
98
+ }
99
+ getFocused() {
100
+ return this.focusedId ? this.get(this.focusedId) : undefined;
101
+ }
102
+ /**
103
+ * Create a new session. M5 requires `projectId` + `cwd`; older callers (M3-era
104
+ * tests) pass neither and get sensible defaults so the existing single-session
105
+ * smoke path keeps working.
106
+ */
107
+ create(opts = {}) {
108
+ const id = opts.id ?? `s${this.nextNumericId++}`;
109
+ if (this.sessions.has(id)) {
110
+ throw new Error(`session ${id} already exists`);
111
+ }
112
+ const cwd = opts.cwd ?? this.defaultCwd;
113
+ const projectId = opts.projectId ?? '__default__';
114
+ const label = opts.label ?? id;
115
+ const now = this.now();
116
+ const process = this.spawn({
117
+ cwd,
118
+ resumeId: opts.resumeClaudeSessionId,
119
+ model: opts.model,
120
+ env: this.buildHookEnv(id),
121
+ });
122
+ const splitter = new LineSplitter();
123
+ const internal = {
124
+ id,
125
+ projectId,
126
+ cwd,
127
+ label,
128
+ status: 'starting',
129
+ kind: 'spawned',
130
+ claudeSessionId: opts.resumeClaudeSessionId,
131
+ createdAt: now,
132
+ lastActivityAt: now,
133
+ lastEventId: 0,
134
+ unreadBadges: 0,
135
+ process,
136
+ splitter,
137
+ disposers: [],
138
+ };
139
+ const onData = process.onData(chunk => this.handleChunk(internal, chunk));
140
+ const onExit = process.onExit(info => this.handleExit(internal, info));
141
+ internal.disposers.push(onData, onExit);
142
+ this.sessions.set(id, internal);
143
+ this.emit(internal, 'session.created', {
144
+ sessionId: id,
145
+ projectId,
146
+ cwd,
147
+ label,
148
+ createdAt: now,
149
+ });
150
+ if (opts.initialPrompt && opts.initialPrompt.length > 0) {
151
+ // Defer one tick so the SSE bus has a chance to deliver session.created
152
+ // before the user_input event lands on the same channel.
153
+ this.setTimer(() => {
154
+ if (this.sessions.has(id))
155
+ this.writeInput(id, opts.initialPrompt);
156
+ }, 0);
157
+ }
158
+ return toPublic(internal);
159
+ }
160
+ /**
161
+ * Register an attached session — one whose `claude` process is owned by the
162
+ * `claude-hud` wrapper running in the user's terminal, not by us. We tail
163
+ * the on-disk `.jsonl` file Claude Code writes and emit the same
164
+ * `assistant_text` / `system_init` / `turn_end` events the spawned path
165
+ * does, so the glasses-side router doesn't need to care about the kind.
166
+ */
167
+ attach(input) {
168
+ if (this.sessions.has(input.id)) {
169
+ const existing = this.sessions.get(input.id);
170
+ if (existing)
171
+ return toPublic(existing);
172
+ throw new Error(`session ${input.id} already exists`);
173
+ }
174
+ const now = this.now();
175
+ const label = input.label ?? input.id;
176
+ const splitter = new LineSplitter();
177
+ const internal = {
178
+ id: input.id,
179
+ projectId: input.projectId,
180
+ cwd: input.cwd,
181
+ label,
182
+ status: 'idle',
183
+ kind: 'attached',
184
+ claudeSessionId: input.id,
185
+ wrapperPid: input.wrapperPid,
186
+ createdAt: now,
187
+ lastActivityAt: now,
188
+ lastEventId: 0,
189
+ unreadBadges: 0,
190
+ splitter,
191
+ disposers: [],
192
+ seenAssistantIds: new Set(),
193
+ };
194
+ this.sessions.set(input.id, internal);
195
+ const tailer = this.tailerFactory({
196
+ filePath: input.jsonlPath,
197
+ onEvent: ev => this.handleAttachedEvent(internal, ev),
198
+ onError: err => {
199
+ console.warn(`[sessions] attached=${internal.id} jsonl: ${err.message}`);
200
+ },
201
+ });
202
+ internal.tailer = tailer;
203
+ void tailer.start().catch(err => {
204
+ console.warn(`[sessions] attached=${internal.id} tailer.start failed: ${err.message}`);
205
+ });
206
+ this.emit(internal, 'session.created', {
207
+ sessionId: internal.id,
208
+ projectId: internal.projectId,
209
+ cwd: internal.cwd,
210
+ label,
211
+ createdAt: now,
212
+ kind: 'attached',
213
+ });
214
+ return toPublic(internal);
215
+ }
216
+ /**
217
+ * The wrapper reported its `claude` process has exited (or got disconnected).
218
+ * Mark the session ended and tear down the tailer.
219
+ */
220
+ async detach(id, opts = {}) {
221
+ const s = this.sessions.get(id);
222
+ if (!s)
223
+ return false;
224
+ if (s.kind !== 'attached')
225
+ return false;
226
+ if (s.status === 'exited' || s.status === 'error' || s.status === 'archived')
227
+ return false;
228
+ s.status = 'exited';
229
+ s.exitCode = opts.exitCode;
230
+ s.lastActivityAt = this.now();
231
+ if (s.tailer) {
232
+ try {
233
+ await s.tailer.stop();
234
+ }
235
+ catch {
236
+ // ignore
237
+ }
238
+ s.tailer = undefined;
239
+ }
240
+ this.attachedHub?.unregister(id);
241
+ this.emit(s, 'session.ended', {
242
+ sessionId: id,
243
+ exitCode: opts.exitCode,
244
+ });
245
+ this.onTerminate?.(id, 'exited');
246
+ if (this.focusedId === id)
247
+ this.refocusAfterDrop(id);
248
+ return true;
249
+ }
250
+ handleAttachedEvent(s, ev) {
251
+ s.lastActivityAt = this.now();
252
+ this.bumpBadge(s);
253
+ const type = typeof ev.type === 'string' ? ev.type : '';
254
+ // Claude Code's session jsonl uses different field names than the spawned
255
+ // stream-json transport. The cases we care about for the glasses:
256
+ // - `{ type: 'summary', summary }` — emit as system_init equivalent
257
+ // - `{ type: 'user', message: {...} }` — user echo (already shown locally)
258
+ // - `{ type: 'assistant', message: { id, content: [...] } }` — text out
259
+ if (type === 'assistant') {
260
+ const message = (ev.message ?? {});
261
+ const msgId = typeof message.id === 'string' ? message.id : undefined;
262
+ if (msgId) {
263
+ if (s.seenAssistantIds?.has(msgId))
264
+ return;
265
+ s.seenAssistantIds?.add(msgId);
266
+ }
267
+ const text = extractAssistantTextFromContent(message.content);
268
+ if (text !== null) {
269
+ s.status = 'thinking';
270
+ this.emit(s, 'assistant_text', { text });
271
+ }
272
+ else {
273
+ // Tool-use blocks etc. — pass raw so the plugin can render glyphs.
274
+ this.emit(s, 'stream_raw', ev);
275
+ }
276
+ return;
277
+ }
278
+ if (type === 'user') {
279
+ const message = (ev.message ?? {});
280
+ const content = message.content;
281
+ if (typeof content === 'string') {
282
+ this.emit(s, 'user_input', { text: content });
283
+ }
284
+ else if (Array.isArray(content)) {
285
+ // user blocks include tool_result; filter to plain text only.
286
+ const text = content
287
+ .filter(b => b?.type === 'text' && typeof b.text === 'string')
288
+ .map(b => b.text)
289
+ .join('');
290
+ if (text)
291
+ this.emit(s, 'user_input', { text });
292
+ }
293
+ return;
294
+ }
295
+ if (type === 'summary' || type === 'system') {
296
+ // Treat the first system/summary line as the equivalent of system_init.
297
+ this.emit(s, 'system_init', { claudeSessionId: s.claudeSessionId, model: ev.model });
298
+ return;
299
+ }
300
+ // Anything else — pass through as raw for transparency / debug.
301
+ this.emit(s, 'stream_raw', ev);
302
+ }
303
+ writeInput(id, text) {
304
+ const s = this.sessions.get(id);
305
+ if (!s)
306
+ return false;
307
+ if (s.status === 'exited' || s.status === 'error' || s.status === 'archived')
308
+ return false;
309
+ if (s.kind === 'attached') {
310
+ // Attached sessions: the wrapper owns the pty. Push the text through the
311
+ // wrapper's inject WS instead of writing stream-json. If the wrapper has
312
+ // disconnected (broker survived but the user's terminal closed), drop
313
+ // the input — the caller can resurface a `[wrapper offline]` toast.
314
+ const delivered = this.attachedHub?.send(id, text) ?? false;
315
+ if (!delivered)
316
+ return false;
317
+ s.lastActivityAt = this.now();
318
+ s.status = 'thinking';
319
+ this.emit(s, 'user_input', { text });
320
+ return true;
321
+ }
322
+ if (!s.process)
323
+ return false;
324
+ s.process.write(encodeUserTurn(text));
325
+ s.lastActivityAt = this.now();
326
+ s.status = 'thinking';
327
+ this.emit(s, 'user_input', { text });
328
+ return true;
329
+ }
330
+ /**
331
+ * Record a user intent (M4: only `approve` so far). Does not touch the pty —
332
+ * intents are bookkeeping events for the SSE bus. M7 wires `approve` into the
333
+ * Claude Code permission / AskUserQuestion hook responses.
334
+ */
335
+ /**
336
+ * Flip a session's status to/from `awaiting_permission` / `awaiting_question`.
337
+ * Wired from `HookManager.onPendingChange`: when a session has any pending
338
+ * hook, status is `awaiting_*`; when the last one resolves, status returns
339
+ * to whatever it was (defaults to `thinking` since hooks land mid-turn).
340
+ * Idempotent and a no-op for ended sessions.
341
+ */
342
+ setHookStatus(id, kind) {
343
+ const s = this.sessions.get(id);
344
+ if (!s)
345
+ return false;
346
+ if (s.status === 'exited' || s.status === 'error' || s.status === 'archived')
347
+ return false;
348
+ if (kind === 'permission')
349
+ s.status = 'awaiting_permission';
350
+ else if (kind === 'question')
351
+ s.status = 'awaiting_question';
352
+ else
353
+ s.status = 'thinking';
354
+ s.lastActivityAt = this.now();
355
+ return true;
356
+ }
357
+ recordIntent(id, intent, source) {
358
+ const s = this.sessions.get(id);
359
+ if (!s)
360
+ return false;
361
+ if (s.status === 'exited' || s.status === 'error' || s.status === 'archived')
362
+ return false;
363
+ s.lastActivityAt = this.now();
364
+ this.emit(s, 'user_intent', { intent, source: source ?? 'unknown' });
365
+ return true;
366
+ }
367
+ /**
368
+ * Focus a session. No-op if it's already focused or doesn't exist. Resets the
369
+ * unread-badge counter and emits `focus.changed` on the focused session's bus
370
+ * channel (so clients subscribed to that channel see the focus event).
371
+ */
372
+ focus(id) {
373
+ if (id === this.focusedId)
374
+ return false;
375
+ if (id !== undefined && !this.sessions.has(id))
376
+ return false;
377
+ const previousId = this.focusedId;
378
+ this.focusedId = id;
379
+ if (id !== undefined) {
380
+ const s = this.sessions.get(id);
381
+ if (s) {
382
+ s.unreadBadges = 0;
383
+ this.emit(s, 'focus.changed', { focusedId: id, previousId });
384
+ }
385
+ }
386
+ else if (previousId !== undefined) {
387
+ const prev = this.sessions.get(previousId);
388
+ if (prev)
389
+ this.emit(prev, 'focus.changed', { focusedId: null, previousId });
390
+ }
391
+ return true;
392
+ }
393
+ /**
394
+ * Initiate graceful shutdown. Sends SIGTERM, waits `sigkillGraceMs`, then
395
+ * SIGKILL if the process is still alive. `handleExit` does the final cleanup
396
+ * + emits `session.ended`.
397
+ *
398
+ * For attached sessions the broker doesn't own the `claude` process — the
399
+ * wrapper in the user's terminal does. We can't kill it, but we can
400
+ * synthesise a detach so the glasses reflect that this session is gone.
401
+ */
402
+ end(id) {
403
+ const s = this.sessions.get(id);
404
+ if (!s)
405
+ return false;
406
+ if (s.status === 'exited' || s.status === 'error' || s.status === 'archived')
407
+ return false;
408
+ if (s.kind === 'attached') {
409
+ void this.detach(id);
410
+ return true;
411
+ }
412
+ if (!s.process)
413
+ return false;
414
+ try {
415
+ s.process.kill('SIGTERM');
416
+ }
417
+ catch {
418
+ // pty already gone — onExit will fire (or has fired).
419
+ }
420
+ s.sigkillTimer = this.setTimer(() => {
421
+ const alive = this.sessions.get(id);
422
+ if (!alive || !alive.process)
423
+ return;
424
+ if (alive.status === 'exited' || alive.status === 'error' || alive.status === 'archived')
425
+ return;
426
+ try {
427
+ alive.process.kill('SIGKILL');
428
+ }
429
+ catch {
430
+ // ignore
431
+ }
432
+ }, this.sigkillGraceMs);
433
+ return true;
434
+ }
435
+ /**
436
+ * Archive an idle session. Kills the pty, marks status `archived`, leaves the
437
+ * registry entry in place so it can be resumed later (M5 stretch).
438
+ */
439
+ archive(id) {
440
+ const s = this.sessions.get(id);
441
+ if (!s)
442
+ return false;
443
+ if (s.status === 'archived')
444
+ return false;
445
+ if (s.process) {
446
+ try {
447
+ s.process.kill('SIGTERM');
448
+ }
449
+ catch {
450
+ // ignore
451
+ }
452
+ }
453
+ if (s.tailer) {
454
+ // Best-effort — let the tailer close async; we don't wait.
455
+ void s.tailer.stop().catch(() => undefined);
456
+ s.tailer = undefined;
457
+ }
458
+ this.attachedHub?.unregister(id);
459
+ if (s.sigkillTimer)
460
+ this.clearTimer(s.sigkillTimer);
461
+ s.status = 'archived';
462
+ s.lastActivityAt = this.now();
463
+ for (const d of s.disposers) {
464
+ try {
465
+ d();
466
+ }
467
+ catch {
468
+ // ignore
469
+ }
470
+ }
471
+ s.disposers = [];
472
+ s.process = undefined;
473
+ this.emit(s, 'session.archived', { sessionId: id });
474
+ this.onTerminate?.(id, 'archived');
475
+ if (this.focusedId === id)
476
+ this.refocusAfterDrop(id);
477
+ return true;
478
+ }
479
+ /**
480
+ * Sweep idle sessions older than `idleArchiveMs`. Returns the number archived.
481
+ * Sessions in `thinking`, `starting`, or any awaiting state are never touched.
482
+ */
483
+ archiveIdle() {
484
+ const cutoff = this.now() - this.idleArchiveMs;
485
+ let count = 0;
486
+ for (const s of [...this.sessions.values()]) {
487
+ if (s.status !== 'idle')
488
+ continue;
489
+ if (s.lastActivityAt >= cutoff)
490
+ continue;
491
+ if (this.archive(s.id))
492
+ count += 1;
493
+ }
494
+ return count;
495
+ }
496
+ startIdleArchiver(intervalMs = 60_000) {
497
+ if (this.idleInterval)
498
+ return;
499
+ this.idleInterval = setInterval(() => this.archiveIdle(), intervalMs);
500
+ }
501
+ stopIdleArchiver() {
502
+ if (this.idleInterval) {
503
+ clearInterval(this.idleInterval);
504
+ this.idleInterval = undefined;
505
+ }
506
+ }
507
+ async closeAll() {
508
+ this.stopIdleArchiver();
509
+ for (const s of [...this.sessions.values()]) {
510
+ if (s.sigkillTimer)
511
+ this.clearTimer(s.sigkillTimer);
512
+ if (s.process) {
513
+ try {
514
+ s.process.kill('SIGTERM');
515
+ }
516
+ catch {
517
+ // ignore
518
+ }
519
+ }
520
+ }
521
+ }
522
+ /** Persist the registry to a portable snapshot. */
523
+ snapshot() {
524
+ return {
525
+ version: 1,
526
+ sessions: [...this.sessions.values()].map(snapshotSession),
527
+ focusedId: this.focusedId,
528
+ };
529
+ }
530
+ /**
531
+ * Re-attach to active sessions on broker restart. Each session whose status
532
+ * was not `archived`/`exited`/`error` is respawned with `claude --resume`.
533
+ * Sessions that have no `claudeSessionId` (never got past `starting`) are
534
+ * dropped — there's nothing to resume.
535
+ */
536
+ hydrate(file) {
537
+ for (const snap of file.sessions) {
538
+ if (this.sessions.has(snap.id))
539
+ continue;
540
+ // Attached sessions can't be hydrated: the wrapper owns the `claude`
541
+ // process and the wrapper is no longer running. The user will re-attach
542
+ // by running `claude-hud` again. We keep the entry in `archived` so the
543
+ // glasses can show it as dormant rather than dropping it entirely.
544
+ if ((snap.kind ?? 'spawned') === 'attached') {
545
+ const splitter = new LineSplitter();
546
+ this.sessions.set(snap.id, {
547
+ ...snap,
548
+ kind: 'attached',
549
+ status: 'archived',
550
+ lastEventId: 0,
551
+ splitter,
552
+ disposers: [],
553
+ });
554
+ continue;
555
+ }
556
+ if (snap.status === 'archived' || snap.status === 'exited' || snap.status === 'error') {
557
+ // Dormant — keep the metadata so the user can revive it later.
558
+ const splitter = new LineSplitter();
559
+ this.sessions.set(snap.id, {
560
+ ...snap,
561
+ kind: 'spawned',
562
+ lastEventId: 0,
563
+ splitter,
564
+ disposers: [],
565
+ });
566
+ continue;
567
+ }
568
+ if (!snap.claudeSessionId)
569
+ continue;
570
+ const process = this.spawn({
571
+ cwd: snap.cwd,
572
+ resumeId: snap.claudeSessionId,
573
+ env: this.buildHookEnv(snap.id),
574
+ });
575
+ const splitter = new LineSplitter();
576
+ const internal = {
577
+ ...snap,
578
+ kind: 'spawned',
579
+ status: 'starting',
580
+ lastEventId: 0,
581
+ process,
582
+ splitter,
583
+ disposers: [],
584
+ };
585
+ const onData = process.onData(chunk => this.handleChunk(internal, chunk));
586
+ const onExit = process.onExit(info => this.handleExit(internal, info));
587
+ internal.disposers.push(onData, onExit);
588
+ this.sessions.set(snap.id, internal);
589
+ this.emit(internal, 'session.created', {
590
+ sessionId: snap.id,
591
+ projectId: snap.projectId,
592
+ cwd: snap.cwd,
593
+ label: snap.label,
594
+ resumed: true,
595
+ });
596
+ }
597
+ if (file.focusedId && this.sessions.has(file.focusedId)) {
598
+ this.focusedId = file.focusedId;
599
+ }
600
+ }
601
+ handleChunk(s, chunk) {
602
+ const lines = s.splitter.push(chunk);
603
+ for (const line of lines) {
604
+ const ev = parseStreamJsonLine(line);
605
+ if (!ev)
606
+ continue;
607
+ this.handleStreamEvent(s, ev);
608
+ }
609
+ }
610
+ handleStreamEvent(s, ev) {
611
+ s.lastActivityAt = this.now();
612
+ this.bumpBadge(s);
613
+ if (ev.type === 'system' && ev.subtype === 'init') {
614
+ if (typeof ev.session_id === 'string') {
615
+ s.claudeSessionId = ev.session_id;
616
+ }
617
+ s.status = 'idle';
618
+ this.emit(s, 'system_init', {
619
+ claudeSessionId: ev.session_id,
620
+ model: ev.model,
621
+ });
622
+ return;
623
+ }
624
+ if (ev.type === 'assistant') {
625
+ const text = extractAssistantText(ev);
626
+ if (text !== null) {
627
+ s.status = 'thinking';
628
+ this.emit(s, 'assistant_text', { text });
629
+ }
630
+ return;
631
+ }
632
+ if (ev.type === 'result') {
633
+ s.status = 'idle';
634
+ this.emit(s, 'turn_end', {
635
+ durationMs: ev.duration_ms,
636
+ costUsd: ev.total_cost_usd,
637
+ isError: ev.is_error ?? false,
638
+ result: ev.result,
639
+ });
640
+ return;
641
+ }
642
+ if (ev.type === 'error') {
643
+ s.status = 'error';
644
+ this.emit(s, 'session_error', { message: ev.message ?? 'unknown error' });
645
+ return;
646
+ }
647
+ // Anything else (user echo, tool_use, etc.) — emit as raw for transparency.
648
+ this.emit(s, 'stream_raw', ev);
649
+ }
650
+ handleExit(s, info) {
651
+ if (s.sigkillTimer) {
652
+ this.clearTimer(s.sigkillTimer);
653
+ s.sigkillTimer = undefined;
654
+ }
655
+ // If we'd already archived, the archive path emitted `session.archived` —
656
+ // exit is the natural follow-on, no need for a duplicate `session.ended`.
657
+ if (s.status === 'archived') {
658
+ for (const d of s.disposers) {
659
+ try {
660
+ d();
661
+ }
662
+ catch {
663
+ // ignore
664
+ }
665
+ }
666
+ s.disposers = [];
667
+ s.process = undefined;
668
+ return;
669
+ }
670
+ const wasError = s.status === 'error';
671
+ s.status = wasError ? 'error' : 'exited';
672
+ s.exitCode = info.exitCode;
673
+ s.lastActivityAt = this.now();
674
+ for (const d of s.disposers) {
675
+ try {
676
+ d();
677
+ }
678
+ catch {
679
+ // ignore
680
+ }
681
+ }
682
+ s.disposers = [];
683
+ s.process = undefined;
684
+ this.emit(s, 'session.ended', {
685
+ sessionId: s.id,
686
+ exitCode: info.exitCode,
687
+ signal: info.signal,
688
+ });
689
+ this.onTerminate?.(s.id, wasError ? 'error' : 'exited');
690
+ if (this.focusedId === s.id)
691
+ this.refocusAfterDrop(s.id);
692
+ }
693
+ /**
694
+ * CLAUDE.md §8.7 — when the focused session ends/archives, jump to the
695
+ * most-recent other active session, or clear focus if there is none.
696
+ */
697
+ refocusAfterDrop(droppedId) {
698
+ const candidates = [...this.sessions.values()]
699
+ .filter(s => s.id !== droppedId &&
700
+ s.status !== 'archived' &&
701
+ s.status !== 'exited' &&
702
+ s.status !== 'error')
703
+ .sort((a, b) => b.lastActivityAt - a.lastActivityAt);
704
+ const next = candidates[0]?.id;
705
+ this.focusedId = next;
706
+ if (next) {
707
+ const s = this.sessions.get(next);
708
+ if (s) {
709
+ s.unreadBadges = 0;
710
+ this.emit(s, 'focus.changed', {
711
+ focusedId: next,
712
+ previousId: droppedId,
713
+ reason: 'fallback',
714
+ });
715
+ }
716
+ }
717
+ else {
718
+ // No surviving sessions to focus — emit `focus.changed` on the dropped
719
+ // channel so clients watching that channel can route to the empty switcher.
720
+ const prev = this.sessions.get(droppedId);
721
+ if (prev)
722
+ this.emit(prev, 'focus.changed', {
723
+ focusedId: null,
724
+ previousId: droppedId,
725
+ reason: 'fallback',
726
+ });
727
+ }
728
+ }
729
+ bumpBadge(s) {
730
+ if (this.focusedId === s.id)
731
+ return;
732
+ s.unreadBadges += 1;
733
+ }
734
+ emit(s, event, data) {
735
+ const published = this.bus.publish(channelName(s.id), event, data);
736
+ s.lastEventId = published.id;
737
+ // Mirror lifecycle-shaped events onto the global channel so the plugin
738
+ // (or any future client) can observe every session's lifecycle without
739
+ // pre-subscribing to each per-session channel. Without this mirror, a
740
+ // voice-spawned session emits `session.created` + `focus.changed` on a
741
+ // channel nobody is yet listening to.
742
+ if (LIFECYCLE_EVENTS.has(event)) {
743
+ this.bus.publish(LIFECYCLE_CHANNEL, event, { sessionId: s.id, ...data });
744
+ }
745
+ }
746
+ }
747
+ export const LIFECYCLE_CHANNEL = 'broker:lifecycle';
748
+ const LIFECYCLE_EVENTS = new Set([
749
+ 'session.created',
750
+ 'session.ended',
751
+ 'session.archived',
752
+ 'focus.changed',
753
+ ]);
754
+ export function channelName(sessionId) {
755
+ return `session:${sessionId}`;
756
+ }
757
+ function toPublic(s) {
758
+ const { process: _p, splitter: _sp, disposers: _d, sigkillTimer: _t, tailer: _tailer, seenAssistantIds: _seen, ...pub } = s;
759
+ return pub;
760
+ }
761
+ function snapshotSession(s) {
762
+ return {
763
+ id: s.id,
764
+ projectId: s.projectId,
765
+ cwd: s.cwd,
766
+ label: s.label,
767
+ status: s.status,
768
+ kind: s.kind,
769
+ claudeSessionId: s.claudeSessionId,
770
+ createdAt: s.createdAt,
771
+ lastActivityAt: s.lastActivityAt,
772
+ unreadBadges: s.unreadBadges,
773
+ };
774
+ }
775
+ /**
776
+ * Pull plain assistant text out of the jsonl's `content` field. The shape is
777
+ * the same as the spawned stream-json's `message.content`: an array of typed
778
+ * blocks. We keep tool_use / tool_result blocks for future glyph rendering
779
+ * (they fall out through `stream_raw`).
780
+ */
781
+ function extractAssistantTextFromContent(content) {
782
+ if (typeof content === 'string')
783
+ return content.length > 0 ? content : null;
784
+ if (!Array.isArray(content))
785
+ return null;
786
+ const text = content
787
+ .filter(b => b?.type === 'text' && typeof b.text === 'string')
788
+ .map(b => b.text)
789
+ .join('');
790
+ return text.length > 0 ? text : null;
791
+ }
792
+ //# sourceMappingURL=sessions.js.map