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
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Offline demo: run the real dashboard against a scripted trace, with no
4
+ * Harness and no model provider involved.
5
+ *
6
+ * This exists so the board can be seen (and its layout checked) in a second,
7
+ * without installing the plugin anywhere. It drives the same `TraceHub` and
8
+ * `createTraceServer` the Host plugin uses, so what appears on screen is the
9
+ * production rendering path.
10
+ *
11
+ * node scripts/demo.mjs [--duration <ms>] [--no-color]
12
+ *
13
+ * Without `--duration` the board stays live until you press q in it.
14
+ */
15
+
16
+ import { spawn } from 'node:child_process'
17
+ import { mkdirSync, rmSync } from 'node:fs'
18
+ import { tmpdir } from 'node:os'
19
+ import { dirname, join } from 'node:path'
20
+ import { fileURLToPath } from 'node:url'
21
+
22
+ import { serversDir } from '../lib/paths.js'
23
+ import { writeServerRecord } from '../lib/registry.js'
24
+ import { TraceHub } from '../lib/tracker.js'
25
+ import { createTraceServer } from '../lib/transport.js'
26
+
27
+ const HERE = dirname(fileURLToPath(import.meta.url))
28
+
29
+ function parseArgs(argv) {
30
+ const options = { duration: undefined, color: true }
31
+ for (let index = 0; index < argv.length; index += 1) {
32
+ if (argv[index] === '--duration') options.duration = Number.parseInt(argv[index + 1], 10)
33
+ else if (argv[index] === '--no-color') options.color = false
34
+ }
35
+ return options
36
+ }
37
+
38
+ const options = parseArgs(process.argv.slice(2))
39
+ const runtimeDir = join(tmpdir(), `dsh-live-trace-demo-${process.pid}`)
40
+ const socketPath = join(runtimeDir, 'sockets', `${process.pid}.sock`)
41
+ const sessionId = 'session-6e3caa1a-3c38-4222-8d66-0bb3b8c30c45'
42
+ const startedAt = Date.now()
43
+ const session = { id: sessionId, header: { createdAt: startedAt, cwd: process.cwd(), agentPreset: 'standard' }, seq: 0 }
44
+
45
+ const hub = new TraceHub({ now: () => Date.now(), streamIntervalMs: 500, backlogSize: 500 })
46
+ const server = createTraceServer({
47
+ socketPath,
48
+ runtimeDir,
49
+ hub,
50
+ serverInfo: () => ({ pid: process.pid, version: 'demo', profile: 'demo', cwd: process.cwd() }),
51
+ heartbeatMs: 2000
52
+ })
53
+ await server.ready
54
+
55
+ mkdirSync(serversDir(runtimeDir), { recursive: true })
56
+ const publishRegistry = () => {
57
+ writeServerRecord(serversDir(runtimeDir), {
58
+ pid: process.pid,
59
+ socket: socketPath,
60
+ version: 'demo',
61
+ profile: 'demo',
62
+ cwd: process.cwd(),
63
+ startedAt,
64
+ heartbeat: Date.now(),
65
+ activeSessionId: sessionId,
66
+ sessions: [
67
+ { id: sessionId, createdAt: startedAt, cwd: process.cwd(), title: 'live trace demo', turn: 3, step: 2 }
68
+ ]
69
+ })
70
+ }
71
+ publishRegistry()
72
+
73
+ const child = spawn(
74
+ process.execPath,
75
+ [
76
+ join(HERE, '..', 'bin', 'dsh-live-trace.js'),
77
+ '--runtime-dir',
78
+ runtimeDir,
79
+ '--session',
80
+ sessionId,
81
+ // Force the full-screen board even when the demo's own stdout is a pipe,
82
+ // which is what a capture or a CI check needs.
83
+ '--alt-screen',
84
+ ...(options.color ? [] : ['--no-color'])
85
+ ],
86
+ { stdio: 'inherit' }
87
+ )
88
+
89
+ let finished = false
90
+ /** Tear down the viewer, the socket, and the temporary runtime directory. */
91
+ function finish() {
92
+ if (finished) return
93
+ finished = true
94
+ child.kill('SIGTERM')
95
+ server.close().finally(() => {
96
+ rmSync(runtimeDir, { recursive: true, force: true })
97
+ process.exit(0)
98
+ })
99
+ }
100
+ // Registered before the script runs: the viewer may quit at any moment.
101
+ child.on('exit', finish)
102
+ process.on('SIGINT', finish)
103
+ process.on('SIGTERM', finish)
104
+
105
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
106
+ let seq = 0
107
+ const appendEvent = (type, data) => hub.onSessionEvent(session, { type, seq: seq++, time: Date.now(), data })
108
+
109
+ async function script() {
110
+ hub.onSessionCreated(session, 'startup')
111
+ appendEvent('permission/preset', { preset: 'workspace-write' })
112
+ appendEvent('sandbox/mode', { mode: 'workspace-write' })
113
+ appendEvent('approval/policy', { policy: 'ask' })
114
+ hub.onAgentCreated({ options: { provider: 'deepseek-official', model: 'deepseek-flash' }, session }, 'startup')
115
+ appendEvent('turn/start', { turn: 3 })
116
+ await sleep(400)
117
+ appendEvent('step/start', { turn: 3, step: 1 })
118
+ appendEvent('session/title', { title: 'live trace demo', messageSeqs: [] })
119
+ appendEvent('user/message', {
120
+ id: 'u1',
121
+ role: 'user',
122
+ content: [{ type: 'text', text: '把登录逻辑抽到 src/auth.ts,并补上测试' }],
123
+ source: { kind: 'user' }
124
+ })
125
+ await sleep(300)
126
+
127
+ hub.onAssistantStream({ session }, { type: 'start', attemptId: 'attempt-1', revision: 1, turn: 3, step: 1 })
128
+ for (const text of ['先读一下现有的登录代码,', '确认调用点,', '再决定抽象边界…']) {
129
+ hub.onAssistantStream(
130
+ { session },
131
+ { type: 'chunk', attemptId: 'attempt-1', revision: 1, index: 0, time: Date.now(), chunk: { type: 'text-delta', index: 0, text } }
132
+ )
133
+ hub.flushStreams()
134
+ await sleep(320)
135
+ }
136
+ appendEvent('assistant/message', {
137
+ turn: 3,
138
+ step: 1,
139
+ message: {
140
+ id: 'a1',
141
+ role: 'assistant',
142
+ content: [
143
+ {
144
+ type: 'reasoning',
145
+ text: [
146
+ '先确认 `login()` 的所有调用点,再决定抽象边界。',
147
+ '',
148
+ '1. 只有两处直接调用,都在 `src/routes/` 里。',
149
+ '2. 会话创建逻辑已经在 `src/session.ts`,可以直接复用。',
150
+ '3. 把校验逻辑留在原文件,导出 `login()` 即可。',
151
+ '4. 测试需要覆盖:正常登录、错误密码、会话过期。',
152
+ '5. lint 规则要求显式返回类型,所以要标注 `Promise<Session>`。'
153
+ ].join('\n')
154
+ },
155
+ {
156
+ type: 'text',
157
+ text: [
158
+ '## 计划',
159
+ '',
160
+ '把 `login()` 抽到 **src/auth.ts**,然后跑测试:',
161
+ '',
162
+ '- 读取 `src/login.ts`',
163
+ '- 写入新模块',
164
+ '- 运行 `npm test`',
165
+ '',
166
+ '```ts',
167
+ 'export async function login(user: string): Promise<Session> {',
168
+ ' // 校验并创建会话',
169
+ ' return createSession(user)',
170
+ '}',
171
+ '```'
172
+ ].join('\n')
173
+ }
174
+ ],
175
+ source: { kind: 'model' }
176
+ },
177
+ stream: [],
178
+ usage: { inputTokens: 3200, outputTokens: 180, totalTokens: 3380 }
179
+ })
180
+ await sleep(200)
181
+
182
+ const calls = [
183
+ { id: 'c1', name: 'read_file', args: { path: 'src/login.ts' }, result: '返回 234 行', ms: 300 },
184
+ { id: 'c2', name: 'write_file', args: { path: 'src/auth.ts' }, result: '写入 96 行', ms: 300 },
185
+ { id: 'c3', name: 'bash', args: { command: 'npm test', description: 'Run the test suite' }, result: '测试通过 (12 passed)', ms: 900 },
186
+ { id: 'c4', name: 'bash', args: { command: 'npm run lint', description: 'Lint the workspace' }, result: '2 problems (0 errors, 2 warnings)', ok: false, ms: 700 }
187
+ ]
188
+ for (const call of calls) {
189
+ appendEvent('tool/call', { turn: 3, step: 1, callId: call.id, name: call.name, arguments: JSON.stringify(call.args) })
190
+ await sleep(240)
191
+ appendEvent('tool/result', {
192
+ turn: 3,
193
+ step: 1,
194
+ message: {
195
+ id: `r-${call.id}`,
196
+ role: 'tool',
197
+ toolCallId: call.id,
198
+ isError: call.ok === false,
199
+ content: [{ type: 'text', text: call.result }],
200
+ source: { kind: 'tool', callId: call.id }
201
+ },
202
+ ...(call.ok === false ? { error: { name: 'LintError', code: 'LINT_WARNINGS', reason: '2 warnings' } } : {})
203
+ })
204
+ await sleep(call.ms)
205
+ }
206
+
207
+ // A file write whose content exists only in the call arguments, exactly like
208
+ // a real `write` tool creating a new file.
209
+ const writeArgs = JSON.stringify({
210
+ file_path: 'src/auth.ts',
211
+ content: [
212
+ "import { createSession, type Session } from './session'",
213
+ '',
214
+ 'export async function login(user: string): Promise<Session> {',
215
+ ' return createSession(user)',
216
+ '}',
217
+ ''
218
+ ].join('\n')
219
+ })
220
+ appendEvent('tool/call', { turn: 3, step: 1, callId: 'c5', name: 'write', arguments: writeArgs })
221
+ await sleep(260)
222
+ appendEvent('tool/result', {
223
+ turn: 3,
224
+ step: 1,
225
+ message: {
226
+ id: 'r-c5',
227
+ role: 'tool',
228
+ toolCallId: 'c5',
229
+ content: [{ type: 'text', text: 'wrote 96 bytes to src/auth.ts' }],
230
+ source: { kind: 'tool', callId: 'c5' }
231
+ },
232
+ meta: { operation: 'create', diffs: [] }
233
+ })
234
+ await sleep(400)
235
+
236
+ appendEvent('approval/asked', { id: 'ap-1', toolName: 'bash', reason: 'runs outside the sandbox' })
237
+ await sleep(1000)
238
+ appendEvent('approval/decided', { id: 'ap-1', outcome: 'allowed-once' })
239
+ await sleep(300)
240
+
241
+ appendEvent('step/start', { turn: 3, step: 2 })
242
+ appendEvent('assistant/message', {
243
+ turn: 3,
244
+ step: 2,
245
+ message: {
246
+ id: 'a2',
247
+ role: 'assistant',
248
+ content: [
249
+ {
250
+ type: 'text',
251
+ text: [
252
+ '### 完成',
253
+ '',
254
+ '登录逻辑已抽到 `src/auth.ts`:',
255
+ '',
256
+ '| 检查 | 结果 |',
257
+ '| --- | --- |',
258
+ '| 测试 | 12 passed |',
259
+ '| lint | 2 warnings |',
260
+ '',
261
+ '```bash',
262
+ '$ npm test',
263
+ '✓ 12 passed',
264
+ '```'
265
+ ].join('\n')
266
+ }
267
+ ],
268
+ source: { kind: 'model' }
269
+ },
270
+ stream: [],
271
+ usage: { inputTokens: 4400, outputTokens: 260, totalTokens: 4660 }
272
+ })
273
+ appendEvent('request/context', { provider: 'deepseek-official', model: 'deepseek-flash', capacity: 1_000_000 })
274
+ appendEvent('turn/end', { turn: 3, reason: { kind: 'completed' } })
275
+ publishRegistry()
276
+ }
277
+
278
+ await script()
279
+
280
+ if (Number.isFinite(options.duration)) {
281
+ setTimeout(finish, options.duration)
282
+ } else {
283
+ process.stdout.write('\nDemo trace complete. The board stays live — press q in the dashboard to exit.\n')
284
+ }
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Wire `dsh-live-trace` into a DSH profile's composition.
4
+ *
5
+ * This is a file-level installer: it edits the profile's `package.json`
6
+ * (dependency + bundle selection) and links the package into the profile's
7
+ * `node_modules`. It deliberately does not run a package manager, so it works
8
+ * offline and never touches the packages pnpm already manages. Use
9
+ * `dsh plugin --profile <name> add <dir>` instead when you would rather have
10
+ * pnpm own the dependency.
11
+ *
12
+ * node scripts/install-profile.mjs [--profile web] [--home <dsh-home>]
13
+ * [--package <dir>] [--uninstall] [--dry-run]
14
+ */
15
+
16
+ import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
17
+ import { homedir } from 'node:os'
18
+ import { dirname, join, resolve } from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+
21
+ const PACKAGE_NAME = 'dsh-live-trace'
22
+ const HERE = dirname(fileURLToPath(import.meta.url))
23
+ const PACKAGE_DIR = resolve(HERE, '..')
24
+
25
+ function parseArgs(argv) {
26
+ const options = { profile: 'web', home: undefined, packageDir: PACKAGE_DIR, uninstall: false, dryRun: false, link: true }
27
+ for (let index = 0; index < argv.length; index += 1) {
28
+ const value = () => {
29
+ const next = argv[index + 1]
30
+ if (next === undefined || next.startsWith('--')) throw new Error(`missing value for ${argv[index]}`)
31
+ index += 1
32
+ return next
33
+ }
34
+ switch (argv[index]) {
35
+ case '--profile':
36
+ options.profile = value()
37
+ break
38
+ case '--home':
39
+ options.home = resolve(value())
40
+ break
41
+ case '--package':
42
+ options.packageDir = resolve(value())
43
+ break
44
+ case '--uninstall':
45
+ options.uninstall = true
46
+ break
47
+ case '--dry-run':
48
+ options.dryRun = true
49
+ break
50
+ case '--no-link':
51
+ options.link = false
52
+ break
53
+ case '--help':
54
+ options.help = true
55
+ break
56
+ default:
57
+ throw new Error(`unknown option: ${argv[index]}`)
58
+ }
59
+ }
60
+ return options
61
+ }
62
+
63
+ function resolveHome(explicit) {
64
+ if (explicit !== undefined) return explicit
65
+ const fromEnv = process.env.DSH_HOME
66
+ if (typeof fromEnv === 'string' && fromEnv.trim().length > 0) return resolve(fromEnv.trim())
67
+ return join(homedir(), '.dsh')
68
+ }
69
+
70
+ const HELP = `dsh-live-trace profile installer
71
+
72
+ --profile <name> DSH profile to wire up (default: web)
73
+ --home <dir> DSH home (default: $DSH_HOME or ~/.dsh)
74
+ --package <dir> package directory to link (default: this package)
75
+ --no-link edit package.json only; do not create a node_modules link
76
+ --uninstall remove the dependency, bundle entry, and link
77
+ --dry-run print what would change and exit
78
+ --help show this message
79
+ `
80
+
81
+ function main() {
82
+ const options = parseArgs(process.argv.slice(2))
83
+ if (options.help === true) {
84
+ process.stdout.write(HELP)
85
+ return 0
86
+ }
87
+
88
+ const home = resolveHome(options.home)
89
+ const profileDir = join(home, 'profiles', options.profile)
90
+ const manifestPath = join(profileDir, 'package.json')
91
+ const linkPath = join(profileDir, 'node_modules', PACKAGE_NAME)
92
+
93
+ if (!existsSync(manifestPath)) {
94
+ process.stderr.write(
95
+ `dsh-live-trace: no profile manifest at ${manifestPath}\n` +
96
+ `Create the profile first (booting it once with "dsh --profile ${options.profile}" does this),\n` +
97
+ 'or pass --home to point at the right DSH home.\n'
98
+ )
99
+ return 1
100
+ }
101
+
102
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
103
+ const dependencySpec = `link:${options.packageDir}`
104
+
105
+ if (options.uninstall === true) {
106
+ if (manifest.dependencies?.[PACKAGE_NAME] !== undefined) delete manifest.dependencies[PACKAGE_NAME]
107
+ removeFromBundles(manifest, PACKAGE_NAME)
108
+ if (options.dryRun !== true) {
109
+ writeManifest(manifestPath, manifest)
110
+ rmSync(linkPath, { force: true })
111
+ }
112
+ process.stdout.write(`dsh-live-trace: ${options.dryRun ? 'would remove from' : 'removed from'} ${manifestPath}\n`)
113
+ return 0
114
+ }
115
+
116
+ manifest.dependencies = { ...(manifest.dependencies ?? {}), [PACKAGE_NAME]: dependencySpec }
117
+ const bundles = manifest.dsh?.profile?.bundles
118
+ if (!Array.isArray(bundles)) {
119
+ process.stderr.write(
120
+ `dsh-live-trace: ${manifestPath} has no dsh.profile.bundles list, so the plugin cannot be selected.\n` +
121
+ 'Add one (see any shipped profile template) and re-run.\n'
122
+ )
123
+ return 1
124
+ }
125
+ const added = !bundles.includes(PACKAGE_NAME)
126
+ if (added) bundles.push(PACKAGE_NAME)
127
+
128
+ if (options.dryRun === true) {
129
+ process.stdout.write(`would write ${manifestPath}:\n${JSON.stringify(manifest, null, 2)}\n`)
130
+ if (options.link) process.stdout.write(`would link ${linkPath} -> ${options.packageDir}\n`)
131
+ return 0
132
+ }
133
+
134
+ writeManifest(manifestPath, manifest)
135
+
136
+ if (options.link === true) {
137
+ mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
138
+ rmSync(linkPath, { recursive: true, force: true })
139
+ symlinkSync(options.packageDir, linkPath, 'dir')
140
+ }
141
+
142
+ process.stdout.write(
143
+ [
144
+ `dsh-live-trace: installed into profile "${options.profile}"`,
145
+ ` manifest: ${manifestPath}`,
146
+ ` dependency: ${PACKAGE_NAME} -> ${dependencySpec}`,
147
+ options.link ? ` link: ${linkPath} -> ${options.packageDir}` : ' link: skipped (--no-link)',
148
+ '',
149
+ 'Next:',
150
+ ` 1. restart the Harness (or let it hot-reload): dsh --profile ${options.profile}`,
151
+ ' 2. in another terminal, run: dsh-live-trace',
152
+ ''
153
+ ].join('\n')
154
+ )
155
+ return 0
156
+ }
157
+
158
+ function removeFromBundles(manifest, name) {
159
+ const bundles = manifest.dsh?.profile?.bundles
160
+ if (!Array.isArray(bundles)) return
161
+ const index = bundles.indexOf(name)
162
+ if (index !== -1) bundles.splice(index, 1)
163
+ }
164
+
165
+ function writeManifest(path, manifest) {
166
+ writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`)
167
+ }
168
+
169
+ try {
170
+ process.exitCode = main()
171
+ } catch (error) {
172
+ process.stderr.write(`dsh-live-trace: ${error instanceof Error ? error.message : String(error)}\n`)
173
+ process.exitCode = 2
174
+ }
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * A minimal Anthropic-Messages-compatible SSE provider.
4
+ *
5
+ * DEVELOPMENT TOOL — not part of the plugin's runtime. It exists so the whole
6
+ * pipeline (real agent loop -> real session events -> observer plugin ->
7
+ * `dsh-live-trace`) can be exercised offline, without a provider account:
8
+ *
9
+ * node scripts/mock-provider.mjs & # listens on 127.0.0.1:8799
10
+ * DEEPSEEK_BASE_URL=http://127.0.0.1:8799 \
11
+ * DEEPSEEK_API_KEY=sk-mock \
12
+ * dsh --profile <profile> "<task>"
13
+ *
14
+ * Turn 1 streams thinking + text and asks for one `bash` call; turn 2 streams a
15
+ * final answer. Env: MOCK_LLM_PORT, MOCK_LLM_CHUNK_DELAY_MS.
16
+ */
17
+
18
+
19
+ import { createServer } from 'node:http'
20
+
21
+ const PORT = Number(process.env.MOCK_LLM_PORT ?? 8799)
22
+ const CHUNK_DELAY_MS = Number(process.env.MOCK_LLM_CHUNK_DELAY_MS ?? 90)
23
+
24
+ function send(res, event) {
25
+ res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`)
26
+ }
27
+
28
+ function sse(res, events) {
29
+ let chain = Promise.resolve()
30
+ for (const event of events) {
31
+ chain = chain.then(
32
+ () =>
33
+ new Promise((resolve) => {
34
+ setTimeout(() => {
35
+ send(res, event)
36
+ resolve()
37
+ }, CHUNK_DELAY_MS)
38
+ })
39
+ )
40
+ }
41
+ return chain
42
+ }
43
+
44
+ function firstToolTurn() {
45
+ return [
46
+ { type: 'message_start', message: { usage: { input_tokens: 4200, output_tokens: 0 } } },
47
+ { type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } },
48
+ { type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: '用户想看这个插件的实现,' } },
49
+ { type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: '所以我先列一下目录,再看入口文件。' } },
50
+ { type: 'content_block_stop', index: 0 },
51
+ { type: 'content_block_start', index: 1, content_block: { type: 'text', text: '' } },
52
+ { type: 'content_block_delta', index: 1, delta: { type: 'text_delta', text: '我先看看项目结构,' } },
53
+ { type: 'content_block_delta', index: 1, delta: { type: 'text_delta', text: '然后读一下插件的入口。' } },
54
+ { type: 'content_block_stop', index: 1 },
55
+ {
56
+ type: 'content_block_start',
57
+ index: 2,
58
+ content_block: {
59
+ type: 'tool_use',
60
+ id: 'toolu_01',
61
+ name: 'bash',
62
+ input: {
63
+ command: 'ls -1 dsh-live-trace && echo "--- entry ---" && head -4 dsh-live-trace/index.js',
64
+ description: 'Inspect the plugin package layout'
65
+ }
66
+ }
67
+ },
68
+ { type: 'content_block_stop', index: 2 },
69
+ { type: 'message_delta', delta: { stop_reason: 'tool_use' }, usage: { output_tokens: 186 } },
70
+ { type: 'message_stop' }
71
+ ]
72
+ }
73
+
74
+ /** Second turn: run something that produces rich, multi-line output. */
75
+ function commandTurn() {
76
+ return [
77
+ { type: 'message_start', message: { usage: { input_tokens: 4600, output_tokens: 0 } } },
78
+ { type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } },
79
+ { type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: '接着跑一遍测试,确认当前状态。' } },
80
+ { type: 'content_block_stop', index: 0 },
81
+ {
82
+ type: 'content_block_start',
83
+ index: 1,
84
+ content_block: {
85
+ type: 'tool_use',
86
+ id: 'toolu_02',
87
+ name: 'bash',
88
+ input: {
89
+ command: 'cd dsh-live-trace && node --test test/width.test.js test/tools.test.js 2>&1 | tail -12',
90
+ description: 'Run the measurement and tool-helper tests'
91
+ }
92
+ }
93
+ },
94
+ { type: 'content_block_stop', index: 1 },
95
+ { type: 'message_delta', delta: { stop_reason: 'tool_use' }, usage: { output_tokens: 96 } },
96
+ { type: 'message_stop' }
97
+ ]
98
+ }
99
+
100
+ /** Third turn: write a file, so the edits panel has a real diff to show. */
101
+ function writeTurn() {
102
+ const scratch = process.env.MOCK_LLM_SCRATCH ?? './demo-scratch.md'
103
+ return [
104
+ { type: 'message_start', message: { usage: { input_tokens: 5200, output_tokens: 0 } } },
105
+ { type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } },
106
+ { type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: '把结论写成一个文件,顺便演示 diff 面板。' } },
107
+ { type: 'content_block_stop', index: 0 },
108
+ {
109
+ type: 'content_block_start',
110
+ index: 1,
111
+ content_block: {
112
+ type: 'tool_use',
113
+ id: 'toolu_03',
114
+ name: 'write',
115
+ input: {
116
+ file_path: scratch,
117
+ content: '# dsh-live-trace\n\n一个只读的实时看板。\n\n- Host 插件订阅 session/event\n- 归一化后经 unix socket 推送\n- 独立终端渲染\n'
118
+ }
119
+ }
120
+ },
121
+ { type: 'content_block_stop', index: 1 },
122
+ { type: 'message_delta', delta: { stop_reason: 'tool_use' }, usage: { output_tokens: 128 } },
123
+ { type: 'message_stop' }
124
+ ]
125
+ }
126
+
127
+ /** Final turn: a markdown answer with prose, a list, and a highlighted fence. */
128
+ function finalTurn() {
129
+ const answer = [
130
+ '## 结论',
131
+ '',
132
+ '这是一个 **只读** 的实时看板插件:Host 侧订阅 `session/event`,把归一化后的事件通过本地 socket 推给独立终端里的 `dsh-live-trace`。',
133
+ '',
134
+ '- 不改动 Harness 核心,不干预 Agent 运行',
135
+ '- 卸载时 socket、监听器、定时器全部回收',
136
+ '- 终端里用 `Markdown` 渲染模型输出,并给代码着色',
137
+ '',
138
+ '```ts',
139
+ 'export function apply(ctx, config) {',
140
+ ' return ctx.effect(() => {',
141
+ ' const dispose = ctx.on("session/event", (session, event) => {',
142
+ ' hub.onSessionEvent(session, event)',
143
+ ' })',
144
+ ' return () => dispose()',
145
+ ' })',
146
+ '}',
147
+ '```',
148
+ '',
149
+ '| 面板 | 快捷键 | 内容 |',
150
+ '| --- | --- | --- |',
151
+ '| trace | 1 | 事件轨迹 |',
152
+ '| sessions | 2 | 会话切换 |',
153
+ '| edits | 3 | 文件改动 |',
154
+ '| commands | 4 | 命令与输出 |'
155
+ ].join('\n')
156
+
157
+ const events = [
158
+ { type: 'message_start', message: { usage: { input_tokens: 6100, output_tokens: 0 } } },
159
+ { type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } },
160
+ { type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: '整理结论,用列表和表格说明面板。' } },
161
+ { type: 'content_block_stop', index: 0 },
162
+ { type: 'content_block_start', index: 1, content_block: { type: 'text', text: '' } }
163
+ ]
164
+ for (const chunk of answer.match(/[\s\S]{1,24}/g) ?? []) {
165
+ events.push({ type: 'content_block_delta', index: 1, delta: { type: 'text_delta', text: chunk } })
166
+ }
167
+ events.push(
168
+ { type: 'content_block_stop', index: 1 },
169
+ { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 320 } },
170
+ { type: 'message_stop' }
171
+ )
172
+ return events
173
+ }
174
+
175
+ const server = createServer((req, res) => {
176
+ if (req.method !== 'POST' || !req.url?.startsWith('/v1/messages')) {
177
+ res.writeHead(404).end('not found')
178
+ return
179
+ }
180
+ let body = ''
181
+ req.setEncoding('utf8')
182
+ req.on('data', (chunk) => {
183
+ body += chunk
184
+ })
185
+ req.on('end', async () => {
186
+ let parsed = {}
187
+ try {
188
+ parsed = JSON.parse(body)
189
+ } catch {
190
+ /* fall through to the tool turn */
191
+ }
192
+ const messages = Array.isArray(parsed.messages) ? parsed.messages : []
193
+ // Each turn is chosen by how many tool results the conversation already
194
+ // carries, which walks the agent loop through bash -> bash -> write -> answer.
195
+ const toolResults = (JSON.stringify(messages).match(/"type":"tool_result"/g) ?? []).length
196
+ const script = toolResults === 0 ? firstToolTurn() : toolResults === 1 ? commandTurn() : toolResults === 2 ? writeTurn() : finalTurn()
197
+ process.stderr.write(`mock-llm: request model=${parsed.model} toolResults=${toolResults}\n`)
198
+
199
+ res.writeHead(200, {
200
+ 'content-type': 'text/event-stream',
201
+ 'cache-control': 'no-cache',
202
+ connection: 'keep-alive'
203
+ })
204
+ await sse(res, script)
205
+ res.end()
206
+ })
207
+ })
208
+
209
+ server.listen(PORT, '127.0.0.1', () => {
210
+ process.stderr.write(`mock-llm: listening on http://127.0.0.1:${PORT}\n`)
211
+ })