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/lib/paths.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path resolution shared by the Host plugin and the standalone viewer.
|
|
3
|
+
*
|
|
4
|
+
* Both halves must agree on where the plugin publishes its sockets and its
|
|
5
|
+
* server registry, so resolution lives here rather than in either half.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-live-trace/paths
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
import { join, resolve } from 'node:path'
|
|
12
|
+
|
|
13
|
+
/** Environment variable that overrides the DeepSeek Harness home. */
|
|
14
|
+
export const DSH_HOME_ENV = 'DSH_HOME'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Environment variable that overrides the runtime directory.
|
|
18
|
+
*
|
|
19
|
+
* The plugin writes its sockets and server registry here and the viewer reads
|
|
20
|
+
* them from here. Setting it on both sides lets a test, a sandboxed shell, or a
|
|
21
|
+
* second Harness home keep its live-trace state isolated without touching
|
|
22
|
+
* `$DSH_HOME`.
|
|
23
|
+
*/
|
|
24
|
+
export const RUNTIME_DIR_ENV = 'DSH_LIVE_TRACE_DIR'
|
|
25
|
+
|
|
26
|
+
/** Expand `~`, `~/`, and `~\` against the operating-system home. */
|
|
27
|
+
export function expandHomePath(path) {
|
|
28
|
+
if (path === '~') return homedir()
|
|
29
|
+
if (path.startsWith('~/') || path.startsWith('~\\')) return join(homedir(), path.slice(2))
|
|
30
|
+
return path
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the Harness home with the same precedence the Harness itself uses:
|
|
35
|
+
* `$DSH_HOME`, then `~/.dsh`. A blank override counts as unset.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveDshHome(env = process.env) {
|
|
38
|
+
const configured = env[DSH_HOME_ENV]
|
|
39
|
+
if (typeof configured === 'string' && configured.trim().length > 0) {
|
|
40
|
+
return resolve(expandHomePath(configured.trim()))
|
|
41
|
+
}
|
|
42
|
+
return join(homedir(), '.dsh')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the directory holding live-trace runtime state.
|
|
47
|
+
* @param {Record<string, string | undefined>} [env]
|
|
48
|
+
* @returns {string} absolute runtime directory (not necessarily created yet).
|
|
49
|
+
*/
|
|
50
|
+
export function resolveRuntimeDir(env = process.env) {
|
|
51
|
+
const configured = env[RUNTIME_DIR_ENV]
|
|
52
|
+
if (typeof configured === 'string' && configured.trim().length > 0) {
|
|
53
|
+
return resolve(expandHomePath(configured.trim()))
|
|
54
|
+
}
|
|
55
|
+
return join(resolveDshHome(env), 'live-trace')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Subdirectory holding one socket file per running Harness process. */
|
|
59
|
+
export function socketsDir(runtimeDir) {
|
|
60
|
+
return join(runtimeDir, 'sockets')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Subdirectory holding one server-registry JSON record per running Harness process. */
|
|
64
|
+
export function serversDir(runtimeDir) {
|
|
65
|
+
return join(runtimeDir, 'servers')
|
|
66
|
+
}
|
package/lib/protocol.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire protocol between the Host observer plugin and the standalone
|
|
3
|
+
* `dsh-live-trace` viewer.
|
|
4
|
+
*
|
|
5
|
+
* Transport is newline-delimited JSON over a Unix domain socket. Every record
|
|
6
|
+
* is a plain JSON object with a `v` (protocol version) and a `kind`. The
|
|
7
|
+
* viewer treats unknown kinds as forward-compatible noise, and the plugin
|
|
8
|
+
* ignores unknown client kinds: neither side can break the other by being
|
|
9
|
+
* newer.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-live-trace/protocol
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Protocol version carried on every record in both directions. */
|
|
15
|
+
export const PROTOCOL_VERSION = 1
|
|
16
|
+
|
|
17
|
+
/** Record kinds the plugin sends to a viewer. */
|
|
18
|
+
export const SERVER_KIND = {
|
|
19
|
+
/** First record on every connection: server identity, sessions, backlog policy. */
|
|
20
|
+
HELLO: 'hello',
|
|
21
|
+
/** The full session list changed (a session was created or disposed). */
|
|
22
|
+
SESSIONS: 'sessions',
|
|
23
|
+
/** One normalized trace entry for the selected session. */
|
|
24
|
+
ENTRY: 'entry',
|
|
25
|
+
/** Coalesced live streaming text for the selected session. */
|
|
26
|
+
STREAM: 'stream',
|
|
27
|
+
/** The live stream for the selected session settled or was abandoned. */
|
|
28
|
+
STREAM_END: 'stream-end',
|
|
29
|
+
/** Liveness/status change for the selected session. */
|
|
30
|
+
STATUS: 'status',
|
|
31
|
+
/** Cumulative token accounting for the selected session. */
|
|
32
|
+
USAGE: 'usage',
|
|
33
|
+
/** The session's aggregated file changes, one entry per touched path. */
|
|
34
|
+
EDITS: 'edits',
|
|
35
|
+
/** Periodic keepalive; carries server clock and connection count. */
|
|
36
|
+
HEARTBEAT: 'heartbeat',
|
|
37
|
+
/** A protocol-level problem the viewer should surface. */
|
|
38
|
+
ERROR: 'error'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Record kinds a viewer sends to the plugin. */
|
|
42
|
+
export const CLIENT_KIND = {
|
|
43
|
+
/** Bind to a session id, or to the server's current default when omitted. */
|
|
44
|
+
SELECT: 'select',
|
|
45
|
+
/** Ask for the retained backlog of the bound session. */
|
|
46
|
+
REPLAY: 'replay',
|
|
47
|
+
/** Application-level ping; the plugin answers with a heartbeat. */
|
|
48
|
+
PING: 'ping'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** How many normalized entries a viewer may request in one replay. */
|
|
52
|
+
export const MAX_REPLAY_ENTRIES = 5000
|
|
53
|
+
|
|
54
|
+
/** Longest single NDJSON line either side will buffer before failing the connection. */
|
|
55
|
+
export const MAX_LINE_BYTES = 8 * 1024 * 1024
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Serialize one record as an NDJSON line.
|
|
59
|
+
* @param {object} record
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
export function encodeRecord(record) {
|
|
63
|
+
return `${JSON.stringify(record)}\n`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build an incremental NDJSON decoder.
|
|
68
|
+
*
|
|
69
|
+
* Sockets deliver arbitrary byte splits, so the decoder buffers until it sees a
|
|
70
|
+
* newline and rejects a single over-long line instead of growing without bound.
|
|
71
|
+
* @returns {{ push(chunk: string | Buffer): object[], pending(): number }}
|
|
72
|
+
*/
|
|
73
|
+
export function createLineDecoder() {
|
|
74
|
+
let buffer = ''
|
|
75
|
+
return {
|
|
76
|
+
push(chunk) {
|
|
77
|
+
buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8')
|
|
78
|
+
const out = []
|
|
79
|
+
let index = buffer.indexOf('\n')
|
|
80
|
+
while (index !== -1) {
|
|
81
|
+
const line = buffer.slice(0, index)
|
|
82
|
+
buffer = buffer.slice(index + 1)
|
|
83
|
+
const trimmed = line.endsWith('\r') ? line.slice(0, -1) : line
|
|
84
|
+
if (trimmed.trim().length > 0) {
|
|
85
|
+
let parsed
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(trimmed)
|
|
88
|
+
} catch {
|
|
89
|
+
throw new Error(`dsh-live-trace: malformed JSON record (${trimmed.length} bytes)`)
|
|
90
|
+
}
|
|
91
|
+
out.push(parsed)
|
|
92
|
+
}
|
|
93
|
+
index = buffer.indexOf('\n')
|
|
94
|
+
}
|
|
95
|
+
if (buffer.length > MAX_LINE_BYTES) {
|
|
96
|
+
buffer = ''
|
|
97
|
+
throw new Error(`dsh-live-trace: record exceeded ${MAX_LINE_BYTES} bytes`)
|
|
98
|
+
}
|
|
99
|
+
return out
|
|
100
|
+
},
|
|
101
|
+
pending() {
|
|
102
|
+
return buffer.length
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @returns {boolean} whether a decoded record is one this module understands. */
|
|
108
|
+
export function isKnownRecord(record, kinds) {
|
|
109
|
+
return (
|
|
110
|
+
record !== null &&
|
|
111
|
+
typeof record === 'object' &&
|
|
112
|
+
typeof record.kind === 'string' &&
|
|
113
|
+
Object.values(kinds).includes(record.kind)
|
|
114
|
+
)
|
|
115
|
+
}
|
package/lib/registry.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery registry for running Harness processes that host the observer.
|
|
3
|
+
*
|
|
4
|
+
* The plugin owns one socket per process, but a viewer starts with no idea
|
|
5
|
+
* which process or socket to use. Each plugin instance therefore publishes a
|
|
6
|
+
* small JSON record (its pid, socket path, working directory, and session
|
|
7
|
+
* list) and refreshes a heartbeat; the viewer scans that directory, drops dead
|
|
8
|
+
* processes, and picks the best match.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-live-trace/registry
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
|
|
16
|
+
import { PROTOCOL_VERSION } from './protocol.js'
|
|
17
|
+
import { serversDir } from './paths.js'
|
|
18
|
+
|
|
19
|
+
/** A record older than this (by heartbeat) is treated as abandoned. */
|
|
20
|
+
export const STALE_HEARTBEAT_MS = 60_000
|
|
21
|
+
|
|
22
|
+
/** Registry records are tiny; anything larger is corruption. */
|
|
23
|
+
const MAX_RECORD_BYTES = 1024 * 1024
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} dir servers directory
|
|
27
|
+
* @param {number} pid
|
|
28
|
+
* @returns {string} absolute path of one process's registry record
|
|
29
|
+
*/
|
|
30
|
+
export function serverRecordPath(dir, pid) {
|
|
31
|
+
return join(dir, `${pid}.json`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} dir servers directory
|
|
36
|
+
* @param {object} record
|
|
37
|
+
* @returns {string} the path written
|
|
38
|
+
*/
|
|
39
|
+
export function writeServerRecord(dir, record) {
|
|
40
|
+
mkdirSync(dir, { recursive: true })
|
|
41
|
+
const target = serverRecordPath(dir, record.pid)
|
|
42
|
+
const temp = `${target}.${process.pid}.tmp`
|
|
43
|
+
writeFileSync(temp, `${JSON.stringify({ ...record, v: PROTOCOL_VERSION })}\n`, { mode: 0o600 })
|
|
44
|
+
renameSync(temp, target)
|
|
45
|
+
return target
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {string} dir servers directory
|
|
50
|
+
* @param {number} pid
|
|
51
|
+
*/
|
|
52
|
+
export function removeServerRecord(dir, pid) {
|
|
53
|
+
rmSync(serverRecordPath(dir, pid), { force: true })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Remove a registry record only when it still describes `socket`.
|
|
58
|
+
*
|
|
59
|
+
* A reloaded plugin generation and a late disposer of the previous generation
|
|
60
|
+
* share one pid, so an unconditional delete could remove the live generation's
|
|
61
|
+
* record.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} dir servers directory
|
|
64
|
+
* @param {number} pid
|
|
65
|
+
* @param {string} socket socket path the caller owns
|
|
66
|
+
* @returns {boolean} whether a record was removed
|
|
67
|
+
*/
|
|
68
|
+
export function removeServerRecordIfOwner(dir, pid, socket) {
|
|
69
|
+
const path = serverRecordPath(dir, pid)
|
|
70
|
+
try {
|
|
71
|
+
const record = JSON.parse(readFileSync(path, 'utf8'))
|
|
72
|
+
if (record !== null && typeof record === 'object' && record.socket !== socket) return false
|
|
73
|
+
} catch {
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
rmSync(path, { force: true })
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Read every parseable registry record from a directory.
|
|
82
|
+
* @param {string} dir servers directory
|
|
83
|
+
* @returns {object[]}
|
|
84
|
+
*/
|
|
85
|
+
export function readServerRecords(dir) {
|
|
86
|
+
let names
|
|
87
|
+
try {
|
|
88
|
+
names = readdirSync(dir)
|
|
89
|
+
} catch {
|
|
90
|
+
return []
|
|
91
|
+
}
|
|
92
|
+
const records = []
|
|
93
|
+
for (const name of names) {
|
|
94
|
+
if (!name.endsWith('.json')) continue
|
|
95
|
+
const path = join(dir, name)
|
|
96
|
+
try {
|
|
97
|
+
const raw = readFileSync(path, 'utf8')
|
|
98
|
+
if (raw.length > MAX_RECORD_BYTES) continue
|
|
99
|
+
const parsed = JSON.parse(raw)
|
|
100
|
+
if (parsed !== null && typeof parsed === 'object' && typeof parsed.pid === 'number') records.push(parsed)
|
|
101
|
+
} catch {
|
|
102
|
+
/* a half-written or corrupt record is not a reason to fail discovery */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return records
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {number} pid
|
|
110
|
+
* @returns {boolean} whether the process exists and is signalable by us.
|
|
111
|
+
*/
|
|
112
|
+
export function isProcessAlive(pid) {
|
|
113
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
114
|
+
try {
|
|
115
|
+
process.kill(pid, 0)
|
|
116
|
+
return true
|
|
117
|
+
} catch (error) {
|
|
118
|
+
// EPERM means the process exists but belongs to another user.
|
|
119
|
+
return error?.code === 'EPERM'
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Drop records whose process is gone and whose heartbeat has gone stale.
|
|
125
|
+
*
|
|
126
|
+
* A fresh heartbeat is trusted unconditionally, because only a live writer can
|
|
127
|
+
* refresh it. Liveness probing alone is not enough: a viewer running in a
|
|
128
|
+
* different PID namespace than the Harness — a container, WSL, or a sandboxed
|
|
129
|
+
* shell — cannot signal the Harness process, and treating that as "dead" would
|
|
130
|
+
* let a read-only discovery command delete a live server's record.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} dir servers directory
|
|
133
|
+
* @param {{ now?: number, staleMs?: number, removeFiles?: boolean }} [options]
|
|
134
|
+
* @returns {object[]} the surviving records, newest heartbeat first
|
|
135
|
+
*/
|
|
136
|
+
export function pruneServerRecords(dir, options = {}) {
|
|
137
|
+
const now = options.now ?? Date.now()
|
|
138
|
+
const staleMs = options.staleMs ?? STALE_HEARTBEAT_MS
|
|
139
|
+
const removeFiles = options.removeFiles ?? true
|
|
140
|
+
const surviving = []
|
|
141
|
+
for (const record of readServerRecords(dir)) {
|
|
142
|
+
const heartbeat = typeof record.heartbeat === 'number' ? record.heartbeat : record.startedAt ?? 0
|
|
143
|
+
const fresh = now - heartbeat <= staleMs
|
|
144
|
+
if (fresh || isProcessAlive(record.pid)) {
|
|
145
|
+
surviving.push(record)
|
|
146
|
+
} else if (removeFiles) {
|
|
147
|
+
// Best effort: a concurrent viewer on another account may lack permission.
|
|
148
|
+
try {
|
|
149
|
+
rmSync(serverRecordPath(dir, record.pid), { force: true })
|
|
150
|
+
} catch {
|
|
151
|
+
/* ignore */
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
surviving.sort((a, b) => (b.heartbeat ?? b.startedAt ?? 0) - (a.heartbeat ?? a.startedAt ?? 0))
|
|
156
|
+
return surviving
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Choose the Harness process a viewer should attach to.
|
|
161
|
+
*
|
|
162
|
+
* Precedence: an explicit socket wins outright; then an explicit pid; then the
|
|
163
|
+
* record whose serving directory matches `cwd`; then the freshest record.
|
|
164
|
+
*
|
|
165
|
+
* @param {object[]} records records from {@link pruneServerRecords}
|
|
166
|
+
* @param {{ socket?: string, pid?: number, cwd?: string }} [preferences]
|
|
167
|
+
* @returns {object | undefined}
|
|
168
|
+
*/
|
|
169
|
+
export function selectServerRecord(records, preferences = {}) {
|
|
170
|
+
if (preferences.socket !== undefined) {
|
|
171
|
+
const exact = records.find((record) => record.socket === preferences.socket)
|
|
172
|
+
if (exact !== undefined) return exact
|
|
173
|
+
// An explicit socket is usable even when no registry record describes it,
|
|
174
|
+
// which is what `--socket` promises and what a sandboxed run needs.
|
|
175
|
+
return { pid: -1, socket: preferences.socket, cwd: preferences.cwd, sessions: [], activeSessionId: null }
|
|
176
|
+
}
|
|
177
|
+
if (records.length === 0) return undefined
|
|
178
|
+
if (preferences.pid !== undefined) {
|
|
179
|
+
return records.find((record) => record.pid === preferences.pid) ?? freshest(records)
|
|
180
|
+
}
|
|
181
|
+
if (preferences.cwd !== undefined) {
|
|
182
|
+
const byCwd = records.find((record) => record.cwd === preferences.cwd)
|
|
183
|
+
if (byCwd !== undefined) return byCwd
|
|
184
|
+
}
|
|
185
|
+
return freshest(records)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** @returns {object} the record with the newest heartbeat, irrespective of input order. */
|
|
189
|
+
function freshest(records) {
|
|
190
|
+
return records.reduce((best, record) =>
|
|
191
|
+
(record.heartbeat ?? record.startedAt ?? 0) > (best.heartbeat ?? best.startedAt ?? 0) ? record : best
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Pick the session a viewer should bind to.
|
|
197
|
+
*
|
|
198
|
+
* Precedence: an explicit session id; then the session whose `cwd` matches the
|
|
199
|
+
* viewer's; then the record's own `activeSessionId`; then the newest session.
|
|
200
|
+
*
|
|
201
|
+
* @param {object} record a registry record
|
|
202
|
+
* @param {{ sessionId?: string, cwd?: string }} [preferences]
|
|
203
|
+
* @returns {object | undefined}
|
|
204
|
+
*/
|
|
205
|
+
export function selectSession(record, preferences = {}) {
|
|
206
|
+
const sessions = Array.isArray(record?.sessions) ? record.sessions : []
|
|
207
|
+
if (preferences.sessionId !== undefined) {
|
|
208
|
+
return sessions.find((session) => session.id === preferences.sessionId)
|
|
209
|
+
}
|
|
210
|
+
if (sessions.length === 0) return undefined
|
|
211
|
+
if (preferences.cwd !== undefined) {
|
|
212
|
+
const matches = sessions.filter((session) => session.cwd === preferences.cwd)
|
|
213
|
+
if (matches.length > 0) return newestSession(matches)
|
|
214
|
+
}
|
|
215
|
+
if (typeof record.activeSessionId === 'string') {
|
|
216
|
+
const active = sessions.find((session) => session.id === record.activeSessionId)
|
|
217
|
+
if (active !== undefined) return active
|
|
218
|
+
}
|
|
219
|
+
return newestSession(sessions)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** @returns {object} the most recently created session in a list */
|
|
223
|
+
export function newestSession(sessions) {
|
|
224
|
+
return sessions.reduce((best, session) =>
|
|
225
|
+
(session.createdAt ?? 0) >= (best.createdAt ?? 0) ? session : best
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Convenience wrapper resolving the standard servers directory. */
|
|
230
|
+
export function resolveServersDir(runtimeDir) {
|
|
231
|
+
return serversDir(runtimeDir)
|
|
232
|
+
}
|
package/lib/tools.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the two structured things a trace is most often read for:
|
|
3
|
+
* the commands the model ran (with their output and exit status) and the files
|
|
4
|
+
* it changed (with their diffs).
|
|
5
|
+
*
|
|
6
|
+
* Nothing here imports the Harness. The shell exit-status marker contract and
|
|
7
|
+
* the file-diff metadata shape are *mirrored* rather than imported so the
|
|
8
|
+
* observer keeps working across Harness versions and stays dependency-free; the
|
|
9
|
+
* mirroring is deliberately tolerant, and an unrecognized shape degrades to
|
|
10
|
+
* "no structured detail" instead of an error.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-live-trace/tools
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Tool names treated as shell commands. */
|
|
16
|
+
const SHELL_TOOLS = new Set([
|
|
17
|
+
'bash',
|
|
18
|
+
'sh',
|
|
19
|
+
'zsh',
|
|
20
|
+
'shell',
|
|
21
|
+
'pwsh',
|
|
22
|
+
'powershell',
|
|
23
|
+
'cmd',
|
|
24
|
+
'exec',
|
|
25
|
+
'terminal',
|
|
26
|
+
'bash_persistent',
|
|
27
|
+
'pwsh_persistent'
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
/** Default cap on retained command output, in lines and characters. */
|
|
31
|
+
export const OUTPUT_LINE_LIMIT = 200
|
|
32
|
+
export const OUTPUT_CHAR_LIMIT = 20_000
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {unknown} name
|
|
36
|
+
* @returns {boolean} whether a tool name denotes a shell command
|
|
37
|
+
*/
|
|
38
|
+
export function isShellTool(name) {
|
|
39
|
+
if (typeof name !== 'string' || name.length === 0) return false
|
|
40
|
+
const normalized = name.toLowerCase()
|
|
41
|
+
if (SHELL_TOOLS.has(normalized)) return true
|
|
42
|
+
// Persistent or namespaced variants such as `local:bash`.
|
|
43
|
+
const tail = normalized.split(/[:/.]/).pop() ?? normalized
|
|
44
|
+
return SHELL_TOOLS.has(tail)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Split a rendered shell result into its output body and exit status.
|
|
49
|
+
*
|
|
50
|
+
* Mirrors the marker contract the shell tools append: a trailing
|
|
51
|
+
* `[exit code: N]` or `[killed by signal: X]` on its own final line. Absent
|
|
52
|
+
* both markers means a clean exit 0.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} text
|
|
55
|
+
* @returns {{ body: string, exitCode?: number, signal?: string }}
|
|
56
|
+
*/
|
|
57
|
+
export function parseExitStatus(text) {
|
|
58
|
+
if (typeof text !== 'string' || text.length === 0) return { body: '', exitCode: 0 }
|
|
59
|
+
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
|
|
60
|
+
if (signal !== null && signal[1] !== undefined) return { body: text.slice(0, signal.index), signal: signal[1] }
|
|
61
|
+
const exit = /\n\[exit code: (\d+)\]$/.exec(text)
|
|
62
|
+
if (exit !== null && exit[1] !== undefined) return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) }
|
|
63
|
+
return { body: text, exitCode: 0 }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse a tool call's raw argument JSON without ever throwing.
|
|
68
|
+
* @param {unknown} raw
|
|
69
|
+
* @returns {Record<string, unknown> | undefined}
|
|
70
|
+
*/
|
|
71
|
+
export function parseArguments(raw) {
|
|
72
|
+
if (raw === null || raw === undefined) return undefined
|
|
73
|
+
if (typeof raw === 'object' && !Array.isArray(raw)) return /** @type {Record<string, unknown>} */ (raw)
|
|
74
|
+
if (typeof raw !== 'string' || raw.trim().length === 0) return undefined
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(raw)
|
|
77
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
78
|
+
? /** @type {Record<string, unknown>} */ (parsed)
|
|
79
|
+
: undefined
|
|
80
|
+
} catch {
|
|
81
|
+
return undefined
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Structured shell facts for one call, when the tool is a shell tool.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} name tool name
|
|
89
|
+
* @param {unknown} rawArguments the raw arguments JSON string
|
|
90
|
+
* @returns {{ command: string, description?: string, cwd?: string } | undefined}
|
|
91
|
+
*/
|
|
92
|
+
export function shellCallFrom(name, rawArguments) {
|
|
93
|
+
if (!isShellTool(name)) return undefined
|
|
94
|
+
const args = parseArguments(rawArguments)
|
|
95
|
+
const command = typeof args?.command === 'string' ? args.command : undefined
|
|
96
|
+
if (command === undefined || command.length === 0) return undefined
|
|
97
|
+
/** @type {{ command: string, description?: string, cwd?: string }} */
|
|
98
|
+
const shell = { command }
|
|
99
|
+
if (typeof args?.description === 'string' && args.description.length > 0) shell.description = args.description
|
|
100
|
+
if (typeof args?.cwd === 'string' && args.cwd.length > 0) shell.cwd = args.cwd
|
|
101
|
+
return shell
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A file-edit tool's target path, when the call names one.
|
|
106
|
+
* @param {unknown} rawArguments
|
|
107
|
+
* @returns {string | undefined}
|
|
108
|
+
*/
|
|
109
|
+
export function filePathFrom(rawArguments) {
|
|
110
|
+
const args = parseArguments(rawArguments)
|
|
111
|
+
if (args === undefined) return undefined
|
|
112
|
+
for (const key of ['file_path', 'path', 'filePath', 'filename']) {
|
|
113
|
+
const value = args[key]
|
|
114
|
+
if (typeof value === 'string' && value.length > 0) return value
|
|
115
|
+
}
|
|
116
|
+
return undefined
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A `write`-style call's file path and content, when the arguments carry both.
|
|
121
|
+
*
|
|
122
|
+
* A brand-new file has no prior text, so the tool reports an empty hunk list
|
|
123
|
+
* and the applied content exists only in the call arguments. Recovering it here
|
|
124
|
+
* is what lets a create show up as a whole-file addition instead of nothing.
|
|
125
|
+
*
|
|
126
|
+
* @param {unknown} rawArguments
|
|
127
|
+
* @param {string} [filePath]
|
|
128
|
+
* @returns {{ filePath: string, content: string } | undefined}
|
|
129
|
+
*/
|
|
130
|
+
export function writeCallFrom(rawArguments, filePath) {
|
|
131
|
+
const args = parseArguments(rawArguments)
|
|
132
|
+
if (args === undefined) return undefined
|
|
133
|
+
const content = args.content
|
|
134
|
+
if (typeof content !== 'string') return undefined
|
|
135
|
+
const path = filePath ?? (typeof args.file_path === 'string' ? args.file_path : undefined)
|
|
136
|
+
if (path === undefined || path.length === 0) return undefined
|
|
137
|
+
return { filePath: path, content }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Narrow opaque `tool/result` metadata into file diffs.
|
|
142
|
+
*
|
|
143
|
+
* Mirrors the `{ diffs: [{ path, oldText, newText }], operation? }` payload a
|
|
144
|
+
* file-writing tool attaches. Malformed metadata yields `undefined` so
|
|
145
|
+
* presentation falls back instead of throwing during replay.
|
|
146
|
+
*
|
|
147
|
+
* @param {unknown} meta
|
|
148
|
+
* @returns {{ path: string, oldText: string | null, newText: string }[] | undefined}
|
|
149
|
+
*/
|
|
150
|
+
export function narrowFileDiffs(meta) {
|
|
151
|
+
if (meta === null || typeof meta !== 'object') return undefined
|
|
152
|
+
const diffs = /** @type {Record<string, unknown>} */ (meta).diffs
|
|
153
|
+
if (!Array.isArray(diffs) || diffs.length === 0) return undefined
|
|
154
|
+
const out = []
|
|
155
|
+
for (const candidate of diffs) {
|
|
156
|
+
if (candidate === null || typeof candidate !== 'object') continue
|
|
157
|
+
const record = /** @type {Record<string, unknown>} */ (candidate)
|
|
158
|
+
if (typeof record.path !== 'string') continue
|
|
159
|
+
if (typeof record.newText !== 'string') continue
|
|
160
|
+
const oldText = record.oldText
|
|
161
|
+
if (oldText !== null && typeof oldText !== 'string') continue
|
|
162
|
+
out.push({ path: record.path, oldText: oldText ?? null, newText: record.newText })
|
|
163
|
+
}
|
|
164
|
+
return out.length > 0 ? out : undefined
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The operation a diff payload reports (`create` or `update`).
|
|
169
|
+
* @param {unknown} meta
|
|
170
|
+
* @returns {'create' | 'update' | undefined}
|
|
171
|
+
*/
|
|
172
|
+
export function diffOperationFrom(meta) {
|
|
173
|
+
if (meta === null || typeof meta !== 'object') return undefined
|
|
174
|
+
const operation = /** @type {Record<string, unknown>} */ (meta).operation
|
|
175
|
+
return operation === 'create' || operation === 'update' ? operation : undefined
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Count the added and removed lines across diff hunks.
|
|
180
|
+
*
|
|
181
|
+
* A hunk is a replaced region: `oldText === null` is a pure insertion. The
|
|
182
|
+
* counts are line-based so they match what a `git diff --numstat` reader
|
|
183
|
+
* expects.
|
|
184
|
+
*
|
|
185
|
+
* @param {{ oldText: string | null, newText: string }[]} hunks
|
|
186
|
+
* @returns {{ added: number, removed: number }}
|
|
187
|
+
*/
|
|
188
|
+
export function countDiffLines(hunks) {
|
|
189
|
+
let added = 0
|
|
190
|
+
let removed = 0
|
|
191
|
+
for (const hunk of hunks) {
|
|
192
|
+
if (hunk.newText.length > 0) added += hunk.newText.split('\n').length
|
|
193
|
+
if (typeof hunk.oldText === 'string' && hunk.oldText.length > 0) {
|
|
194
|
+
removed += hunk.oldText.split('\n').length
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { added, removed }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Flatten diff hunks into unified-diff display lines.
|
|
202
|
+
*
|
|
203
|
+
* Each hunk becomes a `@@` header followed by its context/removed/added lines,
|
|
204
|
+
* with a one-line gap marker between non-adjacent hunks so a reader can tell
|
|
205
|
+
* that content was elided.
|
|
206
|
+
*
|
|
207
|
+
* @param {{ path: string, oldText: string | null, newText: string }[]} hunks
|
|
208
|
+
* @returns {{ kind: 'hunk' | 'context' | 'add' | 'del' | 'gap' | 'meta', text: string }[]}
|
|
209
|
+
*/
|
|
210
|
+
export function diffLines(hunks) {
|
|
211
|
+
const out = []
|
|
212
|
+
hunks.forEach((hunk, index) => {
|
|
213
|
+
if (index > 0) out.push({ kind: 'gap', text: '⋮' })
|
|
214
|
+
out.push({ kind: 'hunk', text: `@@ ${hunk.path}${hunk.oldText === null ? ' (new file)' : ''}` })
|
|
215
|
+
if (typeof hunk.oldText === 'string' && hunk.oldText.length > 0) {
|
|
216
|
+
for (const line of hunk.oldText.split('\n')) out.push({ kind: 'del', text: line })
|
|
217
|
+
}
|
|
218
|
+
for (const line of hunk.newText.split('\n')) out.push({ kind: 'add', text: line })
|
|
219
|
+
})
|
|
220
|
+
return out
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Cap command output for display, reporting what was dropped.
|
|
225
|
+
*
|
|
226
|
+
* The tail is kept rather than the head: when a command fails, the interesting
|
|
227
|
+
* part is almost always the end.
|
|
228
|
+
*
|
|
229
|
+
* @param {string} text
|
|
230
|
+
* @param {{ maxLines?: number, maxChars?: number }} [options]
|
|
231
|
+
* @returns {{ text: string, lines: number, truncated: boolean, omitted: number }}
|
|
232
|
+
*/
|
|
233
|
+
export function capOutput(text, options = {}) {
|
|
234
|
+
const maxLines = options.maxLines ?? OUTPUT_LINE_LIMIT
|
|
235
|
+
const maxChars = options.maxChars ?? OUTPUT_CHAR_LIMIT
|
|
236
|
+
const source = typeof text === 'string' ? text : ''
|
|
237
|
+
if (source.length === 0) return { text: '', lines: 0, truncated: false, omitted: 0 }
|
|
238
|
+
let lines = source.split('\n')
|
|
239
|
+
let truncated = false
|
|
240
|
+
let omitted = 0
|
|
241
|
+
if (lines.length > maxLines) {
|
|
242
|
+
omitted = lines.length - maxLines
|
|
243
|
+
lines = lines.slice(lines.length - maxLines)
|
|
244
|
+
truncated = true
|
|
245
|
+
}
|
|
246
|
+
let body = lines.join('\n')
|
|
247
|
+
if (body.length > maxChars) {
|
|
248
|
+
const dropped = body.length - maxChars
|
|
249
|
+
// Count the extra lines folded into the character cut so `omitted` stays honest.
|
|
250
|
+
const extraLines = body.slice(0, dropped).split('\n').length - 1
|
|
251
|
+
omitted += extraLines
|
|
252
|
+
body = body.slice(dropped)
|
|
253
|
+
lines = body.split('\n')
|
|
254
|
+
truncated = true
|
|
255
|
+
}
|
|
256
|
+
return { text: body, lines: lines.length, truncated, omitted }
|
|
257
|
+
}
|