@seanmars/tospec 0.23.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.
- package/CHANGELOG.md +40 -0
- package/README.md +17 -3
- package/assets/claude-mod/tospec-dashboard/.claude-plugin/plugin.json +7 -0
- package/assets/claude-mod/tospec-dashboard/hooks/hooks.json +1 -0
- package/assets/claude-mod/tospec-dashboard/hooks/register.ts +695 -0
- package/assets/claude-mod/tospec-dashboard/tests/register.test.ts +448 -0
- package/assets/claude-mod/tospec-dashboard/types/index.d.ts +8 -0
- package/assets/dashboard/app.js +218 -1180
- package/assets/dashboard/index.html +31 -22
- package/assets/dashboard/lib/charts.js +355 -0
- package/assets/dashboard/lib/derive.js +369 -0
- package/assets/dashboard/lib/dom.js +121 -0
- package/assets/dashboard/lib/routes.js +52 -0
- package/assets/dashboard/lib/store.js +324 -0
- package/assets/dashboard/style.css +3103 -664
- package/assets/dashboard/views/alerts.js +170 -0
- package/assets/dashboard/views/change.js +232 -0
- package/assets/dashboard/views/docs.js +214 -0
- package/assets/dashboard/views/home.js +357 -0
- package/assets/dashboard/views/project.js +168 -0
- package/assets/dashboard/views/session.js +270 -0
- package/assets/dashboard/views/sidebar.js +131 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +74 -95
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/dashboard-relay.d.ts +11 -0
- package/dist/commands/dashboard-relay.d.ts.map +1 -0
- package/dist/commands/dashboard-relay.js +88 -0
- package/dist/commands/dashboard-relay.js.map +1 -0
- package/dist/commands/dashboard.d.ts +62 -102
- package/dist/commands/dashboard.d.ts.map +1 -1
- package/dist/commands/dashboard.js +375 -609
- package/dist/commands/dashboard.js.map +1 -1
- package/dist/core/dashboard-hub.d.ts +258 -0
- package/dist/core/dashboard-hub.d.ts.map +1 -0
- package/dist/core/dashboard-hub.js +643 -0
- package/dist/core/dashboard-hub.js.map +1 -0
- package/dist/core/dashboard-routes.d.ts +56 -0
- package/dist/core/dashboard-routes.d.ts.map +1 -0
- package/dist/core/dashboard-routes.js +329 -0
- package/dist/core/dashboard-routes.js.map +1 -0
- package/dist/core/init.d.ts.map +1 -1
- package/dist/core/init.js +4 -1
- package/dist/core/init.js.map +1 -1
- package/dist/core/local-server.d.ts +1 -1
- package/dist/core/local-server.js +1 -1
- package/dist/core/shared/dashboard-mod.d.ts +24 -0
- package/dist/core/shared/dashboard-mod.d.ts.map +1 -0
- package/dist/core/shared/dashboard-mod.js +44 -0
- package/dist/core/shared/dashboard-mod.js.map +1 -0
- package/dist/core/shared/index.d.ts +1 -0
- package/dist/core/shared/index.d.ts.map +1 -1
- package/dist/core/shared/index.js +1 -0
- package/dist/core/shared/index.js.map +1 -1
- package/dist/core/update.d.ts.map +1 -1
- package/dist/core/update.js +4 -1
- package/dist/core/update.js.map +1 -1
- package/package.json +3 -2
- 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
|
+
}
|