dsh-plugin-agent-tree 0.0.0-stage → 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/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.en.md +118 -0
- package/README.md +136 -2
- package/client.js +1261 -0
- package/cordis.patch.yml +8 -0
- package/docs/assets/screenshot.png +0 -0
- package/docs/compat.md +43 -0
- package/docs/install.en.md +87 -0
- package/docs/install.md +95 -0
- package/docs/manual.en.md +197 -0
- package/docs/manual.md +199 -0
- package/index.js +541 -0
- package/package.json +68 -4
package/index.js
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dsh-plugin-agent-tree` — 宿主半部(v0.1.0)。
|
|
3
|
+
*
|
|
4
|
+
* 唯一职责:给浏览器端一个只读的小接口 `GET /local/dsh-agent-tree/processes`,
|
|
5
|
+
* 报告本机上正在跑的 claude / codex CLI 进程(浏览器自己看不到进程表),
|
|
6
|
+
* 以及 codex 自己的会话线程:
|
|
7
|
+
* - 进程树:`ps` + `lsof` 读 pid / ppid / cwd / etime,按 ppid 归组;
|
|
8
|
+
* - codex 会话:读 `~/.codex/sessions/…/rollout-*.jsonl` 的 `session_meta`
|
|
9
|
+
* 头(按 `parent_thread_id` 还原 subagent 线程树,首条真实用户消息当行名)。
|
|
10
|
+
*
|
|
11
|
+
* 只读本机、不联网、不外发;所有读取都有超时和缓存,任何一步失败都只是少一行,
|
|
12
|
+
* 不影响 DSH 启动。
|
|
13
|
+
*
|
|
14
|
+
* 设计原则:绝不因为这一半出错而影响 DSH 启动。
|
|
15
|
+
* - 路由注册失败只 warn,不影响其它插件;
|
|
16
|
+
* - 不注入 connection 服务,鉴权走 best-effort(拿得到就用它挡未授权请求);
|
|
17
|
+
* - 每次最多扫 8 个匹配进程,lsof 有超时,结果缓存 3 秒。
|
|
18
|
+
*/
|
|
19
|
+
import { execFile } from 'node:child_process';
|
|
20
|
+
import { open, readdir, stat } from 'node:fs/promises';
|
|
21
|
+
import { homedir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
|
|
24
|
+
export const name = 'dsh-agent-tree';
|
|
25
|
+
|
|
26
|
+
// webServer 在 web 组合里必然存在(官方 web 插件都用它);只用这一个注入。
|
|
27
|
+
export const inject = ['webServer'];
|
|
28
|
+
|
|
29
|
+
const ROUTE_PATH = '/local/dsh-agent-tree/processes';
|
|
30
|
+
const CACHE_MS = 3000;
|
|
31
|
+
const MAX_PROCESSES = 8;
|
|
32
|
+
const MAX_CHILDREN = 12;
|
|
33
|
+
const MAX_CHILD_DEPTH = 3;
|
|
34
|
+
const PS_TIMEOUT_MS = 4000;
|
|
35
|
+
const LSOF_TIMEOUT_MS = 1500;
|
|
36
|
+
|
|
37
|
+
// codex 的子对话(subagent threads):读它自己的 rollout 记录,只读、只在本机。
|
|
38
|
+
const CODEX_LOOKBACK_MS = 48 * 3600 * 1000;
|
|
39
|
+
const CODEX_MAX_FILES = 240;
|
|
40
|
+
const CODEX_HEADER_BYTES = 4 * 1024 * 1024;
|
|
41
|
+
const CODEX_ACTIVE_MS = 120 * 1000;
|
|
42
|
+
const CODEX_SCAN_DAYS = 3;
|
|
43
|
+
const MAX_THREADS = 8;
|
|
44
|
+
const MAX_THREAD_DEPTH = 3;
|
|
45
|
+
|
|
46
|
+
/** 可执行文件名(小写精确):只认真正的 CLI,不认 ChatGPT.app 里的 Codex 框架/助手进程。 */
|
|
47
|
+
const AGENT_EXECUTABLES = new Set(['claude', 'codex']);
|
|
48
|
+
|
|
49
|
+
/** 子孙进程的可读标签(顺序有意义:先匹配更具体的)。 */
|
|
50
|
+
const CHILD_HINTS = [
|
|
51
|
+
[/cua-repl|node_repl|SkyComputerUseService|server\.mjs/i, 'computer use'],
|
|
52
|
+
[/code-mode-host/i, 'code mode'],
|
|
53
|
+
[/mcp-server/i, 'MCP'],
|
|
54
|
+
[/\(Renderer\)/i, 'renderer'],
|
|
55
|
+
[/crashpad/i, 'crashpad'],
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/** 跑一个短命令,失败就给空字符串(探测型调用,不需要抛错)。 */
|
|
59
|
+
function run(file, args, timeout) {
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
try {
|
|
62
|
+
execFile(file, args, { timeout, maxBuffer: 1 << 20, windowsHide: true }, (error, stdout) => {
|
|
63
|
+
resolve(error ? '' : String(stdout));
|
|
64
|
+
});
|
|
65
|
+
} catch (err) {
|
|
66
|
+
resolve('');
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `01:23` / `1-02:03:04` → 秒。 */
|
|
72
|
+
function elapsedSeconds(etime) {
|
|
73
|
+
if (typeof etime !== 'string' || etime.length === 0) return 0;
|
|
74
|
+
const [daysPart, clockPart] = etime.includes('-') ? etime.split('-') : ['0', etime];
|
|
75
|
+
const days = Number(daysPart) || 0;
|
|
76
|
+
const clock = clockPart.split(':').map(Number);
|
|
77
|
+
while (clock.length < 3) clock.unshift(0);
|
|
78
|
+
const [hours, minutes, seconds] = clock;
|
|
79
|
+
return days * 86400 + hours * 3600 + minutes * 60 + seconds;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 从命令行里认出 claude / codex CLI;不是就返回 null。 */
|
|
83
|
+
export function detectAgent(command) {
|
|
84
|
+
if (typeof command !== 'string' || command.length === 0) return null;
|
|
85
|
+
if (command.includes('dsh-agent-tree') || command.includes('ps -axo')) return null;
|
|
86
|
+
for (const token of command.split(/\s+/)) {
|
|
87
|
+
if (token.length === 0 || token.startsWith('-')) continue;
|
|
88
|
+
const base = token.split('/').pop().replace(/\.(js|mjs|cjs)$/, '');
|
|
89
|
+
if (AGENT_EXECUTABLES.has(base)) return base === 'codex' ? 'codex' : 'claude';
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 解析 `ps -axo pid=,ppid=,etime=,pcpu=,command=`(保留全部行,agent 判定在 kind 上)。 */
|
|
95
|
+
export function parseProcessTable(text) {
|
|
96
|
+
const rows = [];
|
|
97
|
+
for (const line of String(text).split('\n')) {
|
|
98
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+([\d.]+)\s+(.*)$/.exec(line);
|
|
99
|
+
if (match === null) continue;
|
|
100
|
+
const command = match[5];
|
|
101
|
+
rows.push({
|
|
102
|
+
pid: Number(match[1]),
|
|
103
|
+
ppid: Number(match[2]),
|
|
104
|
+
kind: detectAgent(command),
|
|
105
|
+
elapsedSeconds: elapsedSeconds(match[3]),
|
|
106
|
+
cpu: Number(match[4]) || 0,
|
|
107
|
+
command,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return rows;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 一行能看懂的子孙进程标签:codex app-server 的 computer-use / MCP 子进程等。 */
|
|
114
|
+
export function childLabel(command) {
|
|
115
|
+
const text = String(command || '');
|
|
116
|
+
for (const [pattern, label] of CHILD_HINTS) {
|
|
117
|
+
if (pattern.test(text)) return label;
|
|
118
|
+
}
|
|
119
|
+
const first = text.split(/\s+/)[0] || '';
|
|
120
|
+
return baseName(first).replace(/\.(js|mjs|cjs|ts)$/, '') || 'child';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 从 ps 行里挑出 claude/codex 根进程,并把它们的非 agent 后代挂成子树。 */
|
|
124
|
+
export function buildProcessRoots(rows, maxRoots = MAX_PROCESSES) {
|
|
125
|
+
const childrenOf = new Map();
|
|
126
|
+
for (const row of rows) {
|
|
127
|
+
if (!childrenOf.has(row.ppid)) childrenOf.set(row.ppid, []);
|
|
128
|
+
childrenOf.get(row.ppid).push(row);
|
|
129
|
+
}
|
|
130
|
+
const roots = rows
|
|
131
|
+
.filter((row) => row.kind !== null)
|
|
132
|
+
.sort((a, b) => b.elapsedSeconds - a.elapsedSeconds)
|
|
133
|
+
.slice(0, maxRoots);
|
|
134
|
+
return roots.map((root) => {
|
|
135
|
+
const children = [];
|
|
136
|
+
const walk = (pid, depth) => {
|
|
137
|
+
if (depth > MAX_CHILD_DEPTH || children.length >= MAX_CHILDREN) return;
|
|
138
|
+
for (const child of childrenOf.get(pid) || []) {
|
|
139
|
+
// 嵌套的 agent 自己就是一个根,别重复挂
|
|
140
|
+
if (child.kind !== null) continue;
|
|
141
|
+
children.push({
|
|
142
|
+
pid: child.pid,
|
|
143
|
+
label: childLabel(child.command),
|
|
144
|
+
elapsedSeconds: child.elapsedSeconds,
|
|
145
|
+
cpu: child.cpu,
|
|
146
|
+
});
|
|
147
|
+
walk(child.pid, depth + 1);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
walk(root.pid, 1);
|
|
151
|
+
children.sort((a, b) => b.elapsedSeconds - a.elapsedSeconds);
|
|
152
|
+
return { ...root, children: children.slice(0, MAX_CHILDREN) };
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 命令行里显式写出的工作目录(`--cwd /x`、`--cd=/x`、`-C /x`),拿不到就空串。 */
|
|
157
|
+
export function argCwd(command) {
|
|
158
|
+
const match = /(?:--cwd|--cd|-C)[=\s]+([^\s"'`]+)/.exec(String(command || ''));
|
|
159
|
+
return match === null ? '' : match[1];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------- codex 子对话
|
|
163
|
+
//
|
|
164
|
+
// codex(CLI 与 ChatGPT 桌面端)每次会话都会写 `<CODEX_HOME>/sessions/<y>/<m>/<d>/rollout-*.jsonl`,
|
|
165
|
+
// 第一行是 `session_meta`,里面有 cwd、是否 subagent、父线程 id、昵称、agent 路径。
|
|
166
|
+
// 所以「codex 的子对话」不用猜:按 parent_thread_id 就能还原成树。
|
|
167
|
+
|
|
168
|
+
/** codex 的数据目录(`CODEX_HOME` 优先)。 */
|
|
169
|
+
export function codexHome() {
|
|
170
|
+
const fromEnv = process.env.CODEX_HOME;
|
|
171
|
+
return typeof fromEnv === 'string' && fromEnv !== '' ? fromEnv : join(homedir(), '.codex');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** rollout 里的一行 `session_meta` → 归一化后的线程信息;第一条不是 meta 就返回 null。 */
|
|
175
|
+
export function parseRolloutMeta(text) {
|
|
176
|
+
const lines = String(text || '').split('\n');
|
|
177
|
+
for (const line of lines) {
|
|
178
|
+
if (line.trim() === '') continue;
|
|
179
|
+
let obj;
|
|
180
|
+
try {
|
|
181
|
+
obj = JSON.parse(line);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
return null; // 头部被截断/不是 JSON:当作读不到
|
|
184
|
+
}
|
|
185
|
+
if (obj === null || typeof obj !== 'object' || obj.type !== 'session_meta') return null;
|
|
186
|
+
const payload = obj.payload;
|
|
187
|
+
if (payload === null || typeof payload !== 'object') return null;
|
|
188
|
+
const source = payload.source !== null && typeof payload.source === 'object' ? payload.source : {};
|
|
189
|
+
const subagent = source.subagent !== null && typeof source.subagent === 'object' ? source.subagent : {};
|
|
190
|
+
const spawn = subagent.thread_spawn !== null && typeof subagent.thread_spawn === 'object' ? subagent.thread_spawn : {};
|
|
191
|
+
const id = typeof payload.id === 'string' ? payload.id : '';
|
|
192
|
+
return {
|
|
193
|
+
id,
|
|
194
|
+
sessionId: typeof payload.session_id === 'string' ? payload.session_id : '',
|
|
195
|
+
parentId: typeof spawn.parent_thread_id === 'string' ? spawn.parent_thread_id : '',
|
|
196
|
+
depth: Number(spawn.depth) || 0,
|
|
197
|
+
isSubagent: payload.thread_source === 'subagent' || Object.keys(spawn).length > 0,
|
|
198
|
+
nickname: typeof payload.agent_nickname === 'string' ? payload.agent_nickname : '',
|
|
199
|
+
agentPath: typeof payload.agent_path === 'string' ? payload.agent_path : '',
|
|
200
|
+
cwd: typeof payload.cwd === 'string' ? payload.cwd : '',
|
|
201
|
+
originator: typeof payload.originator === 'string' ? payload.originator : '',
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 从文件头读若干行(rollout 的第一行可能很大,含完整 base_instructions)。 */
|
|
208
|
+
async function readHeader(path) {
|
|
209
|
+
let handle;
|
|
210
|
+
try {
|
|
211
|
+
handle = await open(path, 'r');
|
|
212
|
+
} catch (err) {
|
|
213
|
+
return '';
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
const buffer = Buffer.allocUnsafe(CODEX_HEADER_BYTES);
|
|
217
|
+
const { bytesRead } = await handle.read(buffer, 0, CODEX_HEADER_BYTES, 0);
|
|
218
|
+
return buffer.subarray(0, bytesRead).toString('utf8');
|
|
219
|
+
} catch (err) {
|
|
220
|
+
return '';
|
|
221
|
+
} finally {
|
|
222
|
+
await handle.close().catch(() => {});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** 最近几天的 rollout 文件(按 mtime 倒序,最多 CODEX_MAX_FILES 个)。 */
|
|
227
|
+
async function recentRollouts(now) {
|
|
228
|
+
const root = join(codexHome(), 'sessions');
|
|
229
|
+
const files = [];
|
|
230
|
+
for (let back = 0; back < CODEX_SCAN_DAYS && files.length < CODEX_MAX_FILES * 4; back += 1) {
|
|
231
|
+
const day = new Date(now - back * 86400 * 1000);
|
|
232
|
+
const dir = join(
|
|
233
|
+
root,
|
|
234
|
+
String(day.getFullYear()),
|
|
235
|
+
String(day.getMonth() + 1).padStart(2, '0'),
|
|
236
|
+
String(day.getDate()).padStart(2, '0'),
|
|
237
|
+
);
|
|
238
|
+
let names;
|
|
239
|
+
try {
|
|
240
|
+
names = await readdir(dir);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
for (const name of names) {
|
|
245
|
+
if (!name.startsWith('rollout-') || !name.endsWith('.jsonl')) continue;
|
|
246
|
+
const path = join(dir, name);
|
|
247
|
+
let info;
|
|
248
|
+
try {
|
|
249
|
+
info = await stat(path);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (now - info.mtimeMs > CODEX_LOOKBACK_MS) continue;
|
|
254
|
+
files.push({ path, mtime: info.mtimeMs });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
files.sort((a, b) => b.mtime - a.mtime);
|
|
258
|
+
return files.slice(0, CODEX_MAX_FILES);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const metaCache = new Map();
|
|
262
|
+
|
|
263
|
+
/** 最近 rollout 的 meta 列表(按 path+mtime 缓存,避免每 3 秒重读全部文件)。 */
|
|
264
|
+
async function recentCodexMetas(now) {
|
|
265
|
+
const metas = [];
|
|
266
|
+
for (const file of await recentRollouts(now)) {
|
|
267
|
+
const hit = metaCache.get(file.path);
|
|
268
|
+
if (hit !== undefined && hit.mtime === file.mtime) {
|
|
269
|
+
metas.push(hit.meta);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const meta = parseRolloutMeta(await readHeader(file.path));
|
|
273
|
+
if (meta === null) continue;
|
|
274
|
+
const entry = { ...meta, path: file.path, mtime: file.mtime };
|
|
275
|
+
metaCache.set(file.path, { mtime: file.mtime, meta: entry });
|
|
276
|
+
metas.push(entry);
|
|
277
|
+
}
|
|
278
|
+
return metas;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** 这些前缀是 codex 自己塞的上下文包裹,不算用户真正说的话。 */
|
|
282
|
+
const PROMPT_SKIP =
|
|
283
|
+
/^\s*(<(environment_context|app-context|user_instructions|user-instructions|system|developer)|#\s*AGENTS\.md|<INSTRUCTIONS)/i;
|
|
284
|
+
|
|
285
|
+
/** 从一行 rollout 事件里取「用户真正说的那句话」,取不到返回空串。 */
|
|
286
|
+
export function userPromptOf(event) {
|
|
287
|
+
if (event === null || typeof event !== 'object') return '';
|
|
288
|
+
const payload = event.payload !== null && typeof event.payload === 'object' ? event.payload : {};
|
|
289
|
+
if (event.type === 'response_item' && payload.role === 'user') {
|
|
290
|
+
const content = Array.isArray(payload.content) ? payload.content : [];
|
|
291
|
+
for (const item of content) {
|
|
292
|
+
const text = item !== null && typeof item === 'object' && typeof item.text === 'string' ? item.text : '';
|
|
293
|
+
if (text !== '' && !PROMPT_SKIP.test(text)) return text;
|
|
294
|
+
}
|
|
295
|
+
return '';
|
|
296
|
+
}
|
|
297
|
+
if (event.type === 'event_msg' && payload.type === 'user_message' && typeof payload.message === 'string') {
|
|
298
|
+
return PROMPT_SKIP.test(payload.message) ? '' : payload.message;
|
|
299
|
+
}
|
|
300
|
+
return '';
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** 压成一行、截短,用来当行标题。 */
|
|
304
|
+
export function shortPrompt(text, limit = 36) {
|
|
305
|
+
const flat = String(text || '').replace(/\s+/g, ' ').trim();
|
|
306
|
+
if (flat === '') return '';
|
|
307
|
+
return flat.length > limit ? `${flat.slice(0, limit - 1)}…` : flat;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** 读 rollout 里第一条用户消息(最多读 2MB),拿不到就空串。 */
|
|
311
|
+
export async function firstUserPrompt(path) {
|
|
312
|
+
let handle;
|
|
313
|
+
try {
|
|
314
|
+
handle = await open(path, 'r');
|
|
315
|
+
} catch (err) {
|
|
316
|
+
return '';
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
let buffer = '';
|
|
320
|
+
const chunk = Buffer.allocUnsafe(65536);
|
|
321
|
+
let total = 0;
|
|
322
|
+
while (total < 2 * 1024 * 1024) {
|
|
323
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, total);
|
|
324
|
+
if (bytesRead <= 0) break;
|
|
325
|
+
total += bytesRead;
|
|
326
|
+
buffer += chunk.subarray(0, bytesRead).toString('utf8');
|
|
327
|
+
let newline = buffer.indexOf('\n');
|
|
328
|
+
while (newline >= 0) {
|
|
329
|
+
const line = buffer.slice(0, newline);
|
|
330
|
+
buffer = buffer.slice(newline + 1);
|
|
331
|
+
newline = buffer.indexOf('\n');
|
|
332
|
+
if (line.trim() === '') continue;
|
|
333
|
+
let event;
|
|
334
|
+
try {
|
|
335
|
+
event = JSON.parse(line);
|
|
336
|
+
} catch (err) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const prompt = userPromptOf(event);
|
|
340
|
+
if (prompt !== '') return prompt;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return '';
|
|
344
|
+
} catch (err) {
|
|
345
|
+
return '';
|
|
346
|
+
} finally {
|
|
347
|
+
await handle.close().catch(() => {});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const titleCache = new Map();
|
|
352
|
+
|
|
353
|
+
/** 某条 rollout 的会话标题(首条用户消息,截短;按 path+mtime 缓存)。 */
|
|
354
|
+
export async function promptTitleOf(root) {
|
|
355
|
+
if (root === undefined || typeof root.path !== 'string') return '';
|
|
356
|
+
const hit = titleCache.get(root.path);
|
|
357
|
+
if (hit !== undefined && hit.mtime === root.mtime) return hit.title;
|
|
358
|
+
const title = shortPrompt(await firstUserPrompt(root.path));
|
|
359
|
+
titleCache.set(root.path, { mtime: root.mtime, title });
|
|
360
|
+
return title;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** 导出给自检/排查用:本机最近 rollout 的 meta 列表。 */
|
|
364
|
+
export async function collectCodexMetas(now = Date.now()) {
|
|
365
|
+
return await recentCodexMetas(now);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** 纯函数:给一个根线程 id,收集它下面(最多 MAX_THREADS 个、最深 MAX_THREAD_DEPTH 层)的子对话。 */
|
|
369
|
+
export function codexThreadsUnder(metas, rootId, now) {
|
|
370
|
+
if (!Array.isArray(metas) || typeof rootId !== 'string' || rootId === '') return [];
|
|
371
|
+
const children = new Map();
|
|
372
|
+
for (const meta of metas) {
|
|
373
|
+
if (!meta || !meta.isSubagent || meta.parentId === '') continue;
|
|
374
|
+
const list = children.get(meta.parentId) || [];
|
|
375
|
+
list.push(meta);
|
|
376
|
+
children.set(meta.parentId, list);
|
|
377
|
+
}
|
|
378
|
+
const out = [];
|
|
379
|
+
const walk = (parentId, depth) => {
|
|
380
|
+
if (depth > MAX_THREAD_DEPTH) return;
|
|
381
|
+
const list = (children.get(parentId) || []).slice().sort((a, b) => b.mtime - a.mtime);
|
|
382
|
+
for (const meta of list) {
|
|
383
|
+
if (out.length >= MAX_THREADS) return;
|
|
384
|
+
const idle = Math.max(0, Math.round((now - meta.mtime) / 1000));
|
|
385
|
+
out.push({
|
|
386
|
+
nickname: meta.nickname || baseName(meta.agentPath) || '子对话',
|
|
387
|
+
agentPath: meta.agentPath || '',
|
|
388
|
+
depth,
|
|
389
|
+
idleSeconds: idle,
|
|
390
|
+
running: now - meta.mtime < CODEX_ACTIVE_MS,
|
|
391
|
+
});
|
|
392
|
+
walk(meta.id, depth + 1);
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
walk(rootId, 1);
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* 纯函数:给一个 codex 进程,挑出它对应的主线程(rollout 的根)。
|
|
401
|
+
*
|
|
402
|
+
* 匹配顺序:同 cwd → cwd 互为前缀(进程在 worktree 里跑)→ 桌面端进程优先挑
|
|
403
|
+
* `originator` 带 Desktop 的会话(ChatGPT.app 里的 codex 进程 cwd 常常是 `/`)→ 最新的主线程。
|
|
404
|
+
* @param metas - `collectCodexMetas` 的结果
|
|
405
|
+
* @param cwd - 该进程的工作目录
|
|
406
|
+
* @param options - `preferDesktop`:进程来自 ChatGPT / Codex 桌面端
|
|
407
|
+
*/
|
|
408
|
+
export function pickCodexRoot(metas, cwd, options = {}) {
|
|
409
|
+
if (!Array.isArray(metas)) return undefined;
|
|
410
|
+
const exclude = options !== null && typeof options === 'object' && options.exclude instanceof Set ? options.exclude : null;
|
|
411
|
+
const roots = metas.filter((meta) => meta && !meta.isSubagent && (exclude === null || !exclude.has(meta.id)));
|
|
412
|
+
if (roots.length === 0) return undefined;
|
|
413
|
+
const sorted = roots.slice().sort((a, b) => b.mtime - a.mtime);
|
|
414
|
+
if (typeof cwd === 'string' && cwd !== '' && cwd !== '/') {
|
|
415
|
+
const exact = sorted.find((meta) => meta.cwd === cwd);
|
|
416
|
+
if (exact !== undefined) return exact;
|
|
417
|
+
const nested = sorted.find(
|
|
418
|
+
(meta) => meta.cwd !== '' && (meta.cwd.startsWith(cwd) || cwd.startsWith(meta.cwd)),
|
|
419
|
+
);
|
|
420
|
+
if (nested !== undefined) return nested;
|
|
421
|
+
}
|
|
422
|
+
if (options !== null && typeof options === 'object' && options.preferDesktop === true) {
|
|
423
|
+
const desktop = sorted.find((meta) => /desktop/i.test(meta.originator));
|
|
424
|
+
if (desktop !== undefined) return desktop;
|
|
425
|
+
}
|
|
426
|
+
return sorted[0];
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** 用 lsof 取进程工作目录;拿不到就空串。 */
|
|
430
|
+
async function cwdOf(pid) {
|
|
431
|
+
const out = await run('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], LSOF_TIMEOUT_MS);
|
|
432
|
+
const line = out.split('\n').find((item) => item.startsWith('n'));
|
|
433
|
+
return line === undefined ? '' : line.slice(1);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function baseName(path) {
|
|
437
|
+
const parts = String(path).split('/').filter(Boolean);
|
|
438
|
+
return parts.length === 0 ? String(path) : parts[parts.length - 1];
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
let cache = { at: 0, payload: null };
|
|
442
|
+
|
|
443
|
+
async function snapshot() {
|
|
444
|
+
const now = Date.now();
|
|
445
|
+
if (cache.payload !== null && now - cache.at < CACHE_MS) return cache.payload;
|
|
446
|
+
|
|
447
|
+
const table = await run('ps', ['-axo', 'pid=,ppid=,etime=,pcpu=,command='], PS_TIMEOUT_MS);
|
|
448
|
+
const rows = buildProcessRoots(parseProcessTable(table));
|
|
449
|
+
const codexMetas = rows.some((row) => row.kind === 'codex') ? await recentCodexMetas(now) : [];
|
|
450
|
+
const processes = [];
|
|
451
|
+
const claimedRoots = new Set();
|
|
452
|
+
for (const row of rows) {
|
|
453
|
+
let cwd = await cwdOf(row.pid);
|
|
454
|
+
if (cwd === '' || cwd === '/') cwd = argCwd(row.command) || cwd;
|
|
455
|
+
const root =
|
|
456
|
+
row.kind === 'codex'
|
|
457
|
+
? pickCodexRoot(codexMetas, cwd, {
|
|
458
|
+
preferDesktop: /ChatGPT\.app|CodexCLI\.app|Codex\.app/i.test(row.command),
|
|
459
|
+
exclude: claimedRoots,
|
|
460
|
+
})
|
|
461
|
+
: undefined;
|
|
462
|
+
if (root !== undefined) claimedRoots.add(root.id);
|
|
463
|
+
const title = root === undefined ? '' : await promptTitleOf(root);
|
|
464
|
+
processes.push({
|
|
465
|
+
pid: row.pid,
|
|
466
|
+
kind: row.kind,
|
|
467
|
+
elapsedSeconds: row.elapsedSeconds,
|
|
468
|
+
cpu: row.cpu,
|
|
469
|
+
cwd,
|
|
470
|
+
label: title !== '' ? title : displayLabel(row.command, cwd),
|
|
471
|
+
sessionTitle: title,
|
|
472
|
+
children: row.children,
|
|
473
|
+
threads: root === undefined ? [] : codexThreadsUnder(codexMetas, root.id, now),
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
const payload = { at: now, processes };
|
|
477
|
+
cache = { at: now, payload };
|
|
478
|
+
return payload;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** 一行能看懂的来源标签:项目目录 > 显式 --cwd > 桌面端 backend > 空。 */
|
|
482
|
+
export function displayLabel(command, cwd) {
|
|
483
|
+
if (typeof cwd === 'string' && cwd !== '' && cwd !== '/') return baseName(cwd);
|
|
484
|
+
const explicit = argCwd(command);
|
|
485
|
+
if (explicit !== '') return baseName(explicit);
|
|
486
|
+
if (/CodexCLI\.app|ChatGPT\.app/.test(String(command || ''))) return 'ChatGPT';
|
|
487
|
+
return '';
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function sendJson(res, status, payload) {
|
|
491
|
+
res.statusCode = status;
|
|
492
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
493
|
+
res.setHeader('cache-control', 'no-store');
|
|
494
|
+
res.end(JSON.stringify(payload));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export function apply(ctx) {
|
|
498
|
+
ctx.effect(() => {
|
|
499
|
+
try {
|
|
500
|
+
if (!ctx.webServer || typeof ctx.webServer.register !== 'function') {
|
|
501
|
+
console.warn('[dsh-agent-tree] webServer unavailable; external-process route skipped');
|
|
502
|
+
return () => {};
|
|
503
|
+
}
|
|
504
|
+
return ctx.webServer.register({
|
|
505
|
+
kind: 'exact',
|
|
506
|
+
path: ROUTE_PATH,
|
|
507
|
+
handler: async (req, res) => {
|
|
508
|
+
// best-effort 鉴权:能拿到 connection 的拒答判定就用它
|
|
509
|
+
try {
|
|
510
|
+
const connection = typeof ctx.get === 'function' ? ctx.get('connection') : undefined;
|
|
511
|
+
const rejection =
|
|
512
|
+
connection && typeof connection.requestRejection === 'function'
|
|
513
|
+
? connection.requestRejection(req)
|
|
514
|
+
: undefined;
|
|
515
|
+
if (rejection !== undefined) {
|
|
516
|
+
res.statusCode = rejection;
|
|
517
|
+
res.end();
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
} catch (err) {
|
|
521
|
+
/* 鉴权探测失败不阻塞读取 */
|
|
522
|
+
}
|
|
523
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
524
|
+
res.statusCode = 405;
|
|
525
|
+
res.setHeader('allow', 'GET');
|
|
526
|
+
res.end();
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
sendJson(res, 200, await snapshot());
|
|
531
|
+
} catch (err) {
|
|
532
|
+
sendJson(res, 500, { error: String(err && err.message ? err.message : err) });
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
} catch (err) {
|
|
537
|
+
console.warn('[dsh-agent-tree] route registration failed:', err);
|
|
538
|
+
return () => {};
|
|
539
|
+
}
|
|
540
|
+
}, `dsh-agent-tree: GET ${ROUTE_PATH}`);
|
|
541
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-agent-tree",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DSH Web mini agent tree: a tiny always-on floating card showing workspace → conversation → subagent as a live tree, with per-row model, scheduled-task countdowns, and external claude/codex CLI processes.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"agent-tree",
|
|
10
|
+
"subagent",
|
|
11
|
+
"agent-team",
|
|
12
|
+
"observability",
|
|
13
|
+
"monitor",
|
|
14
|
+
"session",
|
|
15
|
+
"tree",
|
|
16
|
+
"claude",
|
|
17
|
+
"codex"
|
|
18
|
+
],
|
|
19
|
+
"homepage": "https://github.com/yuna78/dsh-agent-tree#readme",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/yuna78/dsh-agent-tree.git"
|
|
23
|
+
},
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/yuna78/dsh-agent-tree/issues"
|
|
26
|
+
},
|
|
27
|
+
"author": "Yuna",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"private": false,
|
|
30
|
+
"type": "module",
|
|
31
|
+
"main": "./index.js",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": "./index.js",
|
|
34
|
+
"./client": "./client.js",
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"index.js",
|
|
39
|
+
"client.js",
|
|
40
|
+
"cordis.patch.yml",
|
|
41
|
+
"docs/",
|
|
42
|
+
"README.md",
|
|
43
|
+
"README.en.md",
|
|
44
|
+
"CHANGELOG.md"
|
|
45
|
+
],
|
|
46
|
+
"dsh": {
|
|
47
|
+
"bundle": {
|
|
48
|
+
"patch": "./cordis.patch.yml"
|
|
49
|
+
},
|
|
50
|
+
"client": {
|
|
51
|
+
"platform": "web",
|
|
52
|
+
"immediately": true,
|
|
53
|
+
"inject": [
|
|
54
|
+
"@deepseek-ai/dsh-client-ui-workspace"
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": "^22.19.0 || >=24.0.0",
|
|
60
|
+
"dsh": ">=0.2.1-rc.2 <0.3.0"
|
|
61
|
+
},
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"@deepseek-ai/dsh": ">=0.2.1-rc.2 <0.3.0"
|
|
64
|
+
},
|
|
65
|
+
"scripts": {
|
|
66
|
+
"test": "node --test test/*.test.mjs",
|
|
67
|
+
"verify": "node verify/smoke.mjs",
|
|
68
|
+
"prepublishOnly": "node verify/smoke.mjs"
|
|
69
|
+
}
|
|
70
|
+
}
|