@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.
- package/README.md +50 -0
- package/dist/_broker/attached.js +78 -0
- package/dist/_broker/attached.js.map +1 -0
- package/dist/_broker/audio-codec.js +82 -0
- package/dist/_broker/audio-codec.js.map +1 -0
- package/dist/_broker/audio-session.js +81 -0
- package/dist/_broker/audio-session.js.map +1 -0
- package/dist/_broker/auth.js +32 -0
- package/dist/_broker/auth.js.map +1 -0
- package/dist/_broker/bus.js +76 -0
- package/dist/_broker/bus.js.map +1 -0
- package/dist/_broker/cert.js +72 -0
- package/dist/_broker/cert.js.map +1 -0
- package/dist/_broker/claude.js +160 -0
- package/dist/_broker/claude.js.map +1 -0
- package/dist/_broker/cli.js +134 -0
- package/dist/_broker/cli.js.map +1 -0
- package/dist/_broker/cors.js +48 -0
- package/dist/_broker/cors.js.map +1 -0
- package/dist/_broker/hooks.js +196 -0
- package/dist/_broker/hooks.js.map +1 -0
- package/dist/_broker/index.js +48 -0
- package/dist/_broker/index.js.map +1 -0
- package/dist/_broker/intent-dispatcher.js +86 -0
- package/dist/_broker/intent-dispatcher.js.map +1 -0
- package/dist/_broker/intent.js +127 -0
- package/dist/_broker/intent.js.map +1 -0
- package/dist/_broker/jsonl-tail.js +185 -0
- package/dist/_broker/jsonl-tail.js.map +1 -0
- package/dist/_broker/mdns.js +41 -0
- package/dist/_broker/mdns.js.map +1 -0
- package/dist/_broker/projects.js +161 -0
- package/dist/_broker/projects.js.map +1 -0
- package/dist/_broker/qr.js +11 -0
- package/dist/_broker/qr.js.map +1 -0
- package/dist/_broker/routes.js +379 -0
- package/dist/_broker/routes.js.map +1 -0
- package/dist/_broker/server.js +325 -0
- package/dist/_broker/server.js.map +1 -0
- package/dist/_broker/sessions-store.js +50 -0
- package/dist/_broker/sessions-store.js.map +1 -0
- package/dist/_broker/sessions.js +792 -0
- package/dist/_broker/sessions.js.map +1 -0
- package/dist/_broker/store.js +79 -0
- package/dist/_broker/store.js.map +1 -0
- package/dist/_broker/stt.js +93 -0
- package/dist/_broker/stt.js.map +1 -0
- package/dist/broker-bin.js +37 -0
- package/dist/broker-bin.js.map +1 -0
- package/dist/broker-lifecycle.js +186 -0
- package/dist/broker-lifecycle.js.map +1 -0
- package/dist/index.js +189 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.js +17 -0
- package/dist/paths.js.map +1 -0
- package/dist/wrapper/index.js +263 -0
- package/dist/wrapper/index.js.map +1 -0
- package/dist/wrapper/slug.js +5 -0
- package/dist/wrapper/slug.js.map +1 -0
- 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
|