dsh-remote-web 0.7.9 → 0.7.10-beta.10

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.
@@ -0,0 +1,511 @@
1
+ /**
2
+ * 事件映射(M2 第 4 周:**驱动通用化**)—— 一份「jsonl 驱动」+ 一份「Claude 映射」。
3
+ *
4
+ * ## 分层(接第二家只需要换 mapLine)
5
+ *
6
+ * ```
7
+ * 子进程 stdout(逐行 JSON)
8
+ * │
9
+ * ▼
10
+ * createJsonlDriver({ mapLine, sink, onUnknown }) ← 通用:分帧/解析/统计/落盘/seq/未知兜底
11
+ * │ (不认识任何一家 CLI)
12
+ * ▼
13
+ * mapLine(obj, { now, maxPreviewChars }) ← 一家一份:Claude=mapClaudeObject、Codex=mapCodexObject
14
+ * │
15
+ * ▼
16
+ * 5 类归一化事件:status / message.delta / tool.call / tool.result / turn.end
17
+ * ```
18
+ *
19
+ * 换句话说:**驱动不懂 Claude,也不懂 Codex**;换 harness 只换 `mapLine`(见 `agent/README.md` 接入清单)。
20
+ * 这是设计稿"2 个驱动(jsonl + acp)+ 1 套事件模型"里 jsonl 那半个驱动的落地。
21
+ *
22
+ * ## 三条硬规矩(沿用第 3 周,不变)
23
+ *
24
+ * ① **未知形状不许崩**:未知 type/字段、坏 JSON、超长行、甚至 `mapLine` 自己抛错 → 计数后丢弃,
25
+ * 绝不让一行意外输出把整条会话的采集带死。
26
+ * ② **不记录命令原文**:`tool.call` 只带输入**字节数**;`tool.result` 只带截断预览(默认 120 字符)。
27
+ * ③ **默认不启用**:没有 `filePath` 的 sink 什么都不写;落盘失败**静默**(计数 + 只告警一次)。
28
+ *
29
+ * ## 对外行为承诺
30
+ *
31
+ * `mapStreamJsonLine` / `createEventMapper` / `createJsonlSink` / `EVENT_TYPES` / `turnReasonOf`
32
+ * 都是第 3 周就有的公开 API,**行为保持不变**(第 3 周的 18 条用例继续守它们);
33
+ * 本轮新增的是 `createJsonlDriver`、`mapClaudeLine`、`mapClaudeObject`。
34
+ */
35
+
36
+ import fsSync from "node:fs";
37
+
38
+ import { toolKindOf } from "./permission-bridge.mjs";
39
+
40
+ /** 归一化事件类型(封版;加第 6 种属于破坏性变更)。 */
41
+ export const EVENT_TYPES = Object.freeze({
42
+ STATUS: "status",
43
+ MESSAGE_DELTA: "message.delta",
44
+ TOOL_CALL: "tool.call",
45
+ TOOL_RESULT: "tool.result",
46
+ TURN_END: "turn.end"
47
+ });
48
+
49
+ /** 单行上限:正常 stream-json 单行 < 64KB;超过这个数多半不是我们认识的输出。 */
50
+ export const DEFAULT_MAX_LINE_BYTES = 256 * 1024;
51
+ /** tool_result 预览上限(字符)。设 0 = 完全不带输出内容。 */
52
+ export const DEFAULT_MAX_PREVIEW_CHARS = 120;
53
+
54
+ /** `result.subtype` → 归一化 turn.end reason(Claude 专用,保留导出供测试/复用)。 */
55
+ export function turnReasonOf(result) {
56
+ const subtype = String(result?.subtype || "");
57
+ if (result?.is_error) return "error";
58
+ if (subtype === "success") {
59
+ const terminal = String(result?.terminal_reason || "");
60
+ if (terminal === "aborted") return "aborted";
61
+ if (terminal === "interrupted") return "interrupted";
62
+ return "completed";
63
+ }
64
+ if (subtype.includes("max_turns")) return "max-turns";
65
+ if (subtype.includes("interrupt")) return "interrupted";
66
+ if (subtype.includes("abort")) return "aborted";
67
+ if (subtype.startsWith("error")) return "error";
68
+ return subtype || "completed";
69
+ }
70
+
71
+ /** 从 tool_result 的 content(string / 数组 / 对象)里取文本。 */
72
+ function textOfContent(content) {
73
+ if (typeof content === "string") return content;
74
+ if (Array.isArray(content)) {
75
+ return content
76
+ .map((c) => {
77
+ if (typeof c === "string") return c;
78
+ if (c && typeof c === "object") {
79
+ if (typeof c.text === "string") return c.text;
80
+ if (typeof c.content === "string") return c.content;
81
+ }
82
+ return "";
83
+ })
84
+ .filter(Boolean)
85
+ .join("\n");
86
+ }
87
+ if (content && typeof content === "object" && typeof content.text === "string") return content.text;
88
+ return "";
89
+ }
90
+
91
+ /** UTF-8 字节数(隐私策略用"字节数"而不是内容)。 */
92
+ export function byteLength(s) {
93
+ return Buffer.byteLength(String(s ?? ""), "utf8");
94
+ }
95
+
96
+ /** 截断预览(0 表示不带内容)。 */
97
+ export function previewOf(text, maxPreviewChars) {
98
+ if (!(maxPreviewChars > 0)) return "";
99
+ return String(text ?? "").slice(0, maxPreviewChars);
100
+ }
101
+
102
+ /** 归一化事件的基础字段。`at` 由各 mapper 打(便于单测固定时钟),`v/seq` 由驱动补。 */
103
+ function base(type, at) {
104
+ return { v: 1, type, at };
105
+ }
106
+
107
+ // ─────────────────────────────────────────────────────────────────────────────
108
+ // Claude Code(stream-json)映射
109
+ // ─────────────────────────────────────────────────────────────────────────────
110
+
111
+ /**
112
+ * Claude Code 一行的**对象**映射(驱动解析完 JSON 后调用)。
113
+ *
114
+ * 返回 `{ok:true, events}` 或 `{ok:false, reason}`;**永远不抛错**。
115
+ *
116
+ * @param {object} obj 已解析的 stream-json 对象
117
+ * @param {{now?:()=>number, maxPreviewChars?:number}} [ctx]
118
+ */
119
+ export function mapClaudeObject(obj, {
120
+ now = () => Date.now(),
121
+ maxPreviewChars = DEFAULT_MAX_PREVIEW_CHARS
122
+ } = {}) {
123
+ if (!obj || typeof obj !== "object") return { ok: false, reason: "not-object" };
124
+
125
+ const at = now();
126
+ const events = [];
127
+
128
+ switch (obj.type) {
129
+ case "system": {
130
+ const subtype = String(obj.subtype || "");
131
+ if (subtype === "init") {
132
+ events.push({
133
+ ...base(EVENT_TYPES.STATUS, at),
134
+ status: "working",
135
+ sessionId: typeof obj.session_id === "string" ? obj.session_id : "",
136
+ permissionMode: typeof obj.permissionMode === "string" ? obj.permissionMode : "",
137
+ toolCount: Array.isArray(obj.tools) ? obj.tools.length : 0
138
+ });
139
+ } else if (subtype === "hook_started" || subtype === "hook_response") {
140
+ // 审批钩子的生命周期(E2E/排障用;不含任何命令内容)
141
+ events.push({
142
+ ...base(EVENT_TYPES.STATUS, at),
143
+ status: "hook",
144
+ hookPhase: subtype === "hook_started" ? "started" : "finished",
145
+ hookEvent: String(obj.hook_event || "")
146
+ });
147
+ } else if (subtype === "api_retry") {
148
+ events.push({
149
+ ...base(EVENT_TYPES.STATUS, at),
150
+ status: "retrying",
151
+ attempt: Number(obj.attempt) || 0,
152
+ retryDelayMs: Number(obj.retry_delay_ms) || 0
153
+ });
154
+ } else if (subtype === "permission_denied") {
155
+ /*
156
+ * 实测形状(2.1.318,deny 之后由 CLI 自己发出):
157
+ * {type:"system",subtype:"permission_denied",tool_name,tool_use_id,
158
+ * decision_reason_type:"hook"|…, decision_reason:"<我们的 deny message>", message}
159
+ * 这是"审批被拒"的权威回执;reason 是**判定理由**(不是命令原文)。
160
+ */
161
+ events.push({
162
+ ...base(EVENT_TYPES.STATUS, at),
163
+ status: "denied",
164
+ toolName: String(obj.tool_name || ""),
165
+ toolCallId: String(obj.tool_use_id || ""),
166
+ reasonType: String(obj.decision_reason_type || ""),
167
+ reason: String(obj.decision_reason || obj.message || "").slice(0, 120)
168
+ });
169
+ } else if (subtype === "thinking_tokens") {
170
+ // 一次会话里能有几十条;落盘没有价值,直接忽略(也不算 unknown)
171
+ return { ok: true, events: [] };
172
+ } else {
173
+ return { ok: false, reason: `unknown-system-subtype:${subtype || "?"}` };
174
+ }
175
+ break;
176
+ }
177
+
178
+ case "stream_event": {
179
+ const ev = obj.event || {};
180
+ const delta = ev.delta || {};
181
+ if (delta.type === "text_delta" && typeof delta.text === "string" && delta.text) {
182
+ events.push({ ...base(EVENT_TYPES.MESSAGE_DELTA, at), text: delta.text, final: false });
183
+ } else {
184
+ return { ok: false, reason: "unknown-stream-event" };
185
+ }
186
+ break;
187
+ }
188
+
189
+ case "assistant": {
190
+ const content = obj?.message?.content;
191
+ if (!Array.isArray(content)) return { ok: false, reason: "assistant-without-content" };
192
+ for (const block of content) {
193
+ if (!block || typeof block !== "object") continue;
194
+ if (block.type === "text" && typeof block.text === "string") {
195
+ if (!block.text) continue;
196
+ events.push({ ...base(EVENT_TYPES.MESSAGE_DELTA, at), text: block.text, final: true });
197
+ } else if (block.type === "tool_use") {
198
+ const input = block.input;
199
+ events.push({
200
+ ...base(EVENT_TYPES.TOOL_CALL, at),
201
+ toolCallId: String(block.id || ""),
202
+ name: String(block.name || ""),
203
+ kind: toolKindOf(block.name),
204
+ // 只留大小,**不留内容**(命令原文不进日志)
205
+ inputBytes: input === undefined ? 0 : byteLength(JSON.stringify(input))
206
+ });
207
+ }
208
+ }
209
+ break;
210
+ }
211
+
212
+ case "user": {
213
+ const content = obj?.message?.content;
214
+ if (!Array.isArray(content)) return { ok: false, reason: "user-without-content" };
215
+ for (const block of content) {
216
+ if (!block || typeof block !== "object" || block.type !== "tool_result") continue;
217
+ const text = textOfContent(block.content);
218
+ events.push({
219
+ ...base(EVENT_TYPES.TOOL_RESULT, at),
220
+ toolCallId: String(block.tool_use_id || ""),
221
+ isError: block.is_error === true,
222
+ outputBytes: byteLength(text),
223
+ outputPreview: previewOf(text, maxPreviewChars)
224
+ });
225
+ }
226
+ break;
227
+ }
228
+
229
+ case "result": {
230
+ events.push({
231
+ ...base(EVENT_TYPES.TURN_END, at),
232
+ reason: turnReasonOf(obj),
233
+ isError: obj.is_error === true,
234
+ durationMs: Number(obj.duration_ms) || 0,
235
+ numTurns: Number(obj.num_turns) || 0,
236
+ sessionId: typeof obj.session_id === "string" ? obj.session_id : ""
237
+ });
238
+ break;
239
+ }
240
+
241
+ default:
242
+ return { ok: false, reason: `unknown-type:${String(obj.type || "?")}` };
243
+ }
244
+
245
+ return { ok: true, events };
246
+ }
247
+
248
+ /**
249
+ * Claude Code 一行(字符串)的映射 —— 保留第 3 周的公开入口。
250
+ *
251
+ * @param {string} line
252
+ * @param {{now?:()=>number, maxLineBytes?:number, maxPreviewChars?:number}} [opts]
253
+ */
254
+ export function mapClaudeLine(line, {
255
+ now = () => Date.now(),
256
+ maxLineBytes = DEFAULT_MAX_LINE_BYTES,
257
+ maxPreviewChars = DEFAULT_MAX_PREVIEW_CHARS
258
+ } = {}) {
259
+ const framed = frameLine(line, { maxLineBytes });
260
+ if (!framed.ok) return framed;
261
+ const parsed = parseLineJson(framed.raw);
262
+ if (!parsed.ok) return parsed;
263
+ return mapClaudeObject(parsed.obj, { now, maxPreviewChars });
264
+ }
265
+
266
+ /**
267
+ * 第 3 周的旧名字 —— **行为完全一致**,保留给既有调用方/用例。
268
+ * @deprecated 用 `mapClaudeLine`(明确"这是 Claude 的映射")。
269
+ */
270
+ export const mapStreamJsonLine = mapClaudeLine;
271
+
272
+ // ─────────────────────────────────────────────────────────────────────────────
273
+ // 通用 jsonl 驱动
274
+ // ─────────────────────────────────────────────────────────────────────────────
275
+
276
+ /** 分帧:去空白 → 空行 / 超长行判定(两家 CLI 同样的规矩)。 */
277
+ export function frameLine(line, { maxLineBytes = DEFAULT_MAX_LINE_BYTES } = {}) {
278
+ const raw = String(line ?? "").trim();
279
+ if (!raw) return { ok: false, reason: "empty" };
280
+ if (byteLength(raw) > maxLineBytes) return { ok: false, reason: "too-long" };
281
+ return { ok: true, raw };
282
+ }
283
+
284
+ /** 解析 JSON:坏 JSON 只返回 reason,不抛。 */
285
+ export function parseLineJson(raw) {
286
+ try {
287
+ const obj = JSON.parse(raw);
288
+ if (!obj || typeof obj !== "object") return { ok: false, reason: "not-object" };
289
+ return { ok: true, obj };
290
+ } catch {
291
+ return { ok: false, reason: "bad-json" };
292
+ }
293
+ }
294
+
295
+ /**
296
+ * **通用 jsonl 驱动**:分帧 → 解析 JSON → `mapLine(obj, ctx)` → 归一化信封(v/seq)→ sink/回调。
297
+ *
298
+ * 驱动不认识任何一家 CLI;`mapLine` 返回 `{ok:true, events:[{type,...}]}` 或 `{ok:false, reason}`。
299
+ * `mapLine` 自己抛错也会被兜住(记 `mapperErrors`,走 `onUnknown`),**不向上抛**。
300
+ *
301
+ * @param {object} opts
302
+ * @param {(obj:object, ctx:{now:()=>number, maxPreviewChars:number}) => {ok:boolean, events?:object[], reason?:string}} opts.mapLine
303
+ * @param {{write:(e:object)=>boolean}} [opts.sink] 落盘(见 createJsonlSink)
304
+ * @param {(e:object)=>void} [opts.onEvent] 每条归一化事件回调
305
+ * @param {(reason:string, raw:string)=>void} [opts.onUnknown] 未识别行回调
306
+ * @param {()=>number} [opts.now]
307
+ * @param {number} [opts.maxLineBytes]
308
+ * @param {number} [opts.maxPreviewChars]
309
+ * @param {string} [opts.driverName] 统计用(claude / codex / …)
310
+ */
311
+ export function createJsonlDriver({
312
+ mapLine,
313
+ sink = null,
314
+ onEvent,
315
+ onUnknown,
316
+ now = () => Date.now(),
317
+ maxLineBytes = DEFAULT_MAX_LINE_BYTES,
318
+ maxPreviewChars = DEFAULT_MAX_PREVIEW_CHARS,
319
+ driverName = "jsonl"
320
+ } = {}) {
321
+ if (typeof mapLine !== "function") throw new Error("createJsonlDriver: 缺少 mapLine");
322
+ let seq = 0;
323
+ const stats = {
324
+ driverName,
325
+ lines: 0,
326
+ events: 0,
327
+ badJson: 0,
328
+ tooLong: 0,
329
+ empty: 0,
330
+ unknown: 0,
331
+ mapperErrors: 0,
332
+ byType: Object.create(null),
333
+ byReason: Object.create(null),
334
+ // 未识别原因(排障用:到底是哪种新事件让我们丢了一行)
335
+ unknownReasons: Object.create(null)
336
+ };
337
+ const notifyEvent = typeof onEvent === "function" ? onEvent : null;
338
+ const notifyUnknown = typeof onUnknown === "function" ? onUnknown : null;
339
+
340
+ function countUnknown(reason) {
341
+ stats.unknown += 1;
342
+ const key = String(reason || "unknown").slice(0, 80);
343
+ stats.unknownReasons[key] = (stats.unknownReasons[key] || 0) + 1;
344
+ }
345
+
346
+ return {
347
+ /** 喂一行;返回本行产出的事件数组(可能为空)。永不抛错。 */
348
+ writeLine(line) {
349
+ stats.lines += 1;
350
+ const framed = frameLine(line, { maxLineBytes });
351
+ if (!framed.ok) {
352
+ if (framed.reason === "too-long") stats.tooLong += 1;
353
+ else stats.empty += 1;
354
+ return [];
355
+ }
356
+ const parsed = parseLineJson(framed.raw);
357
+ if (!parsed.ok) {
358
+ if (parsed.reason === "bad-json") stats.badJson += 1;
359
+ else countUnknown(parsed.reason);
360
+ return [];
361
+ }
362
+
363
+ let result;
364
+ try {
365
+ result = mapLine(parsed.obj, { now, maxPreviewChars });
366
+ } catch (err) {
367
+ stats.mapperErrors += 1;
368
+ countUnknown(`mapper-error:${String(err?.message || err).slice(0, 60)}`);
369
+ if (notifyUnknown) {
370
+ try {
371
+ notifyUnknown("mapper-error", framed.raw);
372
+ } catch {
373
+ /* 回调抛错不影响采集 */
374
+ }
375
+ }
376
+ return [];
377
+ }
378
+
379
+ if (!result || result.ok !== true || !Array.isArray(result.events)) {
380
+ const reason = String(result?.reason || "unknown");
381
+ countUnknown(reason);
382
+ if (notifyUnknown) {
383
+ try {
384
+ notifyUnknown(reason, framed.raw);
385
+ } catch {
386
+ /* 同上 */
387
+ }
388
+ }
389
+ return [];
390
+ }
391
+
392
+ const out = [];
393
+ for (const ev of result.events) {
394
+ if (!ev || typeof ev !== "object" || typeof ev.type !== "string") {
395
+ countUnknown("bad-event");
396
+ continue;
397
+ }
398
+ seq += 1;
399
+ const full = { v: 1, ...ev, seq };
400
+ stats.events += 1;
401
+ stats.byType[full.type] = (stats.byType[full.type] || 0) + 1;
402
+ if (full.type === EVENT_TYPES.TURN_END) {
403
+ stats.byReason[full.reason] = (stats.byReason[full.reason] || 0) + 1;
404
+ }
405
+ if (sink) sink.write(full);
406
+ if (notifyEvent) {
407
+ try {
408
+ notifyEvent(full);
409
+ } catch {
410
+ /* 回调抛错不能影响采集 */
411
+ }
412
+ }
413
+ out.push(full);
414
+ }
415
+ return out;
416
+ },
417
+ stats() {
418
+ return {
419
+ ...stats,
420
+ byType: { ...stats.byType },
421
+ byReason: { ...stats.byReason },
422
+ unknownReasons: { ...stats.unknownReasons }
423
+ };
424
+ }
425
+ };
426
+ }
427
+
428
+ // ─────────────────────────────────────────────────────────────────────────────
429
+ // jsonl 落盘
430
+ // ─────────────────────────────────────────────────────────────────────────────
431
+
432
+ const DEFAULT_SINK_FS = {
433
+ appendFileSync: (p, data, opts) => fsSync.appendFileSync(p, data, opts)
434
+ };
435
+
436
+ /**
437
+ * append-only jsonl 落盘(**默认不启用**:不给 filePath 就什么都不写)。
438
+ *
439
+ * 落盘失败**静默**:只计数 + 告警一次,绝不抛回调用方(磁盘满不能影响正在跑的会话)。
440
+ *
441
+ * @param {{filePath?:string, fs?:object, log?:(e:object)=>void, now?:()=>number}} opts
442
+ */
443
+ export function createJsonlSink({ filePath, fs = DEFAULT_SINK_FS, log, now = () => Date.now() } = {}) {
444
+ const enabled = Boolean(filePath);
445
+ const logger = typeof log === "function" ? log : () => {};
446
+ const stats = { enabled, events: 0, bytes: 0, writeErrors: 0, firstErrorAt: 0, lastError: "" };
447
+ let warned = false;
448
+
449
+ return {
450
+ enabled,
451
+ filePath: enabled ? filePath : "",
452
+ write(event) {
453
+ if (!enabled) return false;
454
+ let line;
455
+ try {
456
+ line = JSON.stringify(event) + "\n";
457
+ } catch {
458
+ stats.writeErrors += 1;
459
+ return false;
460
+ }
461
+ try {
462
+ fs.appendFileSync(filePath, line, { encoding: "utf8" });
463
+ stats.events += 1;
464
+ stats.bytes += byteLength(line);
465
+ return true;
466
+ } catch (err) {
467
+ stats.writeErrors += 1;
468
+ if (!stats.firstErrorAt) stats.firstErrorAt = now();
469
+ stats.lastError = String(err?.code || err?.message || err).slice(0, 120);
470
+ if (!warned) {
471
+ warned = true;
472
+ try {
473
+ logger({ level: "warn", event: "events.sink-error", error: stats.lastError });
474
+ } catch {
475
+ /* 日志失败也不影响 */
476
+ }
477
+ }
478
+ return false;
479
+ }
480
+ },
481
+ stats() {
482
+ return { ...stats };
483
+ }
484
+ };
485
+ }
486
+
487
+ /**
488
+ * Claude Code 的现成映射器(第 3 周 API,行为保持不变)。
489
+ *
490
+ * @param {{sink?:object, onEvent?:(e:object)=>void, onUnknown?:Function, now?:()=>number,
491
+ * maxLineBytes?:number, maxPreviewChars?:number}} [opts]
492
+ */
493
+ export function createEventMapper({
494
+ sink = null,
495
+ onEvent,
496
+ onUnknown,
497
+ now = () => Date.now(),
498
+ maxLineBytes = DEFAULT_MAX_LINE_BYTES,
499
+ maxPreviewChars = DEFAULT_MAX_PREVIEW_CHARS
500
+ } = {}) {
501
+ return createJsonlDriver({
502
+ mapLine: mapClaudeObject,
503
+ sink,
504
+ onEvent,
505
+ onUnknown,
506
+ now,
507
+ maxLineBytes,
508
+ maxPreviewChars,
509
+ driverName: "claude"
510
+ });
511
+ }