dsh-live-trace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +920 -0
  3. package/README.zh.md +790 -0
  4. package/assets/rain.ogg +0 -0
  5. package/bin/dsh-glyph-probe.js +51 -0
  6. package/bin/dsh-live-trace.js +29 -0
  7. package/bin/dsh-live-working.js +14 -0
  8. package/cordis.patch.yml +31 -0
  9. package/icon.svg +12 -0
  10. package/index.js +328 -0
  11. package/lib/client.js +178 -0
  12. package/lib/instance.js +68 -0
  13. package/lib/normalize.js +850 -0
  14. package/lib/paths.js +66 -0
  15. package/lib/protocol.js +115 -0
  16. package/lib/registry.js +232 -0
  17. package/lib/tools.js +257 -0
  18. package/lib/tracker.js +648 -0
  19. package/lib/transport.js +231 -0
  20. package/locale/en.json +6 -0
  21. package/locale/zh.json +6 -0
  22. package/package.json +94 -0
  23. package/picture/call1.png +0 -0
  24. package/picture/call2.png +0 -0
  25. package/picture/sleep1.png +0 -0
  26. package/picture/sleep2.png +0 -0
  27. package/picture/tui1.png +0 -0
  28. package/picture/tui2.png +0 -0
  29. package/picture/type1.png +0 -0
  30. package/picture/type2.png +0 -0
  31. package/scripts/bench-render.mjs +69 -0
  32. package/scripts/demo-working.mjs +130 -0
  33. package/scripts/demo.mjs +284 -0
  34. package/scripts/install-profile.mjs +174 -0
  35. package/scripts/mock-provider.mjs +211 -0
  36. package/src/cli/cellsize.js +120 -0
  37. package/src/cli/format.js +73 -0
  38. package/src/cli/highlight.js +932 -0
  39. package/src/cli/i18n.js +457 -0
  40. package/src/cli/main.js +630 -0
  41. package/src/cli/markdown.js +753 -0
  42. package/src/cli/renderer.js +1044 -0
  43. package/src/cli/screen.js +270 -0
  44. package/src/cli/theme.js +221 -0
  45. package/src/cli/view-state.js +396 -0
  46. package/src/cli/views.js +406 -0
  47. package/src/cli/width.js +337 -0
  48. package/src/cli/working/art.js +413 -0
  49. package/src/cli/working/main.js +569 -0
  50. package/src/cli/working/packing.js +159 -0
  51. package/src/cli/working/picker.js +75 -0
  52. package/src/cli/working/props.js +385 -0
  53. package/src/cli/working/scene.js +837 -0
  54. package/src/cli/working/sky.js +641 -0
  55. package/src/cli/working/sound.js +400 -0
  56. package/src/cli/working/state.js +528 -0
Binary file
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Print a sample sheet for each way a terminal cell can carry pixels.
4
+ *
5
+ * Whether your font has these glyphs decides what resolution is available, and
6
+ * a missing glyph does not error — it silently substitutes a box or a blank.
7
+ * Run this and look.
8
+ */
9
+ import { cellsForWindow, queryTerminalSize } from '../src/cli/cellsize.js'
10
+ import { PACKINGS, probe } from '../src/cli/working/packing.js'
11
+
12
+ const argv = process.argv.slice(2)
13
+ if (argv.includes('--help') || argv.includes('-h')) {
14
+ process.stdout.write('usage: dsh-glyph-probe [--raw]\n\n')
15
+ process.stdout.write('Prints a sample of half-block, quadrant and braille glyphs.\n')
16
+ process.stdout.write('--raw write the characters without colour\n')
17
+ process.exit(0)
18
+ }
19
+
20
+ const dim = (text) => `\u001b[38;5;245m${text}\u001b[0m`
21
+
22
+ // How big the cells really are decides how much can be drawn at all, so ask.
23
+ const geometry = await queryTerminalSize({ stdin: process.stdin, stdout: process.stdout })
24
+ const cols = process.stdout.columns ?? 80
25
+ const rows = process.stdout.rows ?? 24
26
+ process.stdout.write('\n')
27
+ if (geometry.cell === undefined) {
28
+ process.stdout.write('The terminal did not report its cell size (CSI 16t); most do not.\n')
29
+ process.stdout.write(`Known: ${cols}x${rows} cells.\n`)
30
+ } else {
31
+ const { width, height } = geometry.cell
32
+ const here = cellsForWindow(geometry.cell, geometry.textArea ?? { width: width * cols, height: height * rows })
33
+ const smaller = cellsForWindow({ width: Math.max(1, Math.round(width / 2)), height: Math.max(1, Math.round(height / 2)) }, geometry.textArea ?? { width: width * cols, height: height * rows })
34
+ process.stdout.write(`Cell size ${width}x${height} px (aspect ${(width / height).toFixed(2)})\n`)
35
+ process.stdout.write(`Cells ${cols}x${rows}\n`)
36
+ process.stdout.write(`Drawing budget ${here.artPixels.toLocaleString()} pixels (two per cell)\n`)
37
+ process.stdout.write(`Half the font ${smaller.cols}x${smaller.rows} cells -> ${smaller.artPixels.toLocaleString()} pixels\n`)
38
+ process.stdout.write('\nA terminal will not change its own font: there is no escape sequence.\n')
39
+ process.stdout.write('Shrink it yourself and this number is what you gain.\n')
40
+ }
41
+
42
+
43
+ for (const packing of Object.values(PACKINGS)) {
44
+ process.stdout.write(`\n${dim(`── ${packing.name} ─ ${packing.cols}x${packing.rows} ─ ${packing.square ? 'square' : 'non-square'} pixels ─ ${packing.solid ? 'solid' : 'dotted'} glyphs`)}\n\n`)
45
+ const { rows } = probe(packing)
46
+ for (const row of rows) process.stdout.write(`${row}\n`)
47
+ process.stdout.write('\n')
48
+ }
49
+
50
+ process.stdout.write('Compare the three discs. The one that comes out round and seamless is the one\n')
51
+ process.stdout.write('your font supports; a row of boxes or question marks means it does not.\n')
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `dsh-live-trace` executable.
4
+ *
5
+ * Thin wrapper: resolve the version, hand the arguments to the CLI, and map the
6
+ * returned code onto the process exit status.
7
+ */
8
+
9
+ import { readFileSync } from 'node:fs'
10
+
11
+ import { run } from '../src/cli/main.js'
12
+
13
+ const version = (() => {
14
+ try {
15
+ return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version ?? '0.0.0'
16
+ } catch {
17
+ return '0.0.0'
18
+ }
19
+ })()
20
+
21
+ run(process.argv.slice(2), { version }).then(
22
+ (code) => {
23
+ process.exitCode = code
24
+ },
25
+ (error) => {
26
+ process.stderr.write(`dsh-live-trace: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`)
27
+ process.exitCode = 1
28
+ }
29
+ )
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `dsh-live-working` — the orca at its desk, animated for the live session.
4
+ */
5
+
6
+ import { run } from '../src/cli/working/main.js'
7
+
8
+ const code = await run({
9
+ stdout: process.stdout,
10
+ stdin: process.stdin,
11
+ stderr: process.stderr,
12
+ argv: process.argv.slice(2)
13
+ })
14
+ process.exit(code)
@@ -0,0 +1,31 @@
1
+ # Bundle patch for dsh-live-trace.
2
+ #
3
+ # Inserting this row adds the Host-side observer to whichever profile loads the
4
+ # bundle. It is inert until the standalone `dsh-live-trace` viewer connects:
5
+ # the plugin only publishes normalized session events on a local socket, so it
6
+ # never changes what the agent does.
7
+ - insert:
8
+ - id: dsh-live-trace
9
+ name: dsh-live-trace
10
+ config:
11
+ # Set to false to keep the observer from starting at all.
12
+ enabled: true
13
+ # How often coalesced streaming text is pushed, in milliseconds.
14
+ streamIntervalMs: 500
15
+ # Recent normalized entries retained per session for late-joining viewers.
16
+ backlogSize: 2000
17
+ # Command output retained per call, for the commands panel and tool blocks.
18
+ outputLines: 200
19
+ outputChars: 20000
20
+ # Emit the rendered system prompt as a trace entry (noisy; off by default).
21
+ showSystemMessages: false
22
+ # Emit request/header and request/context bookkeeping as trace entries.
23
+ showRequestMetadata: false
24
+ # Emit unrecognized event types as generic entries instead of dropping
25
+ # them. Off by default: most unknown events are plugin bookkeeping.
26
+ showUnknownEvents: false
27
+ # Event types to hide; `*` is a prefix match. Built in:
28
+ # ['session-log-deepseek/*'].
29
+ # mutedEventTypes:
30
+ # - 'session-log-deepseek/*'
31
+ # - 'some-plugin/*'
package/icon.svg ADDED
@@ -0,0 +1,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-label="dsh-live-trace">
2
+ <rect x="2" y="6" width="60" height="52" rx="6" fill="none" stroke="#4cc9f0" stroke-width="3"/>
3
+ <line x1="2" y1="18" x2="62" y2="18" stroke="#4cc9f0" stroke-width="3"/>
4
+ <line x1="2" y1="48" x2="62" y2="48" stroke="#4cc9f0" stroke-width="3"/>
5
+ <circle cx="10" cy="12" r="2" fill="#4cc9f0"/>
6
+ <rect x="10" y="24" width="14" height="3" rx="1.5" fill="#4cc9f0"/>
7
+ <rect x="28" y="24" width="22" height="3" rx="1.5" fill="#4cc9f0" opacity="0.55"/>
8
+ <rect x="16" y="31" width="30" height="3" rx="1.5" fill="#f9c74f"/>
9
+ <rect x="16" y="38" width="20" height="3" rx="1.5" fill="#43aa8b"/>
10
+ <rect x="10" y="53" width="9" height="3" rx="1.5" fill="#4cc9f0" opacity="0.8"/>
11
+ <rect x="23" y="53" width="18" height="3" rx="1.5" fill="#4cc9f0" opacity="0.45"/>
12
+ </svg>
package/index.js ADDED
@@ -0,0 +1,328 @@
1
+ /**
2
+ * dsh-live-trace — Host-side observer plugin.
3
+ *
4
+ * The plugin subscribes to the Harness event bus in-process, normalizes what it
5
+ * sees into flat display records, and publishes them on a Unix domain socket.
6
+ * It is strictly read-only: it never appends a session event, never changes a
7
+ * tool decision, never touches the model request, and never writes to stdout
8
+ * (which belongs to whatever surface the Harness is driving). The standalone
9
+ * `dsh-live-trace` viewer is a separate process that renders those records in
10
+ * a different terminal.
11
+ *
12
+ * Cleanup is owned by the plugin's Cordis fiber: every `ctx.on` registration
13
+ * returns a disposer, every timer is cleared, the socket is closed and unlinked,
14
+ * and the discovery record is removed. Unloading the plugin leaves nothing
15
+ * behind.
16
+ *
17
+ * @module dsh-live-trace
18
+ */
19
+
20
+ import { readFileSync } from 'node:fs'
21
+ import { resolve } from 'node:path'
22
+
23
+ import { disconnectExistingInstance, registerInstance, releaseInstance } from './lib/instance.js'
24
+ import { resolveRuntimeDir, serversDir, socketsDir } from './lib/paths.js'
25
+ import { DEFAULT_MUTED_EVENT_TYPES } from './lib/normalize.js'
26
+ import { MAX_REPLAY_ENTRIES, PROTOCOL_VERSION, SERVER_KIND } from './lib/protocol.js'
27
+ import { removeServerRecordIfOwner, writeServerRecord } from './lib/registry.js'
28
+ import { TraceHub } from './lib/tracker.js'
29
+ import { createTraceServer, defaultSocketPath, DEFAULT_REPLAY_LIMIT, HEARTBEAT_MS } from './lib/transport.js'
30
+
31
+ /** Cordis plugin name. */
32
+ export const name = 'dsh-live-trace'
33
+
34
+ /** This package's version, read lazily so the manifest stays the single source of truth. */
35
+ export const version = (() => {
36
+ try {
37
+ return JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version ?? '0.0.0'
38
+ } catch {
39
+ return '0.0.0'
40
+ }
41
+ })()
42
+
43
+ /** Plugin defaults; every one is overridable from `cordis.patch.yml`. */
44
+ export const CONFIG_DEFAULTS = {
45
+ enabled: true,
46
+ streamIntervalMs: 500,
47
+ backlogSize: 2000,
48
+ heartbeatMs: HEARTBEAT_MS,
49
+ replayLimit: DEFAULT_REPLAY_LIMIT,
50
+ showSystemMessages: false,
51
+ showRequestMetadata: false,
52
+ showUnknownEvents: false,
53
+ mutedEventTypes: undefined,
54
+ textLimit: undefined,
55
+ outputLines: undefined,
56
+ outputChars: undefined,
57
+ runtimeDir: undefined,
58
+ socketPath: undefined
59
+ }
60
+
61
+ /**
62
+ * Coerce the loader's config row into a validated options object.
63
+ *
64
+ * The plugin deliberately declares no `Config` schema: the observer must load
65
+ * even in a profile that cannot resolve the schema package, and a mistyped
66
+ * option should degrade to its default rather than refuse to start.
67
+ *
68
+ * @param {unknown} config
69
+ * @returns {typeof CONFIG_DEFAULTS}
70
+ */
71
+ export function resolveConfig(config) {
72
+ const raw = config !== null && typeof config === 'object' ? /** @type {any} */ (config) : {}
73
+ return {
74
+ enabled: raw.enabled !== false,
75
+ streamIntervalMs: clampInt(raw.streamIntervalMs, CONFIG_DEFAULTS.streamIntervalMs, 50, 60_000),
76
+ backlogSize: clampInt(raw.backlogSize, CONFIG_DEFAULTS.backlogSize, 10, 100_000),
77
+ heartbeatMs: clampInt(raw.heartbeatMs, CONFIG_DEFAULTS.heartbeatMs, 500, 300_000),
78
+ replayLimit: clampInt(raw.replayLimit, CONFIG_DEFAULTS.replayLimit, 1, MAX_REPLAY_ENTRIES),
79
+ showSystemMessages: raw.showSystemMessages === true,
80
+ showRequestMetadata: raw.showRequestMetadata === true,
81
+ showUnknownEvents: raw.showUnknownEvents === true,
82
+ mutedEventTypes: resolveMutedEventTypes(raw.mutedEventTypes),
83
+ textLimit: clampInt(raw.textLimit, undefined, 120, 100_000),
84
+ outputLines: clampInt(raw.outputLines, undefined, 1, 100_000),
85
+ outputChars: clampInt(raw.outputChars, undefined, 200, 1_000_000),
86
+ runtimeDir: typeof raw.runtimeDir === 'string' && raw.runtimeDir.trim().length > 0 ? resolve(raw.runtimeDir) : undefined,
87
+ socketPath: typeof raw.socketPath === 'string' && raw.socketPath.trim().length > 0 ? resolve(raw.socketPath) : undefined
88
+ }
89
+ }
90
+
91
+ /** Accept an override list, or keep the built-in bookkeeping filter. */
92
+ function resolveMutedEventTypes(value) {
93
+ if (value === undefined) return DEFAULT_MUTED_EVENT_TYPES
94
+ if (Array.isArray(value)) return value.filter((entry) => typeof entry === 'string')
95
+ // `false` turns the filter off entirely, which is what debugging wants.
96
+ if (value === false || value === null) return []
97
+ return DEFAULT_MUTED_EVENT_TYPES
98
+ }
99
+
100
+ function clampInt(value, fallback, min, max) {
101
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
102
+ return Math.max(min, Math.min(max, Math.floor(value)))
103
+ }
104
+
105
+ /** Resolve a logger without assuming the Cordis logger service exists. */
106
+ function makeLogger(ctx) {
107
+ const noop = { info() {}, warn() {}, error() {}, debug() {} }
108
+ try {
109
+ const service = ctx?.logger ?? ctx?.get?.('logger')
110
+ if (typeof service === 'function') {
111
+ const scoped = service('dsh-live-trace')
112
+ if (scoped !== null && typeof scoped === 'object') return scoped
113
+ }
114
+ if (service !== null && typeof service === 'object' && typeof service.warn === 'function') return service
115
+ } catch {
116
+ /* fall through to the no-op logger */
117
+ }
118
+ return noop
119
+ }
120
+
121
+ /**
122
+ * Cordis plugin entry.
123
+ *
124
+ * @param {any} ctx plugin fiber context
125
+ * @param {object} [config] the `cordis.patch.yml` row's `config`
126
+ * @returns {any} an effect disposer so Cordis owns teardown
127
+ */
128
+ export function apply(ctx, config) {
129
+ const options = resolveConfig(config)
130
+ if (options.enabled === false) return undefined
131
+
132
+ const logger = makeLogger(ctx)
133
+ const runtimeDir = options.runtimeDir ?? resolveRuntimeDir()
134
+ const socketPath = options.socketPath ?? defaultSocketPath(runtimeDir, process.pid)
135
+ const startedAt = Date.now()
136
+
137
+ const hub = new TraceHub({
138
+ backlogSize: options.backlogSize,
139
+ streamIntervalMs: options.streamIntervalMs,
140
+ normalizeOptions: {
141
+ showSystemMessages: options.showSystemMessages,
142
+ showRequestMetadata: options.showRequestMetadata,
143
+ showUnknownEvents: options.showUnknownEvents,
144
+ mutedEventTypes: options.mutedEventTypes,
145
+ ...(options.textLimit === undefined ? {} : { textLimit: options.textLimit }),
146
+ ...(options.outputLines === undefined && options.outputChars === undefined
147
+ ? {}
148
+ : {
149
+ outputLimits: {
150
+ ...(options.outputLines === undefined ? {} : { maxLines: options.outputLines }),
151
+ ...(options.outputChars === undefined ? {} : { maxChars: options.outputChars })
152
+ }
153
+ })
154
+ }
155
+ })
156
+
157
+ return ctx.effect(async () => {
158
+ /** @type {Array<() => void>} */
159
+ const disposers = []
160
+ /** @type {ReturnType<typeof createTraceServer> | null} */
161
+ let server = null
162
+ /** @type {NodeJS.Timeout | null} */
163
+ let flushTimer = null
164
+ /** @type {NodeJS.Timeout | null} */
165
+ let registryTimer = null
166
+
167
+ const listen = (event, handler) => {
168
+ try {
169
+ const dispose = ctx.on(event, handler)
170
+ if (typeof dispose === 'function') disposers.push(dispose)
171
+ } catch (error) {
172
+ logger.warn?.(`could not subscribe to ${event}: ${describe(error)}`)
173
+ }
174
+ }
175
+
176
+ listen('session/created', (session) => {
177
+ safe(() => hub.onSessionCreated(session, 'live'))
178
+ })
179
+ listen('session/disposed', (session) => {
180
+ safe(() => hub.onSessionDisposed(session))
181
+ })
182
+ listen('session/event', (session, event) => {
183
+ safe(() => hub.onSessionEvent(session, event))
184
+ })
185
+ listen('agent/created', (payload) => {
186
+ safe(() => hub.onAgentCreated(payload?.agent, payload?.source))
187
+ })
188
+ listen('agent/status', (payload) => {
189
+ safe(() => hub.onAgentStatus(payload?.agent, payload?.status))
190
+ })
191
+ listen('agent/assistant-stream', (payload) => {
192
+ safe(() => hub.onAssistantStream(payload?.agent, payload?.frame))
193
+ })
194
+ listen('agent/error', (payload) => {
195
+ safe(() => hub.onAgentError(payload))
196
+ })
197
+
198
+ // Adopt sessions that already exist, so a viewer attached before the next
199
+ // event still sees the current session list.
200
+ try {
201
+ const sessions = ctx.get?.('sessions')
202
+ for (const session of sessions?.list?.() ?? []) hub.onSessionCreated(session, 'startup')
203
+ } catch (error) {
204
+ logger.debug?.(`session list unavailable at startup: ${describe(error)}`)
205
+ }
206
+
207
+ // Coalesced streaming text is pushed on a fixed cadence instead of per
208
+ // chunk, so a fast model cannot flood the viewer.
209
+ flushTimer = setInterval(() => {
210
+ safe(() => hub.flushStreams())
211
+ }, Math.max(50, Math.min(options.streamIntervalMs, 250)))
212
+ flushTimer.unref?.()
213
+
214
+ const serverInfo = () => ({
215
+ pid: process.pid,
216
+ version,
217
+ protocol: PROTOCOL_VERSION,
218
+ profile: process.env.DSH_PROFILE ?? null,
219
+ cwd: process.cwd(),
220
+ runtimeDir,
221
+ socket: socketPath,
222
+ startedAt,
223
+ hubActive: true
224
+ })
225
+
226
+ const publishRegistry = () => {
227
+ try {
228
+ writeServerRecord(serversDir(runtimeDir), {
229
+ ...serverInfo(),
230
+ heartbeat: Date.now(),
231
+ activeSessionId: hub.activeSessionId,
232
+ sessions: hub.sessionsInfo()
233
+ })
234
+ } catch (error) {
235
+ logger.debug?.(`could not write discovery record: ${describe(error)}`)
236
+ }
237
+ }
238
+
239
+ try {
240
+ // A previous instance in this same process (hot reload) must release the
241
+ // socket path before the new one binds it.
242
+ await disconnectExistingInstance(process.pid)
243
+ server = createTraceServer({
244
+ socketPath,
245
+ runtimeDir,
246
+ hub,
247
+ serverInfo,
248
+ heartbeatMs: options.heartbeatMs,
249
+ onError: (error) => logger.warn?.(`socket error: ${describe(error)}`)
250
+ })
251
+ await server.ready
252
+ registerInstance(process.pid, server)
253
+ publishRegistry()
254
+ // A session appearing or disappearing is exactly when a viewer's
255
+ // discovery view must change, so refresh the record immediately rather
256
+ // than waiting for the next heartbeat.
257
+ disposers.push(
258
+ hub.subscribe((record) => {
259
+ if (record.kind === SERVER_KIND.SESSIONS) publishRegistry()
260
+ })
261
+ )
262
+ registryTimer = setInterval(publishRegistry, Math.max(1000, Math.floor(options.heartbeatMs / 2)))
263
+ registryTimer.unref?.()
264
+ logger.info?.(`observing on ${socketPath}`)
265
+ } catch (error) {
266
+ logger.warn?.(`observer transport unavailable: ${describe(error)}`)
267
+ if (server !== null) {
268
+ try {
269
+ await server.close()
270
+ } catch {
271
+ /* already unusable */
272
+ }
273
+ server = null
274
+ }
275
+ }
276
+
277
+ return async () => {
278
+ for (const dispose of disposers.reverse()) {
279
+ try {
280
+ dispose()
281
+ } catch {
282
+ /* a partially registered listener is not a teardown failure */
283
+ }
284
+ }
285
+ disposers.length = 0
286
+ if (flushTimer !== null) clearInterval(flushTimer)
287
+ if (registryTimer !== null) clearInterval(registryTimer)
288
+ flushTimer = null
289
+ registryTimer = null
290
+ if (server !== null) {
291
+ const owned = server
292
+ try {
293
+ await owned.close()
294
+ } catch (error) {
295
+ logger.debug?.(`socket close reported ${describe(error)}`)
296
+ }
297
+ // Only now is the in-process registration safe to drop: close() is what
298
+ // releases the socket, and the identity guard keeps a stale disposer
299
+ // from evicting a newer generation that already registered.
300
+ releaseInstance(process.pid, owned)
301
+ server = null
302
+ }
303
+ try {
304
+ removeServerRecordIfOwner(serversDir(runtimeDir), process.pid, socketPath)
305
+ } catch {
306
+ /* nothing to remove */
307
+ }
308
+ logger.info?.('observer stopped')
309
+ }
310
+ }, 'dsh-live-trace')
311
+ }
312
+
313
+ /** Run a hub mutation without letting an observer bug escape into the Harness. */
314
+ function safe(run) {
315
+ try {
316
+ run()
317
+ } catch {
318
+ /* observation must never disturb the observed */
319
+ }
320
+ }
321
+
322
+ function describe(error) {
323
+ return error instanceof Error ? error.message : String(error)
324
+ }
325
+
326
+ export { TraceHub } from './lib/tracker.js'
327
+ export { normalizeSessionEvent } from './lib/normalize.js'
328
+ export { socketsDir, serversDir, resolveRuntimeDir }
package/lib/client.js ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * The viewer-side socket client: connects to the observer's socket, decodes
3
+ * records, and reconnects automatically when the Harness restarts.
4
+ *
5
+ * @module dsh-live-trace/client
6
+ */
7
+
8
+ import { connect } from 'node:net'
9
+
10
+ import { CLIENT_KIND, createLineDecoder, encodeRecord, SERVER_KIND } from './protocol.js'
11
+
12
+ const RETRY_MIN_MS = 250
13
+ const RETRY_MAX_MS = 4000
14
+
15
+ /**
16
+ * Connect to a running observer.
17
+ *
18
+ * `onRecord` receives every decoded server record, including the initial
19
+ * `hello`. `onOpen` fires on every successful connection and again after a
20
+ * reconnect, so the caller can reset derived view state.
21
+ *
22
+ * @param {object} options
23
+ * @param {string} options.socketPath
24
+ * @param {string | null} [options.sessionId] desired session; `null` follows the server default
25
+ * @param {number} [options.replayLimit]
26
+ * @param {(record: any) => void} options.onRecord
27
+ * @param {(record: any) => void} [options.onOpen]
28
+ * @param {(info: { willRetry: boolean }) => void} [options.onClose]
29
+ * @param {(error: Error) => void} [options.onError]
30
+ * @param {boolean} [options.retry]
31
+ * @returns {{ close: () => void, select: (sessionId: string | null) => void, replay: (limit?: number) => void, isOpen: () => boolean, socketPath: string }}
32
+ */
33
+ export function connectTrace(options) {
34
+ const {
35
+ socketPath,
36
+ replayLimit,
37
+ onRecord,
38
+ onOpen,
39
+ onClose,
40
+ onError,
41
+ retry = true,
42
+ retryMinMs = RETRY_MIN_MS,
43
+ retryMaxMs = RETRY_MAX_MS
44
+ } = options
45
+
46
+ let desiredSession = options.sessionId ?? null
47
+ let boundSession = null
48
+ let selectPending = false
49
+ let socket = null
50
+ let decoder = createLineDecoder()
51
+ let closedByCaller = false
52
+ let retryDelay = retryMinMs
53
+ let timer = null
54
+ let open = false
55
+
56
+ const send = (record) => {
57
+ if (socket === null || socket.destroyed) return
58
+ try {
59
+ socket.write(encodeRecord(record))
60
+ } catch {
61
+ /* the close handler owns recovery */
62
+ }
63
+ }
64
+
65
+ /** Ask the server to bind us to its own default session. */
66
+ const requestDefault = () => {
67
+ if (closedByCaller || selectPending) return
68
+ selectPending = true
69
+ send({ v: 1, kind: CLIENT_KIND.SELECT, limit: replayLimit })
70
+ }
71
+
72
+ const scheduleRetry = () => {
73
+ if (closedByCaller || retry === false || timer !== null) return
74
+ timer = setTimeout(() => {
75
+ timer = null
76
+ attempt()
77
+ }, retryDelay)
78
+ timer.unref?.()
79
+ retryDelay = Math.min(retryMaxMs, Math.round(retryDelay * 1.7))
80
+ }
81
+
82
+ const handle = (record) => {
83
+ const isSelectionAnswer =
84
+ record !== null &&
85
+ typeof record === 'object' &&
86
+ (record.kind === SERVER_KIND.SESSIONS || record.kind === SERVER_KIND.ERROR) &&
87
+ Array.isArray(record.sessions)
88
+ if (isSelectionAnswer) {
89
+ // `sessions` records are the authoritative answer to a selection: they
90
+ // carry the list, the server's default, and the binding this viewer
91
+ // actually holds. The opening `hello` is deliberately not one of them:
92
+ // treating it as an answer would clear the pending flag and duplicate the
93
+ // selection, which shows up as a replayed backlog.
94
+ selectPending = false
95
+ const list = record.sessions
96
+ const reported = record.boundSessionId === undefined ? null : (record.boundSessionId ?? null)
97
+ const wanted = desiredSession ?? reported
98
+ if (record.kind === SERVER_KIND.ERROR || (wanted !== null && !list.some((session) => session.id === wanted))) {
99
+ // The session being followed is gone, or the server refused the one
100
+ // that was requested: fall back to following the server's default.
101
+ desiredSession = null
102
+ boundSession = null
103
+ } else {
104
+ boundSession = wanted
105
+ }
106
+ // A viewer with no binding keeps asking until one exists, so a dashboard
107
+ // opened before the first session still starts following it.
108
+ if (boundSession === null && desiredSession === null && list.length > 0) requestDefault()
109
+ }
110
+ onRecord(record)
111
+ }
112
+
113
+ function attempt() {
114
+ if (closedByCaller) return
115
+ decoder = createLineDecoder()
116
+ const connection = connect(socketPath)
117
+ socket = connection
118
+ connection.setNoDelay(true)
119
+ connection.setEncoding('utf8')
120
+
121
+ connection.on('connect', () => {
122
+ open = true
123
+ retryDelay = retryMinMs
124
+ boundSession = null
125
+ selectPending = true
126
+ send({ v: 1, kind: CLIENT_KIND.SELECT, sessionId: desiredSession ?? undefined, limit: replayLimit })
127
+ onOpen?.({ socketPath })
128
+ })
129
+
130
+ connection.on('data', (chunk) => {
131
+ let records
132
+ try {
133
+ records = decoder.push(chunk)
134
+ } catch (error) {
135
+ onError?.(/** @type {Error} */ (error))
136
+ connection.destroy()
137
+ return
138
+ }
139
+ for (const record of records) handle(record)
140
+ })
141
+
142
+ connection.on('error', (error) => {
143
+ onError?.(/** @type {Error} */ (error))
144
+ })
145
+
146
+ connection.on('close', () => {
147
+ const wasOpen = open
148
+ open = false
149
+ socket = null
150
+ boundSession = null
151
+ selectPending = false
152
+ if (closedByCaller) return
153
+ onClose?.({ willRetry: retry !== false })
154
+ if (wasOpen) retryDelay = retryMinMs
155
+ scheduleRetry()
156
+ })
157
+ }
158
+
159
+ attempt()
160
+
161
+ return {
162
+ socketPath,
163
+ isOpen: () => open,
164
+ select(sessionId) {
165
+ desiredSession = sessionId
166
+ selectPending = true
167
+ send({ v: 1, kind: CLIENT_KIND.SELECT, sessionId: sessionId ?? undefined, limit: replayLimit })
168
+ },
169
+ replay(limit) {
170
+ send({ v: 1, kind: CLIENT_KIND.REPLAY, limit })
171
+ },
172
+ close() {
173
+ closedByCaller = true
174
+ if (timer !== null) clearTimeout(timer)
175
+ if (socket !== null) socket.destroy()
176
+ }
177
+ }
178
+ }