dsh-any-agent 0.6.1 → 0.6.3
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 +96 -1
- package/README.md +47 -6
- package/README.zh.md +48 -7
- package/SECURITY.md +46 -0
- package/USAGE-NOTES.en.md +6 -2
- package/USAGE-NOTES.md +10 -2
- package/bin/dsh-any-agent.mjs +2 -1
- package/docs/en/architecture.md +12 -0
- package/docs/en/limits.md +6 -0
- package/docs/en/live-view-design.md +177 -0
- package/docs/en/reference.md +1 -0
- package/docs/en/release.md +21 -14
- package/docs/en/tests.md +6 -5
- package/docs/en/usage.md +3 -1
- package/docs/zh/architecture.md +12 -0
- package/docs/zh/limits.md +6 -0
- package/docs/zh/live-view-design.md +12 -6
- package/docs/zh/reference.md +1 -0
- package/docs/zh/release.md +20 -11
- package/docs/zh/tests.md +6 -5
- package/docs/zh/usage.md +3 -1
- package/package.json +6 -5
- package/src/conversation.mjs +69 -1
- package/src/doctor.mjs +63 -2
- package/src/live-cli.mjs +86 -22
- package/src/live-dashboard.html +477 -53
- package/src/live-view.mjs +281 -24
- package/DESIGN.md +0 -484
- package/docs/brand/README.md +0 -58
- package/docs/brand/header.jpg +0 -0
- package/scripts/check-release.mjs +0 -73
- package/scripts/commit.mjs +0 -122
- package/scripts/detect-dsh.mjs +0 -55
- package/scripts/lint.mjs +0 -595
- package/scripts/sync-test-counts.mjs +0 -699
package/src/conversation.mjs
CHANGED
|
@@ -40,7 +40,7 @@ import { rssMb } from './procstat.mjs';
|
|
|
40
40
|
import { readConversationMeta } from './titles.mjs';
|
|
41
41
|
import { gitStatus, sameWorkspaceSnapshot } from './git-snapshot.mjs';
|
|
42
42
|
import { writeTombstone } from './hub-store.mjs';
|
|
43
|
-
import { emitLive, livePreview, removeLive, summarizeToolInput } from './live.mjs';
|
|
43
|
+
import { emitLive, livePreview, liveSettings, removeLive, summarizeToolInput } from './live.mjs';
|
|
44
44
|
import {
|
|
45
45
|
classifyHolder,
|
|
46
46
|
describeLeaseConflict,
|
|
@@ -158,6 +158,61 @@ export class Conversation {
|
|
|
158
158
|
emitLive(this.id, type, { run: this.#liveRunId(), turn: this.currentTurn, ...fields }, detail);
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/**
|
|
162
|
+
* live 事件流的 token 跟踪:`{runId, base, at, last}`。
|
|
163
|
+
* DSH 在投影缓存里维护**会话累计**的 tokenUsage.totals,并在回合中每个模型步之后更新
|
|
164
|
+
* (真机实测);回合开始时记下基线,之后发的是"本回合增量"。
|
|
165
|
+
*/
|
|
166
|
+
#liveUsage = null;
|
|
167
|
+
|
|
168
|
+
/** 读 DSH 投影缓存里的 token 累计与上下文压力;读不到返回 null。 */
|
|
169
|
+
#readTokenUsage() {
|
|
170
|
+
try {
|
|
171
|
+
const meta = readConversationMeta(this.id);
|
|
172
|
+
const t = meta?.usage?.totals;
|
|
173
|
+
const p = meta?.pressure;
|
|
174
|
+
if (!t && !p) return null;
|
|
175
|
+
const n = (v) => (Number.isFinite(v) && v > 0 ? v : 0);
|
|
176
|
+
return {
|
|
177
|
+
totals: t
|
|
178
|
+
? { input: n(t.uncachedInputTokens), output: n(t.outputTokens), cache_read: n(t.cacheReadTokens), cache_write: n(t.cacheWriteTokens) }
|
|
179
|
+
: null,
|
|
180
|
+
context: p && Number.isFinite(p.contextWindow) ? { used: n(p.pressureTokens ?? p.surfaceTokens), window: p.contextWindow } : null,
|
|
181
|
+
};
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 发一条 `usage` 事件(本回合 token 增量 + 会话累计 + 上下文占用)。
|
|
189
|
+
* 回合中节流到 2 秒一次、且只在数字变化时发;回合结束后 force 再发一次最终值。
|
|
190
|
+
*/
|
|
191
|
+
#liveUsageTick({ force = false } = {}) {
|
|
192
|
+
const u = this.#liveUsage;
|
|
193
|
+
if (!u) return;
|
|
194
|
+
const t = now();
|
|
195
|
+
if (!force && t - u.at < 2000) return;
|
|
196
|
+
u.at = t;
|
|
197
|
+
const cur = this.#readTokenUsage();
|
|
198
|
+
if (!cur?.totals && !cur?.context) return;
|
|
199
|
+
const base = u.base ?? { input: 0, output: 0, cache_read: 0, cache_write: 0 };
|
|
200
|
+
const tokens = cur.totals
|
|
201
|
+
? Object.fromEntries(Object.keys(base).map((k) => [k, Math.max(0, cur.totals[k] - base[k])]))
|
|
202
|
+
: null;
|
|
203
|
+
const sig = JSON.stringify([tokens, cur.context]);
|
|
204
|
+
if (!force && sig === u.last) return;
|
|
205
|
+
u.last = sig;
|
|
206
|
+
emitLive(this.id, 'usage', {
|
|
207
|
+
run: u.runId,
|
|
208
|
+
turn: u.turn,
|
|
209
|
+
tokens,
|
|
210
|
+
session_tokens: cur.totals,
|
|
211
|
+
context: cur.context,
|
|
212
|
+
final: force || undefined,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
161
216
|
/** 审批裁决事件(decide / 超时 / 回合结束 / 关闭 统一走这里)。 */
|
|
162
217
|
#liveApprovalDecided(approvalId, by, decision = null, effective = null) {
|
|
163
218
|
this.#live('approval.decided', { approval_id: approvalId, by, decision, effective });
|
|
@@ -598,6 +653,7 @@ export class Conversation {
|
|
|
598
653
|
}
|
|
599
654
|
case 'usage_update':
|
|
600
655
|
c.usage = u;
|
|
656
|
+
this.#liveUsageTick();
|
|
601
657
|
break;
|
|
602
658
|
case 'plan':
|
|
603
659
|
case 'plan_update':
|
|
@@ -635,6 +691,7 @@ export class Conversation {
|
|
|
635
691
|
},
|
|
636
692
|
{ title: livePreview(merged.title, 200), input_preview: summarizeToolInput(merged.input) },
|
|
637
693
|
);
|
|
694
|
+
this.#liveUsageTick();
|
|
638
695
|
}
|
|
639
696
|
|
|
640
697
|
async onPermissionRequest(params) {
|
|
@@ -789,6 +846,7 @@ export class Conversation {
|
|
|
789
846
|
runRecord.status = 'error';
|
|
790
847
|
runRecord.error = error?.message ?? String(error);
|
|
791
848
|
runRecord.elapsed_ms = 0;
|
|
849
|
+
if (this.#liveUsage?.runId === runRecord.run_id) this.#liveUsage = null;
|
|
792
850
|
emitLive(
|
|
793
851
|
this.id,
|
|
794
852
|
'run.end',
|
|
@@ -912,6 +970,10 @@ export class Conversation {
|
|
|
912
970
|
this.pushOut('user', text.length > MAX_PROMPT_ECHO ? `${text.slice(0, MAX_PROMPT_ECHO)}…(已截断)` : text);
|
|
913
971
|
this.pushOut('turn', `第 ${this.currentTurn} 轮开始`, { phase: 'start' });
|
|
914
972
|
this.#liveTools.clear();
|
|
973
|
+
// live 关闭时不读投影缓存(emitLive 会直接丢弃,读了也白读)。
|
|
974
|
+
this.#liveUsage = liveSettings().level === 'off'
|
|
975
|
+
? null
|
|
976
|
+
: { runId: runRecord.run_id, turn: this.currentTurn, base: this.#readTokenUsage()?.totals ?? null, at: 0, last: '' };
|
|
915
977
|
this.#live(
|
|
916
978
|
'run.start',
|
|
917
979
|
{ via, permission: this.permission, on_approval: this.onApproval, cwd: this.cwd },
|
|
@@ -1019,6 +1081,12 @@ export class Conversation {
|
|
|
1019
1081
|
this.#scheduleDrain();
|
|
1020
1082
|
// 到这里才允许被 git 拖慢;结果/审核单/哨兵必须等快照完成后再落。
|
|
1021
1083
|
const after = await afterPromise;
|
|
1084
|
+
// 回合结束后 DSH 的 token 累计已经落盘(真机实测在哨兵之前):发一次最终值。
|
|
1085
|
+
// 只发给本回合自己的跟踪器——下一回合可能已经在 git 快照期间开始了。
|
|
1086
|
+
if (this.#liveUsage?.runId === runRecord.run_id) {
|
|
1087
|
+
this.#liveUsageTick({ force: true });
|
|
1088
|
+
this.#liveUsage = null;
|
|
1089
|
+
}
|
|
1022
1090
|
const result = {
|
|
1023
1091
|
conversation_id: this.id,
|
|
1024
1092
|
run_id: runRecord.run_id,
|
package/src/doctor.mjs
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh_doctor:只读体检(DSH 可执行文件与版本、profile、状态文件、runs/locks 目录、
|
|
3
|
-
*
|
|
3
|
+
* live 事件流、孤儿锁、会话数与 RSS、生效配置)。只报告,不修改任何东西
|
|
4
|
+
* (目录可写性用一个临时探测文件,写完立刻删掉)。
|
|
4
5
|
*
|
|
5
6
|
* 从 tools.mjs 拆出来:它是处理器表里最长的一项,且只依赖 `hub` 的两个只读字段
|
|
6
7
|
* (conversations / hosts)与一批探测函数。返回值形状与原处理器一致:
|
|
7
8
|
* `{ structured: { ok, verdict, checks }, text }`。
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
|
-
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
11
12
|
import { join } from 'node:path';
|
|
12
13
|
import {
|
|
13
14
|
DEFAULT_PERMISSION,
|
|
@@ -34,6 +35,58 @@ import {
|
|
|
34
35
|
import { inspectProfile } from './bootstrap.mjs';
|
|
35
36
|
import { listOrphans, LOCKS_DIR } from './locks.mjs';
|
|
36
37
|
import { rssMb } from './procstat.mjs';
|
|
38
|
+
import { liveSettings } from './live.mjs';
|
|
39
|
+
|
|
40
|
+
/** live 目录占用超过这个值就提醒(TTL 正常时远到不了)。 */
|
|
41
|
+
const LIVE_WARN_BYTES = 500 * 1024 * 1024;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 体检 live 事件流:级别、目录可写、会话数、占用、最近一条事件的时间。
|
|
45
|
+
* 返回 `[status, detail, fix]`,供 add('live', …) 使用。
|
|
46
|
+
*/
|
|
47
|
+
export function inspectLive({ dir = liveSettings().dir, level = liveSettings().level, now = Date.now() } = {}) {
|
|
48
|
+
if (level === 'off') {
|
|
49
|
+
return ['ok', `已关闭(DSH_MCP_LIVE=off):watch / dashboard 看不到新的派活;目录 ${dir}`, null];
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
mkdirSync(dir, { recursive: true });
|
|
53
|
+
const probe = join(dir, `.doctor-${process.pid}`);
|
|
54
|
+
writeFileSync(probe, '');
|
|
55
|
+
rmSync(probe, { force: true });
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return [
|
|
58
|
+
'warn',
|
|
59
|
+
`级别=${level},但目录不可写:${dir}(${e.message})—— 派活不受影响,只是 watch / dashboard 看不到事件`,
|
|
60
|
+
'检查权限,或用 DSH_MCP_LIVE_DIR 指向可写目录',
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
let convs = 0;
|
|
64
|
+
let bytes = 0;
|
|
65
|
+
let newest = 0;
|
|
66
|
+
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
|
67
|
+
if (!ent.isDirectory()) continue;
|
|
68
|
+
convs++;
|
|
69
|
+
for (const f of readdirSync(join(dir, ent.name), { withFileTypes: true })) {
|
|
70
|
+
if (!f.isFile()) continue;
|
|
71
|
+
try {
|
|
72
|
+
const st = statSync(join(dir, ent.name, f.name));
|
|
73
|
+
bytes += st.size;
|
|
74
|
+
if (st.mtimeMs > newest) newest = st.mtimeMs;
|
|
75
|
+
} catch {
|
|
76
|
+
/* 正在滚动 / 被清理:跳过 */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const mb = (bytes / 1024 / 1024).toFixed(1);
|
|
81
|
+
const age = newest ? Math.round((now - newest) / 60000) : null;
|
|
82
|
+
const last = newest ? (age < 1 ? '最近一条事件:刚刚' : `最近一条事件:${age} 分钟前`) : '还没有任何事件';
|
|
83
|
+
const big = bytes > LIVE_WARN_BYTES;
|
|
84
|
+
return [
|
|
85
|
+
big ? 'warn' : 'ok',
|
|
86
|
+
`级别=${level},${dir},${convs} 个会话,${mb}MB;${last}`,
|
|
87
|
+
big ? '占用偏大:检查 DSH_MCP_SENTINEL_TTL_MS 是否被设成 0(关闭清理),或改用 DSH_MCP_LIVE=meta' : null,
|
|
88
|
+
];
|
|
89
|
+
}
|
|
37
90
|
|
|
38
91
|
export async function runDoctor(hub) {
|
|
39
92
|
const checks = [];
|
|
@@ -148,6 +201,14 @@ export async function runDoctor(hub) {
|
|
|
148
201
|
}
|
|
149
202
|
}
|
|
150
203
|
|
|
204
|
+
// live 事件流(watch / dashboard 的数据源)。可选功能、写失败不影响派活,
|
|
205
|
+
// 所以有问题只报 warn,不报 fail;但"为什么 watch 里什么都看不到"要能从这里查出来。
|
|
206
|
+
try {
|
|
207
|
+
add('live', ...inspectLive());
|
|
208
|
+
} catch (e) {
|
|
209
|
+
add('live', 'warn', e.message);
|
|
210
|
+
}
|
|
211
|
+
|
|
151
212
|
try {
|
|
152
213
|
const orphans = listOrphans();
|
|
153
214
|
add(
|
package/src/live-cli.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { LIVE_DIR, STATE_FILE } from './config.mjs';
|
|
9
|
-
import { LiveTail, renderText } from './live-view.mjs';
|
|
9
|
+
import { LiveTail, TEXT, detectLang, diffFrame, layoutFrame, renderText } from './live-view.mjs';
|
|
10
10
|
import { readConversationMeta } from './titles.mjs';
|
|
11
11
|
|
|
12
12
|
function parseFlags(args) {
|
|
@@ -36,14 +36,17 @@ export function makeTail(dir = LIVE_DIR) {
|
|
|
36
36
|
});
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export const WATCH_USAGE =
|
|
40
|
-
'用法: dsh-any-agent watch [会话id前缀] [--once] [--interval 毫秒] [--runs N] [--items N] [--no-color]\n'
|
|
39
|
+
export const WATCH_USAGE = {
|
|
40
|
+
zh: '用法: dsh-any-agent watch [会话id前缀] [--once] [--interval 毫秒] [--runs N] [--items N] [--no-color] [--lang zh|en]\n',
|
|
41
|
+
en: 'usage: dsh-any-agent watch [conversation id prefix] [--once] [--interval ms] [--runs N] [--items N] [--no-color] [--lang zh|en]\n',
|
|
42
|
+
};
|
|
41
43
|
|
|
42
44
|
/** watch:终端里实时显示调用链(Ctrl+C 退出)。`--once` 打印一次就退出。 */
|
|
43
45
|
export async function runWatch(args, { out = process.stdout } = {}) {
|
|
44
46
|
const { flags, positional } = parseFlags(args);
|
|
47
|
+
const lang = detectLang(typeof flags.lang === 'string' ? flags.lang : null);
|
|
45
48
|
if (flags.help || flags.h) {
|
|
46
|
-
out.write(WATCH_USAGE);
|
|
49
|
+
out.write(WATCH_USAGE[lang]);
|
|
47
50
|
return 0;
|
|
48
51
|
}
|
|
49
52
|
const tail = makeTail(typeof flags.dir === 'string' ? flags.dir : LIVE_DIR);
|
|
@@ -52,6 +55,7 @@ export async function runWatch(args, { out = process.stdout } = {}) {
|
|
|
52
55
|
runs: intFlag(flags.runs, 3, 1, 30),
|
|
53
56
|
items: intFlag(flags.items, 12, 1, 300),
|
|
54
57
|
color: !flags['no-color'] && Boolean(out.isTTY),
|
|
58
|
+
lang,
|
|
55
59
|
};
|
|
56
60
|
tail.poll();
|
|
57
61
|
if (flags.once) {
|
|
@@ -59,19 +63,42 @@ export async function runWatch(args, { out = process.stdout } = {}) {
|
|
|
59
63
|
return 0;
|
|
60
64
|
}
|
|
61
65
|
const interval = intFlag(flags.interval, 500, 100, 10_000);
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
const tty = Boolean(out.isTTY);
|
|
67
|
+
const clock = () => new Date().toTimeString().slice(0, 8);
|
|
68
|
+
const frameText = () => renderText(tail.view(), opts) + `\n\n\x1b[2m${TEXT[lang].footer(clock(), tail.dir)}\x1b[0m`;
|
|
69
|
+
|
|
70
|
+
// ★ 在 TTY 上用"备用屏幕"原地刷新(同 top / htop):每帧只重写变化的行。
|
|
71
|
+
// 旧实现每次 `ESC[2J` 清屏,Windows Terminal 会把被清掉的整屏推进回滚区,
|
|
72
|
+
// 往上一翻就是一摞重复的帧。备用屏幕没有回滚区,退出时原样恢复用户的终端。
|
|
73
|
+
let prev = null;
|
|
74
|
+
let lastText = '';
|
|
75
|
+
const draw = (force = false) => {
|
|
76
|
+
const text = frameText();
|
|
77
|
+
if (!tty) {
|
|
78
|
+
// 管道 / 重定向:没有光标控制,内容变了才追加一帧(去掉时间行再比较,免得每次都算变化)。
|
|
79
|
+
const body = text.slice(0, text.lastIndexOf('\n\n'));
|
|
80
|
+
if (body !== lastText) out.write(text.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '') + '\n\n');
|
|
81
|
+
lastText = body;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const next = layoutFrame(text, { cols: out.columns ?? 120, rows: out.rows ?? 40, lang });
|
|
85
|
+
const seq = diffFrame(force ? null : prev, next);
|
|
86
|
+
if (seq) out.write(seq);
|
|
87
|
+
prev = next;
|
|
88
|
+
};
|
|
89
|
+
const restore = () => {
|
|
90
|
+
if (tty) out.write('\x1b[0m\x1b[?25h\x1b[?1049l');
|
|
65
91
|
};
|
|
66
|
-
|
|
67
|
-
|
|
92
|
+
if (tty) out.write('\x1b[?1049h\x1b[?25l');
|
|
93
|
+
process.once('exit', restore);
|
|
94
|
+
draw(true);
|
|
95
|
+
const onResize = () => draw(true);
|
|
96
|
+
if (tty) out.on?.('resize', onResize);
|
|
68
97
|
await new Promise((resolve) => {
|
|
69
98
|
const timer = setInterval(() => {
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
lastDraw = Date.now();
|
|
74
|
-
}
|
|
99
|
+
// 有新事件才重算;时间行每秒在变,所以每次都会写,但 diffFrame 只重写真正变了的那几行。
|
|
100
|
+
tail.poll();
|
|
101
|
+
draw();
|
|
75
102
|
}, interval);
|
|
76
103
|
const stop = () => {
|
|
77
104
|
clearInterval(timer);
|
|
@@ -80,16 +107,55 @@ export async function runWatch(args, { out = process.stdout } = {}) {
|
|
|
80
107
|
process.once('SIGINT', stop);
|
|
81
108
|
process.once('SIGTERM', stop);
|
|
82
109
|
});
|
|
110
|
+
out.off?.('resize', onResize);
|
|
111
|
+
restore();
|
|
112
|
+
process.off('exit', restore);
|
|
83
113
|
return 0;
|
|
84
114
|
}
|
|
85
115
|
|
|
86
|
-
export const DASHBOARD_USAGE =
|
|
116
|
+
export const DASHBOARD_USAGE = {
|
|
117
|
+
zh: '用法: dsh-any-agent dashboard [--port 端口] [--interval 毫秒] [--open] [--lang zh|en]\n',
|
|
118
|
+
en: 'usage: dsh-any-agent dashboard [--port port] [--interval ms] [--open] [--lang zh|en]\n',
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const DASHBOARD_BANNER = {
|
|
122
|
+
zh: (url, dir) =>
|
|
123
|
+
`DSH live dashboard(只读)\n 打开: ${url}\n live 目录: ${dir}\n 链接里带一次性 token,只在本机 127.0.0.1 可访问;Ctrl+C 退出。\n`,
|
|
124
|
+
en: (url, dir) =>
|
|
125
|
+
`DSH live dashboard (read-only)\n open: ${url}\n live dir: ${dir}\n The link carries a one-time token and only works on 127.0.0.1; Ctrl+C to quit.\n`,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 用系统默认浏览器打开地址。失败不抛错(打印的地址照样能手动打开)。
|
|
130
|
+
* 地址里的 token 只含 base64url 字符,不会被 cmd 的 `start` 当成元字符。
|
|
131
|
+
*
|
|
132
|
+
* ★ Windows:`start` 的第一个带引号参数是窗口标题,所以要先给一个空标题。
|
|
133
|
+
* 必须传空字符串 ''(Node 会把它转成 `""`);传字面量 '""' 会被转义成 `"\"\""`,
|
|
134
|
+
* cmd 收到的就不是空标题了(实测)。
|
|
135
|
+
*/
|
|
136
|
+
export function openInBrowser(url, { platform = process.platform, spawnFn } = {}) {
|
|
137
|
+
const [cmd, args] =
|
|
138
|
+
platform === 'win32'
|
|
139
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
140
|
+
: platform === 'darwin'
|
|
141
|
+
? ['open', [url]]
|
|
142
|
+
: ['xdg-open', [url]];
|
|
143
|
+
return import('node:child_process')
|
|
144
|
+
.then(({ spawn }) => {
|
|
145
|
+
const child = (spawnFn ?? spawn)(cmd, args, { stdio: 'ignore', detached: true, windowsHide: true });
|
|
146
|
+
child.on?.('error', () => {});
|
|
147
|
+
child.unref?.();
|
|
148
|
+
return true;
|
|
149
|
+
})
|
|
150
|
+
.catch(() => false);
|
|
151
|
+
}
|
|
87
152
|
|
|
88
153
|
/** dashboard:起一个只读本地页面(127.0.0.1 + 一次性 token),Ctrl+C 退出。 */
|
|
89
154
|
export async function runDashboard(args, { out = process.stdout } = {}) {
|
|
90
155
|
const { flags } = parseFlags(args);
|
|
156
|
+
const lang = detectLang(typeof flags.lang === 'string' ? flags.lang : null);
|
|
91
157
|
if (flags.help || flags.h) {
|
|
92
|
-
out.write(DASHBOARD_USAGE);
|
|
158
|
+
out.write(DASHBOARD_USAGE[lang]);
|
|
93
159
|
return 0;
|
|
94
160
|
}
|
|
95
161
|
const { startDashboard } = await import('./live-dashboard.mjs');
|
|
@@ -99,12 +165,10 @@ export async function runDashboard(args, { out = process.stdout } = {}) {
|
|
|
99
165
|
port: intFlag(flags.port, 0, 0, 65535),
|
|
100
166
|
pollMs: intFlag(flags.interval, 500, 100, 10_000),
|
|
101
167
|
});
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
` 链接里带一次性 token,只在本机 127.0.0.1 可访问;Ctrl+C 退出。\n`,
|
|
107
|
-
);
|
|
168
|
+
// 页面自己也会按浏览器语言选;这里把命令行的语言带过去,两边一致(页面上还能再切换)。
|
|
169
|
+
const url = `${dash.url}&lang=${lang}`;
|
|
170
|
+
out.write(DASHBOARD_BANNER[lang](url, tail.dir));
|
|
171
|
+
if (flags.open) await openInBrowser(url);
|
|
108
172
|
await new Promise((resolve) => {
|
|
109
173
|
const stop = () => resolve();
|
|
110
174
|
process.once('SIGINT', stop);
|