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/scripts/demo.mjs
ADDED
|
@@ -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
|
+
})
|