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.
- package/LICENSE +21 -0
- package/README.md +920 -0
- package/README.zh.md +790 -0
- package/assets/rain.ogg +0 -0
- package/bin/dsh-glyph-probe.js +51 -0
- package/bin/dsh-live-trace.js +29 -0
- package/bin/dsh-live-working.js +14 -0
- package/cordis.patch.yml +31 -0
- package/icon.svg +12 -0
- package/index.js +328 -0
- package/lib/client.js +178 -0
- package/lib/instance.js +68 -0
- package/lib/normalize.js +850 -0
- package/lib/paths.js +66 -0
- package/lib/protocol.js +115 -0
- package/lib/registry.js +232 -0
- package/lib/tools.js +257 -0
- package/lib/tracker.js +648 -0
- package/lib/transport.js +231 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +94 -0
- package/picture/call1.png +0 -0
- package/picture/call2.png +0 -0
- package/picture/sleep1.png +0 -0
- package/picture/sleep2.png +0 -0
- package/picture/tui1.png +0 -0
- package/picture/tui2.png +0 -0
- package/picture/type1.png +0 -0
- package/picture/type2.png +0 -0
- package/scripts/bench-render.mjs +69 -0
- package/scripts/demo-working.mjs +130 -0
- package/scripts/demo.mjs +284 -0
- package/scripts/install-profile.mjs +174 -0
- package/scripts/mock-provider.mjs +211 -0
- package/src/cli/cellsize.js +120 -0
- package/src/cli/format.js +73 -0
- package/src/cli/highlight.js +932 -0
- package/src/cli/i18n.js +457 -0
- package/src/cli/main.js +630 -0
- package/src/cli/markdown.js +753 -0
- package/src/cli/renderer.js +1044 -0
- package/src/cli/screen.js +270 -0
- package/src/cli/theme.js +221 -0
- package/src/cli/view-state.js +396 -0
- package/src/cli/views.js +406 -0
- package/src/cli/width.js +337 -0
- package/src/cli/working/art.js +413 -0
- package/src/cli/working/main.js +569 -0
- package/src/cli/working/packing.js +159 -0
- package/src/cli/working/picker.js +75 -0
- package/src/cli/working/props.js +385 -0
- package/src/cli/working/scene.js +837 -0
- package/src/cli/working/sky.js +641 -0
- package/src/cli/working/sound.js +400 -0
- package/src/cli/working/state.js +528 -0
package/assets/rain.ogg
ADDED
|
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)
|
package/cordis.patch.yml
ADDED
|
@@ -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
|
+
}
|