@epoch-agent/cli 0.1.0 → 0.2.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/dist/tui-entry.js CHANGED
@@ -1,55 +1,19 @@
1
1
  // src/tui-entry.ts
2
2
  import { getCapability as getCapability2, loadKeybindings } from "@epoch-agent/core";
3
- import { keybindingsPath } from "@epoch-agent/infra";
4
- import { diagnosticToLine } from "@epoch-agent/protocol";
3
+ import { keybindingsPath, t as t14 } from "@epoch-agent/infra";
5
4
  import { buildRuntime, withModelScope, withToolScope } from "@epoch-agent/runtime";
6
5
  import { DEFAULT_KEYBINDINGS, renderApp } from "@epoch-agent/tui";
7
-
6
+ // src/errors.ts
7
+ import { t } from "@epoch-agent/infra";
8
8
  // src/exit-codes.ts
9
9
  var EXIT_CODES = {
10
- /** 任务完成 */
11
10
  SUCCESS: 0,
12
- /** 通用失败(provider 起不来、运行时报错……) */
13
11
  FAILURE: 1,
14
- /**
15
- * 非交互下有操作因**缺少预授权**被拒。
16
- *
17
- * 只在非交互路径出现:交互模式下用户亲手点的「拒绝」是他的决定,
18
- * 不算 agent 被卡住,仍然退 0。
19
- */
20
12
  PERMISSION_DENIED: 3,
21
- /**
22
- * 企业托管设置挡下了这次启动(方案 22)。
23
- *
24
- * 和 FAILURE 分开的理由同上:这不是「epoch 坏了」,是**策略生效了**。
25
- * 管理员按机器批量铺开一条 `disableBypassPermissionsMode` 之后,
26
- * 要能从退出码上一眼看出哪些机器是被自己的策略拦下的,
27
- * 而不是去每台机器上读 stderr 猜。
28
- */
29
13
  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
14
  LIMIT_EXCEEDED: 5,
43
- /**
44
- * `--input-format stream-json` 的输入有坏行(方案 28)。
45
- *
46
- * **不是**用法错误(那是 1):命令敲对了,是宿主写进 stdin 的那一行不合协议。
47
- * 坏行会被报到 stderr 并**跳过**(NDJSON 是按行分帧的,跳一行不会失步),
48
- * 进程接着跑;这个码在收尾时才落。
49
- */
50
15
  INPUT_ERROR: 6
51
16
  };
52
-
53
17
  // src/errors.ts
54
18
  var CliError = class extends Error {
55
19
  constructor(message, exitCode = EXIT_CODES.FAILURE, hint) {
@@ -63,15 +27,12 @@ var CliError = class extends Error {
63
27
  };
64
28
  function isCancellation(err) {
65
29
  if (!(err instanceof Error)) return false;
66
- return err.name === "ExitPromptError" || err.name === "AbortError" || // AbortController.abort() 默认抛的就是这个
30
+ return err.name === "ExitPromptError" || err.name === "AbortError" ||
67
31
  err.name === "Error" && err.message === "The operation was aborted.";
68
32
  }
69
33
  var EXPECTED_FAILURES = {
70
- /** 企业托管策略挡下的启动(方案 22 §2.6)。不是故障,是策略生效了 */
71
34
  ManagedPolicyError: EXIT_CODES.MANAGED_POLICY,
72
- /** `--agent` 给了不认识的角色名(方案 29 验收 #13)。可用角色都在 message 里 */
73
35
  AgentRoleError: EXIT_CODES.FAILURE,
74
- /** `--settings` 指的文件不是合法 JSON(方案 29 验收 #16)。hint 里带行号 */
75
36
  SettingsFileError: EXIT_CODES.FAILURE
76
37
  };
77
38
  function expectedFailureExitCode(err) {
@@ -84,12 +45,14 @@ function hintOf(err) {
84
45
  }
85
46
  function reportFatal(err) {
86
47
  if (isCancellation(err)) {
87
- process.stderr.write("\n\u5DF2\u53D6\u6D88\n");
48
+ process.stderr.write(`
49
+ ${t("errors.cancelled")}
50
+ `);
88
51
  process.exit(130);
89
52
  }
90
53
  const exitCode = err instanceof CliError ? err.exitCode : expectedFailureExitCode(err);
91
54
  if (exitCode !== void 0 && err instanceof Error) {
92
- process.stderr.write(`\u9519\u8BEF: ${err.message}
55
+ process.stderr.write(`${t("errors.fatal", { message: err.message })}
93
56
  `);
94
57
  const hint = hintOf(err);
95
58
  if (hint) process.stderr.write(`${hint}
@@ -100,7 +63,6 @@ function reportFatal(err) {
100
63
  `);
101
64
  process.exit(EXIT_CODES.FAILURE);
102
65
  }
103
-
104
66
  // src/import-prompt.ts
105
67
  import {
106
68
  createImportGate,
@@ -112,8 +74,7 @@ import {
112
74
  setImportGate,
113
75
  TrustManager as TrustManager2
114
76
  } from "@epoch-agent/core";
115
- import { trustedImportsPath, trustPath as trustPath2 } from "@epoch-agent/infra";
116
-
77
+ import { t as t3, trustedImportsPath, trustPath as trustPath2 } from "@epoch-agent/infra";
117
78
  // src/trust-prompt.ts
118
79
  import {
119
80
  findProjectInstructions,
@@ -122,7 +83,7 @@ import {
122
83
  resolveProjectRoot,
123
84
  TrustManager
124
85
  } from "@epoch-agent/core";
125
- import { trustPath } from "@epoch-agent/infra";
86
+ import { t as t2, trustPath } from "@epoch-agent/infra";
126
87
  function trustPromptTarget(deps) {
127
88
  if (!deps.gateEnabled) return null;
128
89
  if (!deps.interactive) return null;
@@ -135,30 +96,31 @@ function trustPromptTarget(deps) {
135
96
  function applyTrustChoice(store, root, choice) {
136
97
  if (choice.kind === "trust") {
137
98
  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`;
99
+ const suffix = choice.scope === "directory-tree" ? t2("trust_prompt.granted_tree_suffix") : "";
100
+ return t2("trust_prompt.granted", { root, suffix });
140
101
  }
141
102
  if (choice.kind === "never") {
142
103
  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`;
104
+ return t2("trust_prompt.denied", { root });
144
105
  }
145
- return "\u672C\u6B21\u4E0D\u52A0\u8F7D\u9879\u76EE\u6307\u4EE4\u3002\u60F3\u8BA9\u5B83\u751F\u6548\u8FD0\u884C epoch trust add";
106
+ return t2("trust_prompt.skipped");
146
107
  }
147
108
  function trustPromptMessage(target) {
148
109
  return [
149
110
  `
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",
111
+ ${t2("trust_prompt.head", { path: target.instructionsPath })}`,
112
+ ` ${t2("trust_prompt.why")}`,
153
113
  ""
154
114
  ].join("\n");
155
115
  }
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
- ];
116
+ function choices() {
117
+ return [
118
+ { name: t2("trust_prompt.choice_skip"), value: { kind: "skip" } },
119
+ { name: t2("trust_prompt.choice_dir"), value: { kind: "trust", scope: "directory" } },
120
+ { name: t2("trust_prompt.choice_tree"), value: { kind: "trust", scope: "directory-tree" } },
121
+ { name: t2("trust_prompt.choice_never"), value: { kind: "never" } }
122
+ ];
123
+ }
162
124
  function restoreStdinDefault() {
163
125
  const stdin = process.stdin;
164
126
  if (stdin.isTTY) stdin.setRawMode?.(false);
@@ -186,23 +148,27 @@ async function maybePromptForTrust(opts = {}) {
186
148
  try {
187
149
  print(applyTrustChoice(store, target.root, choice));
188
150
  } 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}`);
151
+ print(
152
+ t2("trust_prompt.write_failed", {
153
+ message: err instanceof Error ? err.message : String(err)
154
+ })
155
+ );
190
156
  }
191
157
  return true;
192
158
  }
193
159
  async function askTrustChoice(_target) {
194
160
  const { select } = await import("@inquirer/prompts");
195
161
  try {
162
+ const options = choices();
196
163
  return await select({
197
- message: "\u662F\u5426\u4FE1\u4EFB\u672C\u76EE\u5F55\uFF1F",
198
- choices: [...CHOICES],
199
- default: CHOICES[0]?.value
164
+ message: t2("trust_prompt.ask"),
165
+ choices: [...options],
166
+ default: options[0]?.value
200
167
  });
201
168
  } catch {
202
169
  return null;
203
170
  }
204
171
  }
205
-
206
172
  // src/import-prompt.ts
207
173
  function externalImportTargets(deps) {
208
174
  if (!deps.gateEnabled) return [];
@@ -228,35 +194,36 @@ function externalImportTargets(deps) {
228
194
  function importPromptMessage(target) {
229
195
  return [
230
196
  `
231
- \u26A0 ${target.from} \u5F15\u7528\u4E86\u9879\u76EE\u4E4B\u5916\u7684\u6587\u4EF6\uFF1A`,
197
+ ${t3("import_prompt.head", { from: target.from })}`,
232
198
  ` ${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",
199
+ ` ${t3("import_prompt.spec", { spec: target.spec })}`,
200
+ ` ${t3("import_prompt.why")}`,
236
201
  ""
237
202
  ].join("\n");
238
203
  }
239
204
  function applyImportChoice(store, target, choice) {
240
205
  if (choice.kind === "once") {
241
206
  store.allowOnce(target.path);
242
- return `\u2713 \u672C\u6B21\u52A0\u8F7D ${target.path}\uFF08\u4E0B\u6B21\u8FD8\u4F1A\u95EE\uFF09`;
207
+ return t3("import_prompt.once", { path: target.path });
243
208
  }
244
209
  if (choice.kind === "remember") {
245
210
  store.remember(target.path);
246
- return `\u2713 \u5DF2\u8BB0\u4F4F ${target.path}\uFF0C\u4E4B\u540E\u4E0D\u518D\u8BE2\u95EE`;
211
+ return t3("import_prompt.remembered", { path: target.path });
247
212
  }
248
213
  if (choice.kind === "never") {
249
214
  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";
215
+ return t3("import_prompt.never");
251
216
  }
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
- ];
217
+ return t3("import_prompt.skipped", { path: target.path });
218
+ }
219
+ function choices2() {
220
+ return [
221
+ { name: t3("import_prompt.choice_skip"), value: { kind: "skip" } },
222
+ { name: t3("import_prompt.choice_once"), value: { kind: "once" } },
223
+ { name: t3("import_prompt.choice_remember"), value: { kind: "remember" } },
224
+ { name: t3("import_prompt.choice_never"), value: { kind: "never" } }
225
+ ];
226
+ }
260
227
  async function maybePromptForExternalImports(opts = {}) {
261
228
  const config = opts.config ?? loadConfig2();
262
229
  const store = opts.store ?? new ImportTrustStore(trustedImportsPath(config.homeDir));
@@ -281,7 +248,9 @@ async function maybePromptForExternalImports(opts = {}) {
281
248
  print(applyImportChoice(store, target, choice));
282
249
  } catch (err) {
283
250
  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}`
251
+ t3("import_prompt.write_failed", {
252
+ message: err instanceof Error ? err.message : String(err)
253
+ })
285
254
  );
286
255
  }
287
256
  if (choice.kind === "never") break;
@@ -295,16 +264,123 @@ async function maybePromptForExternalImports(opts = {}) {
295
264
  async function askImportChoice(_target) {
296
265
  const { select } = await import("@inquirer/prompts");
297
266
  try {
267
+ const options = choices2();
298
268
  return await select({
299
- message: "\u662F\u5426\u52A0\u8F7D\u5B83\uFF1F",
300
- choices: [...CHOICES2],
301
- default: CHOICES2[0]?.value
269
+ message: t3("import_prompt.ask"),
270
+ choices: [...options],
271
+ default: options[0]?.value
302
272
  });
303
273
  } catch {
304
274
  return null;
305
275
  }
306
276
  }
307
-
277
+ // src/keybindings-view.ts
278
+ import { t as t4 } from "@epoch-agent/infra";
279
+ import {
280
+ ACTION_CONTEXTS,
281
+ KEY_ACTIONS,
282
+ RESERVED_CHORDS,
283
+ sequenceId
284
+ } from "@epoch-agent/protocol";
285
+ import { SEQUENCE_WINDOW_MS } from "@epoch-agent/tui";
286
+ var CONTEXT_TITLE_KEYS = {
287
+ global: "keybindings.list_context_global",
288
+ input: "keybindings.list_context_input",
289
+ dialog: "keybindings.list_context_dialog"
290
+ };
291
+ var REJECT_KEYS = {
292
+ reserved: "keybindings.list_rejected_reserved",
293
+ conflict: "keybindings.list_rejected_conflict",
294
+ invalid: "keybindings.list_rejected_invalid",
295
+ "unknown-action": "keybindings.list_rejected_unknown",
296
+ unsupported: "keybindings.list_rejected_unsupported"
297
+ };
298
+ function groupOf(action) {
299
+ return ACTION_CONTEXTS[action][0] ?? "global";
300
+ }
301
+ function describeKeybindings(report2) {
302
+ const lines = [t4("keybindings.list_title", { path: report2.path })];
303
+ if (!report2.present) lines.push(t4("keybindings.list_no_file"));
304
+ const width = Math.max(...KEY_ACTIONS.map((a) => a.length)) + 2;
305
+ const keysWidth = Math.max(
306
+ ...KEY_ACTIONS.map((a) => keysTextOf(report2, a).length),
307
+ t4("keybindings.list_unbound").length
308
+ ) + 2;
309
+ for (const context of ["global", "input", "dialog"]) {
310
+ const actions = KEY_ACTIONS.filter((action) => groupOf(action) === context);
311
+ if (actions.length === 0) continue;
312
+ lines.push("", t4(CONTEXT_TITLE_KEYS[context]));
313
+ for (const action of actions) {
314
+ lines.push(
315
+ ` ${action.padEnd(width)}${keysTextOf(report2, action).padEnd(keysWidth)}${sourceTextOf(report2, action)}`.trimEnd()
316
+ );
317
+ }
318
+ }
319
+ if (report2.rejected.length > 0) {
320
+ lines.push("", t4("keybindings.list_rejected_title"));
321
+ for (const item of report2.rejected) {
322
+ lines.push(` ${item.action.padEnd(width)}${rejectText(item)}`);
323
+ }
324
+ }
325
+ lines.push(
326
+ "",
327
+ t4("keybindings.list_reserved_note", { keys: RESERVED_CHORDS.join(" \xB7 ") }),
328
+ t4("keybindings.list_sequence_note", { ms: SEQUENCE_WINDOW_MS })
329
+ );
330
+ return lines.join("\n");
331
+ }
332
+ function keysTextOf(report2, action) {
333
+ const bound = report2.bindings[action];
334
+ if (bound.length === 0) return t4("keybindings.list_unbound");
335
+ return bound.map((b) => sequenceId(b.keys)).join(" \xB7 ");
336
+ }
337
+ function sourceTextOf(report2, action) {
338
+ const bound = report2.bindings[action];
339
+ if (bound.length === 0) return "";
340
+ const sources = [...new Set(bound.map((b) => b.source))];
341
+ return sources.map(
342
+ (s) => s === "user" ? t4("keybindings.list_source_user") : t4("keybindings.list_source_default")
343
+ ).join("+");
344
+ }
345
+ function rejectText(item) {
346
+ return t4(REJECT_KEYS[item.reason], { keys: item.keys, winner: item.winner ?? "" });
347
+ }
348
+ // src/language.ts
349
+ import { loadConfig as loadConfig3 } from "@epoch-agent/core";
350
+ import { resolveLang, setLang } from "@epoch-agent/infra";
351
+ function applyConfiguredLanguage() {
352
+ try {
353
+ setLang(resolveLang(loadConfig3().display?.language));
354
+ } catch {
355
+ }
356
+ }
357
+ // src/schedule/labels.ts
358
+ import { t as t5 } from "@epoch-agent/infra";
359
+ import {
360
+ collectPendingApprovals
361
+ } from "@epoch-agent/protocol";
362
+ function pendingNotice(defs, runs) {
363
+ const items = collectPendingApprovals(defs, runs);
364
+ const first = items[0];
365
+ if (!first) return null;
366
+ const rest = items.length - 1;
367
+ return t5(rest > 0 ? "schedule.startup_pending_more" : "schedule.startup_pending", {
368
+ name: first.scheduleName,
369
+ n: first.approvals.length,
370
+ rest,
371
+ id: first.scheduleId.slice(0, 12)
372
+ });
373
+ }
374
+ // src/startup-notices.ts
375
+ import { diagnosticToLine } from "@epoch-agent/protocol";
376
+ function composeStartupNotices(parts) {
377
+ return [
378
+ ...parts.runtimeDiags,
379
+ ...parts.keyDiags.map(diagnosticToLine),
380
+ ...parts.pending ? [parts.pending] : [],
381
+ ...parts.goalNotice ? [parts.goalNotice] : []
382
+ ];
383
+ }
308
384
  // src/tui-host.ts
309
385
  import {
310
386
  getCapability,
@@ -314,12 +390,12 @@ import {
314
390
  setPluginEnabled,
315
391
  suggestRuleFromApproval
316
392
  } from "@epoch-agent/core";
317
- import { pluginsStatePath as pluginsStatePath2 } from "@epoch-agent/infra";
393
+ import { pluginsStatePath as pluginsStatePath2, t as t11 } from "@epoch-agent/infra";
318
394
  import { listBackgroundTasks } from "@epoch-agent/runtime";
319
-
320
395
  // src/clipboard.ts
321
396
  import { execFile } from "child_process";
322
397
  import { readImageSize, sniffMediaType } from "@epoch-agent/core";
398
+ import { t as t6 } from "@epoch-agent/infra";
323
399
  var MAX_BYTES = 32 * 1024 * 1024;
324
400
  var TIMEOUT_MS = 5e3;
325
401
  var WINDOWS_SCRIPT = [
@@ -338,7 +414,7 @@ function clipboardImageCandidates(platform = process.platform, env = process.env
338
414
  command: "osascript",
339
415
  args: ["-e", "get the clipboard as \xABclass PNGf\xBB"],
340
416
  decode: "applescript-hex",
341
- install: "osascript \u662F macOS \u81EA\u5E26\u7684\uFF0C\u627E\u4E0D\u5230\u8BF4\u660E PATH \u6709\u95EE\u9898"
417
+ install: t6("clipboard.install_macos")
342
418
  }
343
419
  ];
344
420
  }
@@ -348,20 +424,20 @@ function clipboardImageCandidates(platform = process.platform, env = process.env
348
424
  command,
349
425
  args: flags,
350
426
  decode: "base64",
351
- install: "Windows \u81EA\u5E26 powershell.exe\uFF0C\u627E\u4E0D\u5230\u8BF4\u660E PATH \u88AB\u6539\u8FC7"
427
+ install: t6("clipboard.install_windows")
352
428
  }));
353
429
  }
354
430
  const wayland = {
355
431
  command: "wl-paste",
356
432
  args: ["--no-newline", "--type", "image/png"],
357
433
  decode: "raw",
358
- install: "Wayland \u4E0B\u88C5 wl-clipboard\uFF08apt install wl-clipboard\uFF09"
434
+ install: t6("clipboard.install_wayland")
359
435
  };
360
436
  const x11 = {
361
437
  command: "xclip",
362
438
  args: ["-selection", "clipboard", "-t", "image/png", "-o"],
363
439
  decode: "raw",
364
- install: "X11 \u4E0B\u88C5 xclip\uFF08apt install xclip\uFF09"
440
+ install: t6("clipboard.install_x11")
365
441
  };
366
442
  return env["WAYLAND_DISPLAY"] ? [wayland, x11] : [x11, wayland];
367
443
  }
@@ -414,23 +490,26 @@ async function readClipboardImage(platform = process.platform, env = process.env
414
490
  const image = decodeImage(bytes);
415
491
  if (!image) continue;
416
492
  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`);
493
+ throw new Error(t6("clipboard.too_large", { mb: Math.round(image.bytes / 1024 / 1024) }));
418
494
  }
419
495
  return image;
420
496
  }
421
497
  if (!anyRan) {
422
- throw new Error(`\u8BFB\u4E0D\u4E86\u526A\u8D34\u677F\uFF1A${candidates.map((c) => c.install).join("\uFF1B")}`);
498
+ throw new Error(
499
+ t6("clipboard.unreadable", {
500
+ hints: candidates.map((c) => c.install).join(t6("clipboard.hint_sep"))
501
+ })
502
+ );
423
503
  }
424
504
  return null;
425
505
  }
426
-
427
506
  // src/goal-view.ts
428
507
  import {
429
508
  DEFAULT_GOAL_MAX_ROUNDS,
430
509
  MAX_GOAL_MAX_ROUNDS,
431
510
  MAX_OBJECTIVE_CHARS
432
511
  } from "@epoch-agent/core";
433
- import { t } from "@epoch-agent/infra";
512
+ import { t as t7 } from "@epoch-agent/infra";
434
513
  var PHASE_LABEL_KEYS = {
435
514
  active: "goal.phase_active",
436
515
  paused: "goal.phase_paused",
@@ -438,28 +517,28 @@ var PHASE_LABEL_KEYS = {
438
517
  complete: "goal.phase_complete"
439
518
  };
440
519
  function phaseLabel(phase) {
441
- return t(PHASE_LABEL_KEYS[phase]);
520
+ return t7(PHASE_LABEL_KEYS[phase]);
442
521
  }
443
522
  function usage() {
444
- return t("goal.usage", { rounds: DEFAULT_GOAL_MAX_ROUNDS, max: MAX_GOAL_MAX_ROUNDS });
523
+ return t7("goal.usage", { rounds: DEFAULT_GOAL_MAX_ROUNDS, max: MAX_GOAL_MAX_ROUNDS });
445
524
  }
446
525
  function extraOf(goal) {
447
526
  switch (goal.phase) {
448
527
  case "blocked":
449
- return t("goal.extra_blocked", {
528
+ return t7("goal.extra_blocked", {
450
529
  code: goal.block?.code ?? "",
451
530
  message: goal.block?.message ?? ""
452
531
  });
453
532
  case "complete":
454
- return t("goal.extra_complete", { evidence: goal.completeEvidence ?? "" });
533
+ return t7("goal.extra_complete", { evidence: goal.completeEvidence ?? "" });
455
534
  case "paused":
456
- return t("goal.extra_paused");
535
+ return t7("goal.extra_paused");
457
536
  case "active":
458
- return t("goal.extra_active");
537
+ return t7("goal.extra_active");
459
538
  }
460
539
  }
461
540
  function describeGoal(goal) {
462
- return t("goal.status", {
541
+ return t7("goal.status", {
463
542
  objective: goal.objective,
464
543
  phase: phaseLabel(goal.phase),
465
544
  used: goal.roundsUsed,
@@ -471,27 +550,24 @@ function refusalText(reason, phase) {
471
550
  const at = phase ? phaseLabel(phase) : "";
472
551
  switch (reason) {
473
552
  case "busy":
474
- return t("goal.refuse_busy", { phase: at });
553
+ return t7("goal.refuse_busy", { phase: at });
475
554
  case "no-goal":
476
- return t("goal.refuse_no_goal");
555
+ return t7("goal.refuse_no_goal");
477
556
  case "empty-objective":
478
- return t("goal.refuse_empty_objective");
557
+ return t7("goal.refuse_empty_objective");
479
558
  case "objective-too-long":
480
- return t("goal.refuse_objective_too_long", { max: MAX_OBJECTIVE_CHARS });
559
+ return t7("goal.refuse_objective_too_long", { max: MAX_OBJECTIVE_CHARS });
481
560
  case "bad-budget":
482
- return t("goal.refuse_bad_budget", { max: MAX_GOAL_MAX_ROUNDS });
561
+ return t7("goal.refuse_bad_budget", { max: MAX_GOAL_MAX_ROUNDS });
483
562
  case "empty-evidence":
484
- return t("goal.refuse_empty_evidence");
563
+ return t7("goal.refuse_empty_evidence");
485
564
  case "wrong-phase":
486
- return t("goal.refuse_wrong_phase", { phase: at });
565
+ return t7("goal.refuse_wrong_phase", { phase: at });
487
566
  case "unavailable":
488
- return t("goal.refuse_unavailable");
489
- // 这两个码只有**模型**那条路(`goal` 工具)产得出来 —— `/goal` 的
490
- // 每一支都不带 block 参数。留着分支而不是 `never`:将来给人也开一个
491
- // 「报卡住」的入口时,这里会是编译期就该改的地方,不是运行期才发现的
567
+ return t7("goal.refuse_unavailable");
492
568
  case "bad-block-code":
493
569
  case "empty-block-message":
494
- return t("goal.refuse_wrong_phase", { phase: at });
570
+ return t7("goal.refuse_wrong_phase", { phase: at });
495
571
  }
496
572
  }
497
573
  function report(control, outcome, toText) {
@@ -503,18 +579,18 @@ function buildGoalActions(control) {
503
579
  return {
504
580
  describe: () => {
505
581
  const goal = control.current();
506
- return goal ? describeGoal(goal) : t("goal.none", { usage: usage() });
582
+ return goal ? describeGoal(goal) : t7("goal.none", { usage: usage() });
507
583
  },
508
584
  usage,
509
585
  create: (objective) => report(
510
586
  control,
511
587
  control.create(objective),
512
- (goal) => t("goal.created", { objective: goal.objective, max: goal.maxRounds })
588
+ (goal) => t7("goal.created", { objective: goal.objective, max: goal.maxRounds })
513
589
  ),
514
590
  edit: (objective) => report(
515
591
  control,
516
592
  control.edit(objective),
517
- (goal) => t("goal.edited", {
593
+ (goal) => t7("goal.edited", {
518
594
  objective: goal.objective,
519
595
  used: goal.roundsUsed,
520
596
  max: goal.maxRounds
@@ -524,52 +600,44 @@ function buildGoalActions(control) {
524
600
  control,
525
601
  control.setBudget(rounds),
526
602
  (goal) => (
527
- // 调小到已用之下时引擎当场把它转成 blocked(`GoalService.setBudget`)——
528
- // 这里照着**写完之后**那份 phase 说话,而不是照着用户的意图说
529
- t(goal.phase === "blocked" ? "goal.budget_set_exhausted" : "goal.budget_set", {
603
+ t7(goal.phase === "blocked" ? "goal.budget_set_exhausted" : "goal.budget_set", {
530
604
  used: goal.roundsUsed,
531
605
  max: goal.maxRounds
532
606
  })
533
607
  )
534
608
  ),
535
- pause: () => report(control, control.pause(), () => t("goal.paused")),
609
+ pause: () => report(control, control.pause(), () => t7("goal.paused")),
536
610
  resume: () => report(
537
611
  control,
538
612
  control.resume(),
539
- (goal) => t("goal.resumed", { used: goal.roundsUsed, max: goal.maxRounds })
613
+ (goal) => t7("goal.resumed", { used: goal.roundsUsed, max: goal.maxRounds })
540
614
  ),
541
615
  done: (evidence) => report(
542
616
  control,
543
617
  control.complete(evidence),
544
- (goal) => t("goal.completed", { evidence: goal.completeEvidence ?? "" })
618
+ (goal) => t7("goal.completed", { evidence: goal.completeEvidence ?? "" })
545
619
  ),
546
620
  clear: () => {
547
621
  const outcome = control.clear();
548
622
  if (!outcome.ok) return { ok: false, message: refusalText(outcome.reason) };
549
- return { ok: true, message: t(outcome.cleared ? "goal.cleared" : "goal.clear_noop") };
623
+ return { ok: true, message: t7(outcome.cleared ? "goal.cleared" : "goal.clear_noop") };
550
624
  },
551
- /**
552
- * 开机那一行(验收 8)。**只有 blocked 才出现**:`active` / `paused` /
553
- * `complete` 三档都不是「需要人现在做点什么」,而开机提示那一栏是给
554
- * 「有事要你处理」用的,塞满常态信息会让真有事的那次被淹掉。
555
- */
556
625
  blockedNotice: () => {
557
626
  const goal = control.current();
558
627
  if (!goal || goal.phase !== "blocked" || !goal.block) return null;
559
- return t("goal.startup_blocked", {
628
+ return t7("goal.startup_blocked", {
560
629
  code: goal.block.code,
561
630
  message: goal.block.message
562
631
  });
563
632
  }
564
633
  };
565
634
  }
566
-
567
635
  // src/host-actions.ts
568
636
  import { execFile as execFile2 } from "child_process";
569
637
  import { writeFileSync } from "fs";
570
638
  import { isAbsolute, resolve } from "path";
571
639
  import { loadPlugins, readPluginRecords, scanPluginDir } from "@epoch-agent/core";
572
- import { pluginsStatePath } from "@epoch-agent/infra";
640
+ import { pluginsStatePath, t as t8 } from "@epoch-agent/infra";
573
641
  var TIMEOUT_MS2 = 1e4;
574
642
  var MAX_BUFFER = 1024 * 1024;
575
643
  function run(command, args, cwd) {
@@ -613,7 +681,7 @@ async function writeClipboardText(text) {
613
681
  (err) => {
614
682
  const e = err;
615
683
  if (e?.code === "ENOENT") return resolve_("missing");
616
- resolve_(err ? err.message ?? "\u5199\u5165\u5931\u8D25" : "ok");
684
+ resolve_(err ? err.message ?? t8("host_actions.clipboard_write_failed") : "ok");
617
685
  }
618
686
  );
619
687
  child.stdin?.end(text, "utf-8");
@@ -623,15 +691,15 @@ async function writeClipboardText(text) {
623
691
  throw new Error(done);
624
692
  }
625
693
  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"
694
+ process.platform === "linux" ? t8("host_actions.clipboard_missing_linux") : t8("host_actions.clipboard_missing")
627
695
  );
628
696
  }
629
697
  async function gitDiff(cwd) {
630
698
  const result = await run("git", ["--no-pager", "diff", "HEAD"], cwd);
631
- if (result.missing) return { text: "", ok: false, message: "git \u4E0D\u5728 PATH \u91CC" };
699
+ if (result.missing) return { text: "", ok: false, message: t8("host_actions.git_missing") };
632
700
  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}` };
701
+ const why = result.stderr.trim().split("\n")[0] ?? t8("git.unknown_reason");
702
+ return { text: "", ok: false, message: t8("host_actions.diff_failed", { why }) };
635
703
  }
636
704
  return { text: result.stdout, ok: true };
637
705
  }
@@ -643,20 +711,20 @@ async function gitDirty(cwd) {
643
711
  }
644
712
  function renderSessionMarkdown(sessionId, messages, now) {
645
713
  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"
714
+ user: t8("host_actions.role_user"),
715
+ assistant: t8("host_actions.role_assistant"),
716
+ tool: t8("host_actions.role_tool"),
717
+ system: t8("host_actions.role_system")
650
718
  };
651
719
  const lines = [
652
- `# Epoch \u4F1A\u8BDD ${sessionId}`,
720
+ t8("host_actions.export_title", { id: sessionId }),
653
721
  "",
654
- `\u5BFC\u51FA\u4E8E ${now.toISOString()} \xB7 \u5171 ${messages.length} \u6761\u6D88\u606F`,
722
+ t8("host_actions.export_meta", { when: now.toISOString(), count: messages.length }),
655
723
  ""
656
724
  ];
657
725
  for (const m of messages) {
658
726
  lines.push(`## ${ROLE_LABEL[m.role] ?? m.role}`, "");
659
- lines.push(m.content.trim() || "\uFF08\u7A7A\uFF09", "");
727
+ lines.push(m.content.trim() || t8("host_actions.export_empty"), "");
660
728
  }
661
729
  return lines.join("\n");
662
730
  }
@@ -683,8 +751,6 @@ function describePlugins(homeDir) {
683
751
  enabled: record.enabled,
684
752
  loaded,
685
753
  ...loaded || reason === void 0 ? {} : { reason },
686
- // 只给加载了的数扩展物:一个目录已经没了的插件扫出来必然是全零,
687
- // 而「0 条命令」会被读成「这插件是空的」,那是另一个结论
688
754
  contributes: loaded ? contributionLines(record.path, record.name) : []
689
755
  };
690
756
  });
@@ -693,20 +759,20 @@ function describePlugins(homeDir) {
693
759
  function contributionLines(dir, name) {
694
760
  const inv = scanPluginDir(dir, name);
695
761
  const hooks = inv.hooks.reduce((sum, h) => sum + h.count, 0);
762
+ const line = (n, key) => n > 0 ? t8(key, { count: n }) : "";
696
763
  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` : ""
764
+ line(inv.commands.length, "plugin.line_commands"),
765
+ line(inv.roles.length, "plugin.line_roles"),
766
+ line(inv.skills.length, "plugin.line_skills"),
767
+ line(inv.denyRules, "plugin.line_deny"),
768
+ line(hooks, "plugin.line_hooks")
702
769
  ].filter(Boolean);
703
- return out.length > 0 ? out : ["\uFF08\u6CA1\u5E26\u4EFB\u4F55\u6269\u5C55\u7269\uFF09"];
770
+ return out.length > 0 ? out : [t8("plugin.line_nothing")];
704
771
  }
705
-
706
772
  // src/statusline.ts
707
773
  import { execFile as execFile3 } from "child_process";
708
774
  import { sanitizeToolOutput } from "@epoch-agent/core";
709
- import { shellSpawnArgs } from "@epoch-agent/infra";
775
+ import { shellSpawnArgs, t as t9 } from "@epoch-agent/infra";
710
776
  var TIMEOUT_MS3 = 2e3;
711
777
  var MAX_WIDTH = 60;
712
778
  var DEFAULT_INTERVAL_MS = 5e3;
@@ -728,7 +794,6 @@ function runStatusLineCommand(command, cwd) {
728
794
  timeout: TIMEOUT_MS3,
729
795
  maxBuffer: MAX_BUFFER2,
730
796
  encoding: "utf-8",
731
- // 不弹黑框:Windows 上每 5 秒闪一个控制台窗口是没法用的
732
797
  windowsHide: true
733
798
  },
734
799
  (err, stdout) => {
@@ -736,7 +801,7 @@ function runStatusLineCommand(command, cwd) {
736
801
  const killed = err.killed === true;
737
802
  settle({
738
803
  text: null,
739
- error: killed ? `\u8D85\u8FC7 ${TIMEOUT_MS3}ms \u6CA1\u8FD4\u56DE` : err.message.trim()
804
+ error: killed ? t9("statusline.timeout", { ms: TIMEOUT_MS3 }) : err.message.trim()
740
805
  });
741
806
  return;
742
807
  }
@@ -768,70 +833,587 @@ function createStatusLine(config, cwd) {
768
833
  if (!running && Date.now() - idleSince >= interval) refresh();
769
834
  return value;
770
835
  },
771
- // 循环而不是一次 await:`pending` 结束的那一刻别人可能已经又 read() 了一次
772
- // (`intervalMs: 0` 时每次 read 都踢一次),只等一次会漏掉后面那个
773
836
  settled: async () => {
774
837
  while (pending) await pending;
775
838
  }
776
839
  };
777
840
  }
778
-
841
+ // src/terminal-setup-view.ts
842
+ import { t as t10 } from "@epoch-agent/infra";
843
+ import { classifyTerminal } from "@epoch-agent/tui";
844
+ // src/terminal-setup.ts
845
+ import { execFileSync } from "child_process";
846
+ import {
847
+ copyFileSync,
848
+ existsSync,
849
+ readdirSync,
850
+ readFileSync,
851
+ rmSync,
852
+ unlinkSync,
853
+ writeFileSync as writeFileSync2
854
+ } from "fs";
855
+ import { homedir } from "os";
856
+ import { basename, dirname, join } from "path";
857
+ var VSCODE_ENTRY = `{
858
+ "key": "shift+enter",
859
+ "command": "workbench.action.terminal.sendSequence",
860
+ "args": { "text": "\\u001b[13;2u" },
861
+ "when": "terminalFocus"
862
+ }`;
863
+ var WINDOWS_TERMINAL_ACTION = `{ "command": { "action": "sendInput", "input": "\\u001b[13;2u" }, "keys": "shift+enter" }`;
864
+ var ITERM2_KEYBOARD_MAP_ENTRY = {
865
+ "Key Code": 13,
866
+ Modifiers: 1,
867
+ Action: 26,
868
+ "Hex Code": "0x1b 0x5b 0x31 0x33 0x3b 0x32 0x75"
869
+ };
870
+ function planForTerminal(terminal, opts = {}) {
871
+ const platform = opts.platform ?? process.platform;
872
+ const env = opts.env ?? process.env;
873
+ const home = opts.home ?? homedir();
874
+ switch (terminal.kind) {
875
+ case "vscode": {
876
+ const base = platform === "darwin" ? join(home, "Library/Application Support/Code/User") : platform === "win32" ? join(env["APPDATA"] ?? "", "Code/User") : join(home, ".config/Code/User");
877
+ const target = join(base, "keybindings.json");
878
+ return { kind: "vscode", target, fileExists: existsSync(target), entry: VSCODE_ENTRY };
879
+ }
880
+ case "windows-terminal": {
881
+ const target = join(
882
+ env["LOCALAPPDATA"] ?? "",
883
+ "Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json"
884
+ );
885
+ return {
886
+ kind: "windows-terminal",
887
+ target,
888
+ fileExists: existsSync(target),
889
+ action: WINDOWS_TERMINAL_ACTION
890
+ };
891
+ }
892
+ case "iterm2": {
893
+ const target = join(home, "Library/Preferences/com.googlecode.iterm2.plist");
894
+ const parsed = existsSync(target) ? readPlistJson(target) : void 0;
895
+ if (parsed === void 0) {
896
+ return { kind: "iterm2", target, readable: false, bookmarkName: "", entry: "" };
897
+ }
898
+ const edited = planIterm2Edit(parsed);
899
+ if (!edited.ok)
900
+ return { kind: "iterm2", target, readable: false, bookmarkName: "", entry: "" };
901
+ return {
902
+ kind: "iterm2",
903
+ target,
904
+ readable: true,
905
+ bookmarkName: edited.bookmarkName,
906
+ entry: entryText(edited.entry)
907
+ };
908
+ }
909
+ case "apple-terminal":
910
+ return { kind: "apple-terminal" };
911
+ case "unknown":
912
+ return { kind: "unknown", label: terminal.label };
913
+ }
914
+ }
915
+ function entryText(entry) {
916
+ return JSON.stringify(entry, null, 2);
917
+ }
918
+ function makeBackup(target, ts = Date.now()) {
919
+ const backupPath = `${target}.epoch-backup.${ts}`;
920
+ const originalAbsent = !existsSync(target);
921
+ try {
922
+ if (originalAbsent) {
923
+ writeFileSync2(backupPath, "");
924
+ } else {
925
+ copyFileSync(target, backupPath);
926
+ }
927
+ writeFileSync2(`${backupPath}.json`, JSON.stringify({ originalAbsent }));
928
+ return { ok: true, backupPath, originalAbsent };
929
+ } catch (error) {
930
+ try {
931
+ rmSync(backupPath, { force: true });
932
+ rmSync(`${backupPath}.json`, { force: true });
933
+ } catch {
934
+ }
935
+ return { ok: false, code: "backup-failed", detail: String(error) };
936
+ }
937
+ }
938
+ function applyPlan(plan, ts = Date.now()) {
939
+ if (plan.kind === "vscode" || plan.kind === "windows-terminal") {
940
+ const target = plan.target;
941
+ const entry = plan.kind === "vscode" ? plan.entry : plan.action;
942
+ const key = plan.kind === "vscode" ? void 0 : "actions";
943
+ let next;
944
+ if (plan.fileExists) {
945
+ const current = safeRead(target);
946
+ if (current === void 0) return { ok: false, code: "target-changed", detail: target };
947
+ const inserted = insertIntoJsonArray(current, entry, key);
948
+ if (inserted === null) return { ok: false, code: "target-changed", detail: target };
949
+ next = inserted;
950
+ } else {
951
+ next = key === void 0 ? `[
952
+ ${entry}
953
+ ]
954
+ ` : `{
955
+ "${key}": [
956
+ ${indentBlock(entry)}
957
+ ]
958
+ }
959
+ `;
960
+ }
961
+ const backup = makeBackup(target, ts);
962
+ if (!backup.ok) return backup;
963
+ try {
964
+ writeFileSync2(target, next);
965
+ writeFileSync2(
966
+ `${backup.backupPath}.json`,
967
+ JSON.stringify({ originalAbsent: backup.originalAbsent, writtenContent: next })
968
+ );
969
+ return { ok: true, target, backupPath: backup.backupPath };
970
+ } catch (error) {
971
+ return {
972
+ ok: false,
973
+ code: "write-failed",
974
+ detail: String(error),
975
+ backupPath: backup.backupPath
976
+ };
977
+ }
978
+ }
979
+ if (plan.kind === "iterm2") {
980
+ const parsed = existsSync(plan.target) ? readPlistJson(plan.target) : void 0;
981
+ if (parsed === void 0) return { ok: false, code: "plist-missing" };
982
+ const edited = planIterm2Edit(parsed);
983
+ if (!edited.ok) return { ok: false, code: "target-changed", detail: plan.target };
984
+ const backup = makeBackup(plan.target, ts);
985
+ if (!backup.ok) return backup;
986
+ if (!writePlistBinary(plan.target, edited.newPlist)) {
987
+ return {
988
+ ok: false,
989
+ code: "plutil-failed",
990
+ detail: plan.target,
991
+ backupPath: backup.backupPath
992
+ };
993
+ }
994
+ writeFileSync2(
995
+ `${backup.backupPath}.json`,
996
+ JSON.stringify({ originalAbsent: backup.originalAbsent })
997
+ );
998
+ return { ok: true, target: plan.target, backupPath: backup.backupPath };
999
+ }
1000
+ return { ok: false, code: "target-changed", detail: plan.kind };
1001
+ }
1002
+ function revertLatest(target) {
1003
+ const latest = findLatestBackup(target);
1004
+ if (latest === void 0) return { ok: false, code: "no-backup", target };
1005
+ let manifest = {};
1006
+ try {
1007
+ manifest = JSON.parse(readFileSync(`${latest}.json`, "utf8"));
1008
+ } catch {
1009
+ }
1010
+ if (manifest.originalAbsent === true) {
1011
+ if (!existsSync(target)) return { ok: false, code: "nothing-to-do", target };
1012
+ const current = safeRead(target);
1013
+ if (current !== manifest.writtenContent) {
1014
+ return { ok: false, code: "revert-blocked", target, backupPath: latest };
1015
+ }
1016
+ try {
1017
+ unlinkSync(target);
1018
+ return { ok: true, kind: "restored-absent", target, backupPath: latest };
1019
+ } catch (error) {
1020
+ return { ok: false, code: "restore-failed", detail: String(error) };
1021
+ }
1022
+ }
1023
+ try {
1024
+ copyFileSync(latest, target);
1025
+ return { ok: true, kind: "restored", target, backupPath: latest };
1026
+ } catch (error) {
1027
+ return { ok: false, code: "restore-failed", detail: String(error) };
1028
+ }
1029
+ }
1030
+ function findLatestBackup(target) {
1031
+ const dir = dirname(target);
1032
+ const base = basename(target);
1033
+ const prefix = `${base}.epoch-backup.`;
1034
+ let best;
1035
+ for (const name of safeReaddir(dir)) {
1036
+ if (!name.startsWith(prefix) || name.endsWith(".json")) continue;
1037
+ const ts = Number(name.slice(prefix.length));
1038
+ if (!Number.isFinite(ts)) continue;
1039
+ if (best === void 0 || ts > best.ts) best = { path: join(dir, name), ts };
1040
+ }
1041
+ return best?.path;
1042
+ }
1043
+ function safeRead(path) {
1044
+ try {
1045
+ return readFileSync(path, "utf8");
1046
+ } catch {
1047
+ return void 0;
1048
+ }
1049
+ }
1050
+ function safeReaddir(dir) {
1051
+ try {
1052
+ return readdirSync(dir);
1053
+ } catch {
1054
+ return [];
1055
+ }
1056
+ }
1057
+ function insertIntoJsonArray(text, entry, key) {
1058
+ let start = -1;
1059
+ if (key === void 0) {
1060
+ for (let i = 0; i < text.length; i++) {
1061
+ const c = text[i];
1062
+ if (c === "[") {
1063
+ start = i;
1064
+ break;
1065
+ }
1066
+ if (c === "{") break;
1067
+ const skip = skipTrivia(text, i);
1068
+ if (skip !== null) i = skip;
1069
+ }
1070
+ } else {
1071
+ const keyAt = text.indexOf(`"${key}"`);
1072
+ if (keyAt === -1) return null;
1073
+ for (let i = keyAt + key.length + 2; i < text.length; i++) {
1074
+ if (text[i] === "[") {
1075
+ start = i;
1076
+ break;
1077
+ }
1078
+ const skip = skipTrivia(text, i);
1079
+ if (skip !== null) i = skip;
1080
+ }
1081
+ }
1082
+ if (start === -1) return null;
1083
+ const end = matchingClose(text, start);
1084
+ if (end === -1) return null;
1085
+ const inner = text.slice(start + 1, end);
1086
+ const isEmpty = inner.trim() === "";
1087
+ const insert = isEmpty ? `
1088
+ ${entry}
1089
+ ` : `,
1090
+ ${key === void 0 ? entry : indentBlock(entry)}
1091
+ `;
1092
+ return text.slice(0, end) + insert + text.slice(end);
1093
+ }
1094
+ function matchingClose(text, start) {
1095
+ let depth = 0;
1096
+ let inString = false;
1097
+ for (let i = start; i < text.length; i++) {
1098
+ const c = text[i];
1099
+ if (inString) {
1100
+ if (c === "\\") i++;
1101
+ else if (c === '"') inString = false;
1102
+ continue;
1103
+ }
1104
+ if (c === '"') {
1105
+ inString = true;
1106
+ continue;
1107
+ }
1108
+ const skip = skipTrivia(text, i);
1109
+ if (skip !== null) {
1110
+ i = skip;
1111
+ continue;
1112
+ }
1113
+ if (c === "[") depth++;
1114
+ else if (c === "]") {
1115
+ depth--;
1116
+ if (depth === 0) return i;
1117
+ }
1118
+ }
1119
+ return -1;
1120
+ }
1121
+ function skipTrivia(text, i) {
1122
+ if (text[i] === "/" && text[i + 1] === "/") {
1123
+ const nl = text.indexOf("\n", i);
1124
+ return nl === -1 ? text.length - 1 : nl;
1125
+ }
1126
+ if (text[i] === "/" && text[i + 1] === "*") {
1127
+ const close = text.indexOf("*/", i + 2);
1128
+ return close === -1 ? text.length - 1 : close + 1;
1129
+ }
1130
+ return null;
1131
+ }
1132
+ function indentBlock(text) {
1133
+ return text.split("\n").map((line) => ` ${line}`).join("\n");
1134
+ }
1135
+ function planIterm2Edit(parsed) {
1136
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return { ok: false };
1137
+ const root = parsed;
1138
+ const guid = root["Default Bookmark Guid"];
1139
+ const bookmarks = root["New Bookmarks"];
1140
+ if (typeof guid !== "string" || !Array.isArray(bookmarks)) return { ok: false };
1141
+ const bookmark = bookmarks.find(
1142
+ (b) => typeof b === "object" && b !== null && b["Guid"] === guid
1143
+ );
1144
+ if (bookmark === void 0) return { ok: false };
1145
+ const entry = { ...ITERM2_KEYBOARD_MAP_ENTRY };
1146
+ const existing = Array.isArray(bookmark["Keyboard Map"]) ? bookmark["Keyboard Map"] : [];
1147
+ bookmark["Keyboard Map"] = [...existing, entry];
1148
+ const name = typeof bookmark["Name"] === "string" ? bookmark["Name"] : guid;
1149
+ return { ok: true, bookmarkName: name, entry, newPlist: root };
1150
+ }
1151
+ function readPlistJson(path) {
1152
+ try {
1153
+ const out = execFileSync("plutil", ["-convert", "json", "-o", "-", path], {
1154
+ encoding: "utf8",
1155
+ stdio: ["ignore", "pipe", "ignore"]
1156
+ });
1157
+ return JSON.parse(out);
1158
+ } catch {
1159
+ return void 0;
1160
+ }
1161
+ }
1162
+ function writePlistBinary(path, value) {
1163
+ const tmp = `${path}.epoch-plist-tmp`;
1164
+ try {
1165
+ writeFileSync2(tmp, JSON.stringify(value));
1166
+ execFileSync("plutil", ["-convert", "binary1", "-o", path, tmp], {
1167
+ stdio: ["ignore", "pipe", "ignore"]
1168
+ });
1169
+ return true;
1170
+ } catch {
1171
+ return false;
1172
+ } finally {
1173
+ rmSync(tmp, { force: true });
1174
+ }
1175
+ }
1176
+ // src/terminal-setup-view.ts
1177
+ async function runTerminalSetup(args, opts = {}) {
1178
+ const terminal = classifyTerminal(opts.env ?? process.env);
1179
+ const plan = planForTerminal(terminal, opts);
1180
+ if (args === "--revert") return revert(plan);
1181
+ if (args === "--write") return write(plan);
1182
+ if (args !== "") return { ok: false, message: t10("terminal_setup.usage") };
1183
+ return planView(plan);
1184
+ }
1185
+ function planView(plan) {
1186
+ switch (plan.kind) {
1187
+ case "vscode":
1188
+ return {
1189
+ ok: true,
1190
+ message: [
1191
+ t10("terminal_setup.vscode_plan_head"),
1192
+ t10("terminal_setup.plan_target", { target: plan.target }),
1193
+ plan.fileExists ? t10("terminal_setup.plan_file_exists") : t10("terminal_setup.plan_file_new"),
1194
+ t10("terminal_setup.plan_add"),
1195
+ plan.entry,
1196
+ t10("terminal_setup.plan_confirm")
1197
+ ].join("\n")
1198
+ };
1199
+ case "windows-terminal":
1200
+ return {
1201
+ ok: true,
1202
+ message: [
1203
+ t10("terminal_setup.wt_plan_head"),
1204
+ t10("terminal_setup.plan_target", { target: plan.target }),
1205
+ plan.fileExists ? t10("terminal_setup.plan_file_exists") : t10("terminal_setup.plan_file_new"),
1206
+ t10("terminal_setup.plan_add"),
1207
+ plan.action,
1208
+ t10("terminal_setup.plan_confirm"),
1209
+ t10("terminal_setup.wt_honest")
1210
+ ].join("\n")
1211
+ };
1212
+ case "iterm2":
1213
+ if (!plan.readable) {
1214
+ return {
1215
+ ok: false,
1216
+ message: t10("terminal_setup.iterm2_unreadable", { target: plan.target })
1217
+ };
1218
+ }
1219
+ return {
1220
+ ok: true,
1221
+ message: [
1222
+ t10("terminal_setup.iterm2_plan_head"),
1223
+ t10("terminal_setup.plan_target", { target: plan.target }),
1224
+ t10("terminal_setup.iterm2_plan_add", { name: plan.bookmarkName }),
1225
+ plan.entry,
1226
+ t10("terminal_setup.plan_confirm")
1227
+ ].join("\n")
1228
+ };
1229
+ case "apple-terminal":
1230
+ return { ok: false, message: t10("terminal_setup.apple") };
1231
+ case "unknown":
1232
+ return { ok: false, message: t10("terminal_setup.unknown", { label: plan.label }) };
1233
+ }
1234
+ }
1235
+ function write(plan) {
1236
+ switch (plan.kind) {
1237
+ case "apple-terminal":
1238
+ return { ok: false, message: t10("terminal_setup.apple_write") };
1239
+ case "unknown":
1240
+ return { ok: false, message: t10("terminal_setup.unknown_write") };
1241
+ case "iterm2":
1242
+ if (!plan.readable) {
1243
+ return {
1244
+ ok: false,
1245
+ message: t10("terminal_setup.iterm2_unreadable", { target: plan.target })
1246
+ };
1247
+ }
1248
+ break;
1249
+ default:
1250
+ break;
1251
+ }
1252
+ const result = applyPlan(plan);
1253
+ if (!result.ok) {
1254
+ switch (result.code) {
1255
+ case "backup-failed":
1256
+ return {
1257
+ ok: false,
1258
+ message: t10("terminal_setup.backup_failed", {
1259
+ target: plan.target,
1260
+ detail: result.detail
1261
+ })
1262
+ };
1263
+ case "target-changed":
1264
+ return { ok: false, message: t10("terminal_setup.target_changed", { target: plan.target }) };
1265
+ case "write-failed":
1266
+ return {
1267
+ ok: false,
1268
+ message: t10("terminal_setup.write_failed", {
1269
+ target: plan.target,
1270
+ detail: result.detail,
1271
+ backup: result.backupPath
1272
+ })
1273
+ };
1274
+ case "plutil-failed":
1275
+ return {
1276
+ ok: false,
1277
+ message: t10("terminal_setup.plutil_failed", {
1278
+ target: plan.target,
1279
+ backup: result.backupPath
1280
+ })
1281
+ };
1282
+ case "plist-missing":
1283
+ return { ok: false, message: t10("terminal_setup.plist_missing", { target: plan.target }) };
1284
+ }
1285
+ }
1286
+ const reload = plan.kind === "vscode" ? t10("terminal_setup.reload_vscode") : plan.kind === "windows-terminal" ? t10("terminal_setup.reload_wt") : t10("terminal_setup.reload_iterm2");
1287
+ return {
1288
+ ok: true,
1289
+ message: [
1290
+ t10("terminal_setup.write_done", { target: result.target, backup: result.backupPath }),
1291
+ reload,
1292
+ t10("terminal_setup.write_revert_hint")
1293
+ ].join("\n")
1294
+ };
1295
+ }
1296
+ function revert(plan) {
1297
+ if (plan.kind === "apple-terminal" || plan.kind === "unknown") {
1298
+ return { ok: false, message: t10("terminal_setup.revert_nowhere") };
1299
+ }
1300
+ const result = revertLatest(plan.target);
1301
+ if (result.ok) {
1302
+ return result.kind === "restored" ? {
1303
+ ok: true,
1304
+ message: t10("terminal_setup.revert_restored", {
1305
+ target: result.target,
1306
+ backup: result.backupPath
1307
+ })
1308
+ } : {
1309
+ ok: true,
1310
+ message: t10("terminal_setup.revert_restored_absent", {
1311
+ target: result.target,
1312
+ backup: result.backupPath
1313
+ })
1314
+ };
1315
+ }
1316
+ switch (result.code) {
1317
+ case "no-backup":
1318
+ return {
1319
+ ok: false,
1320
+ message: t10("terminal_setup.revert_no_backup", { target: result.target })
1321
+ };
1322
+ case "nothing-to-do":
1323
+ return { ok: false, message: t10("terminal_setup.revert_nothing", { target: result.target }) };
1324
+ case "revert-blocked":
1325
+ return {
1326
+ ok: false,
1327
+ message: t10("terminal_setup.revert_blocked", {
1328
+ target: result.target,
1329
+ backup: result.backupPath
1330
+ })
1331
+ };
1332
+ case "restore-failed":
1333
+ return { ok: false, message: t10("terminal_setup.restore_failed", { detail: result.detail }) };
1334
+ }
1335
+ }
779
1336
  // src/tui-host.ts
780
1337
  function sessionHasImages(runtime) {
781
1338
  const history = runtime.session?.getHistory() ?? [];
782
1339
  return history.some((m) => m.parts?.some((p) => p.type === "image"));
783
1340
  }
1341
+ function sessionMentionDeps(runtime) {
1342
+ const references = runtime.sessionReferences;
1343
+ if (!references) return {};
1344
+ const currentSessionId = runtime.session?.sessionId ?? runtime.sessionId;
1345
+ const root = runtime.workspaces.of(currentSessionId)?.projectContext.rootDir;
1346
+ return {
1347
+ sessions: {
1348
+ references,
1349
+ currentSessionId,
1350
+ workspaceCwd: root,
1351
+ allWorkspaces: runtime.config.sessionSearch?.allWorkspaces === true
1352
+ }
1353
+ };
1354
+ }
1355
+ function candidateWhen(ms, now) {
1356
+ const d = new Date(ms);
1357
+ const pad = (n) => String(n).padStart(2, "0");
1358
+ return new Date(now).toDateString() === d.toDateString() ? `${pad(d.getHours())}:${pad(d.getMinutes())}` : `${d.getMonth() + 1}/${d.getDate()}`;
1359
+ }
1360
+ function toCandidateView(row, now) {
1361
+ const where = row.cwd ? row.cwd.split(/[\\/]/).filter(Boolean).pop() ?? row.cwd : "";
1362
+ return {
1363
+ id: row.sessionId,
1364
+ title: row.title,
1365
+ ...row.cwd ? { cwd: row.cwd } : {},
1366
+ hint: row.sameWorkspace ? t11("mention.candidate_here", {
1367
+ when: candidateWhen(row.updatedAt, now),
1368
+ count: row.messageCount
1369
+ }) : t11("mention.candidate_elsewhere", {
1370
+ when: candidateWhen(row.updatedAt, now),
1371
+ count: row.messageCount,
1372
+ where
1373
+ })
1374
+ };
1375
+ }
784
1376
  function buildHostActions(runtime, supportsImages, workDir) {
785
1377
  const { permission, diagnostics, model } = runtime;
786
1378
  const statusLine = createStatusLine(runtime.config, workDir);
787
1379
  const goals = buildGoalActions(runtime.goals);
788
1380
  return {
789
1381
  getDiagnostics: () => diagnostics,
790
- // **不能解构 `tools`**:它是个 getter,解构等于在这里拍一张快照,
791
- // 之后 MCP 热更新(方案 15)了 `/tools` 还显示旧清单
792
1382
  listTools: () => runtime.tools,
793
- // 多模态的两条宿主能力(方案 12 PR-3)。判定都不在 TUI 里:
794
- // 能不能打开归 core 的 artifact-open,怎么读剪贴板归 cli 的 clipboard
795
1383
  openArtifact: (path) => openArtifact(path),
796
1384
  readClipboardImage: () => readClipboardImage(),
797
1385
  supportsImages: () => supportsImages,
798
- // 自定义斜杠命令的展开(方案 23)。插值要用 infra 的 tokenizer,
799
- // 而 tui 只依赖 protocol —— 所以这件事只能在宿主这一侧做
800
1386
  expandCommand: (name, args) => runtime.commands.expand(name, args),
801
- // `@` 文件补全(方案 25)。清单和解析都在这一侧:前者要 spawn git,
802
- // 后者要过工作区边界和**权限判定** —— 让 TUI 自己读文件等于给 `@`
803
- // 开一条绕过 `file_read` 的通道
804
1387
  listWorkspaceFiles: () => Promise.resolve(listWorkspaceFiles(workDir).files),
805
- // 后台任务(方案 36 PR-2):状态栏计数和 `/tasks` 都读它。
806
- // ⚠️ 会话 id 现在是必给的(表按会话分了),而且要给**活的**那个:
807
- // `/resume` 会把 `session.sessionId` 换掉,用装配时那个 `runtime.sessionId`
808
- // 的话,resume 之后状态栏数的是一个已经不存在的会话
809
1388
  listBackgroundTasks: () => listBackgroundTasks(runtime.session?.sessionId ?? runtime.sessionId),
810
- // 用户自配的状态栏那一段(方案 29 §2.6)。没配就不注入这一项
811
1389
  ...statusLine ? { readStatusLine: () => statusLine.read() } : {},
812
- // **不能解构** `contextBreakdown`:它是个 getter,解构等于拍快照,
813
- // `/context` 会一直显示第一次看的那个数
814
1390
  getContextBreakdown: () => runtime.contextBreakdown,
815
- // `!` 和 `#`(方案 25 PR-2)。判定和执行都在引擎侧,TUI 只提供确认框 ——
816
- // 用户手打的命令和模型生成的命令在危险性上没有区别
817
1391
  runShellCommand: (command, approve) => runtime.userActions.runShell(command, approve),
818
1392
  writeMemory: (content, target, approve) => runtime.userActions.writeMemory(content, target, approve),
819
1393
  ...runtime.session ? { noteToSession: (text) => runtime.session.note(text) } : {},
820
- resolveMentions: (paths) => resolveMentions(paths, {
1394
+ resolveMentions: (mentions) => resolveMentions(mentions, {
821
1395
  root: workDir,
822
- ...runtime.permission ? { permission: runtime.permission } : {}
1396
+ ...runtime.permission ? { permission: runtime.permission } : {},
1397
+ ...sessionMentionDeps(runtime)
823
1398
  }),
824
- // ---- 方案 25 PR-4:把引擎里已有、但 TUI 没入口的东西露出来 ----
825
- //
826
- // 三个 getter(skills / mcpServers)**都不能解构**,理由同上面的 `tools`:
827
- // 技能会被 SkillLearner 现学,MCP 会掉线重连,快照拿到的是启动那一刻的
1399
+ listSessionCandidates: (query, limit) => {
1400
+ const deps = sessionMentionDeps(runtime).sessions;
1401
+ if (!deps) return Promise.resolve([]);
1402
+ const rows = deps.references.candidates({
1403
+ query,
1404
+ limit,
1405
+ currentSessionId: deps.currentSessionId,
1406
+ workspaceCwd: deps.workspaceCwd,
1407
+ ...deps.allWorkspaces === void 0 ? {} : { allWorkspaces: deps.allWorkspaces }
1408
+ });
1409
+ const now = Date.now();
1410
+ return Promise.resolve(rows.map((row) => toCandidateView(row, now)));
1411
+ },
828
1412
  listSkills: () => runtime.skills,
829
1413
  listAgentRoles: () => runtime.agentRoles,
830
1414
  listMcpServers: () => runtime.mcpServers.map((s) => ({
831
1415
  name: s.name,
832
- // 三态而不是 connected 的布尔:「要重新登录」和「连不上」的下一步动作
833
- // 完全不同(前者跑 epoch mcp login,后者查配置),合成一个字会让用户白折腾
834
- status: s.needsLogin ? "\u8981\u767B\u5F55" : s.connected ? "\u5DF2\u8FDE\u63A5" : "\u672A\u8FDE\u63A5",
1416
+ status: s.needsLogin ? t11("tui_host.mcp_needs_login") : s.connected ? t11("tui_host.mcp_connected") : t11("tui_host.mcp_disconnected"),
835
1417
  toolCount: s.toolCount,
836
1418
  ...s.lastError ? { error: s.lastError } : {}
837
1419
  })),
@@ -839,12 +1421,6 @@ function buildHostActions(runtime, supportsImages, workDir) {
839
1421
  listMemories: () => runtime.memory.list(),
840
1422
  removeMemory: (content) => runtime.memory.remove(content)
841
1423
  } : {},
842
- // 插件(方案 32)。**无条件注入**:插件系统起不来这回事不存在 ——
843
- // 没装过插件时它就是一份空清单,而那正是 `/plugin` 要显示的东西之一
844
- // (连带「装一个」的提示)。
845
- //
846
- // 两条都现读磁盘、不吃 `runtime.plugins` 那份启动快照,理由写在
847
- // `describePlugins` 上:用户敲这条命令时,磁盘上的状态可能已经不是启动那一刻了。
848
1424
  listPlugins: () => describePlugins(runtime.config.homeDir),
849
1425
  setPluginEnabled: async (name, enabled) => {
850
1426
  const outcome = await setPluginEnabled(name, enabled, {
@@ -852,35 +1428,34 @@ function buildHostActions(runtime, supportsImages, workDir) {
852
1428
  });
853
1429
  return outcome.ok ? { ok: true, message: outcome.message } : { ok: false, message: outcome.reason };
854
1430
  },
855
- // 剪贴板写、git diff、会话导出:三样都要 spawn 或写文件,是宿主的活
856
1431
  copyToClipboard: (text) => writeClipboardText(text),
857
1432
  gitDiff: () => gitDiff(workDir),
858
1433
  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
1434
  checkpoints: {
867
1435
  list: () => runtime.checkpoints.list(),
868
1436
  preview: (turnIndex) => runtime.checkpoints.preview(turnIndex),
869
1437
  rewind: (turnIndex, opts) => runtime.checkpoints.rewind(turnIndex, opts)
870
1438
  },
871
- // 权限规则的可解释性(方案 22 PR-4)。`permissions` 在 runtime 上**不可为 null**,
872
- // 所以这两条无条件注入 —— 一条规则都没配时它是空表加一句「没配过」
873
1439
  listPermissions: () => ({
874
1440
  level: runtime.permissions.level(),
875
1441
  rules: runtime.permissions.rules(),
876
1442
  shadows: runtime.permissions.shadows(),
877
- managed: runtime.permissions.managed()
1443
+ managed: runtime.permissions.managed(),
1444
+ approvals: runtime.permissions.approvals().map((a) => ({
1445
+ id: a.id,
1446
+ toolName: a.toolName,
1447
+ target: a.target,
1448
+ scope: a.scope,
1449
+ decision: a.decision,
1450
+ createdAt: a.createdAt
1451
+ }))
878
1452
  }),
1453
+ revokeApproval: (id) => {
1454
+ const done = runtime.permissions.revokeApproval(id);
1455
+ return done.ok ? { ok: true } : { ok: false, reason: done.reason };
1456
+ },
879
1457
  suggestRule: (request) => suggestRuleFromApproval(request),
880
- // 手动压缩(`/compact`)。会话起不来时不注入 —— 那时压缩也无从谈起
881
1458
  ...runtime.session ? { compactContext: (instruction) => runtime.session.compact(instruction) } : {},
882
- // `/resume` 和 Ctrl+R。三条一起给或一起不给:只给列表不给恢复的话,
883
- // 用户能选中一段会话然后什么也不会发生
884
1459
  ...runtime.sessions ? {
885
1460
  listSessions: () => runtime.sessions.list(),
886
1461
  resumeSession: (sessionId) => runtime.sessions.resume(sessionId),
@@ -895,8 +1470,6 @@ function buildHostActions(runtime, supportsImages, workDir) {
895
1470
  return { path: writeExport(workDir, name, markdown), messages: messages.length };
896
1471
  }
897
1472
  } : {},
898
- // **不能解构 `providerInfo`**:方案 26 之后它是个 getter(`/model` 能换
899
- // provider),解构等于在这里拍快照,切完之后 `/model` 还显示原来那家和那把 key
900
1473
  ...runtime.providerInfo ? { getProviderInfo: () => runtime.providerInfo } : {},
901
1474
  ...model ? {
902
1475
  getModelSelection: () => {
@@ -904,89 +1477,58 @@ function buildHostActions(runtime, supportsImages, workDir) {
904
1477
  const { contextLength } = getCapability(selection.model, runtime.config.homeDir);
905
1478
  return { selection, contextLength };
906
1479
  },
907
- // `hasImages` 由这一侧补,**不能让 TUI 自己判**:TUI 只看得见还没发出去
908
- // 的那几张(`pendingImages`),已经发过的、以及 `-r` 恢复出来的历史里
909
- // 那些,只有会话知道。判成 false 的后果很具体:切到不认图的模型,
910
- // 下一轮把整段带图历史发出去,provider 报 400 且**已经计费**
911
1480
  setModel: (ref, ctx) => {
912
1481
  const merged = { ...ctx, hasImages: sessionHasImages(runtime) };
913
1482
  return ref === "reset" ? model.reset(merged) : model.set(ref, merged);
914
1483
  },
915
- // 引擎攒下的提示(「这个模型连续失败太多次,本会话不再自动重试」)。
916
- // 拉而不是推:router 在 loop.ts 底下,发不出 AgentEvent;TUI 在一轮
917
- // 收尾时拉一次,攒着的话用户会在下一轮才看到上一轮的事
918
1484
  drainModelNotices: () => model.drainNotices()
919
1485
  } : {},
920
1486
  ...permission ? {
921
1487
  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
1488
  setPermissionLevel: (level) => {
936
1489
  const result = runtime.permissions.setLevel(level);
937
1490
  if (!result.ok) {
938
1491
  const managed = runtime.permissions.managed();
939
1492
  return {
940
1493
  ok: false,
941
- reason: `\u4F01\u4E1A\u6258\u7BA1\u8BBE\u7F6E\u7981\u7528\u4E86 bypass\uFF08disableBypassPermissionsMode\uFF0C\u89C1 ${managed.path}\uFF09`
1494
+ reason: t11("tui_host.bypass_managed", { path: managed.path })
942
1495
  };
943
1496
  }
944
- return permission.getLevel() === level ? { ok: true } : { ok: false, reason: `\u5207\u6362\u5230 ${level} \u5931\u8D25` };
1497
+ return permission.getLevel() === level ? { ok: true } : { ok: false, reason: t11("tui_host.switch_failed", { level }) };
945
1498
  }
946
1499
  } : {},
947
- // 长任务的目标(`/goal`,方案 52)。会话库没起来时整组不注入 ——
948
- // 那时 `/goal` 压根不注册(目标是**落盘**的状态,建了就没等于没有)。
949
- //
950
- // ⚠️ 措辞全在 `goal-view.ts` 里走 `t()`:tui 够不着 catalog,判据写在
951
- // `HostActions.goals` 上那段。这里只负责把引擎那一份接过去
952
1500
  ...goals ? { goals } : {},
953
- // Plan 模式的用户入口(`/plan`,方案 35)。**出口处那次审批不在这里** ——
954
- // 它走 `approval-request`,TUI 接的是同一个审批队列
1501
+ terminalSetup: (args) => runTerminalSetup(args),
955
1502
  ...runtime.plan ? {
956
1503
  planMode: {
957
1504
  active: () => runtime.plan?.active() ?? false,
958
1505
  from: () => runtime.plan?.from() ?? null,
959
- enter: () => runtime.plan?.enter() ?? { ok: false, reason: "plan \u6A21\u5F0F\u4E0D\u53EF\u7528" },
1506
+ enter: () => runtime.plan?.enter() ?? { ok: false, reason: t11("tui_host.plan_unavailable") },
960
1507
  leave: () => runtime.plan?.leave() ?? null,
961
- // `/plan show` 的数据源(方案 35 PR-2)。`--resume` 之后屏幕上是空的
962
- // (那张卡片是上一个进程画的),而模型手上那份计划还在 ——
963
- // 这是用户唯一能问出「现在照着什么在做」的地方
964
1508
  approvedPlan: () => runtime.plan?.approvedPlan() ?? null
965
1509
  }
966
1510
  } : {}
967
1511
  };
968
1512
  }
969
-
970
1513
  // src/version.ts
971
- import { readFileSync } from "fs";
972
- import { dirname, join as join2 } from "path";
1514
+ import { readFileSync as readFileSync2 } from "fs";
1515
+ import { dirname as dirname2, join as join3 } from "path";
973
1516
  import { fileURLToPath } from "url";
974
-
1517
+ import { t as t13 } from "@epoch-agent/infra";
975
1518
  // 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
-
1519
+ import { execFileSync as execFileSync2 } from "child_process";
1520
+ import { existsSync as existsSync2, realpathSync } from "fs";
1521
+ import { join as join2 } from "path";
1522
+ import { normalizeForMatch, t as t12 } from "@epoch-agent/infra";
981
1523
  // src/version.ts
982
1524
  function readVersion() {
983
- const here = dirname(fileURLToPath(import.meta.url));
1525
+ const here = dirname2(fileURLToPath(import.meta.url));
984
1526
  for (const candidate of [
985
- join2(here, "..", "package.json"),
986
- join2(here, "..", "..", "package.json")
1527
+ join3(here, "..", "package.json"),
1528
+ join3(here, "..", "..", "package.json")
987
1529
  ]) {
988
1530
  try {
989
- const raw = JSON.parse(readFileSync(candidate, "utf-8"));
1531
+ const raw = JSON.parse(readFileSync2(candidate, "utf-8"));
990
1532
  if (raw.version) return raw.version;
991
1533
  } catch {
992
1534
  }
@@ -994,9 +1536,10 @@ function readVersion() {
994
1536
  return "0.0.0-unknown";
995
1537
  }
996
1538
  var VERSION = readVersion();
997
-
998
1539
  // src/tui-entry.ts
1540
+ var PENDING_SCAN_RUNS = 100;
999
1541
  async function main() {
1542
+ applyConfiguredLanguage();
1000
1543
  if (!await maybePromptForTrust()) {
1001
1544
  process.exit(1);
1002
1545
  }
@@ -1010,11 +1553,9 @@ async function main() {
1010
1553
  });
1011
1554
  const { session, config } = runtime;
1012
1555
  if (!session) {
1013
- process.stderr.write(
1014
- `\u542F\u52A8\u5931\u8D25\uFF1Aprovider \u4E0D\u53EF\u7528\uFF0C\u8FD0\u884C epoch model \u914D\u7F6E
1556
+ process.stderr.write(`${t14("cli.web.no_provider")}
1015
1557
  ${runtime.diagnostics.join("\n")}
1016
- `
1017
- );
1558
+ `);
1018
1559
  process.exit(1);
1019
1560
  }
1020
1561
  const model = config.model || "unknown";
@@ -1031,11 +1572,15 @@ ${runtime.diagnostics.join("\n")}
1031
1572
  path: keybindingsPath(config.homeDir),
1032
1573
  defaults: DEFAULT_KEYBINDINGS
1033
1574
  });
1034
- const host = buildHostActions(runtime, supportsImages, process.cwd());
1575
+ const host = {
1576
+ ...buildHostActions(runtime, supportsImages, process.cwd()),
1577
+ keybindings: () => describeKeybindings(keys.report)
1578
+ };
1035
1579
  const goalNotice = host.goals?.blockedNotice() ?? null;
1580
+ const pending = runtime.schedules.storeAvailable ? pendingNotice(runtime.schedules.list(), runtime.schedules.recentRuns(PENDING_SCAN_RUNS)) : null;
1036
1581
  const instance = renderApp({
1037
1582
  sessionId: session.sessionId,
1038
- // 用量口径必须原样告诉 TUI:它没法从事件流里推出来,猜错就是账算错
1583
+ translate: (key, vars) => t14(key, vars),
1039
1584
  usageScope: runtime.usageScope,
1040
1585
  app: { version: VERSION, cwd: process.cwd() },
1041
1586
  config: {
@@ -1045,32 +1590,17 @@ ${runtime.diagnostics.join("\n")}
1045
1590
  contextLimit,
1046
1591
  getUseBackgroundColor: () => false
1047
1592
  },
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
- ],
1593
+ welcomeMessage: t14("tui_entry.welcome"),
1594
+ startupNotices: composeStartupNotices({
1595
+ runtimeDiags: runtime.diagnostics,
1596
+ keyDiags: keys.diagnostics,
1597
+ pending,
1598
+ goalNotice
1599
+ }),
1061
1600
  keybindings: keys.table,
1601
+ vimMode: keys.vimMode,
1062
1602
  host,
1063
- // 自定义斜杠命令的表(方案 23)。**永远给**:加载不出来时它是空数组,
1064
- // 不是「没有这个能力」—— TUI 那侧因此不用为「有没有命令系统」分支
1065
1603
  customCommands: runtime.commands.list,
1066
- // 入参是 EpochUserContent 而不是 string —— TUI 里 Ctrl+V 粘的图片
1067
- // 跟着这一次提交一路走到引擎,`session.run` 本来就收这个类型。
1068
- //
1069
- // 两层包装管的都是「这一轮」:工具白名单(方案 23)和临时模型(方案 26)。
1070
- // 进出成对、且**中断也会还原**,规矩和用例都在 runtime 那一侧。
1071
- // 模型那层在外面:它要在工具作用域退出之后才还原,顺序和进入时相反 ——
1072
- // 虽然这两件事眼下互不影响,但把嵌套写成不配对的形状,
1073
- // 早晚会有第三样东西掉进那个缝里
1074
1604
  onRun: (userMessage, opts) => withModelScope(
1075
1605
  runtime.model,
1076
1606
  opts.model,
@@ -1080,9 +1610,6 @@ ${runtime.diagnostics.join("\n")}
1080
1610
  session.run(userMessage, { signal: opts.signal })
1081
1611
  )
1082
1612
  ),
1083
- // onExit 是同步回调(ink 的键位处理里调),而 dispose 现在要等后台进程收尸。
1084
- // 先 unmount 把终端还给用户 —— 收尸最多 200ms,但那 200ms 里屏幕不该还挂着
1085
- // 一个已经退出的 TUI
1086
1613
  onExit: () => {
1087
1614
  instance.unmount();
1088
1615
  void runtime.dispose().finally(() => process.exit(0));
@@ -1091,7 +1618,7 @@ ${runtime.diagnostics.join("\n")}
1091
1618
  }
1092
1619
  main().catch((err) => {
1093
1620
  if (expectedFailureExitCode(err) !== void 0) reportFatal(err);
1094
- process.stderr.write(`TUI \u542F\u52A8\u5931\u8D25: ${err}
1621
+ process.stderr.write(`${t14("run.tui_spawn_failed", { message: String(err) })}
1095
1622
  `);
1096
1623
  process.exit(1);
1097
1624
  });