@epoch-agent/cli 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 +219 -0
- package/README.md +199 -0
- package/dist/chunk-3SCZQI5W.js +46 -0
- package/dist/env-file-JUOFDESY.js +15 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +4642 -0
- package/dist/schema-file-DH6X4N4K.js +19 -0
- package/dist/tui-entry.js +1106 -0
- package/package.json +46 -0
|
@@ -0,0 +1,1106 @@
|
|
|
1
|
+
// src/tui-entry.ts
|
|
2
|
+
import { getCapability as getCapability2, loadKeybindings } from "@epoch-agent/core";
|
|
3
|
+
import { keybindingsPath } from "@epoch-agent/infra";
|
|
4
|
+
import { diagnosticToLine } from "@epoch-agent/protocol";
|
|
5
|
+
import { buildRuntime, withModelScope, withToolScope } from "@epoch-agent/runtime";
|
|
6
|
+
import { DEFAULT_KEYBINDINGS, renderApp } from "@epoch-agent/tui";
|
|
7
|
+
|
|
8
|
+
// src/exit-codes.ts
|
|
9
|
+
var EXIT_CODES = {
|
|
10
|
+
/** 任务完成 */
|
|
11
|
+
SUCCESS: 0,
|
|
12
|
+
/** 通用失败(provider 起不来、运行时报错……) */
|
|
13
|
+
FAILURE: 1,
|
|
14
|
+
/**
|
|
15
|
+
* 非交互下有操作因**缺少预授权**被拒。
|
|
16
|
+
*
|
|
17
|
+
* 只在非交互路径出现:交互模式下用户亲手点的「拒绝」是他的决定,
|
|
18
|
+
* 不算 agent 被卡住,仍然退 0。
|
|
19
|
+
*/
|
|
20
|
+
PERMISSION_DENIED: 3,
|
|
21
|
+
/**
|
|
22
|
+
* 企业托管设置挡下了这次启动(方案 22)。
|
|
23
|
+
*
|
|
24
|
+
* 和 FAILURE 分开的理由同上:这不是「epoch 坏了」,是**策略生效了**。
|
|
25
|
+
* 管理员按机器批量铺开一条 `disableBypassPermissionsMode` 之后,
|
|
26
|
+
* 要能从退出码上一眼看出哪些机器是被自己的策略拦下的,
|
|
27
|
+
* 而不是去每台机器上读 stderr 猜。
|
|
28
|
+
*/
|
|
29
|
+
MANAGED_POLICY: 4,
|
|
30
|
+
/**
|
|
31
|
+
* `--max-turns` / `--max-budget-usd` 触顶,任务**没做完**就停了(方案 28)。
|
|
32
|
+
*
|
|
33
|
+
* 和 FAILURE 分开:CI 里这不是「epoch 坏了」,是**限额生效了** ——
|
|
34
|
+
* 该做的是看一眼输出决定要不要加额重跑,而不是去查日志。
|
|
35
|
+
* 跨会话预算(`budget.maxCostUsd`)触顶**不走这个码**,仍然退 0:
|
|
36
|
+
* 那是长期配置,每次都退非 0 会让流水线天天红。
|
|
37
|
+
*
|
|
38
|
+
* ⚠️ 方案 28 立项时写的是 `4`,那是 2026-08-08 的现状(当时只有 0/1/3)。
|
|
39
|
+
* `4` 后来被企业托管策略(方案 22)占了,而那个码已经在根 README 和
|
|
40
|
+
* MDM 文档里对外承诺过,不能挪。所以这里往后排。
|
|
41
|
+
*/
|
|
42
|
+
LIMIT_EXCEEDED: 5,
|
|
43
|
+
/**
|
|
44
|
+
* `--input-format stream-json` 的输入有坏行(方案 28)。
|
|
45
|
+
*
|
|
46
|
+
* **不是**用法错误(那是 1):命令敲对了,是宿主写进 stdin 的那一行不合协议。
|
|
47
|
+
* 坏行会被报到 stderr 并**跳过**(NDJSON 是按行分帧的,跳一行不会失步),
|
|
48
|
+
* 进程接着跑;这个码在收尾时才落。
|
|
49
|
+
*/
|
|
50
|
+
INPUT_ERROR: 6
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// src/errors.ts
|
|
54
|
+
var CliError = class extends Error {
|
|
55
|
+
constructor(message, exitCode = EXIT_CODES.FAILURE, hint) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.exitCode = exitCode;
|
|
58
|
+
this.hint = hint;
|
|
59
|
+
this.name = "CliError";
|
|
60
|
+
}
|
|
61
|
+
exitCode;
|
|
62
|
+
hint;
|
|
63
|
+
};
|
|
64
|
+
function isCancellation(err) {
|
|
65
|
+
if (!(err instanceof Error)) return false;
|
|
66
|
+
return err.name === "ExitPromptError" || err.name === "AbortError" || // AbortController.abort() 默认抛的就是这个
|
|
67
|
+
err.name === "Error" && err.message === "The operation was aborted.";
|
|
68
|
+
}
|
|
69
|
+
var EXPECTED_FAILURES = {
|
|
70
|
+
/** 企业托管策略挡下的启动(方案 22 §2.6)。不是故障,是策略生效了 */
|
|
71
|
+
ManagedPolicyError: EXIT_CODES.MANAGED_POLICY,
|
|
72
|
+
/** `--agent` 给了不认识的角色名(方案 29 验收 #13)。可用角色都在 message 里 */
|
|
73
|
+
AgentRoleError: EXIT_CODES.FAILURE,
|
|
74
|
+
/** `--settings` 指的文件不是合法 JSON(方案 29 验收 #16)。hint 里带行号 */
|
|
75
|
+
SettingsFileError: EXIT_CODES.FAILURE
|
|
76
|
+
};
|
|
77
|
+
function expectedFailureExitCode(err) {
|
|
78
|
+
if (!(err instanceof Error)) return void 0;
|
|
79
|
+
return EXPECTED_FAILURES[err.name];
|
|
80
|
+
}
|
|
81
|
+
function hintOf(err) {
|
|
82
|
+
const hint = err.hint;
|
|
83
|
+
return typeof hint === "string" && hint.length > 0 ? hint : void 0;
|
|
84
|
+
}
|
|
85
|
+
function reportFatal(err) {
|
|
86
|
+
if (isCancellation(err)) {
|
|
87
|
+
process.stderr.write("\n\u5DF2\u53D6\u6D88\n");
|
|
88
|
+
process.exit(130);
|
|
89
|
+
}
|
|
90
|
+
const exitCode = err instanceof CliError ? err.exitCode : expectedFailureExitCode(err);
|
|
91
|
+
if (exitCode !== void 0 && err instanceof Error) {
|
|
92
|
+
process.stderr.write(`\u9519\u8BEF: ${err.message}
|
|
93
|
+
`);
|
|
94
|
+
const hint = hintOf(err);
|
|
95
|
+
if (hint) process.stderr.write(`${hint}
|
|
96
|
+
`);
|
|
97
|
+
process.exit(exitCode);
|
|
98
|
+
}
|
|
99
|
+
process.stderr.write(`${err instanceof Error ? err.stack ?? err.message : String(err)}
|
|
100
|
+
`);
|
|
101
|
+
process.exit(EXIT_CODES.FAILURE);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/import-prompt.ts
|
|
105
|
+
import {
|
|
106
|
+
createImportGate,
|
|
107
|
+
ImportTrustStore,
|
|
108
|
+
isNonInteractive as isNonInteractive2,
|
|
109
|
+
loadConfig as loadConfig2,
|
|
110
|
+
resolveProjectRoot as resolveProjectRoot2,
|
|
111
|
+
scanInstructions,
|
|
112
|
+
setImportGate,
|
|
113
|
+
TrustManager as TrustManager2
|
|
114
|
+
} from "@epoch-agent/core";
|
|
115
|
+
import { trustedImportsPath, trustPath as trustPath2 } from "@epoch-agent/infra";
|
|
116
|
+
|
|
117
|
+
// src/trust-prompt.ts
|
|
118
|
+
import {
|
|
119
|
+
findProjectInstructions,
|
|
120
|
+
isNonInteractive,
|
|
121
|
+
loadConfig,
|
|
122
|
+
resolveProjectRoot,
|
|
123
|
+
TrustManager
|
|
124
|
+
} from "@epoch-agent/core";
|
|
125
|
+
import { trustPath } from "@epoch-agent/infra";
|
|
126
|
+
function trustPromptTarget(deps) {
|
|
127
|
+
if (!deps.gateEnabled) return null;
|
|
128
|
+
if (!deps.interactive) return null;
|
|
129
|
+
const root = resolveProjectRoot(deps.workDir);
|
|
130
|
+
if (deps.store.check(root) !== "unknown") return null;
|
|
131
|
+
const instructionsPath = findProjectInstructions(root, deps.workDir);
|
|
132
|
+
if (!instructionsPath) return null;
|
|
133
|
+
return { root, instructionsPath };
|
|
134
|
+
}
|
|
135
|
+
function applyTrustChoice(store, root, choice) {
|
|
136
|
+
if (choice.kind === "trust") {
|
|
137
|
+
store.record(root, "trusted", choice.scope);
|
|
138
|
+
const suffix = choice.scope === "directory-tree" ? "\uFF08\u542B\u5B50\u76EE\u5F55\uFF09" : "";
|
|
139
|
+
return `\u2713 \u5DF2\u4FE1\u4EFB ${root}${suffix}\uFF0C\u672C\u76EE\u5F55\u7684\u9879\u76EE\u6307\u4EE4\u4F1A\u52A0\u8F7D`;
|
|
140
|
+
}
|
|
141
|
+
if (choice.kind === "never") {
|
|
142
|
+
store.record(root, "untrusted", "directory");
|
|
143
|
+
return `\u2717 \u5DF2\u8BB0\u4E3A\u4E0D\u4FE1\u4EFB ${root}\uFF0C\u4E4B\u540E\u4E0D\u518D\u8BE2\u95EE\uFF08epoch trust rm ${root} \u53EF\u64A4\u9500\uFF09`;
|
|
144
|
+
}
|
|
145
|
+
return "\u672C\u6B21\u4E0D\u52A0\u8F7D\u9879\u76EE\u6307\u4EE4\u3002\u60F3\u8BA9\u5B83\u751F\u6548\u8FD0\u884C epoch trust add";
|
|
146
|
+
}
|
|
147
|
+
function trustPromptMessage(target) {
|
|
148
|
+
return [
|
|
149
|
+
`
|
|
150
|
+
\u26A0 \u68C0\u6D4B\u5230\u9879\u76EE\u6307\u4EE4\u6587\u4EF6\uFF1A${target.instructionsPath}`,
|
|
151
|
+
" \u5B83\u7531\u4ED3\u5E93\u4F5C\u8005\u7F16\u5199\uFF0C\u4E00\u65E6\u52A0\u8F7D\u5C31\u4F1A\u76F4\u63A5\u8FDB\u5165 system prompt\uFF0C\u7B49\u4E8E\u8BA9\u8FD9\u4E2A\u4ED3\u5E93",
|
|
152
|
+
" \u6307\u6325\u4F60\u7684 agent\u3002\u6240\u4EE5\u672A\u7ECF\u786E\u8BA4\u4E0D\u4F1A\u52A0\u8F7D\u3002",
|
|
153
|
+
""
|
|
154
|
+
].join("\n");
|
|
155
|
+
}
|
|
156
|
+
var CHOICES = [
|
|
157
|
+
{ name: "\u672C\u6B21\u4E0D\u52A0\u8F7D\uFF08\u4E0B\u6B21\u8FD8\u4F1A\u95EE\uFF09", value: { kind: "skip" } },
|
|
158
|
+
{ name: `\u4FE1\u4EFB\u672C\u76EE\u5F55`, value: { kind: "trust", scope: "directory" } },
|
|
159
|
+
{ name: "\u4FE1\u4EFB\u672C\u76EE\u5F55\u53CA\u5176\u6240\u6709\u5B50\u76EE\u5F55", value: { kind: "trust", scope: "directory-tree" } },
|
|
160
|
+
{ name: "\u6C38\u4E0D\u4FE1\u4EFB\u672C\u76EE\u5F55\uFF08\u4E0D\u518D\u8BE2\u95EE\uFF09", value: { kind: "never" } }
|
|
161
|
+
];
|
|
162
|
+
function restoreStdinDefault() {
|
|
163
|
+
const stdin = process.stdin;
|
|
164
|
+
if (stdin.isTTY) stdin.setRawMode?.(false);
|
|
165
|
+
stdin.resume();
|
|
166
|
+
}
|
|
167
|
+
async function maybePromptForTrust(opts = {}) {
|
|
168
|
+
const config = opts.config ?? loadConfig();
|
|
169
|
+
const store = opts.store ?? new TrustManager(trustPath(config.homeDir));
|
|
170
|
+
const print = opts.print ?? ((msg) => process.stdout.write(msg + "\n"));
|
|
171
|
+
const target = trustPromptTarget({
|
|
172
|
+
store,
|
|
173
|
+
interactive: (opts.isInteractive ?? (() => !isNonInteractive()))(),
|
|
174
|
+
gateEnabled: config.trust?.enabled !== false,
|
|
175
|
+
workDir: opts.workDir ?? process.cwd()
|
|
176
|
+
});
|
|
177
|
+
if (!target) return true;
|
|
178
|
+
print(trustPromptMessage(target));
|
|
179
|
+
let choice;
|
|
180
|
+
try {
|
|
181
|
+
choice = await (opts.ask ?? askTrustChoice)(target);
|
|
182
|
+
} finally {
|
|
183
|
+
(opts.restoreStdin ?? restoreStdinDefault)();
|
|
184
|
+
}
|
|
185
|
+
if (choice === null) return false;
|
|
186
|
+
try {
|
|
187
|
+
print(applyTrustChoice(store, target.root, choice));
|
|
188
|
+
} catch (err) {
|
|
189
|
+
print(`\u26A0 \u4FE1\u4EFB\u8BB0\u5F55\u5199\u5165\u5931\u8D25\uFF0C\u672C\u6B21\u4ECD\u6309\u4E0D\u4FE1\u4EFB\u5904\u7406\uFF1A${err instanceof Error ? err.message : err}`);
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
async function askTrustChoice(_target) {
|
|
194
|
+
const { select } = await import("@inquirer/prompts");
|
|
195
|
+
try {
|
|
196
|
+
return await select({
|
|
197
|
+
message: "\u662F\u5426\u4FE1\u4EFB\u672C\u76EE\u5F55\uFF1F",
|
|
198
|
+
choices: [...CHOICES],
|
|
199
|
+
default: CHOICES[0]?.value
|
|
200
|
+
});
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// src/import-prompt.ts
|
|
207
|
+
function externalImportTargets(deps) {
|
|
208
|
+
if (!deps.gateEnabled) return [];
|
|
209
|
+
if (!deps.interactive) return [];
|
|
210
|
+
if (deps.store.deniedAll) return [];
|
|
211
|
+
const root = resolveProjectRoot2(deps.workDir);
|
|
212
|
+
if (deps.trust.check(root) !== "trusted") return [];
|
|
213
|
+
const scan = scanInstructions(root, deps.workDir, {
|
|
214
|
+
allowExternal: (p) => deps.store.isAllowed(p)
|
|
215
|
+
});
|
|
216
|
+
const out = [];
|
|
217
|
+
const seen = /* @__PURE__ */ new Set();
|
|
218
|
+
for (const src of scan.sources) {
|
|
219
|
+
for (const node of src.imports) {
|
|
220
|
+
if (node.skipped !== "external-denied") continue;
|
|
221
|
+
if (seen.has(node.path)) continue;
|
|
222
|
+
seen.add(node.path);
|
|
223
|
+
out.push({ path: node.path, from: src.path, spec: node.spec });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
function importPromptMessage(target) {
|
|
229
|
+
return [
|
|
230
|
+
`
|
|
231
|
+
\u26A0 ${target.from} \u5F15\u7528\u4E86\u9879\u76EE\u4E4B\u5916\u7684\u6587\u4EF6\uFF1A`,
|
|
232
|
+
` ${target.path}`,
|
|
233
|
+
` \uFF08\u6587\u4EF6\u91CC\u5199\u7684\u662F @${target.spec}\uFF09`,
|
|
234
|
+
" \u5B83\u4E0D\u5728\u8FD9\u4E2A\u4ED3\u5E93\u91CC\uFF0C\u4E5F\u6CA1\u6709\u88AB\u300C\u4FE1\u4EFB\u8FD9\u4E2A\u76EE\u5F55\u300D\u90A3\u6B21\u5224\u5B9A\u8986\u76D6\u8FC7\u3002",
|
|
235
|
+
" \u52A0\u8F7D\u5B83\u7B49\u4E8E\u628A\u4E00\u4EFD\u6CA1\u88AB\u5BA1\u8FC7\u7684\u5185\u5BB9\u62FC\u8FDB system prompt\u3002",
|
|
236
|
+
""
|
|
237
|
+
].join("\n");
|
|
238
|
+
}
|
|
239
|
+
function applyImportChoice(store, target, choice) {
|
|
240
|
+
if (choice.kind === "once") {
|
|
241
|
+
store.allowOnce(target.path);
|
|
242
|
+
return `\u2713 \u672C\u6B21\u52A0\u8F7D ${target.path}\uFF08\u4E0B\u6B21\u8FD8\u4F1A\u95EE\uFF09`;
|
|
243
|
+
}
|
|
244
|
+
if (choice.kind === "remember") {
|
|
245
|
+
store.remember(target.path);
|
|
246
|
+
return `\u2713 \u5DF2\u8BB0\u4F4F ${target.path}\uFF0C\u4E4B\u540E\u4E0D\u518D\u8BE2\u95EE`;
|
|
247
|
+
}
|
|
248
|
+
if (choice.kind === "never") {
|
|
249
|
+
store.denyAll();
|
|
250
|
+
return "\u2717 \u4E4B\u540E\u4E00\u5F8B\u4E0D\u52A0\u8F7D\u9879\u76EE\u5916\u7684 import\uFF08\u6539\u8FD9\u4E2A\u51B3\u5B9A\uFF1A\u5220\u6389 ~/.epoch/trusted-imports.json\uFF09";
|
|
251
|
+
}
|
|
252
|
+
return `\u672C\u6B21\u4E0D\u52A0\u8F7D ${target.path}`;
|
|
253
|
+
}
|
|
254
|
+
var CHOICES2 = [
|
|
255
|
+
{ name: "\u672C\u6B21\u4E0D\u52A0\u8F7D\uFF08\u4E0B\u6B21\u8FD8\u4F1A\u95EE\uFF09", value: { kind: "skip" } },
|
|
256
|
+
{ name: "\u52A0\u8F7D\u8FD9\u4E00\u4E2A\u6587\u4EF6", value: { kind: "once" } },
|
|
257
|
+
{ name: "\u52A0\u8F7D\uFF0C\u5E76\u8BB0\u4F4F\u8FD9\u4E2A\u8DEF\u5F84\uFF08\u4E0B\u6B21\u4E0D\u95EE\uFF09", value: { kind: "remember" } },
|
|
258
|
+
{ name: "\u6C38\u4E0D\u52A0\u8F7D\u9879\u76EE\u5916\u7684 import", value: { kind: "never" } }
|
|
259
|
+
];
|
|
260
|
+
async function maybePromptForExternalImports(opts = {}) {
|
|
261
|
+
const config = opts.config ?? loadConfig2();
|
|
262
|
+
const store = opts.store ?? new ImportTrustStore(trustedImportsPath(config.homeDir));
|
|
263
|
+
const print = opts.print ?? ((msg) => process.stdout.write(msg + "\n"));
|
|
264
|
+
const targets = externalImportTargets({
|
|
265
|
+
store,
|
|
266
|
+
trust: opts.trust ?? new TrustManager2(trustPath2(config.homeDir)),
|
|
267
|
+
interactive: (opts.isInteractive ?? (() => !isNonInteractive2()))(),
|
|
268
|
+
gateEnabled: config.trust?.enabled !== false,
|
|
269
|
+
workDir: opts.workDir ?? process.cwd()
|
|
270
|
+
});
|
|
271
|
+
if (targets.length === 0) {
|
|
272
|
+
setImportGate(createImportGate(store));
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
for (const target of targets) {
|
|
277
|
+
print(importPromptMessage(target));
|
|
278
|
+
const choice = await (opts.ask ?? askImportChoice)(target);
|
|
279
|
+
if (choice === null) return false;
|
|
280
|
+
try {
|
|
281
|
+
print(applyImportChoice(store, target, choice));
|
|
282
|
+
} catch (err) {
|
|
283
|
+
print(
|
|
284
|
+
`\u26A0 import \u653E\u884C\u8BB0\u5F55\u5199\u5165\u5931\u8D25\uFF0C\u672C\u6B21\u4ECD\u6309\u4E0D\u52A0\u8F7D\u5904\u7406\uFF1A${err instanceof Error ? err.message : err}`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
if (choice.kind === "never") break;
|
|
288
|
+
}
|
|
289
|
+
} finally {
|
|
290
|
+
(opts.restoreStdin ?? restoreStdinDefault)();
|
|
291
|
+
setImportGate(createImportGate(store));
|
|
292
|
+
}
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
async function askImportChoice(_target) {
|
|
296
|
+
const { select } = await import("@inquirer/prompts");
|
|
297
|
+
try {
|
|
298
|
+
return await select({
|
|
299
|
+
message: "\u662F\u5426\u52A0\u8F7D\u5B83\uFF1F",
|
|
300
|
+
choices: [...CHOICES2],
|
|
301
|
+
default: CHOICES2[0]?.value
|
|
302
|
+
});
|
|
303
|
+
} catch {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// src/tui-host.ts
|
|
309
|
+
import {
|
|
310
|
+
getCapability,
|
|
311
|
+
listWorkspaceFiles,
|
|
312
|
+
openArtifact,
|
|
313
|
+
resolveMentions,
|
|
314
|
+
setPluginEnabled,
|
|
315
|
+
suggestRuleFromApproval
|
|
316
|
+
} from "@epoch-agent/core";
|
|
317
|
+
import { pluginsStatePath as pluginsStatePath2 } from "@epoch-agent/infra";
|
|
318
|
+
import { listBackgroundTasks } from "@epoch-agent/runtime";
|
|
319
|
+
|
|
320
|
+
// src/clipboard.ts
|
|
321
|
+
import { execFile } from "child_process";
|
|
322
|
+
import { readImageSize, sniffMediaType } from "@epoch-agent/core";
|
|
323
|
+
var MAX_BYTES = 32 * 1024 * 1024;
|
|
324
|
+
var TIMEOUT_MS = 5e3;
|
|
325
|
+
var WINDOWS_SCRIPT = [
|
|
326
|
+
'$ErrorActionPreference="Stop";',
|
|
327
|
+
"Add-Type -AssemblyName System.Windows.Forms,System.Drawing;",
|
|
328
|
+
"$img=[Windows.Forms.Clipboard]::GetImage();",
|
|
329
|
+
"if($img -eq $null){exit 3};",
|
|
330
|
+
"$ms=New-Object System.IO.MemoryStream;",
|
|
331
|
+
"$img.Save($ms,[System.Drawing.Imaging.ImageFormat]::Png);",
|
|
332
|
+
"[Convert]::ToBase64String($ms.ToArray())"
|
|
333
|
+
].join("");
|
|
334
|
+
function clipboardImageCandidates(platform = process.platform, env = process.env) {
|
|
335
|
+
if (platform === "darwin") {
|
|
336
|
+
return [
|
|
337
|
+
{
|
|
338
|
+
command: "osascript",
|
|
339
|
+
args: ["-e", "get the clipboard as \xABclass PNGf\xBB"],
|
|
340
|
+
decode: "applescript-hex",
|
|
341
|
+
install: "osascript \u662F macOS \u81EA\u5E26\u7684\uFF0C\u627E\u4E0D\u5230\u8BF4\u660E PATH \u6709\u95EE\u9898"
|
|
342
|
+
}
|
|
343
|
+
];
|
|
344
|
+
}
|
|
345
|
+
if (platform === "win32") {
|
|
346
|
+
const flags = ["-NoProfile", "-NonInteractive", "-STA", "-Command", WINDOWS_SCRIPT];
|
|
347
|
+
return ["powershell.exe", "pwsh"].map((command) => ({
|
|
348
|
+
command,
|
|
349
|
+
args: flags,
|
|
350
|
+
decode: "base64",
|
|
351
|
+
install: "Windows \u81EA\u5E26 powershell.exe\uFF0C\u627E\u4E0D\u5230\u8BF4\u660E PATH \u88AB\u6539\u8FC7"
|
|
352
|
+
}));
|
|
353
|
+
}
|
|
354
|
+
const wayland = {
|
|
355
|
+
command: "wl-paste",
|
|
356
|
+
args: ["--no-newline", "--type", "image/png"],
|
|
357
|
+
decode: "raw",
|
|
358
|
+
install: "Wayland \u4E0B\u88C5 wl-clipboard\uFF08apt install wl-clipboard\uFF09"
|
|
359
|
+
};
|
|
360
|
+
const x11 = {
|
|
361
|
+
command: "xclip",
|
|
362
|
+
args: ["-selection", "clipboard", "-t", "image/png", "-o"],
|
|
363
|
+
decode: "raw",
|
|
364
|
+
install: "X11 \u4E0B\u88C5 xclip\uFF08apt install xclip\uFF09"
|
|
365
|
+
};
|
|
366
|
+
return env["WAYLAND_DISPLAY"] ? [wayland, x11] : [x11, wayland];
|
|
367
|
+
}
|
|
368
|
+
function runCandidate(cmd) {
|
|
369
|
+
return new Promise((done) => {
|
|
370
|
+
execFile(
|
|
371
|
+
cmd.command,
|
|
372
|
+
cmd.args,
|
|
373
|
+
{ timeout: TIMEOUT_MS, maxBuffer: MAX_BYTES, encoding: "buffer", windowsHide: true },
|
|
374
|
+
(err, stdout) => {
|
|
375
|
+
const e = err;
|
|
376
|
+
if (e?.code === "ENOENT") {
|
|
377
|
+
done(null);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
done({ stdout: Buffer.from(stdout), code: typeof e?.code === "number" ? e.code : 0 });
|
|
381
|
+
}
|
|
382
|
+
);
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
function decodeClipboardStdout(stdout, decode) {
|
|
386
|
+
if (decode === "raw") return stdout.length > 0 ? stdout : void 0;
|
|
387
|
+
const text = stdout.toString("utf-8").replace(/\s+/g, "");
|
|
388
|
+
if (!text) return void 0;
|
|
389
|
+
if (decode === "base64") return Buffer.from(text, "base64");
|
|
390
|
+
const hex = /dataPNGf([0-9a-f]+)/i.exec(text)?.[1];
|
|
391
|
+
return hex ? Buffer.from(hex, "hex") : void 0;
|
|
392
|
+
}
|
|
393
|
+
function decodeImage(bytes) {
|
|
394
|
+
if (bytes.length === 0) return void 0;
|
|
395
|
+
const mediaType = sniffMediaType(bytes);
|
|
396
|
+
if (!mediaType) return void 0;
|
|
397
|
+
const size = readImageSize(bytes);
|
|
398
|
+
return {
|
|
399
|
+
base64: bytes.toString("base64"),
|
|
400
|
+
mediaType,
|
|
401
|
+
bytes: bytes.length,
|
|
402
|
+
...size ? { width: size.width, height: size.height } : {}
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
async function readClipboardImage(platform = process.platform, env = process.env) {
|
|
406
|
+
const candidates = clipboardImageCandidates(platform, env);
|
|
407
|
+
let anyRan = false;
|
|
408
|
+
for (const candidate of candidates) {
|
|
409
|
+
const result = await runCandidate(candidate);
|
|
410
|
+
if (!result) continue;
|
|
411
|
+
anyRan = true;
|
|
412
|
+
const bytes = decodeClipboardStdout(result.stdout, candidate.decode);
|
|
413
|
+
if (!bytes) continue;
|
|
414
|
+
const image = decodeImage(bytes);
|
|
415
|
+
if (!image) continue;
|
|
416
|
+
if (image.bytes > MAX_BYTES) {
|
|
417
|
+
throw new Error(`\u526A\u8D34\u677F\u56FE\u7247\u8FC7\u5927\uFF08${Math.round(image.bytes / 1024 / 1024)} MB\uFF09`);
|
|
418
|
+
}
|
|
419
|
+
return image;
|
|
420
|
+
}
|
|
421
|
+
if (!anyRan) {
|
|
422
|
+
throw new Error(`\u8BFB\u4E0D\u4E86\u526A\u8D34\u677F\uFF1A${candidates.map((c) => c.install).join("\uFF1B")}`);
|
|
423
|
+
}
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// src/goal-view.ts
|
|
428
|
+
import {
|
|
429
|
+
DEFAULT_GOAL_MAX_ROUNDS,
|
|
430
|
+
MAX_GOAL_MAX_ROUNDS,
|
|
431
|
+
MAX_OBJECTIVE_CHARS
|
|
432
|
+
} from "@epoch-agent/core";
|
|
433
|
+
import { t } from "@epoch-agent/infra";
|
|
434
|
+
var PHASE_LABEL_KEYS = {
|
|
435
|
+
active: "goal.phase_active",
|
|
436
|
+
paused: "goal.phase_paused",
|
|
437
|
+
blocked: "goal.phase_blocked",
|
|
438
|
+
complete: "goal.phase_complete"
|
|
439
|
+
};
|
|
440
|
+
function phaseLabel(phase) {
|
|
441
|
+
return t(PHASE_LABEL_KEYS[phase]);
|
|
442
|
+
}
|
|
443
|
+
function usage() {
|
|
444
|
+
return t("goal.usage", { rounds: DEFAULT_GOAL_MAX_ROUNDS, max: MAX_GOAL_MAX_ROUNDS });
|
|
445
|
+
}
|
|
446
|
+
function extraOf(goal) {
|
|
447
|
+
switch (goal.phase) {
|
|
448
|
+
case "blocked":
|
|
449
|
+
return t("goal.extra_blocked", {
|
|
450
|
+
code: goal.block?.code ?? "",
|
|
451
|
+
message: goal.block?.message ?? ""
|
|
452
|
+
});
|
|
453
|
+
case "complete":
|
|
454
|
+
return t("goal.extra_complete", { evidence: goal.completeEvidence ?? "" });
|
|
455
|
+
case "paused":
|
|
456
|
+
return t("goal.extra_paused");
|
|
457
|
+
case "active":
|
|
458
|
+
return t("goal.extra_active");
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
function describeGoal(goal) {
|
|
462
|
+
return t("goal.status", {
|
|
463
|
+
objective: goal.objective,
|
|
464
|
+
phase: phaseLabel(goal.phase),
|
|
465
|
+
used: goal.roundsUsed,
|
|
466
|
+
max: goal.maxRounds,
|
|
467
|
+
extra: extraOf(goal)
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
function refusalText(reason, phase) {
|
|
471
|
+
const at = phase ? phaseLabel(phase) : "";
|
|
472
|
+
switch (reason) {
|
|
473
|
+
case "busy":
|
|
474
|
+
return t("goal.refuse_busy", { phase: at });
|
|
475
|
+
case "no-goal":
|
|
476
|
+
return t("goal.refuse_no_goal");
|
|
477
|
+
case "empty-objective":
|
|
478
|
+
return t("goal.refuse_empty_objective");
|
|
479
|
+
case "objective-too-long":
|
|
480
|
+
return t("goal.refuse_objective_too_long", { max: MAX_OBJECTIVE_CHARS });
|
|
481
|
+
case "bad-budget":
|
|
482
|
+
return t("goal.refuse_bad_budget", { max: MAX_GOAL_MAX_ROUNDS });
|
|
483
|
+
case "empty-evidence":
|
|
484
|
+
return t("goal.refuse_empty_evidence");
|
|
485
|
+
case "wrong-phase":
|
|
486
|
+
return t("goal.refuse_wrong_phase", { phase: at });
|
|
487
|
+
case "unavailable":
|
|
488
|
+
return t("goal.refuse_unavailable");
|
|
489
|
+
// 这两个码只有**模型**那条路(`goal` 工具)产得出来 —— `/goal` 的
|
|
490
|
+
// 每一支都不带 block 参数。留着分支而不是 `never`:将来给人也开一个
|
|
491
|
+
// 「报卡住」的入口时,这里会是编译期就该改的地方,不是运行期才发现的
|
|
492
|
+
case "bad-block-code":
|
|
493
|
+
case "empty-block-message":
|
|
494
|
+
return t("goal.refuse_wrong_phase", { phase: at });
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
function report(control, outcome, toText) {
|
|
498
|
+
if (outcome.ok) return { ok: true, message: toText(outcome.goal) };
|
|
499
|
+
return { ok: false, message: refusalText(outcome.reason, control.current()?.phase) };
|
|
500
|
+
}
|
|
501
|
+
function buildGoalActions(control) {
|
|
502
|
+
if (!control) return void 0;
|
|
503
|
+
return {
|
|
504
|
+
describe: () => {
|
|
505
|
+
const goal = control.current();
|
|
506
|
+
return goal ? describeGoal(goal) : t("goal.none", { usage: usage() });
|
|
507
|
+
},
|
|
508
|
+
usage,
|
|
509
|
+
create: (objective) => report(
|
|
510
|
+
control,
|
|
511
|
+
control.create(objective),
|
|
512
|
+
(goal) => t("goal.created", { objective: goal.objective, max: goal.maxRounds })
|
|
513
|
+
),
|
|
514
|
+
edit: (objective) => report(
|
|
515
|
+
control,
|
|
516
|
+
control.edit(objective),
|
|
517
|
+
(goal) => t("goal.edited", {
|
|
518
|
+
objective: goal.objective,
|
|
519
|
+
used: goal.roundsUsed,
|
|
520
|
+
max: goal.maxRounds
|
|
521
|
+
})
|
|
522
|
+
),
|
|
523
|
+
budget: (rounds) => report(
|
|
524
|
+
control,
|
|
525
|
+
control.setBudget(rounds),
|
|
526
|
+
(goal) => (
|
|
527
|
+
// 调小到已用之下时引擎当场把它转成 blocked(`GoalService.setBudget`)——
|
|
528
|
+
// 这里照着**写完之后**那份 phase 说话,而不是照着用户的意图说
|
|
529
|
+
t(goal.phase === "blocked" ? "goal.budget_set_exhausted" : "goal.budget_set", {
|
|
530
|
+
used: goal.roundsUsed,
|
|
531
|
+
max: goal.maxRounds
|
|
532
|
+
})
|
|
533
|
+
)
|
|
534
|
+
),
|
|
535
|
+
pause: () => report(control, control.pause(), () => t("goal.paused")),
|
|
536
|
+
resume: () => report(
|
|
537
|
+
control,
|
|
538
|
+
control.resume(),
|
|
539
|
+
(goal) => t("goal.resumed", { used: goal.roundsUsed, max: goal.maxRounds })
|
|
540
|
+
),
|
|
541
|
+
done: (evidence) => report(
|
|
542
|
+
control,
|
|
543
|
+
control.complete(evidence),
|
|
544
|
+
(goal) => t("goal.completed", { evidence: goal.completeEvidence ?? "" })
|
|
545
|
+
),
|
|
546
|
+
clear: () => {
|
|
547
|
+
const outcome = control.clear();
|
|
548
|
+
if (!outcome.ok) return { ok: false, message: refusalText(outcome.reason) };
|
|
549
|
+
return { ok: true, message: t(outcome.cleared ? "goal.cleared" : "goal.clear_noop") };
|
|
550
|
+
},
|
|
551
|
+
/**
|
|
552
|
+
* 开机那一行(验收 8)。**只有 blocked 才出现**:`active` / `paused` /
|
|
553
|
+
* `complete` 三档都不是「需要人现在做点什么」,而开机提示那一栏是给
|
|
554
|
+
* 「有事要你处理」用的,塞满常态信息会让真有事的那次被淹掉。
|
|
555
|
+
*/
|
|
556
|
+
blockedNotice: () => {
|
|
557
|
+
const goal = control.current();
|
|
558
|
+
if (!goal || goal.phase !== "blocked" || !goal.block) return null;
|
|
559
|
+
return t("goal.startup_blocked", {
|
|
560
|
+
code: goal.block.code,
|
|
561
|
+
message: goal.block.message
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// src/host-actions.ts
|
|
568
|
+
import { execFile as execFile2 } from "child_process";
|
|
569
|
+
import { writeFileSync } from "fs";
|
|
570
|
+
import { isAbsolute, resolve } from "path";
|
|
571
|
+
import { loadPlugins, readPluginRecords, scanPluginDir } from "@epoch-agent/core";
|
|
572
|
+
import { pluginsStatePath } from "@epoch-agent/infra";
|
|
573
|
+
var TIMEOUT_MS2 = 1e4;
|
|
574
|
+
var MAX_BUFFER = 1024 * 1024;
|
|
575
|
+
function run(command, args, cwd) {
|
|
576
|
+
return new Promise((done) => {
|
|
577
|
+
execFile2(
|
|
578
|
+
command,
|
|
579
|
+
args,
|
|
580
|
+
{ cwd, timeout: TIMEOUT_MS2, maxBuffer: MAX_BUFFER, encoding: "utf-8", windowsHide: true },
|
|
581
|
+
(err, stdout, stderr) => {
|
|
582
|
+
const e = err;
|
|
583
|
+
if (e?.code === "ENOENT") {
|
|
584
|
+
done({ stdout: "", ok: false, missing: true, stderr: "" });
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
done({ stdout, ok: !err, missing: false, stderr: stderr || "" });
|
|
588
|
+
}
|
|
589
|
+
);
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
function clipboardWriteCommand(platform = process.platform, env = process.env) {
|
|
593
|
+
if (platform === "darwin") return [{ command: "pbcopy", args: [] }];
|
|
594
|
+
if (platform === "win32") {
|
|
595
|
+
const script = "$input | Set-Clipboard";
|
|
596
|
+
return ["powershell.exe", "pwsh"].map((command) => ({
|
|
597
|
+
command,
|
|
598
|
+
args: ["-NoProfile", "-NonInteractive", "-Command", script]
|
|
599
|
+
}));
|
|
600
|
+
}
|
|
601
|
+
const wayland = { command: "wl-copy", args: [] };
|
|
602
|
+
const x11 = { command: "xclip", args: ["-selection", "clipboard"] };
|
|
603
|
+
return env["WAYLAND_DISPLAY"] ? [wayland, x11] : [x11, wayland];
|
|
604
|
+
}
|
|
605
|
+
async function writeClipboardText(text) {
|
|
606
|
+
const candidates = clipboardWriteCommand();
|
|
607
|
+
for (const cmd of candidates) {
|
|
608
|
+
const done = await new Promise((resolve_) => {
|
|
609
|
+
const child = execFile2(
|
|
610
|
+
cmd.command,
|
|
611
|
+
cmd.args,
|
|
612
|
+
{ timeout: TIMEOUT_MS2, windowsHide: true },
|
|
613
|
+
(err) => {
|
|
614
|
+
const e = err;
|
|
615
|
+
if (e?.code === "ENOENT") return resolve_("missing");
|
|
616
|
+
resolve_(err ? err.message ?? "\u5199\u5165\u5931\u8D25" : "ok");
|
|
617
|
+
}
|
|
618
|
+
);
|
|
619
|
+
child.stdin?.end(text, "utf-8");
|
|
620
|
+
});
|
|
621
|
+
if (done === "ok") return;
|
|
622
|
+
if (done === "missing") continue;
|
|
623
|
+
throw new Error(done);
|
|
624
|
+
}
|
|
625
|
+
throw new Error(
|
|
626
|
+
process.platform === "linux" ? "\u526A\u8D34\u677F\u4E0D\u53EF\u7528\uFF1A\u88C5\u4E00\u4E2A xclip \u6216 wl-clipboard" : "\u526A\u8D34\u677F\u4E0D\u53EF\u7528\uFF1A\u7CFB\u7EDF\u81EA\u5E26\u7684\u590D\u5236\u547D\u4EE4\u4E0D\u5728 PATH \u91CC"
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
async function gitDiff(cwd) {
|
|
630
|
+
const result = await run("git", ["--no-pager", "diff", "HEAD"], cwd);
|
|
631
|
+
if (result.missing) return { text: "", ok: false, message: "git \u4E0D\u5728 PATH \u91CC" };
|
|
632
|
+
if (!result.ok) {
|
|
633
|
+
const why = result.stderr.trim().split("\n")[0] ?? "\u672A\u77E5\u539F\u56E0";
|
|
634
|
+
return { text: "", ok: false, message: `\u62FF\u4E0D\u5230 git diff\uFF1A${why}` };
|
|
635
|
+
}
|
|
636
|
+
return { text: result.stdout, ok: true };
|
|
637
|
+
}
|
|
638
|
+
async function gitDirty(cwd) {
|
|
639
|
+
const result = await run("git", ["status", "--porcelain"], cwd);
|
|
640
|
+
if (result.missing || !result.ok) return { ok: false, dirty: false, count: 0 };
|
|
641
|
+
const count = result.stdout.split("\n").filter((line) => line.trim() !== "").length;
|
|
642
|
+
return { ok: true, dirty: count > 0, count };
|
|
643
|
+
}
|
|
644
|
+
function renderSessionMarkdown(sessionId, messages, now) {
|
|
645
|
+
const ROLE_LABEL = {
|
|
646
|
+
user: "\u{1F464} \u7528\u6237",
|
|
647
|
+
assistant: "\u{1F916} \u52A9\u624B",
|
|
648
|
+
tool: "\u{1F527} \u5DE5\u5177",
|
|
649
|
+
system: "\u2699\uFE0F \u7CFB\u7EDF"
|
|
650
|
+
};
|
|
651
|
+
const lines = [
|
|
652
|
+
`# Epoch \u4F1A\u8BDD ${sessionId}`,
|
|
653
|
+
"",
|
|
654
|
+
`\u5BFC\u51FA\u4E8E ${now.toISOString()} \xB7 \u5171 ${messages.length} \u6761\u6D88\u606F`,
|
|
655
|
+
""
|
|
656
|
+
];
|
|
657
|
+
for (const m of messages) {
|
|
658
|
+
lines.push(`## ${ROLE_LABEL[m.role] ?? m.role}`, "");
|
|
659
|
+
lines.push(m.content.trim() || "\uFF08\u7A7A\uFF09", "");
|
|
660
|
+
}
|
|
661
|
+
return lines.join("\n");
|
|
662
|
+
}
|
|
663
|
+
function defaultExportName(sessionId, now) {
|
|
664
|
+
const stamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
665
|
+
return `epoch-session-${sessionId.slice(0, 8)}-${stamp}.md`;
|
|
666
|
+
}
|
|
667
|
+
function writeExport(cwd, file, markdown) {
|
|
668
|
+
const path = isAbsolute(file) ? file : resolve(cwd, file);
|
|
669
|
+
writeFileSync(path, markdown, "utf-8");
|
|
670
|
+
return path;
|
|
671
|
+
}
|
|
672
|
+
function describePlugins(homeDir) {
|
|
673
|
+
const statePath = pluginsStatePath(homeDir);
|
|
674
|
+
const artifacts = loadPlugins({ statePath });
|
|
675
|
+
const skipReason = new Map(artifacts.skipped.map((s) => [s.name, s.reason]));
|
|
676
|
+
const plugins = readPluginRecords(statePath).records.map((record) => {
|
|
677
|
+
const loaded = artifacts.loaded.some((p) => p.name === record.name);
|
|
678
|
+
const reason = skipReason.get(record.name);
|
|
679
|
+
return {
|
|
680
|
+
name: record.name,
|
|
681
|
+
version: record.version,
|
|
682
|
+
source: record.source,
|
|
683
|
+
enabled: record.enabled,
|
|
684
|
+
loaded,
|
|
685
|
+
...loaded || reason === void 0 ? {} : { reason },
|
|
686
|
+
// 只给加载了的数扩展物:一个目录已经没了的插件扫出来必然是全零,
|
|
687
|
+
// 而「0 条命令」会被读成「这插件是空的」,那是另一个结论
|
|
688
|
+
contributes: loaded ? contributionLines(record.path, record.name) : []
|
|
689
|
+
};
|
|
690
|
+
});
|
|
691
|
+
return { statePath, plugins };
|
|
692
|
+
}
|
|
693
|
+
function contributionLines(dir, name) {
|
|
694
|
+
const inv = scanPluginDir(dir, name);
|
|
695
|
+
const hooks = inv.hooks.reduce((sum, h) => sum + h.count, 0);
|
|
696
|
+
const out = [
|
|
697
|
+
inv.commands.length > 0 ? `${inv.commands.length} \u6761\u547D\u4EE4` : "",
|
|
698
|
+
inv.roles.length > 0 ? `${inv.roles.length} \u4E2A\u89D2\u8272` : "",
|
|
699
|
+
inv.skills.length > 0 ? `${inv.skills.length} \u4E2A\u6280\u80FD` : "",
|
|
700
|
+
inv.denyRules > 0 ? `${inv.denyRules} \u6761 deny \u89C4\u5219` : "",
|
|
701
|
+
hooks > 0 ? `\u26A0 ${hooks} \u4E2A hook\uFF08\u4F1A\u6267\u884C shell \u547D\u4EE4\uFF09` : ""
|
|
702
|
+
].filter(Boolean);
|
|
703
|
+
return out.length > 0 ? out : ["\uFF08\u6CA1\u5E26\u4EFB\u4F55\u6269\u5C55\u7269\uFF09"];
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// src/statusline.ts
|
|
707
|
+
import { execFile as execFile3 } from "child_process";
|
|
708
|
+
import { sanitizeToolOutput } from "@epoch-agent/core";
|
|
709
|
+
import { shellSpawnArgs } from "@epoch-agent/infra";
|
|
710
|
+
var TIMEOUT_MS3 = 2e3;
|
|
711
|
+
var MAX_WIDTH = 60;
|
|
712
|
+
var DEFAULT_INTERVAL_MS = 5e3;
|
|
713
|
+
var MAX_BUFFER2 = 64 * 1024;
|
|
714
|
+
function formatStatusLine(stdout) {
|
|
715
|
+
const first = sanitizeToolOutput(stdout).split("\n")[0]?.trim() ?? "";
|
|
716
|
+
if (!first) return null;
|
|
717
|
+
return first.length > MAX_WIDTH ? first.slice(0, MAX_WIDTH - 1) + "\u2026" : first;
|
|
718
|
+
}
|
|
719
|
+
function runStatusLineCommand(command, cwd) {
|
|
720
|
+
const { file, args, options } = shellSpawnArgs(command);
|
|
721
|
+
return new Promise((settle) => {
|
|
722
|
+
execFile3(
|
|
723
|
+
file,
|
|
724
|
+
args,
|
|
725
|
+
{
|
|
726
|
+
...options,
|
|
727
|
+
cwd,
|
|
728
|
+
timeout: TIMEOUT_MS3,
|
|
729
|
+
maxBuffer: MAX_BUFFER2,
|
|
730
|
+
encoding: "utf-8",
|
|
731
|
+
// 不弹黑框:Windows 上每 5 秒闪一个控制台窗口是没法用的
|
|
732
|
+
windowsHide: true
|
|
733
|
+
},
|
|
734
|
+
(err, stdout) => {
|
|
735
|
+
if (err) {
|
|
736
|
+
const killed = err.killed === true;
|
|
737
|
+
settle({
|
|
738
|
+
text: null,
|
|
739
|
+
error: killed ? `\u8D85\u8FC7 ${TIMEOUT_MS3}ms \u6CA1\u8FD4\u56DE` : err.message.trim()
|
|
740
|
+
});
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
settle({ text: formatStatusLine(stdout) });
|
|
744
|
+
}
|
|
745
|
+
);
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
function createStatusLine(config, cwd) {
|
|
749
|
+
const command = config.statusLine?.command?.trim();
|
|
750
|
+
if (!command) return null;
|
|
751
|
+
const interval = config.statusLine?.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
752
|
+
let value = null;
|
|
753
|
+
let running = false;
|
|
754
|
+
let idleSince = 0;
|
|
755
|
+
let pending = null;
|
|
756
|
+
const refresh = () => {
|
|
757
|
+
running = true;
|
|
758
|
+
pending = runStatusLineCommand(command, cwd).then((result) => {
|
|
759
|
+
if (result.text !== null) value = result.text;
|
|
760
|
+
}).finally(() => {
|
|
761
|
+
running = false;
|
|
762
|
+
idleSince = Date.now();
|
|
763
|
+
pending = null;
|
|
764
|
+
});
|
|
765
|
+
};
|
|
766
|
+
return {
|
|
767
|
+
read: () => {
|
|
768
|
+
if (!running && Date.now() - idleSince >= interval) refresh();
|
|
769
|
+
return value;
|
|
770
|
+
},
|
|
771
|
+
// 循环而不是一次 await:`pending` 结束的那一刻别人可能已经又 read() 了一次
|
|
772
|
+
// (`intervalMs: 0` 时每次 read 都踢一次),只等一次会漏掉后面那个
|
|
773
|
+
settled: async () => {
|
|
774
|
+
while (pending) await pending;
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// src/tui-host.ts
|
|
780
|
+
function sessionHasImages(runtime) {
|
|
781
|
+
const history = runtime.session?.getHistory() ?? [];
|
|
782
|
+
return history.some((m) => m.parts?.some((p) => p.type === "image"));
|
|
783
|
+
}
|
|
784
|
+
function buildHostActions(runtime, supportsImages, workDir) {
|
|
785
|
+
const { permission, diagnostics, model } = runtime;
|
|
786
|
+
const statusLine = createStatusLine(runtime.config, workDir);
|
|
787
|
+
const goals = buildGoalActions(runtime.goals);
|
|
788
|
+
return {
|
|
789
|
+
getDiagnostics: () => diagnostics,
|
|
790
|
+
// **不能解构 `tools`**:它是个 getter,解构等于在这里拍一张快照,
|
|
791
|
+
// 之后 MCP 热更新(方案 15)了 `/tools` 还显示旧清单
|
|
792
|
+
listTools: () => runtime.tools,
|
|
793
|
+
// 多模态的两条宿主能力(方案 12 PR-3)。判定都不在 TUI 里:
|
|
794
|
+
// 能不能打开归 core 的 artifact-open,怎么读剪贴板归 cli 的 clipboard
|
|
795
|
+
openArtifact: (path) => openArtifact(path),
|
|
796
|
+
readClipboardImage: () => readClipboardImage(),
|
|
797
|
+
supportsImages: () => supportsImages,
|
|
798
|
+
// 自定义斜杠命令的展开(方案 23)。插值要用 infra 的 tokenizer,
|
|
799
|
+
// 而 tui 只依赖 protocol —— 所以这件事只能在宿主这一侧做
|
|
800
|
+
expandCommand: (name, args) => runtime.commands.expand(name, args),
|
|
801
|
+
// `@` 文件补全(方案 25)。清单和解析都在这一侧:前者要 spawn git,
|
|
802
|
+
// 后者要过工作区边界和**权限判定** —— 让 TUI 自己读文件等于给 `@`
|
|
803
|
+
// 开一条绕过 `file_read` 的通道
|
|
804
|
+
listWorkspaceFiles: () => Promise.resolve(listWorkspaceFiles(workDir).files),
|
|
805
|
+
// 后台任务(方案 36 PR-2):状态栏计数和 `/tasks` 都读它。
|
|
806
|
+
// ⚠️ 会话 id 现在是必给的(表按会话分了),而且要给**活的**那个:
|
|
807
|
+
// `/resume` 会把 `session.sessionId` 换掉,用装配时那个 `runtime.sessionId`
|
|
808
|
+
// 的话,resume 之后状态栏数的是一个已经不存在的会话
|
|
809
|
+
listBackgroundTasks: () => listBackgroundTasks(runtime.session?.sessionId ?? runtime.sessionId),
|
|
810
|
+
// 用户自配的状态栏那一段(方案 29 §2.6)。没配就不注入这一项
|
|
811
|
+
...statusLine ? { readStatusLine: () => statusLine.read() } : {},
|
|
812
|
+
// **不能解构** `contextBreakdown`:它是个 getter,解构等于拍快照,
|
|
813
|
+
// `/context` 会一直显示第一次看的那个数
|
|
814
|
+
getContextBreakdown: () => runtime.contextBreakdown,
|
|
815
|
+
// `!` 和 `#`(方案 25 PR-2)。判定和执行都在引擎侧,TUI 只提供确认框 ——
|
|
816
|
+
// 用户手打的命令和模型生成的命令在危险性上没有区别
|
|
817
|
+
runShellCommand: (command, approve) => runtime.userActions.runShell(command, approve),
|
|
818
|
+
writeMemory: (content, target, approve) => runtime.userActions.writeMemory(content, target, approve),
|
|
819
|
+
...runtime.session ? { noteToSession: (text) => runtime.session.note(text) } : {},
|
|
820
|
+
resolveMentions: (paths) => resolveMentions(paths, {
|
|
821
|
+
root: workDir,
|
|
822
|
+
...runtime.permission ? { permission: runtime.permission } : {}
|
|
823
|
+
}),
|
|
824
|
+
// ---- 方案 25 PR-4:把引擎里已有、但 TUI 没入口的东西露出来 ----
|
|
825
|
+
//
|
|
826
|
+
// 三个 getter(skills / mcpServers)**都不能解构**,理由同上面的 `tools`:
|
|
827
|
+
// 技能会被 SkillLearner 现学,MCP 会掉线重连,快照拿到的是启动那一刻的
|
|
828
|
+
listSkills: () => runtime.skills,
|
|
829
|
+
listAgentRoles: () => runtime.agentRoles,
|
|
830
|
+
listMcpServers: () => runtime.mcpServers.map((s) => ({
|
|
831
|
+
name: s.name,
|
|
832
|
+
// 三态而不是 connected 的布尔:「要重新登录」和「连不上」的下一步动作
|
|
833
|
+
// 完全不同(前者跑 epoch mcp login,后者查配置),合成一个字会让用户白折腾
|
|
834
|
+
status: s.needsLogin ? "\u8981\u767B\u5F55" : s.connected ? "\u5DF2\u8FDE\u63A5" : "\u672A\u8FDE\u63A5",
|
|
835
|
+
toolCount: s.toolCount,
|
|
836
|
+
...s.lastError ? { error: s.lastError } : {}
|
|
837
|
+
})),
|
|
838
|
+
...runtime.memory ? {
|
|
839
|
+
listMemories: () => runtime.memory.list(),
|
|
840
|
+
removeMemory: (content) => runtime.memory.remove(content)
|
|
841
|
+
} : {},
|
|
842
|
+
// 插件(方案 32)。**无条件注入**:插件系统起不来这回事不存在 ——
|
|
843
|
+
// 没装过插件时它就是一份空清单,而那正是 `/plugin` 要显示的东西之一
|
|
844
|
+
// (连带「装一个」的提示)。
|
|
845
|
+
//
|
|
846
|
+
// 两条都现读磁盘、不吃 `runtime.plugins` 那份启动快照,理由写在
|
|
847
|
+
// `describePlugins` 上:用户敲这条命令时,磁盘上的状态可能已经不是启动那一刻了。
|
|
848
|
+
listPlugins: () => describePlugins(runtime.config.homeDir),
|
|
849
|
+
setPluginEnabled: async (name, enabled) => {
|
|
850
|
+
const outcome = await setPluginEnabled(name, enabled, {
|
|
851
|
+
statePath: pluginsStatePath2(runtime.config.homeDir)
|
|
852
|
+
});
|
|
853
|
+
return outcome.ok ? { ok: true, message: outcome.message } : { ok: false, message: outcome.reason };
|
|
854
|
+
},
|
|
855
|
+
// 剪贴板写、git diff、会话导出:三样都要 spawn 或写文件,是宿主的活
|
|
856
|
+
copyToClipboard: (text) => writeClipboardText(text),
|
|
857
|
+
gitDiff: () => gitDiff(workDir),
|
|
858
|
+
gitDirty: () => gitDirty(workDir),
|
|
859
|
+
// 检查点与回退(方案 27 PR-3)。**无条件注入**:`runtime.checkpoints` 不可为
|
|
860
|
+
// null(它只是文件系统上的一个目录,不像 provider / SQLite 那样有起不来的可能)。
|
|
861
|
+
//
|
|
862
|
+
// 三个方法逐条转发而不是 `checkpoints: runtime.checkpoints`:TUI 那侧的形状
|
|
863
|
+
// 是 core 那几个类型的**结构性镜像**(tui 不许 import core),直接赋值等于
|
|
864
|
+
// 让两边的类型偷偷绑在一起 —— 而这一行的作用正是让漂移在编译期就红。
|
|
865
|
+
// `list` 的返回值收窄成 readonly 也是在这里发生的
|
|
866
|
+
checkpoints: {
|
|
867
|
+
list: () => runtime.checkpoints.list(),
|
|
868
|
+
preview: (turnIndex) => runtime.checkpoints.preview(turnIndex),
|
|
869
|
+
rewind: (turnIndex, opts) => runtime.checkpoints.rewind(turnIndex, opts)
|
|
870
|
+
},
|
|
871
|
+
// 权限规则的可解释性(方案 22 PR-4)。`permissions` 在 runtime 上**不可为 null**,
|
|
872
|
+
// 所以这两条无条件注入 —— 一条规则都没配时它是空表加一句「没配过」
|
|
873
|
+
listPermissions: () => ({
|
|
874
|
+
level: runtime.permissions.level(),
|
|
875
|
+
rules: runtime.permissions.rules(),
|
|
876
|
+
shadows: runtime.permissions.shadows(),
|
|
877
|
+
managed: runtime.permissions.managed()
|
|
878
|
+
}),
|
|
879
|
+
suggestRule: (request) => suggestRuleFromApproval(request),
|
|
880
|
+
// 手动压缩(`/compact`)。会话起不来时不注入 —— 那时压缩也无从谈起
|
|
881
|
+
...runtime.session ? { compactContext: (instruction) => runtime.session.compact(instruction) } : {},
|
|
882
|
+
// `/resume` 和 Ctrl+R。三条一起给或一起不给:只给列表不给恢复的话,
|
|
883
|
+
// 用户能选中一段会话然后什么也不会发生
|
|
884
|
+
...runtime.sessions ? {
|
|
885
|
+
listSessions: () => runtime.sessions.list(),
|
|
886
|
+
resumeSession: (sessionId) => runtime.sessions.resume(sessionId),
|
|
887
|
+
searchSessions: (query) => runtime.sessions.search(query)
|
|
888
|
+
} : {},
|
|
889
|
+
...runtime.sessionStore ? {
|
|
890
|
+
exportSession: async (file) => {
|
|
891
|
+
const now = /* @__PURE__ */ new Date();
|
|
892
|
+
const messages = runtime.sessionStore.loadMessages(runtime.sessionId);
|
|
893
|
+
const markdown = renderSessionMarkdown(runtime.sessionId, messages, now);
|
|
894
|
+
const name = file ?? defaultExportName(runtime.sessionId, now);
|
|
895
|
+
return { path: writeExport(workDir, name, markdown), messages: messages.length };
|
|
896
|
+
}
|
|
897
|
+
} : {},
|
|
898
|
+
// **不能解构 `providerInfo`**:方案 26 之后它是个 getter(`/model` 能换
|
|
899
|
+
// provider),解构等于在这里拍快照,切完之后 `/model` 还显示原来那家和那把 key
|
|
900
|
+
...runtime.providerInfo ? { getProviderInfo: () => runtime.providerInfo } : {},
|
|
901
|
+
...model ? {
|
|
902
|
+
getModelSelection: () => {
|
|
903
|
+
const selection = model.get();
|
|
904
|
+
const { contextLength } = getCapability(selection.model, runtime.config.homeDir);
|
|
905
|
+
return { selection, contextLength };
|
|
906
|
+
},
|
|
907
|
+
// `hasImages` 由这一侧补,**不能让 TUI 自己判**:TUI 只看得见还没发出去
|
|
908
|
+
// 的那几张(`pendingImages`),已经发过的、以及 `-r` 恢复出来的历史里
|
|
909
|
+
// 那些,只有会话知道。判成 false 的后果很具体:切到不认图的模型,
|
|
910
|
+
// 下一轮把整段带图历史发出去,provider 报 400 且**已经计费**
|
|
911
|
+
setModel: (ref, ctx) => {
|
|
912
|
+
const merged = { ...ctx, hasImages: sessionHasImages(runtime) };
|
|
913
|
+
return ref === "reset" ? model.reset(merged) : model.set(ref, merged);
|
|
914
|
+
},
|
|
915
|
+
// 引擎攒下的提示(「这个模型连续失败太多次,本会话不再自动重试」)。
|
|
916
|
+
// 拉而不是推:router 在 loop.ts 底下,发不出 AgentEvent;TUI 在一轮
|
|
917
|
+
// 收尾时拉一次,攒着的话用户会在下一轮才看到上一轮的事
|
|
918
|
+
drainModelNotices: () => model.drainNotices()
|
|
919
|
+
} : {},
|
|
920
|
+
...permission ? {
|
|
921
|
+
getPermissionLevel: () => permission.getLevel(),
|
|
922
|
+
/**
|
|
923
|
+
* 切档位。**判定本体 2026-08-15 搬进了 `runtime.permissions.setLevel()`**,
|
|
924
|
+
* 这里只剩「把码渲染成一句人话」。
|
|
925
|
+
*
|
|
926
|
+
* 搬走的三件事(托管挡 bypass → `setLevel` → `plan.forget()`)原来
|
|
927
|
+
* 逐字长在这儿,而 web 那一格要长出真菜单时需要同一段判定 ——
|
|
928
|
+
* 抄一份的具体代价是第三步会被漏掉,判据写在 `PermissionsControl`
|
|
929
|
+
* 的文件头。**行为一个字没变**:同样的挡、同样的作废、同样的回读确认。
|
|
930
|
+
*
|
|
931
|
+
* 这一层留下的只有那句中文:`LevelChangeResult.reason` 是**码**
|
|
932
|
+
* (它的消费方里有 web 服务端,而中文一律不上网线),而 TUI 这一侧
|
|
933
|
+
* 要的是能直接印在屏幕上的一句话,路径也只有本机这一份说得出来。
|
|
934
|
+
*/
|
|
935
|
+
setPermissionLevel: (level) => {
|
|
936
|
+
const result = runtime.permissions.setLevel(level);
|
|
937
|
+
if (!result.ok) {
|
|
938
|
+
const managed = runtime.permissions.managed();
|
|
939
|
+
return {
|
|
940
|
+
ok: false,
|
|
941
|
+
reason: `\u4F01\u4E1A\u6258\u7BA1\u8BBE\u7F6E\u7981\u7528\u4E86 bypass\uFF08disableBypassPermissionsMode\uFF0C\u89C1 ${managed.path}\uFF09`
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
return permission.getLevel() === level ? { ok: true } : { ok: false, reason: `\u5207\u6362\u5230 ${level} \u5931\u8D25` };
|
|
945
|
+
}
|
|
946
|
+
} : {},
|
|
947
|
+
// 长任务的目标(`/goal`,方案 52)。会话库没起来时整组不注入 ——
|
|
948
|
+
// 那时 `/goal` 压根不注册(目标是**落盘**的状态,建了就没等于没有)。
|
|
949
|
+
//
|
|
950
|
+
// ⚠️ 措辞全在 `goal-view.ts` 里走 `t()`:tui 够不着 catalog,判据写在
|
|
951
|
+
// `HostActions.goals` 上那段。这里只负责把引擎那一份接过去
|
|
952
|
+
...goals ? { goals } : {},
|
|
953
|
+
// Plan 模式的用户入口(`/plan`,方案 35)。**出口处那次审批不在这里** ——
|
|
954
|
+
// 它走 `approval-request`,TUI 接的是同一个审批队列
|
|
955
|
+
...runtime.plan ? {
|
|
956
|
+
planMode: {
|
|
957
|
+
active: () => runtime.plan?.active() ?? false,
|
|
958
|
+
from: () => runtime.plan?.from() ?? null,
|
|
959
|
+
enter: () => runtime.plan?.enter() ?? { ok: false, reason: "plan \u6A21\u5F0F\u4E0D\u53EF\u7528" },
|
|
960
|
+
leave: () => runtime.plan?.leave() ?? null,
|
|
961
|
+
// `/plan show` 的数据源(方案 35 PR-2)。`--resume` 之后屏幕上是空的
|
|
962
|
+
// (那张卡片是上一个进程画的),而模型手上那份计划还在 ——
|
|
963
|
+
// 这是用户唯一能问出「现在照着什么在做」的地方
|
|
964
|
+
approvedPlan: () => runtime.plan?.approvedPlan() ?? null
|
|
965
|
+
}
|
|
966
|
+
} : {}
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// src/version.ts
|
|
971
|
+
import { readFileSync } from "fs";
|
|
972
|
+
import { dirname, join as join2 } from "path";
|
|
973
|
+
import { fileURLToPath } from "url";
|
|
974
|
+
|
|
975
|
+
// src/installation.ts
|
|
976
|
+
import { execFileSync } from "child_process";
|
|
977
|
+
import { existsSync, realpathSync } from "fs";
|
|
978
|
+
import { join } from "path";
|
|
979
|
+
import { normalizeForMatch } from "@epoch-agent/infra";
|
|
980
|
+
|
|
981
|
+
// src/version.ts
|
|
982
|
+
function readVersion() {
|
|
983
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
984
|
+
for (const candidate of [
|
|
985
|
+
join2(here, "..", "package.json"),
|
|
986
|
+
join2(here, "..", "..", "package.json")
|
|
987
|
+
]) {
|
|
988
|
+
try {
|
|
989
|
+
const raw = JSON.parse(readFileSync(candidate, "utf-8"));
|
|
990
|
+
if (raw.version) return raw.version;
|
|
991
|
+
} catch {
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return "0.0.0-unknown";
|
|
995
|
+
}
|
|
996
|
+
var VERSION = readVersion();
|
|
997
|
+
|
|
998
|
+
// src/tui-entry.ts
|
|
999
|
+
async function main() {
|
|
1000
|
+
if (!await maybePromptForTrust()) {
|
|
1001
|
+
process.exit(1);
|
|
1002
|
+
}
|
|
1003
|
+
if (!await maybePromptForExternalImports()) {
|
|
1004
|
+
process.exit(1);
|
|
1005
|
+
}
|
|
1006
|
+
const resumeId = process.env.EPOCH_RESUME?.trim();
|
|
1007
|
+
const runtime = await buildRuntime({
|
|
1008
|
+
installSignalHandlers: true,
|
|
1009
|
+
...resumeId ? { resumeId } : {}
|
|
1010
|
+
});
|
|
1011
|
+
const { session, config } = runtime;
|
|
1012
|
+
if (!session) {
|
|
1013
|
+
process.stderr.write(
|
|
1014
|
+
`\u542F\u52A8\u5931\u8D25\uFF1Aprovider \u4E0D\u53EF\u7528\uFF0C\u8FD0\u884C epoch model \u914D\u7F6E
|
|
1015
|
+
${runtime.diagnostics.join("\n")}
|
|
1016
|
+
`
|
|
1017
|
+
);
|
|
1018
|
+
process.exit(1);
|
|
1019
|
+
}
|
|
1020
|
+
const model = config.model || "unknown";
|
|
1021
|
+
let contextLimit = 0;
|
|
1022
|
+
let supportsImages = false;
|
|
1023
|
+
try {
|
|
1024
|
+
const capability = getCapability2(model, config.homeDir);
|
|
1025
|
+
contextLimit = capability.contextLength;
|
|
1026
|
+
supportsImages = capability.supportsImages;
|
|
1027
|
+
} catch {
|
|
1028
|
+
contextLimit = 0;
|
|
1029
|
+
}
|
|
1030
|
+
const keys = loadKeybindings({
|
|
1031
|
+
path: keybindingsPath(config.homeDir),
|
|
1032
|
+
defaults: DEFAULT_KEYBINDINGS
|
|
1033
|
+
});
|
|
1034
|
+
const host = buildHostActions(runtime, supportsImages, process.cwd());
|
|
1035
|
+
const goalNotice = host.goals?.blockedNotice() ?? null;
|
|
1036
|
+
const instance = renderApp({
|
|
1037
|
+
sessionId: session.sessionId,
|
|
1038
|
+
// 用量口径必须原样告诉 TUI:它没法从事件流里推出来,猜错就是账算错
|
|
1039
|
+
usageScope: runtime.usageScope,
|
|
1040
|
+
app: { version: VERSION, cwd: process.cwd() },
|
|
1041
|
+
config: {
|
|
1042
|
+
model,
|
|
1043
|
+
workDir: process.cwd(),
|
|
1044
|
+
permissionLevel: config.permission,
|
|
1045
|
+
contextLimit,
|
|
1046
|
+
getUseBackgroundColor: () => false
|
|
1047
|
+
},
|
|
1048
|
+
welcomeMessage: "\u4F60\u597D\uFF01\u6211\u662F Epoch Agent\u3002\u8F93\u5165\u6D88\u606F\u5F00\u59CB\u5BF9\u8BDD\uFF0C\u8F93\u5165 / \u770B\u53EF\u7528\u547D\u4EE4\u3002",
|
|
1049
|
+
// 启动诊断以前在这里被整个丢掉:配置写错了、某个模块没起来,
|
|
1050
|
+
// TUI 上完全静默,只有 `epoch status` 才看得见。
|
|
1051
|
+
// 键位那几条接在后面 —— 它们和 runtime 的诊断是同一种东西(「你的配置有一处
|
|
1052
|
+
// 没生效」),走同一个通道用户才只有一个地方要看
|
|
1053
|
+
// 目标那一行**排在最后**:诊断说的是「你的配置有一处没生效」,
|
|
1054
|
+
// 而它说的是「上次那个任务卡住了」—— 后者才是用户这一刻要接着干的事,
|
|
1055
|
+
// 放在最靠近输入框的位置
|
|
1056
|
+
startupNotices: [
|
|
1057
|
+
...runtime.diagnostics,
|
|
1058
|
+
...keys.diagnostics.map(diagnosticToLine),
|
|
1059
|
+
...goalNotice ? [goalNotice] : []
|
|
1060
|
+
],
|
|
1061
|
+
keybindings: keys.table,
|
|
1062
|
+
host,
|
|
1063
|
+
// 自定义斜杠命令的表(方案 23)。**永远给**:加载不出来时它是空数组,
|
|
1064
|
+
// 不是「没有这个能力」—— TUI 那侧因此不用为「有没有命令系统」分支
|
|
1065
|
+
customCommands: runtime.commands.list,
|
|
1066
|
+
// 入参是 EpochUserContent 而不是 string —— TUI 里 Ctrl+V 粘的图片
|
|
1067
|
+
// 跟着这一次提交一路走到引擎,`session.run` 本来就收这个类型。
|
|
1068
|
+
//
|
|
1069
|
+
// 两层包装管的都是「这一轮」:工具白名单(方案 23)和临时模型(方案 26)。
|
|
1070
|
+
// 进出成对、且**中断也会还原**,规矩和用例都在 runtime 那一侧。
|
|
1071
|
+
// 模型那层在外面:它要在工具作用域退出之后才还原,顺序和进入时相反 ——
|
|
1072
|
+
// 虽然这两件事眼下互不影响,但把嵌套写成不配对的形状,
|
|
1073
|
+
// 早晚会有第三样东西掉进那个缝里
|
|
1074
|
+
onRun: (userMessage, opts) => withModelScope(
|
|
1075
|
+
runtime.model,
|
|
1076
|
+
opts.model,
|
|
1077
|
+
withToolScope(
|
|
1078
|
+
runtime.commands,
|
|
1079
|
+
opts.allowedTools,
|
|
1080
|
+
session.run(userMessage, { signal: opts.signal })
|
|
1081
|
+
)
|
|
1082
|
+
),
|
|
1083
|
+
// onExit 是同步回调(ink 的键位处理里调),而 dispose 现在要等后台进程收尸。
|
|
1084
|
+
// 先 unmount 把终端还给用户 —— 收尸最多 200ms,但那 200ms 里屏幕不该还挂着
|
|
1085
|
+
// 一个已经退出的 TUI
|
|
1086
|
+
onExit: () => {
|
|
1087
|
+
instance.unmount();
|
|
1088
|
+
void runtime.dispose().finally(() => process.exit(0));
|
|
1089
|
+
}
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
main().catch((err) => {
|
|
1093
|
+
if (expectedFailureExitCode(err) !== void 0) reportFatal(err);
|
|
1094
|
+
process.stderr.write(`TUI \u542F\u52A8\u5931\u8D25: ${err}
|
|
1095
|
+
`);
|
|
1096
|
+
process.exit(1);
|
|
1097
|
+
});
|
|
1098
|
+
/**
|
|
1099
|
+
* @license
|
|
1100
|
+
* Copyright 2025 Google LLC
|
|
1101
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
1102
|
+
*
|
|
1103
|
+
* 改编自 gemini-cli `packages/cli/src/utils/installationInfo.ts`,
|
|
1104
|
+
* 按 epoch 的包名与目录形态调整,去掉了自动更新分支(epoch 只提示不代跑)。
|
|
1105
|
+
* 另修掉了原版同样存在的三处 Windows 误判(见下方「Windows 上的坑」)。
|
|
1106
|
+
*/
|