dsh-any-agent 0.6.0 → 0.6.2

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/src/live.mjs ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * live 事件流:把"DSH 在做什么"写成按会话分的 JSONL,给用户旁路查看。
3
+ *
4
+ * 为什么是文件而不是端口:本服务是 MCP 客户端拉起的 stdio 子进程,可能同时有好几个实例
5
+ * (Claude Code / Codex / Cursor 各一个),彼此内存不通。文件天然跨实例、跨重启;同一会话
6
+ * 同一时刻只有一个写者(写锁保证),所以"一会话一文件、单写者追加"不需要再加锁。
7
+ * 查看端(`dsh-any-agent watch` / `dashboard`,见 live-view.mjs)只读这些文件。
8
+ *
9
+ * 纪律(与 audit.mjs 相同):
10
+ * - 同步追加一行;**所有异常吞掉**,写失败绝不影响派活。
11
+ * - 思考内容在事件格式里**没有位置**:没有任何事件类型接收它,任何级别都不落盘。
12
+ * - 字符串一律先过 sanitizeApprovalContext 脱敏,再截断。
13
+ *
14
+ * 布局:`<LIVE_DIR>/<conversation_id>/events.jsonl`(满了滚动成 `events.jsonl.1`,只留一份)。
15
+ * 按会话分目录是为了直接复用 pruneDirTree 做 TTL 清理。
16
+ * 数据契约见 docs/zh/live-view-design.md §6。
17
+ */
18
+
19
+ import { appendFileSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
20
+ import { randomBytes } from 'node:crypto';
21
+ import { join } from 'node:path';
22
+ import { LIVE_DIR, LIVE_LEVEL, LIVE_LEVELS } from './config.mjs';
23
+ import { sanitizeApprovalContext } from './redact.mjs';
24
+ import { pruneDirTree } from './sentinel.mjs';
25
+
26
+ export const LIVE_SCHEMA = 1;
27
+ export const LIVE_FILE = 'events.jsonl';
28
+ /** 单文件上限;超过就滚动成 `.1`(只留一份旧档)。 */
29
+ const MAX_FILE_BYTES = 5 * 1024 * 1024;
30
+ /** 单行上限;超过先丢掉 detail 字段,仍超就不写。 */
31
+ const MAX_LINE_BYTES = 4096;
32
+ /** 会话 id 会拼进路径:只认安全字符(同 ids.mjs 的约束,宽松到能放下 DSH 的 uuid)。 */
33
+ const SAFE_ID = /^[A-Za-z0-9._-]{1,128}$/;
34
+ /** 这些事件带上客户端名,查看端用它把会话挂到"谁派的"下面。 */
35
+ const CLIENT_TYPES = new Set(['conv.open', 'run.queued', 'run.start']);
36
+
37
+ /** 写者标识:进程 pid + 本次启动的随机串。查看端用 pid 判断写者是否还活着。 */
38
+ export const WRITER = `${process.pid}:${randomBytes(4).toString('hex')}`;
39
+
40
+ const settings = { dir: LIVE_DIR, level: LIVE_LEVEL };
41
+ let client = null;
42
+ let seq = 0;
43
+ /** 本进程已知的文件大小(首次写时 stat 一次,之后自己累加),用于滚动判断。 */
44
+ const sizes = new Map();
45
+
46
+ /** 测试 / 工具用:覆盖目录与级别。 */
47
+ export function configureLive({ dir, level } = {}) {
48
+ if (typeof dir === 'string' && dir) settings.dir = dir;
49
+ if (LIVE_LEVELS.includes(level)) settings.level = level;
50
+ sizes.clear();
51
+ return { ...settings };
52
+ }
53
+
54
+ export function liveSettings() {
55
+ return { ...settings };
56
+ }
57
+
58
+ /** MCP initialize 里的 clientInfo(Claude Code / Codex / …)。 */
59
+ export function setLiveClient(info) {
60
+ if (!info || typeof info !== 'object') return;
61
+ const name = typeof info.name === 'string' ? info.name.slice(0, 80) : null;
62
+ const version = typeof info.version === 'string' ? info.version.slice(0, 40) : null;
63
+ if (name) client = { name, version };
64
+ }
65
+
66
+ export function liveClient() {
67
+ return client ? { ...client } : null;
68
+ }
69
+
70
+ export function liveConvDir(convId, dir = settings.dir) {
71
+ return join(dir, convId);
72
+ }
73
+
74
+ export function liveFilePath(convId, dir = settings.dir) {
75
+ return join(dir, convId, LIVE_FILE);
76
+ }
77
+
78
+ /** 脱敏 + 截断成一行预览。对象先逐字段脱敏再序列化。 */
79
+ export function livePreview(value, max = 300) {
80
+ if (value === undefined || value === null) return null;
81
+ let s;
82
+ try {
83
+ if (typeof value === 'string') s = sanitizeApprovalContext(value);
84
+ else s = JSON.stringify(sanitizeApprovalContext(value));
85
+ } catch {
86
+ return null;
87
+ }
88
+ if (typeof s !== 'string' || !s) return null;
89
+ s = s.replace(/\s+/g, ' ').trim();
90
+ return s.length > max ? `${s.slice(0, max)}…` : s;
91
+ }
92
+
93
+ /**
94
+ * 工具输入摘要:优先取最能说明"做了什么"的字段(命令、路径、搜索词),否则整体序列化。
95
+ * 返回值已脱敏、已截断。
96
+ */
97
+ export function summarizeToolInput(input, max = 300) {
98
+ if (input === undefined || input === null) return null;
99
+ if (typeof input !== 'object') return livePreview(String(input), max);
100
+ const pick = (...keys) => {
101
+ for (const k of keys) {
102
+ const v = input[k];
103
+ if (typeof v === 'string' && v.trim()) return v;
104
+ if (Array.isArray(v) && v.length && v.every((x) => typeof x === 'string')) return v.join(' ');
105
+ }
106
+ return null;
107
+ };
108
+ const cmd = pick('command', 'cmd', 'script');
109
+ if (cmd) return livePreview(cmd, max);
110
+ const path = pick('path', 'file_path', 'filePath', 'file', 'paths', 'target');
111
+ const query = pick('pattern', 'query', 'glob', 'regex', 'url');
112
+ if (path || query) return livePreview([path, query].filter(Boolean).join(' · '), max);
113
+ return livePreview(input, max);
114
+ }
115
+
116
+ /**
117
+ * 写一条事件。永不抛错;返回是否真的写了。
118
+ *
119
+ * @param {string} convId 会话 id
120
+ * @param {string} type 事件类型(run.start / tool / approval / …)
121
+ * @param {object} fields meta 级字段(任何级别都写)
122
+ * @param {object} [detail] detail 级字段(仅 level=detail 时写;调用方负责先脱敏/截断)
123
+ */
124
+ export function emitLive(convId, type, fields = {}, detail = null) {
125
+ if (settings.level === 'off') return false;
126
+ if (typeof convId !== 'string' || !SAFE_ID.test(convId)) return false;
127
+ try {
128
+ const ev = { v: LIVE_SCHEMA, ts: new Date().toISOString(), conv: convId, w: WRITER, n: ++seq, type };
129
+ if (CLIENT_TYPES.has(type) && client) ev.client = client.name;
130
+ for (const [k, v] of Object.entries(fields ?? {})) if (v !== undefined) ev[k] = v;
131
+ let line = JSON.stringify(ev);
132
+ if (settings.level === 'detail' && detail) {
133
+ const withDetail = { ...ev };
134
+ for (const [k, v] of Object.entries(detail)) if (v !== undefined && v !== null) withDetail[k] = v;
135
+ const full = JSON.stringify(withDetail);
136
+ if (Buffer.byteLength(full) <= MAX_LINE_BYTES) line = full;
137
+ else line = JSON.stringify({ ...ev, truncated: true });
138
+ }
139
+ line += '\n';
140
+ const bytes = Buffer.byteLength(line);
141
+ if (bytes > MAX_LINE_BYTES + 1) return false;
142
+
143
+ const dir = liveConvDir(convId);
144
+ const file = join(dir, LIVE_FILE);
145
+ mkdirSync(dir, { recursive: true });
146
+ let size = sizes.get(file);
147
+ if (size === undefined) {
148
+ try {
149
+ size = statSync(file).size;
150
+ } catch {
151
+ size = 0;
152
+ }
153
+ }
154
+ if (size + bytes > MAX_FILE_BYTES && size > 0) {
155
+ try {
156
+ renameSync(file, `${file}.1`);
157
+ } catch {
158
+ /* 被占用就继续往原文件写,下次再滚 */
159
+ }
160
+ size = 0;
161
+ }
162
+ appendFileSync(file, line, 'utf8');
163
+ sizes.set(file, size + bytes);
164
+ return true;
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+
170
+ /** forget 时删掉该会话的事件目录。 */
171
+ export function removeLive(convId) {
172
+ if (typeof convId !== 'string' || !SAFE_ID.test(convId)) return false;
173
+ try {
174
+ const dir = liveConvDir(convId);
175
+ for (const k of [...sizes.keys()]) if (k.startsWith(dir)) sizes.delete(k);
176
+ rmSync(dir, { recursive: true, force: true });
177
+ return true;
178
+ } catch {
179
+ return false;
180
+ }
181
+ }
182
+
183
+ /** 启动时按 TTL 清理过期事件文件(与哨兵同一 TTL)。 */
184
+ export function pruneLive(ttlMs) {
185
+ return pruneDirTree(settings.dir, ttlMs);
186
+ }
package/src/mcp.mjs CHANGED
@@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url';
11
11
  import { createHandlers, TOOL_DEFS } from './tools.mjs';
12
12
  import { DEFAULT_PERMISSION } from './config.mjs';
13
13
  import { LineDecoder } from './line-decoder.mjs';
14
+ import { setLiveClient } from './live.mjs';
14
15
 
15
16
  const SERVER_NAME = 'dsh-any-agent';
16
17
  /**
@@ -156,6 +157,8 @@ export function createMcpServer({ hub, toolDefs = TOOL_DEFS, handlers = createHa
156
157
  // 默认 cwd —— 用户在客户端里看到哪个项目,就在这里干活。
157
158
  const caps = params?.capabilities;
158
159
  clientCapabilities = caps && typeof caps === 'object' ? caps : {};
160
+ // live 事件流用客户端名把会话挂到"谁派的"下面(仅名字/版本,不含其它字段)。
161
+ setLiveClient(params?.clientInfo);
159
162
  ok(id, {
160
163
  protocolVersion: typeof requested === 'string' ? requested : FALLBACK_PROTOCOL,
161
164
  capabilities: { tools: { listChanged: false } },
package/src/residue.mjs CHANGED
@@ -15,6 +15,7 @@
15
15
  * - runs/<id>/ 完成哨兵
16
16
  * - reviews/<id>/ 调用方审核记录
17
17
  * - locks/<id>.json 写锁持有者登记
18
+ * - live/<id>/ live 事件流(watch / dashboard 的数据源)
18
19
  *
19
20
  * ★ 护栏(和 purgeTestSessions 同一条):只清"能证明是测试会话"的。证据按可信度:
20
21
  * 会话头里的 cwd > 元数据侧车里的 cwd > 注册表条目里的 cwd。
@@ -28,7 +29,7 @@
28
29
  import { existsSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
29
30
  import { dirname, join } from 'node:path';
30
31
  import { tmpdir } from 'node:os';
31
- import { REVIEWS_DIR, RUNS_DIR, STATE_FILE } from './config.mjs';
32
+ import { LIVE_DIR, REVIEWS_DIR, RUNS_DIR, STATE_FILE } from './config.mjs';
32
33
  import { LOCKS_DIR } from './locks.mjs';
33
34
  import { isTestSessionCwd, pruneTestWorkspaces, purgeOrphanProjcache, purgeTestSessions } from './workspace.mjs';
34
35
 
@@ -49,12 +50,13 @@ function resolveState(spec) {
49
50
  runsDir: s.runsDir ?? join(dir, 'runs'),
50
51
  reviewsDir: s.reviewsDir ?? join(dir, 'reviews'),
51
52
  locksDir: s.locksDir ?? join(dir, 'locks'),
53
+ liveDir: s.liveDir ?? join(dir, 'live'),
52
54
  };
53
55
  }
54
56
 
55
57
  /** 当前进程配置的状态目录(= MCP 服务默认用的那份,通常是 `~/.dsh/.dsh-any-agent`)。 */
56
58
  export function defaultState() {
57
- return { stateFile: STATE_FILE, runsDir: RUNS_DIR, reviewsDir: REVIEWS_DIR, locksDir: LOCKS_DIR };
59
+ return { stateFile: STATE_FILE, runsDir: RUNS_DIR, reviewsDir: REVIEWS_DIR, locksDir: LOCKS_DIR, liveDir: LIVE_DIR };
58
60
  }
59
61
 
60
62
  const readJson = (file) => {
@@ -105,7 +107,7 @@ export function purgeProjectState({ ids, states, headerCwds = new Map(), trusted
105
107
  const trustedSet = trusted === true ? null : new Set(Array.isArray(trusted) ? trusted : []);
106
108
  const isTrusted = (id) => trusted === true || (trustedSet?.has(id) ?? false);
107
109
  const specs = (states?.length ? states : [defaultState()]).map(resolveState);
108
- const res = { removed: { registry: 0, meta: 0, runs: 0, reviews: 0, locks: 0 }, refused: [], errors: [] };
110
+ const res = { removed: { registry: 0, meta: 0, runs: 0, reviews: 0, locks: 0, live: 0 }, refused: [], errors: [] };
109
111
  const wanted = [...new Set((ids ?? []).filter((x) => typeof x === 'string' && SAFE_ID.test(x)))];
110
112
 
111
113
  for (const st of specs) {
@@ -133,6 +135,7 @@ export function purgeProjectState({ ids, states, headerCwds = new Map(), trusted
133
135
  for (const id of allowed) {
134
136
  if (removePath(join(st.runsDir, id), dryRun)) res.removed.runs++;
135
137
  if (removePath(join(st.reviewsDir, id), dryRun)) res.removed.reviews++;
138
+ if (removePath(join(st.liveDir, id), dryRun)) res.removed.live++;
136
139
  if (removePath(join(st.locksDir, `${id}.json`), dryRun)) res.removed.locks++;
137
140
  if (removePath(join(st.metaDir, `${id}.json`), dryRun)) res.removed.meta++;
138
141
  // 侧车写到一半崩溃会留 `<id>.json.tmp.<pid>.<rand>`
@@ -215,6 +218,7 @@ export function sweepStateDir({ state, dryRun = false, removeEmptyDirs = false }
215
218
  };
216
219
  collect(spec.runsDir, false);
217
220
  collect(spec.reviewsDir, false);
221
+ collect(spec.liveDir, false);
218
222
  collect(spec.locksDir, true);
219
223
  collect(spec.metaDir, true);
220
224
  try {