@seanmars/tospec 0.22.0 → 0.24.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 (62) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +22 -6
  3. package/assets/claude-mod/tospec-dashboard/.claude-plugin/plugin.json +7 -0
  4. package/assets/claude-mod/tospec-dashboard/hooks/hooks.json +1 -0
  5. package/assets/claude-mod/tospec-dashboard/hooks/register.ts +695 -0
  6. package/assets/claude-mod/tospec-dashboard/tests/register.test.ts +448 -0
  7. package/assets/claude-mod/tospec-dashboard/types/index.d.ts +8 -0
  8. package/assets/dashboard/app.js +218 -1180
  9. package/assets/dashboard/index.html +31 -22
  10. package/assets/dashboard/lib/charts.js +355 -0
  11. package/assets/dashboard/lib/derive.js +369 -0
  12. package/assets/dashboard/lib/dom.js +121 -0
  13. package/assets/dashboard/lib/routes.js +52 -0
  14. package/assets/dashboard/lib/store.js +324 -0
  15. package/assets/dashboard/style.css +3103 -664
  16. package/assets/dashboard/views/alerts.js +170 -0
  17. package/assets/dashboard/views/change.js +232 -0
  18. package/assets/dashboard/views/docs.js +214 -0
  19. package/assets/dashboard/views/home.js +357 -0
  20. package/assets/dashboard/views/project.js +168 -0
  21. package/assets/dashboard/views/session.js +270 -0
  22. package/assets/dashboard/views/sidebar.js +131 -0
  23. package/dist/cli/index.d.ts.map +1 -1
  24. package/dist/cli/index.js +74 -95
  25. package/dist/cli/index.js.map +1 -1
  26. package/dist/commands/dashboard-relay.d.ts +11 -0
  27. package/dist/commands/dashboard-relay.d.ts.map +1 -0
  28. package/dist/commands/dashboard-relay.js +88 -0
  29. package/dist/commands/dashboard-relay.js.map +1 -0
  30. package/dist/commands/dashboard.d.ts +62 -102
  31. package/dist/commands/dashboard.d.ts.map +1 -1
  32. package/dist/commands/dashboard.js +375 -609
  33. package/dist/commands/dashboard.js.map +1 -1
  34. package/dist/core/dashboard-hub.d.ts +258 -0
  35. package/dist/core/dashboard-hub.d.ts.map +1 -0
  36. package/dist/core/dashboard-hub.js +643 -0
  37. package/dist/core/dashboard-hub.js.map +1 -0
  38. package/dist/core/dashboard-routes.d.ts +56 -0
  39. package/dist/core/dashboard-routes.d.ts.map +1 -0
  40. package/dist/core/dashboard-routes.js +329 -0
  41. package/dist/core/dashboard-routes.js.map +1 -0
  42. package/dist/core/init.d.ts.map +1 -1
  43. package/dist/core/init.js +4 -1
  44. package/dist/core/init.js.map +1 -1
  45. package/dist/core/local-server.d.ts +1 -1
  46. package/dist/core/local-server.js +1 -1
  47. package/dist/core/shared/dashboard-mod.d.ts +24 -0
  48. package/dist/core/shared/dashboard-mod.d.ts.map +1 -0
  49. package/dist/core/shared/dashboard-mod.js +44 -0
  50. package/dist/core/shared/dashboard-mod.js.map +1 -0
  51. package/dist/core/shared/index.d.ts +1 -0
  52. package/dist/core/shared/index.d.ts.map +1 -1
  53. package/dist/core/shared/index.js +1 -0
  54. package/dist/core/shared/index.js.map +1 -1
  55. package/dist/core/update.d.ts.map +1 -1
  56. package/dist/core/update.js +4 -1
  57. package/dist/core/update.js.map +1 -1
  58. package/package.json +3 -2
  59. package/schemas/decision/schema.yaml +1 -1
  60. package/schemas/issue/schema.yaml +4 -4
  61. package/schemas/sdd/schema.yaml +4 -3
  62. package/assets/dashboard/chart.umd.js +0 -14
@@ -0,0 +1,643 @@
1
+ /**
2
+ * In-memory state of the dashboard hub: one entry per project, keyed by its
3
+ * root, holding the last snapshot any of its sessions pushed, and per live
4
+ * session what it is doing (its status, a short activity log, the question it
5
+ * waits on). Nothing is persisted and nothing is read from disk — a hub
6
+ * restart starts empty, and every live session re-registers on its next poll.
7
+ *
8
+ * The mod cannot listen on a port, so the hub never calls it. Requests the
9
+ * snapshot does not answer, and every write, wait in a per-project queue
10
+ * until one of that project's sessions long-polls for them. The page drives a
11
+ * session in one way only: it answers the question that session asked.
12
+ */
13
+ import * as crypto from 'node:crypto';
14
+ import * as path from 'node:path';
15
+ import { DASHBOARD_PROTOCOL, canonicalRequestKey, } from './dashboard-routes.js';
16
+ /** A rejected push or poll; `status` is the HTTP answer the server gives. */
17
+ export class HubRequestError extends Error {
18
+ status;
19
+ constructor(message, status) {
20
+ super(message);
21
+ this.status = status;
22
+ this.name = 'HubRequestError';
23
+ }
24
+ }
25
+ /**
26
+ * The project's identity: two sessions in the same root share one entry. Case
27
+ * folded on Windows, whose paths are case-insensitive.
28
+ */
29
+ export function projectKey(root) {
30
+ const resolved = path.resolve(root);
31
+ const spelled = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
32
+ return crypto.createHash('sha1').update(spelled).digest('hex').slice(0, 12);
33
+ }
34
+ const OFFLINE_ERROR = 'No Claude Code session is serving this project right now.';
35
+ const READ_ONLY_ERROR = 'This dashboard is bound to a non-loopback address, so it cannot answer for sessions; answer in the terminal.';
36
+ /** Per-session log length: enough for the last few turns, bounded per session. */
37
+ export const ACTIVITY_LOG_LIMIT = 200;
38
+ /** One event's text; the mod cuts to this too, so the bound holds whoever sent it. */
39
+ export const ACTIVITY_TEXT_LIMIT = 500;
40
+ const ACTIVITY_KINDS = new Set(['prompt', 'reply', 'tool', 'ask', 'answer', 'permission']);
41
+ function isNonEmptyString(value, max = 4096) {
42
+ return typeof value === 'string' && value.length > 0 && value.length <= max;
43
+ }
44
+ function isResponse(value) {
45
+ return (typeof value === 'object' &&
46
+ value !== null &&
47
+ Number.isInteger(value.status) &&
48
+ 'body' in value);
49
+ }
50
+ function fieldsOf(value) {
51
+ return typeof value === 'object' && value !== null ? value : {};
52
+ }
53
+ /** A string cut to `max` characters, or undefined for anything that is not one. */
54
+ function clip(value, max) {
55
+ if (typeof value !== 'string' || value.length === 0)
56
+ return undefined;
57
+ return value.length > max ? value.slice(0, max - 1) + '…' : value;
58
+ }
59
+ /** AskUserQuestion's `questions`, kept to what the dashboard draws; null if malformed. */
60
+ function parseQuestions(value) {
61
+ if (!Array.isArray(value) || value.length === 0 || value.length > 4)
62
+ return null;
63
+ const questions = [];
64
+ for (const raw of value) {
65
+ const q = fieldsOf(raw);
66
+ const question = clip(q.question, ACTIVITY_TEXT_LIMIT);
67
+ if (!question || !Array.isArray(q.options) || q.options.length > 8)
68
+ return null;
69
+ const options = [];
70
+ for (const rawOption of q.options) {
71
+ const o = fieldsOf(rawOption);
72
+ const label = clip(o.label, 200);
73
+ if (!label)
74
+ return null;
75
+ options.push({ label, description: clip(o.description, ACTIVITY_TEXT_LIMIT) });
76
+ }
77
+ questions.push({ question, header: clip(q.header, 64), multiSelect: q.multiSelect === true, options });
78
+ }
79
+ return questions;
80
+ }
81
+ export class DashboardHub {
82
+ projects = new Map();
83
+ pending = new Map();
84
+ asks = new Map();
85
+ listeners = new Set();
86
+ now;
87
+ sessionTtlMs;
88
+ pollHoldMs;
89
+ relayTimeoutMs;
90
+ interactive;
91
+ constructor(options = {}) {
92
+ this.now = options.now ?? Date.now;
93
+ // A poll is renewed as soon as it answers, so a live session is seen at
94
+ // least every pollHoldMs; the margin covers a slow reconnect.
95
+ this.pollHoldMs = options.pollHoldMs ?? 25_000;
96
+ this.sessionTtlMs = options.sessionTtlMs ?? this.pollHoldMs + 20_000;
97
+ this.relayTimeoutMs = options.relayTimeoutMs ?? 20_000;
98
+ this.interactive = options.interactive ?? true;
99
+ }
100
+ subscribe(listener) {
101
+ this.listeners.add(listener);
102
+ return () => this.listeners.delete(listener);
103
+ }
104
+ emit(event) {
105
+ for (const listener of this.listeners)
106
+ listener(event);
107
+ }
108
+ /**
109
+ * Registers or refreshes a project from a pushed snapshot. The newest push
110
+ * wins whichever session sent it: every session of a project reads the same
111
+ * files, so "newest" is the only order that matters.
112
+ */
113
+ acceptSnapshot(session, snapshot) {
114
+ if (!isNonEmptyString(session, 256)) {
115
+ throw new HubRequestError('expected a session id', 400);
116
+ }
117
+ const snap = snapshot;
118
+ if (typeof snap !== 'object' || snap === null) {
119
+ throw new HubRequestError('expected a snapshot object', 400);
120
+ }
121
+ if (snap.protocol !== DASHBOARD_PROTOCOL) {
122
+ throw new HubRequestError(`snapshot protocol ${String(snap.protocol)} does not match this dashboard's ${DASHBOARD_PROTOCOL}; run the same tospec version for both`, 409);
123
+ }
124
+ if (!isNonEmptyString(snap.root) || !path.isAbsolute(snap.root)) {
125
+ throw new HubRequestError('expected an absolute snapshot root', 400);
126
+ }
127
+ if (typeof snap.entries !== 'object' || snap.entries === null) {
128
+ throw new HubRequestError('expected snapshot entries', 400);
129
+ }
130
+ const entries = new Map();
131
+ for (const [key, value] of Object.entries(snap.entries)) {
132
+ if (!isResponse(value))
133
+ throw new HubRequestError(`malformed snapshot entry ${key}`, 400);
134
+ entries.set(canonicalRequestKey(key), { status: value.status, body: value.body });
135
+ }
136
+ const key = projectKey(snap.root);
137
+ const existing = this.projects.get(key);
138
+ const state = existing ?? {
139
+ key,
140
+ root: snap.root,
141
+ entries,
142
+ updatedAt: 0,
143
+ updatedBy: session,
144
+ sessions: new Map(),
145
+ queue: [],
146
+ waiters: [],
147
+ presence: '',
148
+ };
149
+ state.root = snap.root;
150
+ // Replaced, not merged: an on-demand answer cached under the old snapshot
151
+ // (a rendered file) may describe content that has since changed.
152
+ state.entries = entries;
153
+ state.updatedAt = this.now();
154
+ const pushedByAnother = state.updatedBy !== session;
155
+ state.updatedBy = session;
156
+ this.touch(state, session);
157
+ this.projects.set(key, state);
158
+ // The list names which session's push is being served, so a push from
159
+ // another session changes it even when the live set did not.
160
+ if (!this.refreshPresence(state) && (!existing || pushedByAnother))
161
+ this.emit({ type: 'projects' });
162
+ this.emit({ type: 'change', project: key });
163
+ return key;
164
+ }
165
+ /**
166
+ * Marks a session seen. One that expired and comes back counts as a new
167
+ * connection, so its `connectedAt` says when this stretch began, and its
168
+ * log starts over with it.
169
+ */
170
+ touch(state, session) {
171
+ const now = this.now();
172
+ const known = state.sessions.get(session);
173
+ if (known && known.lastSeen >= now - this.sessionTtlMs) {
174
+ known.lastSeen = now;
175
+ return known;
176
+ }
177
+ const fresh = { firstSeen: now, lastSeen: now, status: 'idle', statusSince: now, log: [], seq: 0 };
178
+ state.sessions.set(session, fresh);
179
+ return fresh;
180
+ }
181
+ /** Broadcasts `projects` when the live session set changed; true if it did. */
182
+ refreshPresence(state) {
183
+ const presence = this.liveSessions(state)
184
+ .map((s) => s.id)
185
+ .join(',');
186
+ if (presence === state.presence)
187
+ return false;
188
+ state.presence = presence;
189
+ this.emit({ type: 'projects' });
190
+ return true;
191
+ }
192
+ /**
193
+ * Hands queued commands to a session, waiting up to `pollHoldMs` for one.
194
+ * Null means the hub does not know this project (it restarted): the session
195
+ * should push a snapshot first.
196
+ */
197
+ poll(key, session, signal) {
198
+ if (!isNonEmptyString(key, 64) || !isNonEmptyString(session, 256)) {
199
+ return Promise.reject(new HubRequestError('expected { project, session }', 400));
200
+ }
201
+ const state = this.projects.get(key);
202
+ if (!state)
203
+ return Promise.resolve(null);
204
+ // A poll can introduce a session the hub has not seen push: after a
205
+ // /clear, the same mod goes on under the next session id.
206
+ this.touch(state, session);
207
+ this.refreshPresence(state);
208
+ if (state.queue.length > 0)
209
+ return Promise.resolve(state.queue.splice(0));
210
+ return new Promise((resolve) => {
211
+ const waiter = {
212
+ session,
213
+ deliver: (commands) => {
214
+ clearTimeout(timer);
215
+ signal?.removeEventListener('abort', onAbort);
216
+ const at = state.waiters.indexOf(waiter);
217
+ if (at >= 0)
218
+ state.waiters.splice(at, 1);
219
+ this.touch(state, session);
220
+ resolve(commands);
221
+ },
222
+ };
223
+ // A session that hung up must not be handed commands it will never run.
224
+ const onAbort = () => waiter.deliver([]);
225
+ const timer = setTimeout(() => waiter.deliver([]), this.pollHoldMs);
226
+ signal?.addEventListener('abort', onAbort, { once: true });
227
+ state.waiters.push(waiter);
228
+ });
229
+ }
230
+ /** Settles a relayed request. False when it already timed out or never existed. */
231
+ reply(id, response) {
232
+ if (!isNonEmptyString(id, 128) || !isResponse(response)) {
233
+ throw new HubRequestError('expected { id, status, body }', 400);
234
+ }
235
+ const pending = this.pending.get(id);
236
+ if (!pending)
237
+ return false;
238
+ this.pending.delete(id);
239
+ clearTimeout(pending.timer);
240
+ pending.resolve({ status: response.status, body: response.body });
241
+ return true;
242
+ }
243
+ /**
244
+ * A session ended or unregistered. The project and its snapshot stay, as last
245
+ * known.
246
+ *
247
+ * Its held poll is released first: a waiting poll counts as live, so leaving
248
+ * it held kept the project online for up to `pollHoldMs` after an explicit
249
+ * unregister. Released before the delete, since a delivery refreshes the
250
+ * session's last-seen time.
251
+ */
252
+ leave(key, session) {
253
+ if (!isNonEmptyString(key, 64) || !isNonEmptyString(session, 256)) {
254
+ throw new HubRequestError('expected { project, session }', 400);
255
+ }
256
+ const state = this.projects.get(key);
257
+ if (!state)
258
+ return;
259
+ for (const waiter of state.waiters.filter((w) => w.session === session))
260
+ waiter.deliver([]);
261
+ state.sessions.delete(session);
262
+ this.dropAsksOf(key, session);
263
+ this.refreshPresence(state);
264
+ }
265
+ listProjects() {
266
+ return [...this.projects.values()]
267
+ .sort((a, b) => b.updatedAt - a.updatedAt)
268
+ .map((state) => {
269
+ const sessions = this.liveSessions(state);
270
+ return {
271
+ key: state.key,
272
+ root: state.root,
273
+ name: path.basename(state.root) || state.root,
274
+ updatedAt: new Date(state.updatedAt).toISOString(),
275
+ updatedBy: state.updatedBy,
276
+ online: sessions.length > 0,
277
+ sessions,
278
+ };
279
+ });
280
+ }
281
+ /**
282
+ * Serves a read from the snapshot, relaying a miss to a live session. Client
283
+ * errors are cached like successes (a 404 stays a 404 until the next push);
284
+ * a 5xx or an offline answer is not, so the next try asks again.
285
+ */
286
+ async read(key, requestPath) {
287
+ const state = this.projects.get(key);
288
+ if (!state)
289
+ return { status: 404, body: { error: 'unknown project' } };
290
+ const cacheKey = canonicalRequestKey(requestPath);
291
+ const entries = state.entries;
292
+ const cached = entries.get(cacheKey);
293
+ if (cached)
294
+ return cached;
295
+ const answer = await this.relay(state, { method: 'GET', path: cacheKey });
296
+ // Into the map it missed in only: a push that landed meanwhile replaced it,
297
+ // and this answer may predate that push.
298
+ if (answer.status < 500 && state.entries === entries)
299
+ entries.set(cacheKey, answer);
300
+ return answer;
301
+ }
302
+ /**
303
+ * Relays a write. A success empties the cache, so reads that follow ask the
304
+ * session for the files as they are now rather than for the pre-write
305
+ * snapshot; the session pushes a fresh one on its own shortly after.
306
+ */
307
+ async write(key, request) {
308
+ const state = this.projects.get(key);
309
+ if (!state)
310
+ return { status: 404, body: { error: 'unknown project' } };
311
+ const answer = await this.relay(state, request);
312
+ if (answer.status >= 200 && answer.status < 300) {
313
+ state.entries = new Map();
314
+ this.emit({ type: 'change', project: key });
315
+ }
316
+ return answer;
317
+ }
318
+ /** Asks a live session for a fresh snapshot. False when none is live. */
319
+ requestRefresh(key) {
320
+ const state = this.projects.get(key);
321
+ if (!state || !this.isOnline(state))
322
+ return false;
323
+ this.enqueue(state, { id: crypto.randomUUID(), kind: 'refresh' });
324
+ return true;
325
+ }
326
+ // --- session activity -------------------------------------------------------
327
+ /**
328
+ * Takes a mod's report: its status (`working` from a turn's start to its
329
+ * end), what it is doing now, and log events. False for a project the hub
330
+ * does not know, so the mod registers again.
331
+ */
332
+ reportActivity(key, session, report) {
333
+ if (!isNonEmptyString(key, 64) || !isNonEmptyString(session, 256)) {
334
+ throw new HubRequestError('expected { project, session }', 400);
335
+ }
336
+ const state = this.projects.get(key);
337
+ if (!state)
338
+ return false;
339
+ const presence = this.touch(state, session);
340
+ const fields = fieldsOf(report);
341
+ if (fields.status === 'working' || fields.status === 'idle') {
342
+ if (presence.status !== fields.status)
343
+ presence.statusSince = this.now();
344
+ presence.status = fields.status;
345
+ // A finished turn is doing nothing: an action left over would read as work.
346
+ if (fields.status === 'idle')
347
+ presence.action = undefined;
348
+ }
349
+ // A turn that ended is asking nothing: an ask still open was settled where
350
+ // the hub could not see it (the terminal), and must not be answered twice.
351
+ if (fields.status === 'idle') {
352
+ for (const [id, entry] of this.asks) {
353
+ if (entry.key === key && entry.session === session && !entry.answer)
354
+ this.closeAsk(id, '回合已結束, 問題已撤回');
355
+ }
356
+ }
357
+ if ('action' in fields)
358
+ presence.action = clip(fields.action, 300);
359
+ const added = [];
360
+ for (const raw of Array.isArray(fields.events) ? fields.events.slice(0, ACTIVITY_LOG_LIMIT) : []) {
361
+ const e = fieldsOf(raw);
362
+ if (!ACTIVITY_KINDS.has(e.kind))
363
+ continue;
364
+ added.push(this.appendEvent(presence, {
365
+ kind: e.kind,
366
+ text: clip(e.text, ACTIVITY_TEXT_LIMIT),
367
+ tool: clip(e.tool, 64),
368
+ target: clip(e.target, 300),
369
+ ok: typeof e.ok === 'boolean' ? e.ok : undefined,
370
+ }));
371
+ }
372
+ this.refreshPresence(state);
373
+ this.emitActivity(state, session, added);
374
+ return true;
375
+ }
376
+ /** A session's summary and log, for a browser opening its panel. */
377
+ sessionLog(key, session) {
378
+ const state = this.projects.get(key);
379
+ const summary = state && this.liveSessions(state).find((s) => s.id === session);
380
+ if (!state || !summary)
381
+ return null;
382
+ return { session: summary, events: [...state.sessions.get(session).log] };
383
+ }
384
+ appendEvent(presence, event) {
385
+ const entry = { seq: ++presence.seq, at: new Date(this.now()).toISOString(), kind: event.kind };
386
+ // Absent fields stay absent, so a log line is only what was reported.
387
+ for (const field of ['text', 'tool', 'target', 'ok']) {
388
+ if (event[field] !== undefined)
389
+ Object.assign(entry, { [field]: event[field] });
390
+ }
391
+ presence.log.push(entry);
392
+ if (presence.log.length > ACTIVITY_LOG_LIMIT)
393
+ presence.log.splice(0, presence.log.length - ACTIVITY_LOG_LIMIT);
394
+ return entry;
395
+ }
396
+ emitActivity(state, session, events) {
397
+ const summary = this.liveSessions(state).find((s) => s.id === session);
398
+ if (summary)
399
+ this.emit({ type: 'activity', project: state.key, session: summary, events });
400
+ }
401
+ // --- asks: a question or a permission the dashboard may answer --------------------
402
+ /**
403
+ * Opens an ask for a session, which then reads as waiting. The terminal asks
404
+ * the same question at the same time, and the first answer stands: `hold`
405
+ * says whether the dashboard may answer at all (it is interactive), so the
406
+ * mod knows whether to wait for it.
407
+ */
408
+ openAsk(key, session, ask) {
409
+ if (!isNonEmptyString(key, 64) || !isNonEmptyString(session, 256)) {
410
+ throw new HubRequestError('expected { project, session }', 400);
411
+ }
412
+ const state = this.projects.get(key);
413
+ if (!state)
414
+ throw new HubRequestError('unknown project', 404);
415
+ const fields = fieldsOf(ask);
416
+ const id = crypto.randomUUID();
417
+ let pending;
418
+ let event;
419
+ if (fields.kind === 'question') {
420
+ const questions = parseQuestions(fields.questions);
421
+ if (!questions)
422
+ throw new HubRequestError('expected AskUserQuestion questions', 400);
423
+ pending = { id, kind: 'question', questions };
424
+ event = { kind: 'ask', text: questions.map((q) => q.question).join(' / ') };
425
+ }
426
+ else if (fields.kind === 'permission') {
427
+ const tool = clip(fields.tool, 64);
428
+ if (!tool)
429
+ throw new HubRequestError('expected a tool name', 400);
430
+ pending = { id, kind: 'permission', tool, target: clip(fields.target, 300) ?? '' };
431
+ event = { kind: 'permission', tool, target: pending.target, text: '等待確認' };
432
+ }
433
+ else {
434
+ throw new HubRequestError('expected an ask of kind question or permission', 400);
435
+ }
436
+ const presence = this.touch(state, session);
437
+ this.asks.set(id, { key, session, ask: pending, opened: this.now(), waiters: new Set() });
438
+ const added = this.appendEvent(presence, event);
439
+ this.emitActivity(state, session, [added]);
440
+ return { id, hold: this.interactive };
441
+ }
442
+ /**
443
+ * The mod's wait for the dashboard's answer, held up to `pollHoldMs`: a hook
444
+ * waiting on a `$` call costs it none of its time budget, where one waiting
445
+ * on its own timer would.
446
+ */
447
+ waitAsk(id, signal) {
448
+ if (!isNonEmptyString(id, 128))
449
+ return Promise.reject(new HubRequestError('expected { id }', 400));
450
+ const entry = this.asks.get(id);
451
+ if (!entry)
452
+ return Promise.resolve({ closed: 'gone' });
453
+ if (entry.answer) {
454
+ this.asks.delete(id);
455
+ return Promise.resolve({ answer: entry.answer });
456
+ }
457
+ return new Promise((resolve) => {
458
+ const settle = (result) => {
459
+ clearTimeout(timer);
460
+ signal?.removeEventListener('abort', onAbort);
461
+ entry.waiters.delete(settle);
462
+ resolve(result);
463
+ };
464
+ const onAbort = () => settle({ pending: true });
465
+ const timer = setTimeout(() => settle({ pending: true }), this.pollHoldMs);
466
+ signal?.addEventListener('abort', onAbort, { once: true });
467
+ entry.waiters.add(settle);
468
+ });
469
+ }
470
+ /** The dashboard's answer to an open ask. */
471
+ answerAsk(key, session, id, answer) {
472
+ if (!this.interactive)
473
+ return { status: 403, body: { error: READ_ONLY_ERROR } };
474
+ const entry = isNonEmptyString(id, 128) ? this.asks.get(id) : undefined;
475
+ if (!entry || entry.key !== key || entry.session !== session || entry.answer) {
476
+ return { status: 404, body: { error: 'This question was already answered or withdrawn.' } };
477
+ }
478
+ const fields = fieldsOf(answer);
479
+ let settled;
480
+ let text;
481
+ if (entry.ask.kind === 'question') {
482
+ const given = fieldsOf(fields.answers);
483
+ const answers = {};
484
+ for (const q of entry.ask.questions) {
485
+ const value = clip(given[q.question], 2_000);
486
+ if (!value)
487
+ return { status: 400, body: { error: `No answer for: ${q.question}` } };
488
+ answers[q.question] = value;
489
+ }
490
+ settled = { kind: 'question', answers };
491
+ text = Object.values(answers).join(' / ');
492
+ }
493
+ else {
494
+ if (typeof fields.allow !== 'boolean')
495
+ return { status: 400, body: { error: 'expected { allow: boolean }' } };
496
+ settled = { kind: 'permission', allow: fields.allow };
497
+ text = fields.allow ? '允許' : '拒絕';
498
+ }
499
+ // Set before the log line, so the summary it broadcasts no longer waits.
500
+ entry.answer = settled;
501
+ this.logAskEnd(entry, { kind: 'answer', text: `${text} (dashboard)`, ok: settled.kind === 'permission' ? settled.allow : undefined });
502
+ // A wait in flight takes it now; otherwise the entry stays, answered, for
503
+ // the mod's next wait to collect.
504
+ if (entry.waiters.size > 0) {
505
+ this.asks.delete(entry.ask.id);
506
+ for (const deliver of [...entry.waiters])
507
+ deliver({ answer: settled });
508
+ }
509
+ return { status: 200, body: { ok: true } };
510
+ }
511
+ /**
512
+ * Withdraws an ask the dashboard can no longer answer: the person answered
513
+ * it in the terminal, so the page drops its controls instead of offering a
514
+ * second answer. An ask the dashboard already answered stays as it is.
515
+ */
516
+ closeAsk(id, text = '已在 terminal 回答') {
517
+ if (!isNonEmptyString(id, 128))
518
+ throw new HubRequestError('expected { id }', 400);
519
+ const entry = this.asks.get(id);
520
+ if (!entry || entry.answer)
521
+ return;
522
+ // Removed before the log line, so the summary it broadcasts no longer waits.
523
+ this.asks.delete(id);
524
+ this.logAskEnd(entry, { kind: 'answer', text });
525
+ for (const deliver of [...entry.waiters])
526
+ deliver({ closed: 'gone' });
527
+ }
528
+ logAskEnd(entry, event) {
529
+ const state = this.projects.get(entry.key);
530
+ const presence = state?.sessions.get(entry.session);
531
+ if (!state || !presence)
532
+ return;
533
+ this.emitActivity(state, entry.session, [this.appendEvent(presence, event)]);
534
+ }
535
+ dropAsksOf(key, session) {
536
+ for (const [id, entry] of this.asks) {
537
+ if (entry.key !== key || entry.session !== session)
538
+ continue;
539
+ this.asks.delete(id);
540
+ for (const deliver of [...entry.waiters])
541
+ deliver({ closed: 'gone' });
542
+ }
543
+ }
544
+ /**
545
+ * Forgets sessions past their ttl (a session that crashed never says it
546
+ * left) and broadcasts the change; the server calls it on a timer.
547
+ */
548
+ sweep() {
549
+ for (const state of this.projects.values()) {
550
+ const live = new Set(this.liveSessions(state).map((s) => s.id));
551
+ for (const id of [...state.sessions.keys()]) {
552
+ if (live.has(id))
553
+ continue;
554
+ state.sessions.delete(id);
555
+ this.dropAsksOf(state.key, id);
556
+ }
557
+ this.refreshPresence(state);
558
+ }
559
+ }
560
+ /** Releases every held poll, wait and pending relay; the hub is unusable after. */
561
+ close() {
562
+ for (const state of this.projects.values()) {
563
+ for (const waiter of [...state.waiters])
564
+ waiter.deliver([]);
565
+ }
566
+ for (const [id, pending] of this.pending) {
567
+ clearTimeout(pending.timer);
568
+ this.pending.delete(id);
569
+ pending.resolve({ status: 503, body: { error: 'dashboard is shutting down' } });
570
+ }
571
+ for (const [id, entry] of this.asks) {
572
+ this.asks.delete(id);
573
+ for (const deliver of [...entry.waiters])
574
+ deliver({ closed: 'gone' });
575
+ }
576
+ this.listeners.clear();
577
+ }
578
+ /** Sessions seen within the ttl, or holding a poll; oldest connection first. */
579
+ liveSessions(state) {
580
+ const cutoff = this.now() - this.sessionTtlMs;
581
+ const polling = new Set(state.waiters.map((w) => w.session));
582
+ return [...state.sessions.entries()]
583
+ .filter(([id, seen]) => seen.lastSeen >= cutoff || polling.has(id))
584
+ .sort(([, a], [, b]) => a.firstSeen - b.firstSeen)
585
+ .map(([id, seen]) => this.summarize(state.key, id, seen));
586
+ }
587
+ summarize(key, id, seen) {
588
+ const ask = this.openAskOf(key, id);
589
+ const summary = {
590
+ id,
591
+ connectedAt: new Date(seen.firstSeen).toISOString(),
592
+ lastSeenAt: new Date(seen.lastSeen).toISOString(),
593
+ status: ask ? 'waiting' : seen.status,
594
+ statusSince: new Date(ask ? ask.opened : seen.statusSince).toISOString(),
595
+ };
596
+ if (seen.action && !ask)
597
+ summary.action = seen.action;
598
+ if (ask)
599
+ summary.ask = ask.ask;
600
+ return summary;
601
+ }
602
+ /** The session's oldest ask the dashboard can still answer. */
603
+ openAskOf(key, session) {
604
+ let oldest;
605
+ for (const entry of this.asks.values()) {
606
+ if (entry.key !== key || entry.session !== session || entry.answer)
607
+ continue;
608
+ if (!oldest || entry.opened < oldest.opened)
609
+ oldest = entry;
610
+ }
611
+ return oldest;
612
+ }
613
+ isOnline(state) {
614
+ return this.liveSessions(state).length > 0;
615
+ }
616
+ enqueue(state, command) {
617
+ const waiter = state.waiters[0];
618
+ if (waiter)
619
+ waiter.deliver([command]);
620
+ else
621
+ state.queue.push(command);
622
+ }
623
+ relay(state, request) {
624
+ if (!this.isOnline(state)) {
625
+ return Promise.resolve({ status: 503, body: { error: OFFLINE_ERROR, offline: true } });
626
+ }
627
+ const command = { id: crypto.randomUUID(), kind: 'query', request };
628
+ return new Promise((resolve) => {
629
+ const timer = setTimeout(() => {
630
+ this.pending.delete(command.id);
631
+ // Still queued means no session ever took it; drop it so a session
632
+ // that reconnects later does not run a write nobody is waiting for.
633
+ const at = state.queue.indexOf(command);
634
+ if (at >= 0)
635
+ state.queue.splice(at, 1);
636
+ resolve({ status: 504, body: { error: 'the Claude Code session did not answer in time' } });
637
+ }, this.relayTimeoutMs);
638
+ this.pending.set(command.id, { resolve, timer });
639
+ this.enqueue(state, command);
640
+ });
641
+ }
642
+ }
643
+ //# sourceMappingURL=dashboard-hub.js.map