@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,695 @@
1
+ import { atom, read, update } from 'claude-code'
2
+ import type { EngineInterface, Register } from 'claude-code'
3
+
4
+ import type { HubStatus } from '../types'
5
+
6
+ /**
7
+ * Registers this project with the tospec dashboard hub (`tospec dashboard`)
8
+ * and keeps its snapshot current. The hub reads no project files: this mod
9
+ * runs the tospec CLI here and relays what it prints, because a mod has no
10
+ * Node to load tospec with and cannot listen on a port for the hub to call.
11
+ * Requests the snapshot does not answer, and task writes, wait at the hub
12
+ * until the long poll below collects them.
13
+ *
14
+ * It also reports what the session is doing (see "session activity" below),
15
+ * and lets the dashboard answer the questions it asks. Nothing else typed
16
+ * into the dashboard reaches the session.
17
+ *
18
+ * The footer shows where the session stands, and `/tospec-dashboard
19
+ * register|unregister|status` lets the person change it.
20
+ *
21
+ * Generated by `tospec init` / `tospec update`; edits here are overwritten.
22
+ */
23
+
24
+ const DEFAULT_HUB_URL = 'http://127.0.0.1:5620'
25
+ const COMMAND = 'tospec-dashboard'
26
+ // Between probes of a hub that is not running. A probe is one HTTP request;
27
+ // the tospec CLI only runs once the hub answers.
28
+ const RETRY_MS = 20_000
29
+ // Edits come in bursts (one apply step rewrites several files), so push once
30
+ // the burst settles.
31
+ const PUSH_DEBOUNCE_MS = 1_500
32
+ const CLI_TIMEOUT_MS = 120_000
33
+ const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
34
+
35
+ // In `$.state`, not module state: the footer redraws on its writes, and a hot
36
+ // reload keeps it, so a session the person unregistered stays unregistered.
37
+ const STATUS = atom({ plugin: 'tospec-dashboard', key: 'status' } as const, 'connecting')
38
+
39
+ const FOOTER_LABEL: Record<HubStatus, string> = {
40
+ connecting: 'tospec hub: connecting',
41
+ connected: 'tospec hub: connected',
42
+ 'hub-offline': 'tospec hub: offline',
43
+ unregistered: 'tospec hub: unregistered',
44
+ error: 'tospec hub: error',
45
+ }
46
+
47
+ type HubCommand =
48
+ | { id: string; kind: 'query'; request: { method?: string } }
49
+ | { id: string; kind: 'refresh' }
50
+
51
+ type CliResult = { ok: true; payload: Record<string, unknown> } | { ok: false; error: string }
52
+
53
+ type Attempt = 'registered' | 'hub-offline' | 'hub-refused' | 'cli-failed' | 'unregistered'
54
+
55
+ // Module state: a reload starts it over, and `session.start` fires again then.
56
+ const state = {
57
+ hubUrl: DEFAULT_HUB_URL,
58
+ /** The argv prefix that started tospec last time, so the probe runs once. */
59
+ launcher: undefined as string[] | undefined,
60
+ /** The hub's key for this project, once it accepted a snapshot. */
61
+ project: undefined as string | undefined,
62
+ /** `<tospec root>/tospec/`, lowercased with forward slashes, from the snapshot. */
63
+ tospecDir: undefined as string | undefined,
64
+ looping: false,
65
+ ended: false,
66
+ /** Set by `/tospec-dashboard unregister`; nothing registers until `register`. */
67
+ optedOut: false,
68
+ /** Why the last attempt failed, for `/tospec-dashboard status`. */
69
+ detail: '',
70
+ /** The registration in flight, shared by the loop and the command. */
71
+ attempt: undefined as Promise<Attempt> | undefined,
72
+ /** Ends the loop's retry wait early. */
73
+ wake: undefined as (() => void) | undefined,
74
+ pushTimer: undefined as { cancel: () => void } | undefined,
75
+ reported: new Set<string>(),
76
+ }
77
+
78
+ function debug($: EngineInterface, text: string): void {
79
+ // Once per distinct line: a hub that stays down would otherwise log every retry.
80
+ if (state.reported.has(text)) return
81
+ state.reported.add(text)
82
+ $.ui.log(`tospec-dashboard: ${text}`, { to: 'debug' })
83
+ }
84
+
85
+ async function setStatus($: EngineInterface, status: HubStatus, detail = ''): Promise<void> {
86
+ state.detail = detail
87
+ // Unchanged writes would still redraw the footer.
88
+ if ((await read($, STATUS)) !== status) await update($, STATUS, () => status)
89
+ }
90
+
91
+ function sleep($: EngineInterface, ms: number): Promise<void> {
92
+ return new Promise((resolve) => {
93
+ const timer = $.clock.after(ms, () => {
94
+ state.wake = undefined
95
+ resolve()
96
+ })
97
+ state.wake = () => {
98
+ timer.cancel()
99
+ state.wake = undefined
100
+ resolve()
101
+ }
102
+ })
103
+ }
104
+
105
+ async function post(
106
+ $: EngineInterface,
107
+ path: string,
108
+ body: unknown
109
+ ): Promise<{ status: number; text: string } | undefined> {
110
+ try {
111
+ return await $.http.fetch(state.hubUrl + path, {
112
+ method: 'POST',
113
+ // JSON is what keeps web pages out: the hub answers no CORS preflight.
114
+ headers: { 'content-type': 'application/json' },
115
+ body: JSON.stringify(body),
116
+ })
117
+ } catch {
118
+ return undefined
119
+ }
120
+ }
121
+
122
+ async function hubIsUp($: EngineInterface): Promise<boolean> {
123
+ try {
124
+ return (await $.http.fetch(state.hubUrl + '/api/health')).ok
125
+ } catch {
126
+ return false
127
+ }
128
+ }
129
+
130
+ async function runTospec($: EngineInterface, args: string[], stdin?: string): Promise<CliResult> {
131
+ const cwd = await $.session.root()
132
+ const candidates = state.launcher ? [state.launcher] : [['tospec']]
133
+ if (!state.launcher && (await $.env.get('OS')) === 'Windows_NT') {
134
+ // npm installs tospec as a .cmd shim on Windows, which only a shell runs.
135
+ candidates.push(['cmd.exe', '/d', '/s', '/c', 'tospec'])
136
+ }
137
+ for (const base of candidates) {
138
+ let result
139
+ try {
140
+ result = await $.process.run([...base, ...args], { cwd, stdin, timeoutMs: CLI_TIMEOUT_MS })
141
+ } catch {
142
+ continue
143
+ }
144
+ state.launcher = base
145
+ let payload: Record<string, unknown>
146
+ try {
147
+ payload = JSON.parse(result.stdout) as Record<string, unknown>
148
+ } catch {
149
+ return {
150
+ ok: false,
151
+ error: result.isStdoutTruncated
152
+ ? 'tospec printed more than a mod can read'
153
+ : result.stderr.trim() || 'tospec printed no JSON',
154
+ }
155
+ }
156
+ if (result.exitCode === 0) return { ok: true, payload }
157
+ const status = payload.status as Array<{ message?: string }> | undefined
158
+ return { ok: false, error: status?.[0]?.message ?? `tospec exited with ${result.exitCode}` }
159
+ }
160
+ return { ok: false, error: 'could not start the tospec CLI; is it on PATH?' }
161
+ }
162
+
163
+ async function leave($: EngineInterface, project: string, session: string): Promise<void> {
164
+ await post($, '/api/mod/leave', { project, session })
165
+ }
166
+
167
+ async function push($: EngineInterface): Promise<Attempt> {
168
+ if (state.optedOut) return 'unregistered'
169
+ const out = await runTospec($, ['dashboard', 'snapshot'])
170
+ if (!out.ok) {
171
+ debug($, `snapshot failed: ${out.error}`)
172
+ await setStatus($, 'error', `tospec dashboard snapshot failed: ${out.error}`)
173
+ return 'cli-failed'
174
+ }
175
+ const root = (out.payload.root as { path?: string } | undefined)?.path
176
+ if (root) state.tospecDir = `${root.replace(/\\/g, '/').replace(/\/+$/, '')}/tospec/`.toLowerCase()
177
+ const session = await $.session.id()
178
+ const res = await post($, '/api/mod/snapshot', { session, snapshot: out.payload.snapshot })
179
+ if (!res) {
180
+ state.project = undefined
181
+ await setStatus($, 'hub-offline', `no dashboard answered at ${state.hubUrl}`)
182
+ return 'hub-offline'
183
+ }
184
+ if (res.status !== 200) {
185
+ debug($, `the dashboard refused the snapshot: ${res.text}`)
186
+ await setStatus($, 'error', `the dashboard refused the snapshot: ${res.text}`)
187
+ return 'hub-refused'
188
+ }
189
+ const project = (JSON.parse(res.text) as { project: string }).project
190
+ // An unregister that landed while this push was out wins: take it back.
191
+ if (state.optedOut) {
192
+ await leave($, project, session)
193
+ return 'unregistered'
194
+ }
195
+ state.project = project
196
+ await setStatus($, 'connected')
197
+ return 'registered'
198
+ }
199
+
200
+ async function registerOnce($: EngineInterface): Promise<Attempt> {
201
+ if (!(await hubIsUp($))) {
202
+ await setStatus($, 'hub-offline', `no dashboard answered at ${state.hubUrl}`)
203
+ return 'hub-offline'
204
+ }
205
+ return push($)
206
+ }
207
+
208
+ /** One registration at a time: the loop and the command share it. */
209
+ function attemptRegistration($: EngineInterface): Promise<Attempt> {
210
+ state.attempt ??= registerOnce($).finally(() => {
211
+ state.attempt = undefined
212
+ })
213
+ return state.attempt
214
+ }
215
+
216
+ async function handle($: EngineInterface, command: HubCommand): Promise<void> {
217
+ try {
218
+ if (command.kind === 'refresh') {
219
+ await push($)
220
+ return
221
+ }
222
+ const out = await runTospec($, ['dashboard', 'query'], JSON.stringify(command.request))
223
+ const response = out.ok
224
+ ? (out.payload.response as { status: number; body: unknown })
225
+ : { status: 502, body: { error: `tospec could not answer: ${out.error}` } }
226
+ await post($, '/api/mod/reply', { id: command.id, ...response })
227
+ const isWrite = command.request.method === 'POST'
228
+ if (isWrite && response.status >= 200 && response.status < 300) schedulePush($)
229
+ } catch (err) {
230
+ debug($, `a dashboard request failed: ${String(err)}`)
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Registers, then long-polls for as long as the session lives. Ends early when
236
+ * the person unregisters, or when the CLI itself fails (no tospec on PATH, no
237
+ * tospec root): retrying that on a timer would start a process every few
238
+ * seconds for nothing, so the next turn or `register` tries again instead.
239
+ */
240
+ async function loop($: EngineInterface): Promise<void> {
241
+ while (!state.ended && !state.optedOut) {
242
+ try {
243
+ if (!state.project) {
244
+ const attempt = await attemptRegistration($)
245
+ if (attempt === 'cli-failed' || attempt === 'unregistered') break
246
+ if (attempt !== 'registered') {
247
+ await sleep($, RETRY_MS)
248
+ continue
249
+ }
250
+ }
251
+ const project = state.project
252
+ const session = await $.session.id()
253
+ const res = await post($, '/api/mod/poll', { project, session })
254
+ // Unregistered while this poll was out: if it reached the hub after the
255
+ // leave, it registered the session again, so leave once more.
256
+ if (state.optedOut) {
257
+ if (project) await leave($, project, session)
258
+ break
259
+ }
260
+ if (!res) {
261
+ state.project = undefined
262
+ await setStatus($, 'hub-offline', `no dashboard answered at ${state.hubUrl}`)
263
+ await sleep($, RETRY_MS)
264
+ continue
265
+ }
266
+ // The hub restarted and forgot this project: register again at once.
267
+ if (res.status === 404) {
268
+ state.project = undefined
269
+ await setStatus($, 'connecting')
270
+ continue
271
+ }
272
+ if (res.status !== 200) {
273
+ debug($, `the dashboard refused a poll: ${res.text}`)
274
+ await sleep($, RETRY_MS)
275
+ continue
276
+ }
277
+ const { commands } = JSON.parse(res.text) as { commands: HubCommand[] }
278
+ for (const command of commands) void handle($, command)
279
+ } catch (err) {
280
+ debug($, `poll loop: ${String(err)}`)
281
+ await sleep($, RETRY_MS)
282
+ }
283
+ }
284
+ state.looping = false
285
+ }
286
+
287
+ function ensureLoop($: EngineInterface): void {
288
+ if (state.looping || state.ended || state.optedOut) return
289
+ state.looping = true
290
+ void loop($)
291
+ }
292
+
293
+ function schedulePush($: EngineInterface): void {
294
+ if (state.ended || state.optedOut) return
295
+ // Not registered yet: registering pushes a snapshot anyway.
296
+ if (!state.project) {
297
+ ensureLoop($)
298
+ return
299
+ }
300
+ state.pushTimer?.cancel()
301
+ state.pushTimer = $.clock.after(PUSH_DEBOUNCE_MS, () => {
302
+ state.pushTimer = undefined
303
+ void push($)
304
+ })
305
+ }
306
+
307
+ function touchesTospec(file: string): boolean {
308
+ const normalized = file.replace(/\\/g, '/').toLowerCase()
309
+ return state.tospecDir ? normalized.startsWith(state.tospecDir) : normalized.includes('/tospec/')
310
+ }
311
+
312
+ function describeStatus(status: HubStatus): string {
313
+ switch (status) {
314
+ case 'connected':
315
+ return `Registered with the tospec dashboard at ${state.hubUrl} (project ${state.project ?? '?'}).`
316
+ case 'connecting':
317
+ return `Connecting to the tospec dashboard at ${state.hubUrl}.`
318
+ case 'hub-offline':
319
+ return `No tospec dashboard answered at ${state.hubUrl}; retrying every ${RETRY_MS / 1000}s. Start one with: tospec dashboard start`
320
+ case 'unregistered':
321
+ return `Not registered with the tospec dashboard. Run /${COMMAND} register to register.`
322
+ case 'error':
323
+ return `Could not register with the tospec dashboard: ${state.detail || 'unknown error'}. Run /${COMMAND} register to retry.`
324
+ }
325
+ }
326
+
327
+ async function registerCommand($: EngineInterface): Promise<string> {
328
+ state.optedOut = false
329
+ state.ended = false
330
+ // Already registered: push now, so the dashboard shows the files as they are.
331
+ if (state.project) {
332
+ await push($)
333
+ return describeStatus(await read($, STATUS))
334
+ }
335
+ await setStatus($, 'connecting')
336
+ const attempt = attemptRegistration($)
337
+ // The loop joins this attempt and then polls; wake it if it is waiting out
338
+ // a retry, or start it if an unregister or a CLI failure ended it.
339
+ if (state.looping) state.wake?.()
340
+ else ensureLoop($)
341
+ await attempt
342
+ return describeStatus(await read($, STATUS))
343
+ }
344
+
345
+ async function unregisterCommand($: EngineInterface): Promise<string> {
346
+ if (state.optedOut) return describeStatus('unregistered')
347
+ state.optedOut = true
348
+ state.pushTimer?.cancel()
349
+ // A loop waiting out a retry ends now; one in a poll ends when it returns,
350
+ // which the leave below makes immediate.
351
+ state.wake?.()
352
+ const project = state.project
353
+ state.project = undefined
354
+ if (project) await leave($, project, await $.session.id())
355
+ await setStatus($, 'unregistered')
356
+ return project
357
+ ? `Unregistered from the tospec dashboard at ${state.hubUrl}. It keeps this project's last snapshot; run /${COMMAND} register to register again.`
358
+ : `Stopped registering with the tospec dashboard. Run /${COMMAND} register to register again.`
359
+ }
360
+
361
+ async function start($: EngineInterface): Promise<void> {
362
+ state.ended = false
363
+ state.hubUrl = ((await $.env.get('TOSPEC_DASHBOARD_URL')) || DEFAULT_HUB_URL).replace(/\/+$/, '')
364
+ state.optedOut = (await read($, STATUS)) === 'unregistered'
365
+ ensureLoop($)
366
+ // After the loop: a command that cannot be added must not cost the session
367
+ // its registration.
368
+ try {
369
+ await $.command.register({
370
+ name: COMMAND,
371
+ description: 'Show, register or unregister this session with the tospec dashboard hub',
372
+ argumentHint: '[status|register|unregister]',
373
+ immediate: true,
374
+ })
375
+ } catch (err) {
376
+ debug($, `could not add /${COMMAND}: ${String(err)}`)
377
+ }
378
+ }
379
+
380
+ async function end($: EngineInterface, reason: string, session: string): Promise<void> {
381
+ // A /clear ends the conversation, not the process: the loop goes on under
382
+ // the next session id, which every request reads afresh.
383
+ if (reason !== 'clear') state.ended = true
384
+ state.pushTimer?.cancel()
385
+ if (state.project) await leave($, state.project, session)
386
+ }
387
+
388
+ // --- session activity ----------------------------------------------------------
389
+
390
+ /**
391
+ * What this session is doing, for the dashboard: its status (working from a
392
+ * turn's start to its end), the tool it is running, and a short log of
393
+ * prompts, replies and tool calls. Also lets the dashboard answer the session:
394
+ * an AskUserQuestion and a permission both race the terminal's own dialog,
395
+ * and whichever is answered first withdraws the other.
396
+ *
397
+ * Everything here is best effort: a hub that is down or refuses costs the
398
+ * session nothing, and every question falls back to the terminal's own dialog.
399
+ */
400
+
401
+ type ActivityEvent = { kind: string; text?: string; tool?: string; target?: string; ok?: boolean }
402
+ type AskAnswer = { kind: 'question'; answers: Record<string, string> } | { kind: 'permission'; allow: boolean }
403
+ type AskWait = { answer: AskAnswer } | { pending: true } | { closed: string }
404
+
405
+ // The hub keeps 500 characters of an event too; cutting here saves the bytes.
406
+ const TEXT_LIMIT = 500
407
+ const TARGET_LIMIT = 160
408
+ // Reports in a burst (a turn's start, its prompt, its first tool) go as one.
409
+ const FLUSH_MS = 250
410
+
411
+ // Module state: a reload starts it over, losing at most one unsent report.
412
+ const pending = {
413
+ status: undefined as 'working' | 'idle' | undefined,
414
+ action: undefined as string | null | undefined,
415
+ events: [] as ActivityEvent[],
416
+ timer: undefined as { cancel: () => void } | undefined,
417
+ root: undefined as string | undefined,
418
+ }
419
+
420
+ /**
421
+ * Permissions open on the dashboard, by `<tool> <target>`: the hook that
422
+ * opened one may not hear that the terminal answered it, but the tool call it
423
+ * belongs to always ends, and withdraws it then.
424
+ */
425
+ const permissionAsks = new Map<string, string>()
426
+
427
+ /** Takes a permission off the dashboard: the terminal settled it. */
428
+ function withdrawPermission($: EngineInterface, key: string): void {
429
+ const id = permissionAsks.get(key)
430
+ if (!id) return
431
+ permissionAsks.delete(key)
432
+ void post($, '/api/mod/ask/close', { id })
433
+ }
434
+
435
+ function clip(text: string, max: number): string {
436
+ const flat = text.trim()
437
+ return flat.length > max ? flat.slice(0, max - 1) + '…' : flat
438
+ }
439
+
440
+ function textOf(content: readonly unknown[]): string {
441
+ return content
442
+ .map((block) => {
443
+ const b = block as { type?: string; text?: unknown }
444
+ return b.type === 'text' && typeof b.text === 'string' ? b.text : ''
445
+ })
446
+ .filter(Boolean)
447
+ .join('\n')
448
+ }
449
+
450
+ /** A file path as the project names it: relative to the session's root when inside it. */
451
+ function relative(file: string): string {
452
+ const normalized = file.replace(/\\/g, '/')
453
+ const root = pending.root?.replace(/\\/g, '/').replace(/\/+$/, '')
454
+ if (root && normalized.toLowerCase().startsWith(root.toLowerCase() + '/')) return normalized.slice(root.length + 1)
455
+ return normalized
456
+ }
457
+
458
+ /** The one argument that says what a tool call is about. */
459
+ function describeTarget(tool: string, input: Record<string, unknown>): string {
460
+ const pick = (...fields: string[]): string => {
461
+ for (const field of fields) if (typeof input[field] === 'string' && input[field]) return input[field] as string
462
+ return ''
463
+ }
464
+ let target: string
465
+ if (tool === 'Bash' || tool === 'PowerShell') target = pick('command').split(/\r?\n/)[0] ?? ''
466
+ else if (['Read', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(tool)) target = relative(pick('file_path', 'notebook_path'))
467
+ else if (tool === 'WebFetch') target = pick('url')
468
+ else target = pick('pattern', 'query', 'description', 'skill', 'prompt')
469
+ return clip(target, TARGET_LIMIT)
470
+ }
471
+
472
+ function report($: EngineInterface, change: Partial<Pick<typeof pending, 'status' | 'action'>>, event?: ActivityEvent): void {
473
+ if ('status' in change) pending.status = change.status
474
+ if ('action' in change) pending.action = change.action
475
+ if (event) pending.events.push(event)
476
+ if (pending.timer) return
477
+ pending.timer = $.clock.after(FLUSH_MS, () => {
478
+ pending.timer = undefined
479
+ void flush($)
480
+ })
481
+ }
482
+
483
+ async function flush($: EngineInterface): Promise<void> {
484
+ const body: Record<string, unknown> = { events: pending.events.splice(0) }
485
+ if (pending.status) body.status = pending.status
486
+ if (pending.action !== undefined) body.action = pending.action
487
+ pending.status = undefined
488
+ pending.action = undefined
489
+ const project = state.project
490
+ // Not registered: nobody is watching this session, so the report goes nowhere.
491
+ if (!project) return
492
+ await post($, '/api/mod/activity', { project, session: await $.session.id(), ...body })
493
+ }
494
+
495
+ async function postJson<T>($: EngineInterface, path: string, body: unknown): Promise<T | undefined> {
496
+ const res = await post($, path, body)
497
+ if (!res || res.status !== 200) return undefined
498
+ try {
499
+ return JSON.parse(res.text) as T
500
+ } catch {
501
+ return undefined
502
+ }
503
+ }
504
+
505
+ /**
506
+ * Waits for the dashboard's answer, one held request at a time. A hook's own
507
+ * time budget does not run while a `$` call is in flight, so this may wait as
508
+ * long as the person takes; a timer of its own would spend the budget.
509
+ */
510
+ async function waitForAnswer($: EngineInterface, id: string): Promise<AskAnswer | undefined> {
511
+ for (;;) {
512
+ const result = await postJson<AskWait>($, '/api/mod/ask/wait', { id })
513
+ if (!result || 'closed' in result) return undefined
514
+ if ('answer' in result) return result.answer
515
+ }
516
+ }
517
+
518
+ async function openAsk($: EngineInterface, ask: unknown): Promise<{ id: string | null; hold: boolean } | undefined> {
519
+ const project = state.project
520
+ if (!project) return undefined
521
+ return postJson($, '/api/mod/ask', { project, session: await $.session.id(), ask })
522
+ }
523
+
524
+ /** From the mod's `session.start`: tool targets are named relative to this root. */
525
+ async function startActivity($: EngineInterface): Promise<void> {
526
+ pending.root = await $.session.root()
527
+ }
528
+
529
+ /** From the mod's `turn.complete`, for the main loop's turn. */
530
+ function reportTurnEnd($: EngineInterface): void {
531
+ report($, { status: 'idle', action: null })
532
+ }
533
+
534
+ /**
535
+ * From the mod's `tool.call`, around the call: the main loop's tool shows as
536
+ * the session's action while it runs and as a log line once it ends; an
537
+ * AskUserQuestion is put to the dashboard as well as the terminal.
538
+ */
539
+ async function aroundToolCall<E extends { tool: unknown; agentId?: string }, R extends { deny?: unknown; isError?: unknown }>(
540
+ $: EngineInterface,
541
+ e: E,
542
+ next: (e: E) => Promise<R>
543
+ ): Promise<R> {
544
+ if (e.agentId !== undefined) return next(e)
545
+ const tool = String(e.tool)
546
+ const input = e as unknown as Record<string, unknown>
547
+ if (tool === 'AskUserQuestion') return askQuestion($, input, () => next(e))
548
+ const target = describeTarget(tool, input)
549
+ report($, { action: `${tool} ${target}`.trim() })
550
+ const result = await next(e)
551
+ // Allowed or denied, the call is past its permission: a dashboard question
552
+ // still open for it was answered in the terminal.
553
+ withdrawPermission($, `${tool} ${target}`)
554
+ const ok = result.deny === undefined && !result.isError
555
+ report($, { action: null }, { kind: 'tool', tool, target, ok })
556
+ return result
557
+ }
558
+
559
+ /**
560
+ * AskUserQuestion: the terminal dialog and the dashboard both ask, and the
561
+ * first answer stands. Answering from the dashboard returns the result while
562
+ * the dialog is still open, which closes it; answering in the terminal
563
+ * withdraws the question from the dashboard.
564
+ */
565
+ async function askQuestion<R>(
566
+ $: EngineInterface,
567
+ input: Record<string, unknown>,
568
+ dialog: () => Promise<R>
569
+ ): Promise<R> {
570
+ const opened = await openAsk($, { kind: 'question', questions: input.questions })
571
+ if (!opened?.id) return dialog()
572
+ const id = opened.id
573
+ const terminal = dialog().then(
574
+ (result) => ({ from: 'terminal' as const, result }),
575
+ (error: unknown) => ({ from: 'terminal' as const, error })
576
+ )
577
+ const remote = opened.hold
578
+ ? waitForAnswer($, id).then((answer) => (answer?.kind === 'question' ? { from: 'dashboard' as const, answer } : new Promise<never>(() => {})))
579
+ : new Promise<never>(() => {})
580
+ const first = await Promise.race([terminal, remote])
581
+ if (first.from === 'dashboard') {
582
+ return { result: { questions: input.questions, answers: first.answer.answers } } as R
583
+ }
584
+ void post($, '/api/mod/ask/close', { id })
585
+ if ('error' in first) throw first.error
586
+ return first.result
587
+ }
588
+
589
+ // --- registration ----------------------------------------------------------------
590
+
591
+ export const register: Register = (on) => {
592
+ on('session.start', async ($, e, next) => {
593
+ const started = await next(e)
594
+ await startActivity($)
595
+ await start($)
596
+ return started
597
+ })
598
+
599
+ on('command.run', { command: COMMAND }, async ($, e) => {
600
+ // A run that passes no arguments can leave `args` out altogether.
601
+ const action = (e.args ?? '').trim().toLowerCase() || 'status'
602
+ if (action === 'register') return { text: await registerCommand($) }
603
+ if (action === 'unregister') return { text: await unregisterCommand($) }
604
+ if (action === 'status') return { text: describeStatus(await read($, STATUS)) }
605
+ return { text: `Usage: /${COMMAND} [status|register|unregister]` }
606
+ })
607
+
608
+ // The footer's right side: the engine's own mode labels, plus this one.
609
+ on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
610
+ const label = FOOTER_LABEL[await read($, STATUS)]
611
+ return next({ ...e, props: { ...e.props, modes: [...e.props.modes, label] } })
612
+ })
613
+
614
+ on('tool.call', async ($, e, next) => {
615
+ const result = await aroundToolCall($, e, next)
616
+ // A refused or failed edit changed nothing worth a push.
617
+ const changed = result.deny === undefined && !result.isError
618
+ const input = e as unknown as Record<string, unknown>
619
+ const file = input.file_path ?? input.notebook_path
620
+ if (changed && WRITE_TOOLS.has(String(e.tool)) && typeof file === 'string' && touchesTospec(file)) {
621
+ schedulePush($)
622
+ }
623
+ return result
624
+ })
625
+
626
+ // Catches what the edit hook cannot see: tospec commands run through Bash
627
+ // (archive, new change), and anything else that touched the files.
628
+ on('turn.complete', async ($, e, next) => {
629
+ const result = await next(e)
630
+ // A subagent's turn belongs to the main turn, which pushes when it ends.
631
+ if (e.agentId === undefined) {
632
+ schedulePush($)
633
+ reportTurnEnd($)
634
+ }
635
+ return result
636
+ })
637
+
638
+ on('session.end', async ($, e, next) => {
639
+ await end($, e.reason, e.sessionId)
640
+ return next(e)
641
+ })
642
+
643
+ on('turn.start', async ($, e, next) => {
644
+ report($, { status: 'working', action: null })
645
+ return next(e)
646
+ })
647
+
648
+ // The person's prompts and the model's replies, main conversation only: a
649
+ // subagent's rows are its own conversation, summed up by its tool call.
650
+ on('session.append', async ($, e, next) => {
651
+ if (e.agentId === undefined && !e.message.isMeta) {
652
+ const text = textOf(e.message.content)
653
+ if (text && e.door === 'prompt' && e.message.role === 'user') {
654
+ report($, {}, { kind: 'prompt', text: clip(text, TEXT_LIMIT) })
655
+ } else if (text && e.door === 'response' && e.message.role === 'assistant') {
656
+ report($, {}, { kind: 'reply', text: clip(text, TEXT_LIMIT) })
657
+ }
658
+ }
659
+ return next(e)
660
+ })
661
+
662
+ // Runs alongside the terminal's permission dialog, which does not wait for
663
+ // it: the dashboard offers the same question, and the first answer stands.
664
+ on('classic.PermissionRequest', async ($, e, next) => {
665
+ const beneath = await next(e)
666
+ // A settings hook already decided.
667
+ if (beneath.decision) return beneath
668
+ const target = describeTarget(e.tool_name, (e.tool_input ?? {}) as Record<string, unknown>)
669
+ const opened = await openAsk($, { kind: 'permission', tool: e.tool_name, target })
670
+ if (!opened?.id) return beneath
671
+ const key = `${e.tool_name} ${target}`
672
+ permissionAsks.set(key, opened.id)
673
+ // A read-only dashboard shows the question but cannot answer it.
674
+ if (!opened.hold) return beneath
675
+ // The terminal's dialog is open at the same time. When the person answers
676
+ // there first, this dispatch is abandoned: stop waiting and withdraw the
677
+ // question, so the dashboard cannot answer it a second time.
678
+ const abandoned = new Promise<undefined>((resolve) => {
679
+ if (next.signal.aborted) resolve(undefined)
680
+ else next.signal.addEventListener('abort', () => resolve(undefined), { once: true })
681
+ })
682
+ const answer = await Promise.race([waitForAnswer($, opened.id), abandoned])
683
+ if (answer?.kind !== 'permission') {
684
+ withdrawPermission($, key)
685
+ return beneath
686
+ }
687
+ permissionAsks.delete(key)
688
+ return {
689
+ ...beneath,
690
+ decision: answer.allow
691
+ ? { behavior: 'allow' as const }
692
+ : { behavior: 'deny' as const, message: 'Denied from the tospec dashboard.' },
693
+ }
694
+ })
695
+ }