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
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
+ }
@@ -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
+ }
@@ -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
+ }