@epoch-agent/runtime 0.1.0 → 0.3.1

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.
Files changed (4) hide show
  1. package/README.md +152 -78
  2. package/dist/index.d.ts +2143 -78
  3. package/dist/index.js +1754 -1240
  4. package/package.json +9 -9
package/dist/index.js CHANGED
@@ -1,15 +1,15 @@
1
- import { loadCommandDefinitions, createToolScope, expandCommand, RecentWorkspaces, clearModelCache, discoverModels, ScheduleStore, isWithinWindow, acquireScheduleLock, triggerPeriodMs, ScheduleRecorder, pruneRecordings, suggestRuleFromApproval, needsAllowlist, isNonInteractive, loadPlugins, assertLevelAllowed, discoverProject, ProviderRouter, ProviderError, PROVIDER_NO_CREDENTIAL, SessionManager, GoalService, MemoryManager, MemoryReviewer, resolveSkillDirs, SkillSystem, SkillLearner, HookManager, loadHookSources, ApprovalCache, PermissionManager, resolvePolicyDirs, resolvePoliciesDir, loadPolicyFiles, checkShadowing, compileRuleLists, ContextCompressor, compressionOptions, TrackerDB, getConfigOrigins, mapOperationType, affectedPaths, workspaceDisplayPath, persistParts, persistToolResults, ToolRegistry, createTodoTool, createGoalTool, createMemoryTool, createCodeExecTool, createAskQuestionTool, resolveWorkspace, unloadedInstructionFiles, resolveProjectRoot, loadConfig, CodeSandbox, CheckpointManager, createDelegateTool, loadRoleDefinitions, mergeRoles, builtinRoles, unknownRoleTools, BLOCKED_TOOLS, blockedRoleTools, findRole, BudgetStore, mergeHeadlessPolicy, describeHeadlessPolicy, importSkills, previewSkillImport, gateForTool, managedSwitchNotes, originOf, EpochConfigSchema, defaultConfig, SettingsFileSchema, isWritableSettingKey, writeSettingValue, SETTING_WRITE_APPLY, resolveSettings, SETTING_WRITE_LAYERS, readProviderKeyEnv, validateSchedule, createAllowAllGate, setTrustGate, TrustManager, createTrustGate, createDenyAllGate, getImportGate, setImportGate, createAllowAllImportGate, ImportTrustStore, createDenyAllImportGate, createImportGate, getCapability, restoreParts, createSessionSearchTools, PlanModeState, createPlanModeTools, explainSetting, settingWriteTargets, createRoleFile, AgentLoop, roleScopedProvider, createRoleScope, withGoalPrompt, withGoalCompression, ScheduleRegistrar, resolveScheduleBackend, SETTINGS_LAYER_ORDER } from '@epoch-agent/core';
2
- export { SANDBOX_COVERS, SANDBOX_EXCLUDES, SCHEDULE_DEFAULTS, SCHEDULE_DEFAULT_ALLOWLIST, nextRunAt, readRecording, skillIndexDescription } from '@epoch-agent/core';
3
- import { projectCommandsDir, commandsDir, issueDetails, t, resolveHomeDir, configPath, readKey, setScalar, setSectionField, mcpConfigPath, workspacesPath, dbPath, automationLogsDir, pluginsStatePath, safeInit, memoriesDir, skillsDir, hooksPath, artifactsDir, resolveRipgrep, withTimeout, agentsDir, budgetStatePath, createSecretStore, setSecretStore, prefetchProviderSecrets, setSecretValues, migrateEnvSecrets, envPath, setDataKey, parseDataKey, generateDataKey, automationWorkDir, trustPath, trustedImportsPath, setShell, probeShell } from '@epoch-agent/infra';
1
+ import { loadCommandDefinitions, SLASH_COMMANDS_DIAG_MODULE, createToolScope, expandCommand, RecentWorkspaces, clearModelCache, discoverModels, ScheduleStore, isWithinWindow, acquireScheduleLock, triggerPeriodMs, ScheduleRecorder, pruneRecordings, suggestRuleFromApproval, needsAllowlist, isNonInteractive, loadPlugins, assertLevelAllowed, discoverProject, ProviderRouter, ProviderError, PROVIDER_NO_CREDENTIAL, SessionManager, GoalService, MemoryManager, MemoryReviewer, resolveSkillDirs, SkillSystem, SkillLearner, HookManager, loadHookSources, ApprovalCache, PermissionManager, resolvePolicyDirs, resolvePoliciesDir, loadPolicyFiles, checkShadowing, compileRuleLists, ContextCompressor, compressionOptions, TrackerDB, getConfigOrigins, resolveComplianceDirs, resolveComplianceDir, loadLexiconFiles, ComplianceEngine, DEFAULT_COMPLIANCE_SETTINGS, mapOperationType, affectedPaths, workspaceDisplayPath, persistParts, persistToolResults, ToolRegistry, createTodoTool, createGoalTool, createMemoryTool, createCodeExecTool, createRunCodeTool, createSkillViewTool, createSkillWriteTools, createToolSearchTool, createToolRevealState, createAskQuestionTool, resolveWorkspace, unloadedInstructionFiles, resolveProjectRoot, loadConfig, CodeSandbox, CheckpointManager, createDelegateTool, findRole, systemOpenArgv, resolveInWorkspace, listWorkspaceFiles, loadRoleDefinitions, mergeRoles, AGENT_ROLE_DIAG_MODULE, builtinRoles, unknownRoleTools, BLOCKED_TOOLS, blockedRoleTools, unknownRoleSkills, BudgetStore, mergeHeadlessPolicy, describeHeadlessPolicy, importSkills, previewSkillImport, removeSessionArtifacts, gateForTool, managedSwitchNotes, originOf, EpochConfigSchema, defaultConfig, SettingsFileSchema, isWritableSettingKey, writeSettingValue, SETTING_WRITE_APPLY, resolveSettings, SETTING_WRITE_LAYERS, readProviderKeyEnv, validateSchedule, createAllowAllGate, setTrustGate, TrustManager, createTrustGate, createDenyAllGate, getImportGate, setImportGate, createAllowAllImportGate, ImportTrustStore, createDenyAllImportGate, createImportGate, getCapability, restoreParts, createSessionSearchTools, PlanModeState, createPlanModeTools, toolTableTokens, explainSetting, settingWriteTargets, settingValueKind, settingValueChoices, uninstallPlugin, readMarketplaces, searchMarketplaces, resolveEntrySource, resolveSource, readPluginRecords, createRoleFile, AgentLoop, resolveArtifactRetention, roleScopedProvider, createRoleScope, revealScopedProvider, withGoalPrompt, withGoalCompression, ScheduleRegistrar, resolveScheduleBackend, updateMarketplace, addMarketplace, SETTINGS_LAYER_ORDER, resolveMarketplaceRef, BudgetGuard, installPlugin, currentEpochVersion } from '@epoch-agent/core';
2
+ export { DEFAULT_GOAL_MAX_ROUNDS, MAX_GOAL_MAX_ROUNDS, MAX_OBJECTIVE_CHARS, MAX_REASON_CHARS, SANDBOX_COVERS, SANDBOX_EXCLUDES, SCHEDULE_DEFAULTS, SCHEDULE_DEFAULT_ALLOWLIST, nextRunAt, readRecording, skillIndexDescription } from '@epoch-agent/core';
3
+ import { projectCommandsDir, commandsDir, t, issueDetails, resolveHomeDir, configPath, readKey, setScalar, setSectionField, mcpConfigPath, workspacesPath, writeProviderSecret, envPath, maskApiKey, dbPath, automationLogsDir, pluginsStatePath, safeInit, memoriesDir, skillsDir, claudeUserSettingsPath, hooksPath, artifactsDir, resolveRipgrep, withTimeout, createProcessTable, marketplacesPath, pluginsDir, agentsDir, budgetStatePath, createSecretStore, setSecretStore, prefetchProviderSecrets, setSecretValues, migrateEnvSecrets, setDataKey, parseDataKey, generateDataKey, i18nDiagnostics, automationWorkDir, trustPath, trustedImportsPath, setShell, probeShell } from '@epoch-agent/infra';
4
4
  export { t } from '@epoch-agent/infra';
5
- import { isAbsolute, dirname, delimiter, resolve, join } from 'path';
6
- import { AGENT_ROLE_NAME_PATTERN, isValidAgentRoleName, apiKeyEnvVar, getProviderInfo, PROVIDER_INFOS, parseModelRef, contentToText, systemNote, DiagnosticSink, METRIC, API_KEY_ENV_VARS, MCP_DATA_KEY_NAME, isPermissionLevel, NOOP_TELEMETRY, DEFAULT_AGENT_ROLE } from '@epoch-agent/protocol';
7
- import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync, renameSync, rmSync, appendFileSync } from 'fs';
8
- import { parseMcpConfig, McpRegistry, McpAuthStore, mcpAuthStatus, loginToMcpServer, logoutFromMcpServer } from '@epoch-agent/plugin-mcp';
5
+ import { isAbsolute, dirname, delimiter, resolve, join, relative, sep } from 'path';
6
+ import { AGENT_ROLE_NAME_PATTERN, isValidAgentRoleName, apiKeyEnvVar, getProviderInfo, PROVIDER_INFOS, parseModelRef, contentToText, assistantTurnText, systemNote, ToolExposure, isMentionableFilePath, DiagnosticSink, METRIC, API_KEY_ENV_VARS, MCP_DATA_KEY_NAME, isPermissionLevel, NOOP_TELEMETRY, DEFAULT_AGENT_ROLE } from '@epoch-agent/protocol';
7
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync, renameSync, rmSync, realpathSync, openSync, readSync, closeSync, appendFileSync } from 'fs';
9
8
  import { randomUUID, createHash } from 'crypto';
9
+ import { parseMcpConfig, McpRegistry, McpAuthStore, mcpAuthStatus, loginToMcpServer, logoutFromMcpServer } from '@epoch-agent/plugin-mcp';
10
10
  import { filePlugin } from '@epoch-agent/plugin-file';
11
11
  import { lspPlugin } from '@epoch-agent/plugin-lsp';
12
- import { terminalPlugin, cleanupBackgroundProcesses, rekeyTasks, listTasks } from '@epoch-agent/plugin-terminal';
12
+ import { terminalPlugin, rekeyTasks, listTasks } from '@epoch-agent/plugin-terminal';
13
13
  export { taskOutput as backgroundTaskOutput, listTasks as listBackgroundTasks } from '@epoch-agent/plugin-terminal';
14
14
  import { webPlugin, selectSearchProvider, createWebSearchTool } from '@epoch-agent/plugin-web';
15
15
 
@@ -53,18 +53,22 @@ function wireCommands(opts) {
53
53
  });
54
54
  if (summary.commands.length === 0 && summary.issues.length > 0) {
55
55
  diags.warn(
56
- "\u659C\u6760\u547D\u4EE4",
57
- `${issueDetails(summary.issues).length} \u4E2A\u6587\u4EF6\u90FD\u6CA1\u80FD\u52A0\u8F7D\uFF0C\u5F53\u524D\u6CA1\u6709\u53EF\u7528\u7684\u81EA\u5B9A\u4E49\u547D\u4EE4`,
56
+ SLASH_COMMANDS_DIAG_MODULE,
57
+ t("boot.commands_all_failed", { count: issueDetails(summary.issues).length }),
58
58
  "no-command-loaded"
59
59
  );
60
60
  }
61
- const available = new Set(toolProvider.list().map((t13) => t13.name));
61
+ const available = new Set(toolProvider.list().map((t25) => t25.name));
62
62
  for (const cmd of summary.commands) {
63
63
  const unknown = (cmd.allowedTools ?? []).filter((name) => !available.has(name));
64
64
  if (unknown.length > 0) {
65
65
  diags.warn(
66
- "\u659C\u6760\u547D\u4EE4",
67
- `/${cmd.name} \u7684 allowed-tools \u91CC ${unknown.join(", ")} \u4E0D\u5B58\u5728\uFF0C\u5DF2\u5FFD\u7565`,
66
+ SLASH_COMMANDS_DIAG_MODULE,
67
+ (lang) => t(
68
+ "boot.command_allowed_tools",
69
+ { name: `/${cmd.name}`, tools: unknown.join(", ") },
70
+ lang
71
+ ),
68
72
  "unknown-tool"
69
73
  );
70
74
  }
@@ -87,6 +91,65 @@ var init_commands = __esm({
87
91
  "src/commands.ts"() {
88
92
  }
89
93
  });
94
+ // src/events.ts
95
+ function publishAgentEvent(sessionId, event) {
96
+ bus.publish(sessionId, event);
97
+ }
98
+ var ProcessEventBus, bus, processEventBus;
99
+ var init_events = __esm({
100
+ "src/events.ts"() {
101
+ ProcessEventBus = class {
102
+ subscribers = /* @__PURE__ */ new Set();
103
+ failures = 0;
104
+ subscribe(fn) {
105
+ this.subscribers.add(fn);
106
+ let released = false;
107
+ return () => {
108
+ if (released) return;
109
+ released = true;
110
+ this.subscribers.delete(fn);
111
+ };
112
+ }
113
+ get subscriberCount() {
114
+ return this.subscribers.size;
115
+ }
116
+ get deliveryFailures() {
117
+ return this.failures;
118
+ }
119
+ publish(sessionId, event) {
120
+ if (this.subscribers.size === 0) return;
121
+ for (const fn of this.subscribers) {
122
+ try {
123
+ fn(event, sessionId);
124
+ } catch {
125
+ this.failures += 1;
126
+ }
127
+ }
128
+ }
129
+ };
130
+ bus = new ProcessEventBus();
131
+ processEventBus = bus;
132
+ }
133
+ });
134
+ function dropOverlongToolNames(tools, module, code, diags) {
135
+ const kept = [];
136
+ for (const tool of tools) {
137
+ if (tool.name.length > MAX_TOOL_NAME_LENGTH) {
138
+ diags.warn(
139
+ module,
140
+ (lang) => t(
141
+ "host_capabilities.tool_name_too_long",
142
+ { name: tool.name, limit: MAX_TOOL_NAME_LENGTH },
143
+ lang
144
+ ),
145
+ code
146
+ );
147
+ continue;
148
+ }
149
+ kept.push(tool);
150
+ }
151
+ return kept;
152
+ }
90
153
  function wireHostCapabilities(caps, diags) {
91
154
  if (!caps) return EMPTY;
92
155
  const namespace = caps.namespace;
@@ -109,7 +172,8 @@ function wireHostCapabilities(caps, diags) {
109
172
  const roles = wireRoles(caps.roles ?? [], namespace, diags);
110
173
  const skillDirs = wireSkillDirs(caps.skillDirs ?? [], namespace, diags);
111
174
  const mcpServers = wireMcpServers(caps.mcpServers ?? [], namespace, diags);
112
- if (roles.length > 0 || skillDirs.length > 0 || mcpServers.length > 0) {
175
+ const tools = wireTools(caps.tools ?? [], namespace, diags);
176
+ if (roles.length > 0 || skillDirs.length > 0 || mcpServers.length > 0 || tools.length > 0) {
113
177
  diags.ok(
114
178
  HOST_CAPABILITIES_MODULE,
115
179
  (lang) => t(
@@ -118,13 +182,14 @@ function wireHostCapabilities(caps, diags) {
118
182
  namespace,
119
183
  roles: roles.length,
120
184
  skillDirs: skillDirs.length,
121
- mcpServers: mcpServers.length
185
+ mcpServers: mcpServers.length,
186
+ tools: tools.length
122
187
  },
123
188
  lang
124
189
  )
125
190
  );
126
191
  }
127
- return { roles, skillDirs, mcpServers };
192
+ return { roles, skillDirs, mcpServers, tools };
128
193
  }
129
194
  function wireRoles(input, namespace, diags) {
130
195
  const seen = /* @__PURE__ */ new Set();
@@ -153,7 +218,6 @@ function wireRoles(input, namespace, diags) {
153
218
  ...role.prompt ? { prompt: role.prompt } : {},
154
219
  ...role.tools ? { tools: [...role.tools] } : {},
155
220
  ...role.maxTurns ? { maxTurns: role.maxTurns } : {},
156
- // 宿主说不出这一格(`HostAgentRole` 上没有它),这一行是它唯一的来源
157
221
  source: "host"
158
222
  });
159
223
  }
@@ -199,11 +263,95 @@ function wireMcpServers(input, namespace, diags) {
199
263
  }
200
264
  return servers;
201
265
  }
202
- var HOST_CAPABILITIES_MODULE, EMPTY;
266
+ function wireTools(input, namespace, diags) {
267
+ const seen = /* @__PURE__ */ new Set();
268
+ const tools = [];
269
+ for (const tool of input) {
270
+ if (!HOST_TOOL_NAME_PATTERN.test(tool.name)) {
271
+ diags.warn(
272
+ HOST_CAPABILITIES_MODULE,
273
+ (lang) => t("host_capabilities.bad_tool_name", { name: tool.name }, lang),
274
+ "host-tool-invalid-name"
275
+ );
276
+ continue;
277
+ }
278
+ if (seen.has(tool.name)) {
279
+ diags.warn(
280
+ HOST_CAPABILITIES_MODULE,
281
+ (lang) => t("host_capabilities.duplicate_tool", { name: tool.name }, lang),
282
+ "host-tool-duplicate"
283
+ );
284
+ continue;
285
+ }
286
+ seen.add(tool.name);
287
+ const describeTarget = tool.describeTarget;
288
+ tools.push({
289
+ ...tool,
290
+ name: `${namespace}__${tool.name}`,
291
+ execute: (args, ctx) => tool.execute(args, ctx),
292
+ ...describeTarget ? { describeTarget: (args) => describeTarget.call(tool, args) } : {}
293
+ });
294
+ }
295
+ return tools;
296
+ }
297
+ var HOST_CAPABILITIES_MODULE, HOST_TOOL_NAME_PATTERN, MAX_TOOL_NAME_LENGTH, EMPTY;
203
298
  var init_host_capabilities = __esm({
204
299
  "src/host-capabilities.ts"() {
205
300
  HOST_CAPABILITIES_MODULE = "HostCapabilities";
206
- EMPTY = { roles: [], skillDirs: [], mcpServers: [] };
301
+ HOST_TOOL_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
302
+ MAX_TOOL_NAME_LENGTH = 128;
303
+ EMPTY = { roles: [], skillDirs: [], mcpServers: [], tools: [] };
304
+ }
305
+ });
306
+ async function wireHostMarketplaces(input) {
307
+ const { statePath, diagnostics: diags } = input;
308
+ const allowRemote = input.hostMarketplaces.allowRemote === true;
309
+ for (const raw of input.hostMarketplaces.sources) {
310
+ const source = raw.trim();
311
+ if (!source) {
312
+ diags.warn(HOST_MARKETPLACE_MODULE, (l) => t("plugin_control.market_empty", void 0, l));
313
+ continue;
314
+ }
315
+ if (!allowRemote && !isLocalSource(source)) {
316
+ diags.warn(
317
+ HOST_MARKETPLACE_MODULE,
318
+ (l) => t("plugin_control.market_remote_off", { source }, l),
319
+ "remote-disabled"
320
+ );
321
+ continue;
322
+ }
323
+ const outcome = await addOrRefresh(source, statePath);
324
+ if (!outcome.ok) {
325
+ diags.warn(
326
+ HOST_MARKETPLACE_MODULE,
327
+ (l) => t("plugin_control.market_failed", { source, reason: outcome.reason }, l),
328
+ "marketplace-unavailable"
329
+ );
330
+ continue;
331
+ }
332
+ diags.ok(
333
+ HOST_MARKETPLACE_MODULE,
334
+ (l) => t("plugin_control.market_ready", { name: outcome.name, count: outcome.count }, l)
335
+ );
336
+ }
337
+ }
338
+ function isLocalSource(source) {
339
+ return resolveSource(source)?.type === "local";
340
+ }
341
+ async function addOrRefresh(source, statePath) {
342
+ try {
343
+ const existing = readMarketplaces(statePath).records.find((r) => r.source === source);
344
+ const outcome = existing ? await updateMarketplace(existing.name, { statePath }) : await addMarketplace(source, { statePath });
345
+ return outcome.ok ? { ok: true, name: outcome.record.name, count: outcome.record.catalog.plugins.length } : outcome;
346
+ } catch (err) {
347
+ const message = err instanceof Error ? err.message : String(err);
348
+ return { ok: false, reason: message.split("\n")[0] ?? message };
349
+ }
350
+ }
351
+ var HOST_MARKETPLACE_MODULE;
352
+ var init_host_marketplaces = __esm({
353
+ "src/host-marketplaces.ts"() {
354
+ HOST_MARKETPLACE_MODULE = "HostMarketplaces";
207
355
  }
208
356
  });
209
357
  async function applyHostPreset(opts) {
@@ -441,6 +589,233 @@ var init_host_preset = __esm({
441
589
  HOST_PRESET_MODULE = "HostPreset";
442
590
  }
443
591
  });
592
+ function fail(message, start, code, suggestion) {
593
+ return {
594
+ success: false,
595
+ output: "",
596
+ error: { code, message, ...suggestion ? { suggestion } : {} },
597
+ duration: Date.now() - start
598
+ };
599
+ }
600
+ function done(output, start) {
601
+ return { success: true, output, duration: Date.now() - start };
602
+ }
603
+ function str(args, key) {
604
+ const raw = args[key];
605
+ return typeof raw === "string" ? raw : "";
606
+ }
607
+ function toAddInput(args) {
608
+ const name = str(args, "name").trim();
609
+ if (!name) return "name must not be empty";
610
+ const transport = str(args, "transport").trim();
611
+ if (transport !== "stdio" && transport !== "sse" && transport !== "http") {
612
+ return `transport must be one of "stdio" / "sse" / "http", got ${JSON.stringify(transport)}`;
613
+ }
614
+ const rawArgs = args["args"];
615
+ if (rawArgs !== void 0 && !isStringArray(rawArgs)) {
616
+ return "args must be an array of strings";
617
+ }
618
+ const rawEnv = args["env"];
619
+ if (rawEnv !== void 0 && !isStringRecord(rawEnv)) {
620
+ return "env must be an object whose values are all strings";
621
+ }
622
+ const command = str(args, "command").trim();
623
+ const url = str(args, "url").trim();
624
+ return {
625
+ name,
626
+ transport,
627
+ ...command ? { command } : {},
628
+ ...rawArgs === void 0 ? {} : { args: rawArgs },
629
+ ...url ? { url } : {},
630
+ ...rawEnv === void 0 ? {} : { env: rawEnv }
631
+ };
632
+ }
633
+ function isStringArray(value) {
634
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
635
+ }
636
+ function isStringRecord(value) {
637
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
638
+ return Object.values(value).every((v) => typeof v === "string");
639
+ }
640
+ function listTool(deps) {
641
+ return {
642
+ name: "mcp_list",
643
+ description: "\u5217\u51FA\u8FD9\u4E2A\u8FDB\u7A0B\u91CC\u6240\u6709 MCP server \u7684\u72B6\u6001\uFF1A\u540D\u5B57\u3001\u6765\u6E90\u3001\u8FDE\u4E0A\u6CA1\u6709\u3001\u51E0\u4E2A\u5DE5\u5177\u3001\u4E0A\u6B21\u9519\u5728\u54EA\u3002\u6539\u4E00\u53F0\u4E4B\u524D\u5148\u770B\u4E00\u773C\u3002\nsource \u51B3\u5B9A\u6539\u4E0D\u6539\u5F97\u52A8\uFF1A\u53EA\u6709 user \u90A3\u6279\u662F\u7528\u6237\u914D\u7684\uFF1Bhost / plugin \u4E24\u6279\u4E0D\u5728 mcp.json \u91CC\uFF0C\u6539\u4E0D\u4E86\u3002\n\u26A0\uFE0F \u4E0D\u56DE command / url / env \u2014\u2014 \u90A3\u51E0\u683C\u5E38\u85CF\u4EE4\u724C\uFF0C\u8981\u770B\u8BA9\u7528\u6237\u81EA\u5DF1\u6253\u5F00 ~/.epoch/mcp.json\u3002",
644
+ parameters: { type: "object", properties: {} },
645
+ exposure: ToolExposure.Direct,
646
+ supportsParallel: true,
647
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
648
+ operation: "file_read",
649
+ describeTarget: () => MCP_LIST_TOOL,
650
+ execute() {
651
+ const start = Date.now();
652
+ const rows = deps.servers();
653
+ if (rows.length === 0) {
654
+ return Promise.resolve(
655
+ done("No MCP servers are configured in this process. Use mcp_add to add one.", start)
656
+ );
657
+ }
658
+ const lines = rows.map((s) => {
659
+ const state = s.connected ? "connected" : "disconnected";
660
+ const login = s.needsLogin === true ? ", needs login" : "";
661
+ const err = s.lastError === void 0 ? "" : ` \u2014 ${s.lastError}`;
662
+ return `${s.name} [${s.source}] ${state}, ${s.toolCount} tools${login}${err}`;
663
+ });
664
+ return Promise.resolve(done(lines.join("\n"), start));
665
+ }
666
+ };
667
+ }
668
+ function runWrite(deps, args, kind) {
669
+ const start = Date.now();
670
+ const input = toAddInput(args);
671
+ if (typeof input === "string") return Promise.resolve(fail(input, start, 3201));
672
+ const call = kind === "add" ? deps.control.add(input) : deps.control.update(input);
673
+ return call.then((outcome) => {
674
+ if (!outcome.ok) {
675
+ return fail(
676
+ `${outcome.reason}: ${outcome.detail}`,
677
+ start,
678
+ 3202,
679
+ HINT_BY_REASON[outcome.reason]
680
+ );
681
+ }
682
+ return done(describeWrite(kind, outcome.configPath, outcome.status), start);
683
+ });
684
+ }
685
+ function describeWrite(kind, configPath2, status) {
686
+ const verb = kind === "add" ? "Added" : "Updated";
687
+ const why = status.lastError === void 0 ? "" : ` \u2014 ${status.lastError}`;
688
+ const login = status.needsLogin === true ? " It needs a login: run `epoch mcp login`." : "";
689
+ const live = status.connected ? `It is connected and exposes ${status.toolCount} tools now.` : `It is NOT connected right now${why}. The config is saved either way \u2014 the server may just not be up yet.`;
690
+ return `${verb} MCP server "${status.name}" in ${configPath2}. ${live}${login}`;
691
+ }
692
+ function addTool(deps) {
693
+ return {
694
+ name: "mcp_add",
695
+ description: "\u52A0\u4E00\u53F0 MCP server\uFF1A\u5199\u8FDB ~/.epoch/mcp.json\uFF0C\u7136\u540E\u5F53\u573A\u8FDE\u4E00\u6B21\u3002\n\u26A0\uFE0F \u5F71\u54CD\u7684\u4E0D\u53EA\u662F\u8FD9\u6BB5\u4F1A\u8BDD \u2014\u2014 \u90A3\u53F0\u4EE5\u540E\u6BCF\u4E2A epoch \u8FDB\u7A0B\u90FD\u4F1A\u8FDE\u5B83\uFF08stdio \u90A3\u6863\u8981\u8D77\u5B50\u8FDB\u7A0B\uFF09\u3002\u52A0\u4E4B\u524D\u5148\u8DDF\u7528\u6237\u8BF4\u6E05\u4F60\u8981\u52A0\u4EC0\u4E48\u3002\n\u8FDE\u4E0D\u4E0A\u4E0D\u7B97\u5931\u8D25\uFF08\u914D\u7F6E\u5BF9\u3001\u5BF9\u9762\u6CA1\u8D77\u6765\u7167\u6837\u52A0\u5F97\u8FDB\u53BB\uFF0C\u56DE\u6267\u4F1A\u8BF4\u6E05\uFF09\u3002\u540D\u5B57\u88AB\u5360\u4F1A\u88AB\u62D2 \u2014\u2014 \u90A3\u65F6\u6539\u7528 mcp_update\u3002",
696
+ parameters: {
697
+ type: "object",
698
+ properties: SERVER_PROPERTIES,
699
+ required: ["name", "transport"]
700
+ },
701
+ exposure: ToolExposure.Direct,
702
+ supportsParallel: false,
703
+ annotations: ADMIN_ANNOTATIONS,
704
+ operation: "command",
705
+ describeTarget: (args) => `${MCP_ADD_TOOL}:${String(args["name"] ?? "")} ${String(args["command"] ?? args["url"] ?? "")}`.trim(),
706
+ execute: (args) => runWrite(deps, args, "add")
707
+ };
708
+ }
709
+ function updateTool(deps) {
710
+ return {
711
+ name: "mcp_update",
712
+ description: "\u6539\u4E00\u53F0\u5DF2\u6709 MCP server\uFF1A\u5C31\u5730\u6362\u6389 ~/.epoch/mcp.json \u91CC\u90A3\u4E00\u6BB5\uFF0C\u518D\u6309\u65B0\u914D\u7F6E\u91CD\u8FDE\u5B83\u3002\u522B\u7684 server \u548C\u6587\u4EF6\u91CC\u7684\u6CE8\u91CA\u6392\u7248\u4E00\u4E2A\u5B57\u8282\u4E0D\u52A8\u3002\n\u26A0\uFE0F \u6536\u7684\u662F**\u6574\u5957**\u914D\u7F6E\u4E0D\u662F\u8865\u4E01 \u2014\u2014 \u6CA1\u7ED9\u7684\u683C\u5B50\uFF08args / url / env\uFF09\u4F1A\u88AB\u5F53\u6210\u300C\u4E0D\u8981\u4E86\u300D\uFF0C\u8981\u4FDD\u4F4F\u7684\u6BCF\u6B21\u90FD\u5F97\u539F\u6837\u518D\u7ED9\u3002\n\u540D\u5B57\u6539\u4E0D\u4E86\uFF08\u90A3\u7B49\u4E8E\u6362\u4E00\u6279\u5DE5\u5177\u540D\uFF0C\u8981\u6539\u540D\u5C31 mcp_remove \u518D mcp_add\uFF09\u3002\u6587\u4EF6\u91CC\u6CA1\u8FD9\u4E2A\u540D\u5B57\u4F1A\u88AB\u62D2\u3002",
713
+ parameters: {
714
+ type: "object",
715
+ properties: SERVER_PROPERTIES,
716
+ required: ["name", "transport"]
717
+ },
718
+ exposure: ToolExposure.Direct,
719
+ supportsParallel: false,
720
+ annotations: ADMIN_ANNOTATIONS,
721
+ operation: "command",
722
+ describeTarget: (args) => `${MCP_UPDATE_TOOL}:${String(args["name"] ?? "")} ${String(args["command"] ?? args["url"] ?? "")}`.trim(),
723
+ execute: (args) => runWrite(deps, args, "update")
724
+ };
725
+ }
726
+ function removeTool(deps) {
727
+ return {
728
+ name: "mcp_remove",
729
+ description: "\u5220\u6389\u4E00\u53F0 MCP server\uFF1A\u4ECE ~/.epoch/mcp.json \u91CC\u53BB\u6389\u90A3\u4E00\u6BB5\uFF0C\u5E76\u65AD\u5F00\u5B83\u3001\u628A\u5B83\u90A3\u6279\u5DE5\u5177\u6458\u6389\u3002\u522B\u7684 server \u548C\u6CE8\u91CA\u6392\u7248\u4E00\u4E2A\u5B57\u8282\u4E0D\u52A8\u3002\n\u26A0\uFE0F \u4E0D\u53EF\u64A4\u9500 \u2014\u2014 \u90A3\u4E00\u6BB5\u914D\u7F6E\uFF08\u542B env \u91CC\u7684\u4EE4\u724C\uFF09\u4ECE\u6587\u4EF6\u91CC\u6D88\u5931\uFF0C\u8981\u56DE\u6765\u53EA\u80FD\u91CD\u586B\u3002\u5220\u4E4B\u524D\u5148\u8DDF\u7528\u6237\u786E\u8BA4\u3002\n\u6587\u4EF6\u91CC\u6CA1\u8FD9\u4E2A\u540D\u5B57\u4F1A\u88AB\u62D2\uFF08host / plugin \u90A3\u4E24\u6279\u5220\u4E0D\u6389\uFF09\u3002",
730
+ parameters: {
731
+ type: "object",
732
+ properties: { name: SERVER_PROPERTIES.name },
733
+ required: ["name"]
734
+ },
735
+ exposure: ToolExposure.Direct,
736
+ supportsParallel: false,
737
+ annotations: ADMIN_ANNOTATIONS,
738
+ operation: "command",
739
+ describeTarget: (args) => `${MCP_REMOVE_TOOL}:${String(args["name"] ?? "")}`,
740
+ async execute(args) {
741
+ const start = Date.now();
742
+ const name = str(args, "name").trim();
743
+ if (!name) return fail("name must not be empty", start, 3201);
744
+ const outcome = await deps.control.remove(name);
745
+ if (!outcome.ok) {
746
+ return fail(
747
+ `${outcome.reason}: ${outcome.detail}`,
748
+ start,
749
+ 3202,
750
+ HINT_BY_REASON[outcome.reason]
751
+ );
752
+ }
753
+ return done(
754
+ `removed MCP server "${name}" from ${outcome.configPath}. Its tools are gone from this process.`,
755
+ start
756
+ );
757
+ }
758
+ };
759
+ }
760
+ function createMcpTools(deps) {
761
+ return [listTool(deps), addTool(deps), updateTool(deps), removeTool(deps)];
762
+ }
763
+ var MCP_LIST_TOOL, MCP_ADD_TOOL, MCP_UPDATE_TOOL, MCP_REMOVE_TOOL, SERVER_PROPERTIES, ADMIN_ANNOTATIONS, HINT_BY_REASON;
764
+ var init_mcp_tools = __esm({
765
+ "src/mcp-tools.ts"() {
766
+ MCP_LIST_TOOL = "mcp_list";
767
+ MCP_ADD_TOOL = "mcp_add";
768
+ MCP_UPDATE_TOOL = "mcp_update";
769
+ MCP_REMOVE_TOOL = "mcp_remove";
770
+ SERVER_PROPERTIES = {
771
+ name: {
772
+ type: "string",
773
+ description: "server \u540D\u3002\u4F1A\u539F\u6837\u53D8\u6210\u5DE5\u5177\u540D\u7684\u4E00\u90E8\u5206\uFF08mcp__<name>__<\u5DE5\u5177>\uFF09"
774
+ },
775
+ transport: {
776
+ type: "string",
777
+ enum: ["stdio", "sse", "http"],
778
+ description: "\u4F20\u8F93\u7C7B\u578B\uFF1Astdio \u8D77\u4E00\u4E2A\u672C\u5730\u5B50\u8FDB\u7A0B\uFF0Csse / http \u8FDE\u4E00\u4E2A\u5730\u5740"
779
+ },
780
+ command: { type: "string", description: "stdio \u90A3\u4E00\u6863\u7684\u53EF\u6267\u884C\u6587\u4EF6\uFF0C\u5982 npx" },
781
+ args: {
782
+ type: "array",
783
+ items: { type: "string" },
784
+ description: "stdio \u90A3\u4E00\u6863\u7684\u547D\u4EE4\u884C\u53C2\u6570\uFF0C\u5143\u7D20\u5FC5\u987B\u5168\u662F\u5B57\u7B26\u4E32"
785
+ },
786
+ url: { type: "string", description: "sse / http \u90A3\u4E24\u6863\u7684\u5730\u5740" },
787
+ env: {
788
+ type: "object",
789
+ additionalProperties: { type: "string" },
790
+ description: "\u5B50\u8FDB\u7A0B\u73AF\u5883\u53D8\u91CF\uFF0C\u503C\u5FC5\u987B\u5168\u662F\u5B57\u7B26\u4E32"
791
+ }
792
+ };
793
+ ADMIN_ANNOTATIONS = {
794
+ readOnlyHint: false,
795
+ destructiveHint: true,
796
+ idempotentHint: false,
797
+ openWorldHint: true
798
+ };
799
+ HINT_BY_REASON = {
800
+ "invalid-name": 'Use only letters, digits, "_" and "-", starting with a letter or digit (max 48 chars) \u2014 the name becomes part of every tool name this server exposes.',
801
+ "invalid-config": 'stdio needs "command"; sse and http need a valid "url". Fix that one field and retry.',
802
+ duplicate: "That name is already taken \u2014 call mcp_list, then use mcp_update instead of mcp_add.",
803
+ "not-found": "That name is not in ~/.epoch/mcp.json \u2014 call mcp_list first, or use mcp_add.",
804
+ unwritable: "The config file could not be read, written, or parsed. Do not retry \u2014 tell the user to look at ~/.epoch/mcp.json themselves.",
805
+ "apply-failed": "The config file was written but this process could not be brought in line. The change takes effect on the next epoch start."
806
+ };
807
+ }
808
+ });
809
+ function providerLabel(info, lang) {
810
+ switch (info.type) {
811
+ case "ollama":
812
+ return t("provider.label_ollama", void 0, lang);
813
+ case "openai-compatible":
814
+ return t("provider.label_openai_compatible", void 0, lang);
815
+ default:
816
+ return info.label;
817
+ }
818
+ }
444
819
  function buildModelCatalogControl(opts) {
445
820
  const { homeDir, configured } = opts;
446
821
  const keyOf = (envVar) => {
@@ -449,13 +824,16 @@ function buildModelCatalogControl(opts) {
449
824
  };
450
825
  return {
451
826
  providers() {
452
- return PROVIDER_INFOS.filter((p) => p.interactive).map((p) => ({
453
- type: p.type,
454
- label: p.label,
455
- envVar: p.apiKeyEnv,
456
- // 不需要 key 的那家(Ollama)恒 true —— 对它说「没配 key」是假话
457
- hasKey: p.apiKeyEnv === null || keyOf(p.apiKeyEnv) !== ""
458
- }));
827
+ return PROVIDER_INFOS.filter((p) => p.interactive).map((p) => {
828
+ const key = p.apiKeyEnv ? keyOf(p.apiKeyEnv) : "";
829
+ return {
830
+ type: p.type,
831
+ label: p.label,
832
+ envVar: p.apiKeyEnv,
833
+ hasKey: p.apiKeyEnv === null || key !== "",
834
+ keyHint: key === "" ? null : maskApiKey(key)
835
+ };
836
+ });
459
837
  },
460
838
  async discover(provider, discoverOpts) {
461
839
  const envVar = getProviderInfo(provider)?.apiKeyEnv ?? null;
@@ -475,6 +853,19 @@ function buildModelCatalogControl(opts) {
475
853
  source: result.source,
476
854
  status: probeStatus(result, { needsKey: envVar !== null, hasKey: apiKey !== "" })
477
855
  };
856
+ },
857
+ async setKey(provider, apiKey) {
858
+ const envVar = getProviderInfo(provider)?.apiKeyEnv ?? null;
859
+ if (!envVar) throw new Error(t("provider.key_not_applicable", { provider }));
860
+ const written = await writeProviderSecret(envVar, apiKey, envPath(homeDir));
861
+ clearModelCache(homeDir, provider);
862
+ return {
863
+ envVar,
864
+ backend: written.backend,
865
+ encrypted: written.encrypted,
866
+ envPath: written.envPath,
867
+ keyHint: maskApiKey(apiKey)
868
+ };
478
869
  }
479
870
  };
480
871
  }
@@ -488,6 +879,209 @@ var init_model_catalog = __esm({
488
879
  "src/model-catalog.ts"() {
489
880
  }
490
881
  });
882
+ function createInstallFlow(deps) {
883
+ const locations = () => ({
884
+ pluginsDir: deps.pluginsDir,
885
+ statePath: deps.statePath,
886
+ epochVersion: currentEpochVersion()
887
+ });
888
+ return {
889
+ preview: async (source, extra, lang) => {
890
+ let seen;
891
+ const outcome = await installPlugin(source, {
892
+ ...locations(),
893
+ ...extra,
894
+ confirm: async (preview) => {
895
+ seen = preview;
896
+ return false;
897
+ }
898
+ });
899
+ if (seen) return { ok: true, preview: seen, token: previewToken(seen) };
900
+ return refuse(outcome, lang);
901
+ },
902
+ install: async (source, token, extra, lang) => {
903
+ let matched;
904
+ const outcome = await installPlugin(source, {
905
+ ...locations(),
906
+ ...extra,
907
+ confirm: async (preview) => {
908
+ matched = previewToken(preview) === token;
909
+ return matched;
910
+ }
911
+ });
912
+ if (outcome.ok) return { ok: true, record: outcome.record, preview: outcome.preview };
913
+ if (matched === false) {
914
+ return {
915
+ ok: false,
916
+ reason: "stale-token",
917
+ detail: t("plugin_control.stale_token", void 0, lang),
918
+ ...outcome.preview ? { preview: outcome.preview } : {}
919
+ };
920
+ }
921
+ return refuse(outcome, lang);
922
+ }
923
+ };
924
+ }
925
+ function replaceOpts(record) {
926
+ return {
927
+ replace: record.name,
928
+ ...record.marketplace ? { marketplace: record.marketplace } : {}
929
+ };
930
+ }
931
+ function refuse(outcome, lang) {
932
+ if (outcome.ok) {
933
+ return {
934
+ ok: false,
935
+ reason: "failed",
936
+ detail: t("plugin_control.unexpected_commit", { name: outcome.record.name }, lang)
937
+ };
938
+ }
939
+ return {
940
+ ok: false,
941
+ reason: outcome.preview?.conflict ? "conflict" : "failed",
942
+ detail: outcome.reason,
943
+ ...outcome.preview ? { preview: outcome.preview } : {}
944
+ };
945
+ }
946
+ function previewToken(preview) {
947
+ const inv = preview.inventory;
948
+ const hash = createHash("sha256");
949
+ hash.update(`${preview.manifest.name}@${preview.manifest.version}
950
+ `);
951
+ hash.update(`${preview.source.type} ${preview.source.raw}
952
+ `);
953
+ for (const list of [inv.commands, inv.roles, inv.skills, inv.mcpServers, inv.ignoredBuckets]) {
954
+ hash.update(`${[...list].sort().join(",")}
955
+ `);
956
+ }
957
+ for (const tally of [...inv.hooks].sort((a, b) => a.type.localeCompare(b.type))) {
958
+ hash.update(`${tally.type}=${tally.count};`);
959
+ }
960
+ hash.update(`
961
+ ${inv.denyRules}|${inv.jsTools ? 1 : 0}
962
+ `);
963
+ hash.update(`${preview.versionUnmet?.required ?? "-"}
964
+ `);
965
+ return hash.digest("hex").slice(0, 32);
966
+ }
967
+ var init_plugin_install = __esm({
968
+ "src/plugin-install.ts"() {
969
+ }
970
+ });
971
+ function createPluginControl(deps) {
972
+ let pendingRestart = false;
973
+ const live = new Map(deps.loaded.map((p) => [p.name, p.counts]));
974
+ const flow = createInstallFlow({ pluginsDir: deps.pluginsDir, statePath: deps.statePath });
975
+ const mark = (outcome) => {
976
+ if (outcome.ok) pendingRestart = true;
977
+ return outcome;
978
+ };
979
+ const resolveRef = (ref, lang) => {
980
+ const hit = resolveMarketplaceRef(ref, deps.marketplacesPath);
981
+ if (!hit) {
982
+ return {
983
+ ok: false,
984
+ reason: "unknown-ref",
985
+ detail: t("plugin_control.not_a_ref", { ref }, lang)
986
+ };
987
+ }
988
+ if (!hit.ok) return { ok: false, reason: "unknown-ref", detail: hit.reason };
989
+ if (!deps.allowRemote && !isLocalSource(hit.ref.source)) {
990
+ return {
991
+ ok: false,
992
+ reason: "remote-disabled",
993
+ detail: t("plugin_control.entry_remote_off", { ref, source: hit.ref.source }, lang)
994
+ };
995
+ }
996
+ return { ok: true, source: hit.ref.source, marketplace: hit.ref.marketplace };
997
+ };
998
+ const recordOf = (name, lang) => {
999
+ const record = readPluginRecords(deps.statePath).records.find((r) => r.name === name);
1000
+ if (!record) {
1001
+ return {
1002
+ ok: false,
1003
+ reason: "not-installed",
1004
+ detail: t("plugin_control.not_installed", { name }, lang)
1005
+ };
1006
+ }
1007
+ if (record.linked) {
1008
+ return {
1009
+ ok: false,
1010
+ reason: "nothing-to-do",
1011
+ detail: t("plugin_control.linked_no_update", { name, source: record.source }, lang)
1012
+ };
1013
+ }
1014
+ if (!deps.allowRemote && !isLocalSource(record.source)) {
1015
+ return {
1016
+ ok: false,
1017
+ reason: "remote-disabled",
1018
+ detail: t("plugin_control.update_remote_off", { name, source: record.source }, lang)
1019
+ };
1020
+ }
1021
+ return { ok: true, record };
1022
+ };
1023
+ return {
1024
+ list: () => readPluginRecords(deps.statePath).records.map((r) => ({
1025
+ ...r,
1026
+ active: live.has(r.name),
1027
+ ...live.get(r.name) ? { counts: live.get(r.name) } : {}
1028
+ })),
1029
+ search: (keyword = "") => {
1030
+ const markets = new Map(
1031
+ readMarketplaces(deps.marketplacesPath).records.map((r) => [r.name, r.source])
1032
+ );
1033
+ return searchMarketplaces(keyword, deps.marketplacesPath).map((hit) => {
1034
+ const market = markets.get(hit.marketplace);
1035
+ const absolute = market === void 0 ? void 0 : resolveEntrySource(hit.entry.source, market);
1036
+ const kind = absolute === void 0 ? void 0 : resolveSource(absolute)?.type;
1037
+ return {
1038
+ marketplace: hit.marketplace,
1039
+ entry: hit.entry,
1040
+ ref: hit.ref,
1041
+ ...kind ? { sourceType: kind } : {},
1042
+ installable: deps.allowRemote ? kind !== void 0 : kind === "local"
1043
+ };
1044
+ });
1045
+ },
1046
+ preview: async (ref, lang) => {
1047
+ const target = resolveRef(ref, lang);
1048
+ return target.ok ? flow.preview(target.source, { marketplace: target.marketplace }, lang) : target;
1049
+ },
1050
+ install: async (ref, token, lang) => {
1051
+ const target = resolveRef(ref, lang);
1052
+ return target.ok ? mark(await flow.install(target.source, token, { marketplace: target.marketplace }, lang)) : target;
1053
+ },
1054
+ previewUpdate: async (name, lang) => {
1055
+ const found = recordOf(name, lang);
1056
+ return found.ok ? flow.preview(found.record.source, replaceOpts(found.record), lang) : found;
1057
+ },
1058
+ update: async (name, token, lang) => {
1059
+ const found = recordOf(name, lang);
1060
+ return found.ok ? mark(await flow.install(found.record.source, token, replaceOpts(found.record), lang)) : found;
1061
+ },
1062
+ uninstall: async (name, lang) => {
1063
+ const outcome = await uninstallPlugin(name, { statePath: deps.statePath });
1064
+ if (!outcome.ok) {
1065
+ return {
1066
+ ok: false,
1067
+ reason: "not-installed",
1068
+ detail: t("plugin_control.not_installed", { name }, lang)
1069
+ };
1070
+ }
1071
+ pendingRestart = true;
1072
+ return { ok: true, detail: outcome.message };
1073
+ },
1074
+ get pendingRestart() {
1075
+ return pendingRestart;
1076
+ }
1077
+ };
1078
+ }
1079
+ var init_plugin_control = __esm({
1080
+ "src/plugin-control.ts"() {
1081
+ init_host_marketplaces();
1082
+ init_plugin_install();
1083
+ }
1084
+ });
491
1085
  function wirePluginMcpServers(mcpPaths, diags) {
492
1086
  const out = [];
493
1087
  for (const ref of mcpPaths) {
@@ -550,9 +1144,6 @@ function createRoleWriteControl(deps) {
550
1144
  return {
551
1145
  ok: false,
552
1146
  reason: "duplicate",
553
- // 把**哪一层**占着这个名字说出来:内置和项目级的出路不同(前者只能换名字,
554
- // 后者他还可以去改自己仓库里那一份)。只说「重名了」的话,一个撞上
555
- // `general` 的人会以为自己的 `~/.epoch/agents/` 里躺着一份看不见的文件
556
1147
  detail: t(
557
1148
  "role_write.dup_in_table",
558
1149
  { name, layer: t(LAYER_KEY[clash.source], void 0, lang) },
@@ -588,6 +1179,7 @@ function createRoleWriteControl(deps) {
588
1179
  description: input.description,
589
1180
  ...input.prompt === void 0 ? {} : { prompt: input.prompt },
590
1181
  ...input.tools === void 0 ? {} : { tools: input.tools },
1182
+ ...input.skills === void 0 ? {} : { skills: input.skills },
591
1183
  ...input.maxTurns === void 0 ? {} : { maxTurns: input.maxTurns }
592
1184
  },
593
1185
  { dir: deps.userDir, source: "user" }
@@ -597,10 +1189,6 @@ function createRoleWriteControl(deps) {
597
1189
  return {
598
1190
  ok: true,
599
1191
  path: written.path,
600
- // 回**重载之后那张表**里的那一份,不是 core 刚从文件里读出来的那个:
601
- // 两者在 `tools` 上真会不同(`mergeRoles` 的交集语义)。撞名已经在上面
602
- // 拦掉了,所以今天这两份恒等 —— `??` 那一支是给「没有 provider、
603
- // 角色表恒空」的那种进程用的,那时表里找不到它,而文件确实写了
604
1192
  role: findRole(deps.roles(), name) ?? written.role
605
1193
  };
606
1194
  }
@@ -645,32 +1233,11 @@ var ApprovalRelay, QuestionRelay;
645
1233
  var init_serialize = __esm({
646
1234
  "src/serialize.ts"() {
647
1235
  ApprovalRelay = class {
648
- /**
649
- * `requestId` → 请求内容 + 答复闭包 + 登记那一刻。
650
- *
651
- * 存了 `request` 而不只是闭包,是为了 `listPending()` —— 见它的注释。
652
- * Map 的插入序 = 请求发生的时间序,`listPending()` 靠它给出稳定顺序。
653
- *
654
- * ⚠️ **`since` 是 2026-08-16 补的,在那之前这张表「顺序有了、时刻没有」**:
655
- * 插入序答得出「谁先谁后」,答不出「第一条是几点挂上的」,而界面上那一格要说
656
- * 「已等多久」只能问后者。判据在 {@link ApprovalRelay.pendingSince} 上。
657
- */
658
1236
  pendingMap = /* @__PURE__ */ new Map();
659
- /** 登记时刻从哪儿读。见 {@link RelayClock} */
660
1237
  clock;
661
1238
  constructor(clock = Date.now) {
662
1239
  this.clock = clock;
663
1240
  }
664
- /**
665
- * 登记一个刚发给 renderer 的审批。
666
- *
667
- * 直接吃 `toSerializable()` 的产出:非审批事件(没有 `respond`)传进来是空操作,
668
- * 于是宿主的消费循环里不用先判类型。
669
- *
670
- * ⚠️ 时钟**只在真的要插一条时才读**(在 `return` 后面)。写在函数头上的话,
671
- * 每一条流过的事件都会读一次挂钟 —— 而这张表是被 `serializeStream()` 就着
672
- * **每一个**事件调的,注入过时钟的宿主那边会当场看见自己的脚本被走空。
673
- */
674
1241
  track(serialized) {
675
1242
  const { event, respond } = serialized;
676
1243
  if (event.type !== "approval-request" || !respond) return;
@@ -680,13 +1247,6 @@ var init_serialize = __esm({
680
1247
  since: this.clock()
681
1248
  });
682
1249
  }
683
- /**
684
- * 答复。
685
- *
686
- * @returns 是否真的接回了引擎。`false` 意味着这个 `requestId` 不认识 ——
687
- * 重复答复、或者 renderer 拿着一个上一轮的 ID 回来了。宿主该把它
688
- * 当成日志而不是错误:用户在窗口上双击一下审批按钮就会走到这里。
689
- */
690
1250
  respond(requestId, outcome, note) {
691
1251
  const pending = this.pendingMap.get(requestId);
692
1252
  if (!pending) return false;
@@ -694,52 +1254,13 @@ var init_serialize = __esm({
694
1254
  pending.respond(outcome, note);
695
1255
  return true;
696
1256
  }
697
- /** 还有多少个审批挂在 renderer 那边没回来 */
698
1257
  get pendingCount() {
699
1258
  return this.pendingMap.size;
700
1259
  }
701
- /**
702
- * **最早**那一条还挂着的审批是什么时候登记的(epoch 毫秒);一条都没挂着时 null。
703
- *
704
- * 存在的理由只有一个:界面上要说得出「**已等**多久」。`pendingCount` 答的是
705
- * 「有几条」,而那一格问的是「等了多久」—— 两者之间差的正是这个时刻,
706
- * 而在它之前网线上**没有任何一个字段答得出来**(全文在
707
- * `WireSessionSummary.pendingSince` 上,包括「为什么 `updatedAt` 顶不了它」)。
708
- *
709
- * ## 为什么是**最早**那一条,不是最近那一条
710
- *
711
- * 消费它的那一格挑的是「晾得最久的那个会话」。报最近那一条的话,一个已经卡了
712
- * 两小时、刚刚又多挂上一条的会话会说「已等 3 秒」—— 屏幕上那个数会和它被挑中
713
- * 的理由互相打脸。「最早」还有一条硬性质:它**只在这个会话彻底不卡了之后才动**
714
- * (表清空 → null),而「最近」每多挂一条就往前跳一次,读起来像是等待被重置了。
715
- *
716
- * ## 取的是表里第一条,靠的正是上面那条插入序不变量
717
- *
718
- * `pendingMap` 的插入序 = 请求发生的时间序(见它自己的注释),所以第一条就是
719
- * 最早那一条,答完出账之后自然轮到下一条。**刻意不遍历取 `Math.min`**:
720
- * 那等于给「最早」立第二个定义,而两个定义哪天对不上时,症状是屏幕上一个
721
- * 说不出来历的数字 —— 没有任何一处会红。
722
- *
723
- * **只读**:这张表的时刻由 `track()` 一处写死,宿主不许改。一个能被外面拨的
724
- * 「已等多久」等于让每个宿主自己发明一套等待口径。
725
- */
726
1260
  get pendingSince() {
727
1261
  const first = this.pendingMap.values().next();
728
1262
  return first.done ? null : first.value.since;
729
1263
  }
730
- /**
731
- * 列出还挂着的审批,按发生的先后顺序。
732
- *
733
- * 给「**新连上来的宿主要补拉**」用:浏览器刷新一次页面,SSE 是新连接,
734
- * 之前推过去的 `approval-request` 就没人接了 —— 而引擎那侧还阻塞在等答复。
735
- * 没有这个方法,新页面只能干等到超时(对标 OpenCode 的
736
- * `session.permission.list`,`packages/server/src/handlers/permission.ts`)。
737
- *
738
- * 返回的形状**与 SSE 上推的那一条完全一致**(`SerializableApprovalRequest`),
739
- * 于是客户端的「收到一条审批」只有一条代码路径,不用为补拉再写一份解析。
740
- *
741
- * `pendingCount` 不能替代它:数字告诉你有几条挂着,但补拉需要的是内容。
742
- */
743
1264
  listPending() {
744
1265
  return [...this.pendingMap].map(([requestId, { request }]) => ({
745
1266
  type: "approval-request",
@@ -747,16 +1268,6 @@ var init_serialize = __esm({
747
1268
  request
748
1269
  }));
749
1270
  }
750
- /**
751
- * 界面没了:把挂起的审批全部**放弃**,返回放弃的个数。
752
- *
753
- * 刻意**不** resolve 任何 promise —— 那会变成一串「用户拒绝」。调用方拿到非 0
754
- * 返回值后该做的是 `abort()` 掉这一轮(`session.run(msg, { signal })` 的那个
755
- * signal):流一结束,`AgentSession` 的 finally 会把剩下的请求兜底成 deny,
756
- * 引擎不会永久挂住,而模型看到的是「这一轮被中止了」而不是「用户否了我五次」。
757
- *
758
- * 接法见 [docs/EMBEDDING.md](../../../docs/EMBEDDING.md) 的「窗口关掉了怎么办」。
759
- */
760
1271
  abandon() {
761
1272
  const count = this.pendingMap.size;
762
1273
  this.pendingMap.clear();
@@ -764,24 +1275,11 @@ var init_serialize = __esm({
764
1275
  }
765
1276
  };
766
1277
  QuestionRelay = class {
767
- /**
768
- * `requestId` → 请求内容 + 答复闭包 + 登记那一刻。
769
- * Map 的插入序 = 提问发生的时间序。`since` 见 {@link QuestionRelay.pendingSince}
770
- */
771
1278
  pendingMap = /* @__PURE__ */ new Map();
772
- /** 登记时刻从哪儿读。见 {@link RelayClock} */
773
1279
  clock;
774
1280
  constructor(clock = Date.now) {
775
1281
  this.clock = clock;
776
1282
  }
777
- /**
778
- * 登记一个刚发给宿主的提问。
779
- *
780
- * 直接吃 `toSerializable()` 的产出:非提问事件传进来是空操作,
781
- * 于是宿主的消费循环里不用先判类型(同 `ApprovalRelay.track`)。
782
- *
783
- * 时钟同样只在真要插一条时才读,理由逐字同 `ApprovalRelay.track`。
784
- */
785
1283
  track(serialized) {
786
1284
  const { event, respondQuestion } = serialized;
787
1285
  if (event.type !== "question" || !respondQuestion) return;
@@ -791,13 +1289,6 @@ var init_serialize = __esm({
791
1289
  since: this.clock()
792
1290
  });
793
1291
  }
794
- /**
795
- * 答复。
796
- *
797
- * @returns 是否真的接回了引擎。`false` = 这个 `requestId` 不认识
798
- * (重复答复、上一轮的 ID、或者已经被 `abandon()` 掉了)。
799
- * 调用方该把它当日志而不是错误。
800
- */
801
1292
  respond(requestId, answer) {
802
1293
  const pending = this.pendingMap.get(requestId);
803
1294
  if (!pending) return false;
@@ -805,25 +1296,13 @@ var init_serialize = __esm({
805
1296
  pending.respond(answer);
806
1297
  return true;
807
1298
  }
808
- /** 还有多少个提问挂在宿主那边没回来 */
809
1299
  get pendingCount() {
810
1300
  return this.pendingMap.size;
811
1301
  }
812
- /**
813
- * **最早**那一条还挂着的提问是什么时候登记的;一条都没挂着时 null。
814
- *
815
- * 与 {@link ApprovalRelay.pendingSince} 逐字同构、同三条判据(最早不是最近、
816
- * 取表里第一条、只读)。写在那一个上,别在这儿再判一遍。
817
- *
818
- * ⚠️ **两张表各给一个,网线上却只有一格**:合并成「这个会话从什么时候起被拦住」
819
- * 是消费方(Hub)的事,不是这一层的 —— 这一层只知道自己这张表。
820
- * 判据在 `WireSessionSummary.pendingSince` 的第一节。
821
- */
822
1302
  get pendingSince() {
823
1303
  const first = this.pendingMap.values().next();
824
1304
  return first.done ? null : first.value.since;
825
1305
  }
826
- /** 列出还挂着的提问,按发生的先后顺序。给「新连上来的宿主要补拉」用(验收 9) */
827
1306
  listPending() {
828
1307
  return [...this.pendingMap].map(([requestId, { request }]) => ({
829
1308
  type: "question",
@@ -831,13 +1310,6 @@ var init_serialize = __esm({
831
1310
  request
832
1311
  }));
833
1312
  }
834
- /**
835
- * 界面没了:把挂起的提问全部**放弃**,返回放弃的个数(验收 8)。
836
- *
837
- * 刻意**不** resolve 任何 promise —— 那会变成一串编出来的答案。调用方拿到非 0
838
- * 返回值后该 `abort()` 掉这一轮:流一结束,`AgentSession` 的 finally 会把剩下的
839
- * 请求兜底成 `skipped`(那是实话:确实没人答),引擎不会永久挂住。
840
- */
841
1313
  abandon() {
842
1314
  const count = this.pendingMap.size;
843
1315
  this.pendingMap.clear();
@@ -947,12 +1419,7 @@ async function runOnce(store, def, opts, startedAt, now) {
947
1419
  const runtime = await build({
948
1420
  homeDir: opts.homeDir,
949
1421
  workDir: def.workDir,
950
- // **显式 false**:不靠 TTY 探测。OS 调度器起的进程天然没有 TTY,
951
- // 但嵌入宿主那条路(`headless.js` 由宿主起)可能有,而那时按交互处理
952
- // 会让整个「没人可问」的语义塌掉 —— 预授权清单不生效、审批挂着等一个不在的人
953
1422
  interactive: false,
954
- // 任务级清单是**取代**不是叠加:一个任务的授权面必须只由任务自己说清,
955
- // 否则用户改一次 `~/.epoch/config.yaml` 就静默放宽了全部十个定时任务
956
1423
  headless: { allowTools: [...def.allowTools], allowOperations: [...def.allowOperations] },
957
1424
  sessionSource: "schedule",
958
1425
  installSignalHandlers: true
@@ -971,7 +1438,7 @@ async function runOnce(store, def, opts, startedAt, now) {
971
1438
  });
972
1439
  return { status: "failed", exitCode: SCHEDULE_EXIT_CODES.FAILURE, run, detail: reason2 };
973
1440
  }
974
- const level = runtime.permissions.setLevel(def.permission);
1441
+ const level = runtime.permissions.setLevel(def.permission, { remember: false });
975
1442
  if (!level.ok) {
976
1443
  const reason2 = `managed-policy: ${def.permission}`;
977
1444
  const run = store.recordRun({
@@ -1137,37 +1604,50 @@ var init_executor = __esm({
1137
1604
  }
1138
1605
  });
1139
1606
  function createScheduleControl(opts) {
1140
- const store = new ScheduleStore(dbPath(opts.homeDir));
1607
+ let opened = null;
1608
+ let openError = null;
1609
+ try {
1610
+ opened = new ScheduleStore(dbPath(opts.homeDir));
1611
+ } catch (err) {
1612
+ openError = err instanceof Error ? err : new Error(String(err));
1613
+ }
1614
+ const store = () => {
1615
+ if (!opened) {
1616
+ throw new Error(
1617
+ `schedule store unavailable: ${openError?.message ?? "unknown"} (see ScheduleControl.storeAvailable)`
1618
+ );
1619
+ }
1620
+ return opened;
1621
+ };
1141
1622
  let backendPromise = null;
1142
1623
  const backendOf = () => {
1143
1624
  backendPromise ??= resolveScheduleBackend(opts.platform ?? process.platform);
1144
1625
  return backendPromise;
1145
1626
  };
1146
1627
  const registrarOf = async () => new ScheduleRegistrar({
1147
- store,
1628
+ store: store(),
1148
1629
  backend: await backendOf(),
1149
1630
  homeDir: opts.homeDir,
1150
1631
  ...opts.runner ? { runner: opts.runner } : {}
1151
- // ⚠️ **刻意不给 `cli`**:这一层不知道宿主怎么被唤起,猜的代价见文件头
1152
1632
  });
1153
1633
  const save = async (def, register) => {
1154
1634
  if (!register) return { schedule: def, issues: [] };
1155
1635
  const registrar = await registrarOf();
1156
1636
  const registration = await registrar.register(def);
1157
- return { schedule: store.get(def.id) ?? def, issues: [], registration };
1637
+ return { schedule: store().get(def.id) ?? def, issues: [], registration };
1158
1638
  };
1159
1639
  const control = {
1160
- list: () => store.list(),
1161
- get: (id) => store.get(id),
1162
- runs: (id, limit) => store.listRuns(id, limit),
1163
- recentRuns: (limit) => store.listRecentRuns(limit),
1640
+ storeAvailable: opened !== null,
1641
+ list: () => store().list(),
1642
+ get: (id) => store().get(id),
1643
+ runs: (id, limit) => store().listRuns(id, limit),
1644
+ recentRuns: (limit) => store().listRecentRuns(limit),
1164
1645
  capability: async () => {
1165
1646
  const backend = await backendOf();
1166
1647
  return {
1167
1648
  backend: backend?.kind ?? null,
1168
- // 没有后端(Linux)或者没有无界面入口(嵌入宿主没给 runner)都注册不了。
1169
- // 两者的措辞不一样,所以宿主还要看 `backend` 那一格
1170
- canRegister: backend !== null && opts.runner !== void 0
1649
+ canRegister: backend !== null && opts.runner !== void 0,
1650
+ storeAvailable: opened !== null
1171
1651
  };
1172
1652
  },
1173
1653
  create: async (input, saveOpts) => {
@@ -1188,12 +1668,12 @@ function createScheduleControl(opts) {
1188
1668
  homeDir: opts.homeDir
1189
1669
  }).issues;
1190
1670
  if (issues.length > 0) return { issues };
1191
- const created = store.create({ ...input, workDir: workDir || opts.homeDir });
1192
- const def = workDir ? created : store.update(created.id, { workDir: automationWorkDir(created.id, opts.homeDir) }) ?? created;
1671
+ const created = store().create({ ...input, workDir: workDir || opts.homeDir });
1672
+ const def = workDir ? created : store().update(created.id, { workDir: automationWorkDir(created.id, opts.homeDir) }) ?? created;
1193
1673
  return save(def, saveOpts?.register !== false);
1194
1674
  },
1195
1675
  update: async (id, patch, saveOpts) => {
1196
- const current = store.get(id);
1676
+ const current = store().get(id);
1197
1677
  if (!current) return { issues: [] };
1198
1678
  const merged = { ...current, ...patch };
1199
1679
  const issues = validateSchedule({
@@ -1212,18 +1692,18 @@ function createScheduleControl(opts) {
1212
1692
  homeDir: opts.homeDir
1213
1693
  }).issues;
1214
1694
  if (issues.length > 0) return { issues };
1215
- const next = store.update(id, patch);
1695
+ const next = store().update(id, patch);
1216
1696
  if (!next) return { issues: [] };
1217
1697
  return save(next, saveOpts?.register !== false);
1218
1698
  },
1219
1699
  remove: async (id) => {
1220
- const def = store.get(id);
1700
+ const def = store().get(id);
1221
1701
  if (!def) return false;
1222
1702
  if (def.osTaskId) await (await registrarOf()).unregister(def);
1223
- return store.remove(id);
1703
+ return store().remove(id);
1224
1704
  },
1225
1705
  setEnabled: async (id, enabled) => {
1226
- const def = store.get(id);
1706
+ const def = store().get(id);
1227
1707
  if (!def) return { ok: false, reason: "backend-error", detail: "schedule-not-found" };
1228
1708
  return (await registrarOf()).setEnabled(def, enabled);
1229
1709
  },
@@ -1235,7 +1715,7 @@ function createScheduleControl(opts) {
1235
1715
  }),
1236
1716
  needsAllowlist
1237
1717
  };
1238
- return { control, dispose: () => store.close() };
1718
+ return { control, dispose: () => opened?.close(), openError };
1239
1719
  }
1240
1720
  var init_control = __esm({
1241
1721
  "src/schedule/control.ts"() {
@@ -1244,7 +1724,7 @@ var init_control = __esm({
1244
1724
  });
1245
1725
  function resolvePermissionLevel(raw, diags) {
1246
1726
  if (isPermissionLevel(raw)) return raw;
1247
- diags.warn("Perm", `\u672A\u8BC6\u522B\u7684\u6743\u9650\u7EA7\u522B "${raw}"\uFF0C\u56DE\u9000\u5230 default`, "bad-permission-level");
1727
+ diags.warn("Perm", t("boot.bad_permission_level", { raw }), "bad-permission-level");
1248
1728
  return "default";
1249
1729
  }
1250
1730
  function resolveModelCapability(config, diags) {
@@ -1252,45 +1732,49 @@ function resolveModelCapability(config, diags) {
1252
1732
  const cap = getCapability(config.model, config.homeDir);
1253
1733
  return {
1254
1734
  contextLength: cap.contextLength,
1255
- supportsImages: cap.supportsImages,
1256
1735
  ...cap.pricing ? { pricing: cap.pricing } : {}
1257
1736
  };
1258
1737
  } catch {
1259
1738
  diags.warn(
1260
1739
  "Capability",
1261
- `${config.model} \u5143\u6570\u636E\u7F3A\u5931\uFF0CcontextLength \u7528\u914D\u7F6E\u503C`,
1740
+ t("boot.no_model_metadata", { model: config.model }),
1262
1741
  "no-model-metadata"
1263
1742
  );
1264
- return { contextLength: config.contextLength, supportsImages: false };
1743
+ return { contextLength: config.contextLength };
1265
1744
  }
1266
1745
  }
1267
1746
  function reportUtilityModel(info, diags) {
1268
1747
  if (!info.configured) {
1269
- diags.skipped("Utility \u6A21\u578B", "\u672A\u914D\u7F6E\uFF0C\u5185\u90E8\u4EFB\u52A1\uFF08\u4E0A\u4E0B\u6587\u538B\u7F29\uFF09\u8DDF\u7740\u5F53\u524D\u6A21\u578B\u8D70");
1748
+ diags.skipped("UtilityModel", (lang) => t("boot.utility_model_none", void 0, lang));
1270
1749
  return;
1271
1750
  }
1272
1751
  if (info.issue) {
1273
- diags.warn("Utility \u6A21\u578B", info.issue, "bad-model-ref");
1752
+ diags.warn("UtilityModel", info.issue, "bad-model-ref");
1274
1753
  return;
1275
1754
  }
1276
- diags.ok("Utility \u6A21\u578B", `\u5185\u90E8\u4EFB\u52A1\u8D70 ${info.selection.provider}/${info.selection.model}`);
1755
+ diags.ok(
1756
+ "UtilityModel",
1757
+ (lang) => t(
1758
+ "boot.utility_model_selected",
1759
+ { provider: info.selection.provider, model: info.selection.model },
1760
+ lang
1761
+ )
1762
+ );
1277
1763
  }
1278
1764
  function buildTrust(config, workDir, diags, interactive) {
1279
1765
  const root = resolveProjectRoot(workDir);
1280
1766
  if (config.trust?.enabled === false) {
1281
- const gate2 = createAllowAllGate(
1282
- "\u4FE1\u4EFB\u95F8\u95E8\u5DF2\u5173\u95ED\uFF08config.trust.enabled=false\uFF09\uFF1A\u4EFB\u4F55\u76EE\u5F55\u7684\u9879\u76EE\u6307\u4EE4\u90FD\u4F1A\u52A0\u8F7D"
1283
- );
1767
+ const gate2 = createAllowAllGate(t("boot.trust_disabled"));
1284
1768
  setTrustGate(gate2);
1285
1769
  const trust2 = gate2.decide(workDir);
1286
- diags.warn("Trust", trust2.reason, "trust-disabled");
1770
+ diags.skipped("Trust", trust2.reason);
1287
1771
  const disabled = safeInit("Trust", () => new TrustManager(trustPath(config.homeDir)), diags);
1288
1772
  for (const err of disabled?.loadErrors ?? []) diags.warn("Trust", err, "trust-load-error");
1289
1773
  return { manager: disabled, trust: trust2, root, gate: gate2 };
1290
1774
  }
1291
1775
  const manager = safeInit("Trust", () => new TrustManager(trustPath(config.homeDir)), diags);
1292
1776
  for (const err of manager?.loadErrors ?? []) diags.warn("Trust", err, "trust-load-error");
1293
- const gate = manager ? createTrustGate({ manager, interactive }) : createDenyAllGate("\u4FE1\u4EFB\u8BB0\u5F55\u4E0D\u53EF\u7528\uFF0C\u6309\u4E0D\u4FE1\u4EFB\u5904\u7406\uFF1A\u672C\u6B21\u4E0D\u52A0\u8F7D\u4EFB\u4F55\u9879\u76EE\u7EA7\u914D\u7F6E");
1777
+ const gate = manager ? createTrustGate({ manager, interactive }) : createDenyAllGate(t("boot.trust_unavailable"));
1294
1778
  setTrustGate(gate);
1295
1779
  const trust = gate.decide(root);
1296
1780
  if (trust.trusted) diags.ok("Trust", trust.reason);
@@ -1299,11 +1783,11 @@ function buildTrust(config, workDir, diags, interactive) {
1299
1783
  }
1300
1784
  function buildImportGate(config, diags) {
1301
1785
  if (getImportGate()) {
1302
- diags.ok("Instructions", "\u9879\u76EE\u5916\u7684 @import \u7528\u5BBF\u4E3B\u5DF2\u6CE8\u518C\u7684\u95F8\u95E8\uFF08\u542F\u52A8\u65F6\u95EE\u8FC7\u4E00\u6B21\uFF09");
1786
+ diags.ok("Instructions", t("boot.import_host_gate"));
1303
1787
  return;
1304
1788
  }
1305
1789
  if (config.trust?.enabled === false) {
1306
- setImportGate(createAllowAllImportGate("\u4FE1\u4EFB\u95F8\u95E8\u5DF2\u5173\u95ED\uFF1A\u9879\u76EE\u5916\u7684 @import \u4E5F\u4E00\u5F8B\u52A0\u8F7D"));
1790
+ setImportGate(createAllowAllImportGate(t("boot.import_trust_disabled")));
1307
1791
  return;
1308
1792
  }
1309
1793
  const store = safeInit(
@@ -1312,7 +1796,7 @@ function buildImportGate(config, diags) {
1312
1796
  diags
1313
1797
  );
1314
1798
  if (!store) {
1315
- setImportGate(createDenyAllImportGate("import \u653E\u884C\u8BB0\u5F55\u4E0D\u53EF\u7528\uFF0C\u9879\u76EE\u5916\u7684 @import \u4E00\u5F8B\u4E0D\u52A0\u8F7D"));
1799
+ setImportGate(createDenyAllImportGate(t("boot.import_store_unavailable")));
1316
1800
  return;
1317
1801
  }
1318
1802
  for (const err of store.loadErrors) diags.warn("Instructions", err, "import-trust-load-error");
@@ -1359,7 +1843,7 @@ function buildServices(baseConfig, diags, workDir = process.cwd(), opts = {}) {
1359
1843
  for (const p of projectContext.skippedInstructions) {
1360
1844
  diags.warn(
1361
1845
  "Instructions",
1362
- `${p} \u672A\u52A0\u8F7D\uFF08\u5DE5\u4F5C\u533A\u672A\u4FE1\u4EFB\uFF0Cepoch trust add \u53EF\u653E\u884C\uFF09`,
1846
+ t("boot.instructions_untrusted", { path: p }),
1363
1847
  "workspace-untrusted"
1364
1848
  );
1365
1849
  }
@@ -1369,7 +1853,7 @@ function buildServices(baseConfig, diags, workDir = process.cwd(), opts = {}) {
1369
1853
  diags.ok("Provider");
1370
1854
  } catch (err) {
1371
1855
  if (err instanceof ProviderError && err.code === PROVIDER_NO_CREDENTIAL) {
1372
- diags.skipped("Provider", `${err.message}\uFF08\u8DD1 epoch model \u914D\u7F6E\uFF0C\u6216\u8BBE <PROVIDER>_API_KEY\uFF09`);
1856
+ diags.skipped("Provider", t("boot.provider_no_credential", { message: err.message }));
1373
1857
  } else {
1374
1858
  diags.failed(
1375
1859
  "Provider",
@@ -1379,11 +1863,19 @@ function buildServices(baseConfig, diags, workDir = process.cwd(), opts = {}) {
1379
1863
  }
1380
1864
  }
1381
1865
  if (provider) provider.refreshMetadata();
1382
- const providerBridge = provider ? {
1383
- generate: provider.generate.bind(provider),
1384
- stream: provider.stream.bind(provider),
1866
+ const router = provider;
1867
+ const providerBridge = router ? {
1868
+ generate: router.generate.bind(router),
1869
+ stream: router.stream.bind(router),
1385
1870
  get capabilities() {
1386
- return { supportsImages: capability.supportsImages };
1871
+ try {
1872
+ return { supportsImages: router.getCapability().supportsImages };
1873
+ } catch {
1874
+ return { supportsImages: false };
1875
+ }
1876
+ },
1877
+ get modelId() {
1878
+ return router.getSelection().model;
1387
1879
  }
1388
1880
  } : null;
1389
1881
  const session = safeInit("Session", () => new SessionManager(config.dbPath), diags);
@@ -1406,19 +1898,20 @@ function buildServices(baseConfig, diags, workDir = process.cwd(), opts = {}) {
1406
1898
  }
1407
1899
  const skillDirs = resolveSkillDirs({
1408
1900
  userDir: skillsDir(config.homeDir),
1409
- // 插件技能排在最前(优先级最低)且全部带前缀,见 core 的 skill/sources.ts
1410
1901
  pluginDirs: plugins.skillDirs,
1411
- // 宿主注入的紧跟在插件之后(方案 44 §2.1),同样全部带前缀、同样不过工作区
1412
- // 闸门 —— 它的信任来自「宿主就是跑我们这个库的那个进程」,和 secretStore 同一档
1413
1902
  ...opts.hostSkillDirs ? { hostDirs: opts.hostSkillDirs } : {},
1414
1903
  projectRoot: projectContext.rootDir,
1415
1904
  trusted: trust.trusted,
1416
1905
  diags
1417
1906
  });
1418
- const skill = safeInit("Skill", () => new SkillSystem(skillDirs.dirs), diags);
1907
+ const skill = safeInit(
1908
+ "Skill",
1909
+ () => new SkillSystem(skillDirs.dirs, { maxIndexed: config.skills?.maxIndexed }),
1910
+ diags
1911
+ );
1419
1912
  if (skill) {
1420
1913
  for (const detail of issueDetails(skill.getIssues())) {
1421
- diags.warn("Skill", `\u6280\u80FD ${detail}`, "invalid-skill-file");
1914
+ diags.warn("Skill", t("boot.skill_issue", { detail }), "invalid-skill-file");
1422
1915
  }
1423
1916
  }
1424
1917
  let skillLearner;
@@ -1442,15 +1935,17 @@ function buildServices(baseConfig, diags, workDir = process.cwd(), opts = {}) {
1442
1935
  () => new HookManager(opts.logger ? { log: opts.logger } : {}),
1443
1936
  diags
1444
1937
  );
1938
+ const hookStatus = { sources: [], projectSkipped: false };
1445
1939
  if (hookManager && managedPolicy.allowManagedHooksOnly) {
1446
1940
  diags.warn(
1447
1941
  "Hook",
1448
- `\u4F01\u4E1A\u6258\u7BA1\u8BBE\u7F6E\u5F00\u542F\u4E86 allowManagedHooksOnly\uFF0C\u7528\u6237\u7EA7\u4E0E\u9879\u76EE\u7EA7 hook \u90FD\u4E0D\u52A0\u8F7D\uFF08${managedPolicy.path}\uFF09`,
1942
+ t("boot.managed_hooks_only", { path: managedPolicy.path }),
1449
1943
  "managed-hooks-only"
1450
1944
  );
1451
1945
  } else if (hookManager) {
1452
1946
  const hooks = loadHookSources({
1453
1947
  userPath: hooksPath(config.homeDir),
1948
+ claudeUserPath: claudeUserSettingsPath(),
1454
1949
  pluginPaths: plugins.hookPaths,
1455
1950
  ...projectContext.rootDir ? { projectRoot: projectContext.rootDir } : {},
1456
1951
  trusted: trust.trusted,
@@ -1458,6 +1953,8 @@ function buildServices(baseConfig, diags, workDir = process.cwd(), opts = {}) {
1458
1953
  diags
1459
1954
  });
1460
1955
  hookManager.loadEntries(hooks.entries);
1956
+ hookStatus.sources = [...hooks.sources];
1957
+ hookStatus.projectSkipped = hooks.projectSkipped;
1461
1958
  }
1462
1959
  const cache = new ApprovalCache(config.homeDir);
1463
1960
  const permission = safeInit("Perm", () => new PermissionManager(permLevel, cache), diags);
@@ -1484,7 +1981,7 @@ function buildServices(baseConfig, diags, workDir = process.cwd(), opts = {}) {
1484
1981
  policyStatus.projectSkipped = policyDirs.projectSkipped;
1485
1982
  policyStatus.skippedDir = policyDirs.skippedDir ?? null;
1486
1983
  } catch {
1487
- diags.warn("Policy", "\u52A0\u8F7D\u5931\u8D25\uFF08\u8DF3\u8FC7\uFF09", "policy-load-failed");
1984
+ diags.warn("Policy", t("boot.policy_load_failed"), "policy-load-failed");
1488
1985
  }
1489
1986
  const lists = config.permissions;
1490
1987
  const counts = {
@@ -1497,23 +1994,25 @@ function buildServices(baseConfig, diags, workDir = process.cwd(), opts = {}) {
1497
1994
  permission.setPermissionRules(lists);
1498
1995
  diags.ok(
1499
1996
  "Perm",
1500
- `\u6743\u9650\u89C4\u5219 ${ruleCount} \u6761\uFF08allow ${counts.allow} / ask ${counts.ask} / deny ${counts.deny}\uFF09`
1997
+ t("boot.perm_rules", {
1998
+ total: ruleCount,
1999
+ allow: counts.allow,
2000
+ ask: counts.ask,
2001
+ deny: counts.deny
2002
+ })
1501
2003
  );
1502
2004
  for (const finding of checkShadowing(compileRuleLists(lists))) {
1503
2005
  diags.warn("Perm", finding.message, `rule-${finding.kind}`);
1504
2006
  }
1505
2007
  }
1506
2008
  }
2009
+ const compliance = buildCompliance(config, projectContext.rootDir, trust.trusted, diags);
1507
2010
  let contextEngine = null;
1508
2011
  if (provider) {
1509
2012
  reportUtilityModel(provider.getUtilityInfo(), diags);
1510
2013
  contextEngine = safeInit(
1511
2014
  "Context",
1512
2015
  () => new ContextCompressor({
1513
- // 2026-08-16 合并时收的一笔(compressor.ts 文件头那张 📮 的第一条):
1514
- // `compression.enabled` / `compression.threshold` 两个键的装配点在这儿,
1515
- // 而写它们的那一轮禁区正是 `runtime/**`。不接这一行的后果不是少一个功能,
1516
- // 是**一句谎话** —— 配得进、设置页印得出来、还标着「② 用户级」,而压缩照压。
1517
2016
  ...compressionOptions(config),
1518
2017
  summarize: async (prompt) => {
1519
2018
  const utility = provider.getUtilityInfo().selection;
@@ -1534,8 +2033,6 @@ function buildServices(baseConfig, diags, workDir = process.cwd(), opts = {}) {
1534
2033
  const capability = resolveModelCapability(config, diags);
1535
2034
  return {
1536
2035
  config,
1537
- // 合并**之前**那一份,连同「哪个键是谁写的」那本账 —— 两样都只给设置页的
1538
- // 来源标注用(判据在 `Services.baseConfig` 上)
1539
2036
  baseConfig,
1540
2037
  configOrigins: opts.configOrigins ?? getConfigOrigins(),
1541
2038
  provider,
@@ -1559,15 +2056,59 @@ function buildServices(baseConfig, diags, workDir = process.cwd(), opts = {}) {
1559
2056
  settingsLayers: merged.layers,
1560
2057
  managedPolicy,
1561
2058
  policy: policyStatus,
2059
+ hook: hookStatus,
1562
2060
  plugins,
2061
+ compliance,
1563
2062
  cleanups
1564
2063
  };
1565
2064
  }
2065
+ function buildCompliance(config, projectRoot, trusted, diags) {
2066
+ const settings = resolveComplianceSettings(config.compliance);
2067
+ if (!settings.enabled) {
2068
+ diags.skipped("Compliance", t("boot.compliance_off"));
2069
+ return null;
2070
+ }
2071
+ const rules = [];
2072
+ const exempt = [];
2073
+ try {
2074
+ const dirs = resolveComplianceDirs({
2075
+ userDir: resolveComplianceDir(config.homeDir),
2076
+ ...projectRoot ? { projectRoot } : {},
2077
+ trusted,
2078
+ diags
2079
+ });
2080
+ for (const spec of dirs.dirs) {
2081
+ const loaded = loadLexiconFiles(spec.dir, spec.source, diags);
2082
+ rules.push(...loaded.rules);
2083
+ exempt.push(...loaded.exempt);
2084
+ }
2085
+ } catch {
2086
+ diags.warn("Compliance", t("boot.compliance_load_failed"), "compliance-load-failed");
2087
+ }
2088
+ const engine = new ComplianceEngine({ settings, rules, exempt });
2089
+ diags.ok(
2090
+ "Compliance",
2091
+ t("boot.compliance_ready", {
2092
+ active: engine.activeRuleCount,
2093
+ builtin: engine.counts.builtin,
2094
+ external: engine.counts.user + engine.counts.project
2095
+ })
2096
+ );
2097
+ return engine;
2098
+ }
2099
+ function resolveComplianceSettings(raw) {
2100
+ return {
2101
+ enabled: raw?.enabled ?? DEFAULT_COMPLIANCE_SETTINGS.enabled,
2102
+ scope: { ...DEFAULT_COMPLIANCE_SETTINGS.scope, ...raw?.scope },
2103
+ skipCodeBlocks: raw?.skipCodeBlocks ?? DEFAULT_COMPLIANCE_SETTINGS.skipCodeBlocks,
2104
+ maxHoldChars: raw?.maxHoldChars ?? DEFAULT_COMPLIANCE_SETTINGS.maxHoldChars,
2105
+ actions: { ...DEFAULT_COMPLIANCE_SETTINGS.actions, ...raw?.actions }
2106
+ };
2107
+ }
1566
2108
  var init_services = __esm({
1567
2109
  "src/services.ts"() {
1568
2110
  }
1569
2111
  });
1570
-
1571
2112
  // src/turn-steps.ts
1572
2113
  function draft() {
1573
2114
  return { text: "", reasoning: "", toolCalls: [], toolResults: [] };
@@ -1586,12 +2127,6 @@ var TurnStepCollector;
1586
2127
  var init_turn_steps = __esm({
1587
2128
  "src/turn-steps.ts"() {
1588
2129
  TurnStepCollector = class {
1589
- /**
1590
- * @param startedAt 这一轮开跑的毫秒时间戳,用来算 `durationMs`。
1591
- * @param now 取当下时间。**注进来只为让用例能钉一个确定的耗时** ——
1592
- * 生产路径上就是 `Date.now()`,而这一层是服务端,用本机钟是对的
1593
- * (web 那边不许用 `Date.now()`,因为它得用服务端信封上的 `ts`)。
1594
- */
1595
2130
  constructor(startedAt, now = Date.now) {
1596
2131
  this.startedAt = startedAt;
1597
2132
  this.now = now;
@@ -1600,11 +2135,8 @@ var init_turn_steps = __esm({
1600
2135
  now;
1601
2136
  steps = [];
1602
2137
  current = null;
1603
- /** 当前这一步已经收口,下一笔内容另开一步。见文件头那条 ⚠️ */
1604
2138
  sealed = false;
1605
- /** 这一轮发过几次工具调用。**跨步累计**,`toolCalls` 那一格要的是整轮的数 */
1606
2139
  calls = 0;
1607
- /** `finish` 那一帧带回来的账。没收到那一帧就没有 —— 中止 / 报错时就是这样 */
1608
2140
  finish = null;
1609
2141
  observe(event) {
1610
2142
  switch (event.type) {
@@ -1627,8 +2159,6 @@ var init_turn_steps = __esm({
1627
2159
  toolCallId: event.callId,
1628
2160
  toolName: event.toolName,
1629
2161
  output: event.output,
1630
- // artifact 原样带走(含 `data`)。剥 base64 是落盘那一层的事
1631
- // (`persistToolResults()`)—— 内存里的宿主要那份数据渲染缩略图
1632
2162
  ...event.artifacts ? { artifacts: event.artifacts } : {}
1633
2163
  });
1634
2164
  return;
@@ -1646,27 +2176,10 @@ var init_turn_steps = __esm({
1646
2176
  return;
1647
2177
  }
1648
2178
  }
1649
- /** 冲掉最后一步,交出整轮。空步已经在 {@link seal} 里滤掉 */
1650
2179
  take() {
1651
2180
  this.flush();
1652
2181
  return this.steps;
1653
2182
  }
1654
- /**
1655
- * 这一轮的账(决定 8 第三个时刻),落在最后一条 assistant 行上。
1656
- *
1657
- * ## 三格各自可以没有,而且**都不补 0**
1658
- *
1659
- * - `usage` 没有 = provider 这一轮没上报用量。写 0 会读成「这一轮没花钱」,
1660
- * 而真相是「不知道」(同 `SessionDB.recordUsage` 那条「宁可少记」)。
1661
- * - `model` 没有 = 引擎没告诉我们这一轮跑的是谁。**不许拿配置里那个顶上** ——
1662
- * 降级和 `switch_model` 之后那是个从没跑过的模型名,比不显示更糟。
1663
- * - `durationMs` 没有 = 起点未知(没给 `startedAt`)。写 0 是「0s」,那是假事实。
1664
- *
1665
- * `toolCalls` 反过来 —— 它**必有**,因为 `0` 在这一格是真的 0(「这一轮没动手」)。
1666
- *
1667
- * 整帧 `finish` 都没收到(中止 / 报错)时返回 `undefined`:那一轮**没有**账,
1668
- * 而不是一笔全是空格的账。
1669
- */
1670
2183
  facts() {
1671
2184
  if (!this.finish) return void 0;
1672
2185
  const durationMs = this.startedAt === void 0 ? void 0 : Math.max(0, this.finish.at - this.startedAt);
@@ -1700,46 +2213,20 @@ function settleAbandoned(pending) {
1700
2213
  var AgentSession;
1701
2214
  var init_agent_session = __esm({
1702
2215
  "src/agent-session.ts"() {
2216
+ init_events();
1703
2217
  init_turn_steps();
1704
2218
  AgentSession = class {
1705
- /**
1706
- * 当前会话 id。
1707
- *
1708
- * **不再是 `readonly`**(方案 25 PR-4 的 `/resume`):换会话时它必须跟着换,
1709
- * 否则接下来 `store.record()` 会把新对话记到**旧会话**名下 ——
1710
- * 症状是「我明明 resume 了另一段,怎么两段混在一起」。
1711
- * 换的入口只有 `resume()` 一个,不要在别处赋值。
1712
- */
1713
2219
  sessionId;
1714
2220
  agent;
1715
2221
  store;
1716
2222
  checkpoints;
1717
2223
  goals;
1718
2224
  currentRole;
1719
- /**
1720
- * 上一轮是谁在跑(方案 57 §3.5)。`null` = 这段会话此刻没有角色,**那也是一个值**
1721
- * —— 从「有」变成「没有」(用户点掉 chip 退回一段本来就没有角色的会话)同样
1722
- * 要留痕。
1723
- *
1724
- * ⚠️ **它只在进程内有效,从历史里恢复不出来。** 构造时拿的是这个会话**默认**
1725
- * 那个专家,于是重启之后第一条不带 chip 的消息**不插标记** —— 那是对的:
1726
- * 上一条标记还在历史里,语义没断。
1727
- *
1728
- * 别去做一次「从历史里反推上一轮是谁」的解析:那要 parse 我们自己印的那句
1729
- * 中文,而它是可翻译的(catalog 真源 `locales/*.yaml`)—— 换一次界面语言,
1730
- * 那个解析器就静默失灵。
1731
- */
1732
2225
  lastRole;
1733
- /** 不是 `readonly` 的数组内容 —— `compact()` 和 `resume()` 会整体换掉它 */
2226
+ currentWorkDir;
2227
+ lastWorkDir = null;
1734
2228
  history;
1735
- /** 待交给宿主的请求队列(审批 + 提问)+ 「有新请求」的唤醒信号 */
1736
2229
  queue = [];
1737
- /**
1738
- * 已经交给宿主、但还没答复的请求。
1739
- *
1740
- * 必须单独记:请求一旦 yield 给宿主就已经出了 queue,如果宿主提前 break
1741
- * 走了,只看 queue 是空的,那个挂在 await 上的引擎就永久等下去。
1742
- */
1743
2230
  inFlight = /* @__PURE__ */ new Set();
1744
2231
  notify = null;
1745
2232
  constructor(opts) {
@@ -1749,15 +2236,10 @@ var init_agent_session = __esm({
1749
2236
  this.checkpoints = opts.checkpoints;
1750
2237
  this.goals = opts.goals;
1751
2238
  this.currentRole = opts.currentRole;
2239
+ this.currentWorkDir = opts.currentWorkDir;
1752
2240
  this.lastRole = opts.currentRole?.() ?? null;
1753
2241
  this.history = [...opts.history ?? []];
1754
2242
  }
1755
- /**
1756
- * 传给 AgentLoop 的审批回调。
1757
- *
1758
- * 必须在构造 AgentLoop **之前**拿到(它是构造参数),所以这里做成静态工厂:
1759
- * 先建 pending 队列,再建 AgentLoop,最后建 AgentSession。
1760
- */
1761
2243
  static createApprovalBridge() {
1762
2244
  let target = null;
1763
2245
  const buffered = [];
@@ -1770,11 +2252,11 @@ var init_agent_session = __esm({
1770
2252
  }
1771
2253
  };
1772
2254
  return {
1773
- onApprovalRequest: (req) => new Promise((resolve3) => {
1774
- enqueue({ kind: "approval", req, resolve: resolve3 });
2255
+ onApprovalRequest: (req) => new Promise((resolve4) => {
2256
+ enqueue({ kind: "approval", req, resolve: resolve4 });
1775
2257
  }),
1776
- onQuestionRequest: (req) => new Promise((resolve3) => {
1777
- enqueue({ kind: "question", req, resolve: resolve3 });
2258
+ onQuestionRequest: (req) => new Promise((resolve4) => {
2259
+ enqueue({ kind: "question", req, resolve: resolve4 });
1778
2260
  }),
1779
2261
  attach: (session) => {
1780
2262
  target = session;
@@ -1783,49 +2265,24 @@ var init_agent_session = __esm({
1783
2265
  }
1784
2266
  };
1785
2267
  }
1786
- /** 当前累积的历史(只读快照) */
1787
2268
  getHistory() {
1788
2269
  return this.history;
1789
2270
  }
1790
- /**
1791
- * 往上下文里记一句话,**不跑模型**(方案 25 §2.3 的 `!` 走这条)。
1792
- *
1793
- * 用户在 TUI 里敲 `!git status` 时,输出得让模型看得见 —— 「你看一下这个命令的
1794
- * 输出」正是 `!` 存在的意义。但它不该触发一轮对话:用户只是想让它知道,
1795
- * 不是想让它现在就说点什么。
1796
- *
1797
- * 角色是 `user` 而不是 `system`:这**确实是用户做的事**,把它标成系统消息会让
1798
- * 模型以为是我们的指令。也**不落盘** —— `run()` 那条路才管持久化,
1799
- * 而这里没有一轮可以归属;resume 之后丢掉一条「我刚跑了 ls」是可以接受的,
1800
- * 把它塞进 DB 反而会让会话回放里出现一条没有回答的用户消息。
1801
- */
1802
2271
  note(text) {
1803
2272
  if (!text.trim()) return;
1804
2273
  this.history.push({ role: "user", content: text });
1805
2274
  }
1806
- /**
1807
- * 手动压缩一次上下文(`/compact`,方案 25 §2.6)。
1808
- *
1809
- * 和自动压缩共用引擎里同一段实现,区别只有两个:**不看阈值**(用户敲这条命令
1810
- * 就是要它现在压),以及可以带一句「这次帮我留住什么」。
1811
- *
1812
- * 压缩本身是一次真实的 LLM 请求,那笔钱进的是同一个会话预算 —— 账单由
1813
- * `AgentLoop` 抛出来,这里原样转给调用方,别在这一层吞掉。
1814
- *
1815
- * 历史只有一条(或空)时直接不压:压一条消息拿不到任何收益,
1816
- * 却要花一次 utility 模型的请求。
1817
- */
1818
2275
  async compact(instruction) {
1819
2276
  const before = this.history.length;
1820
2277
  if (before <= 1) {
1821
- return { ran: false, before, after: before, reason: "\u5386\u53F2\u592A\u77ED\uFF0C\u6CA1\u4EC0\u4E48\u53EF\u538B\u7684" };
2278
+ return { ran: false, before, after: before, reason: t("boot.compact_too_short") };
1822
2279
  }
1823
2280
  const result = await this.agent.compactNow(
1824
2281
  this.history.map((m) => ({ role: m.role, content: m.content })),
1825
2282
  instruction
1826
2283
  );
1827
2284
  if (!result.ran) {
1828
- return { ran: false, before, after: before, reason: "\u4E0A\u4E0B\u6587\u538B\u7F29\u6A21\u5757\u6CA1\u8D77\u6765\uFF0C\u770B\u8BCA\u65AD" };
2285
+ return { ran: false, before, after: before, reason: t("boot.compact_unavailable") };
1829
2286
  }
1830
2287
  this.history = result.messages.map((m) => ({
1831
2288
  role: m.role === "assistant" ? "assistant" : "user",
@@ -1838,23 +2295,6 @@ var init_agent_session = __esm({
1838
2295
  ...result.usage ? { usage: result.usage } : {}
1839
2296
  };
1840
2297
  }
1841
- /**
1842
- * 把某个会话落盘的那段历史装进内存 —— **「模型看到的」那一份只有这一个装法**。
1843
- *
1844
- * 三处共用:{@link resume}(换到另一段对话)、{@link rewindConversation}
1845
- * (回退之后重装),以及 web 的复活(`SessionRuntimeFactory.revive()`:
1846
- * 引擎装好之后把落盘的那段接上)。抄成三份的代价不是「重复」,
1847
- * 而是**它们一旦分叉,症状就是「屏幕上和模型看到的对不上」** ——
1848
- * 下面 {@link resume} 那段注释骂的正是这个。
1849
- *
1850
- * 没有 store(纯内存模式)就装成空的:没有 DB 就没有历史可言。
1851
- *
1852
- * ⚠️ **不改 `this.sessionId`。** 换会话是 {@link resume} 的事 ——
1853
- * 复活装的就是自己这一段,那儿本来就不该动。
1854
- *
1855
- * @param sessionId 从哪个会话读
1856
- * @returns 装进去的那一份,供宿主回放进屏幕
1857
- */
1858
2298
  reloadHistory(sessionId) {
1859
2299
  const restored = this.store?.loadHistory(sessionId) ?? [];
1860
2300
  this.history = restored.map((m) => ({
@@ -1864,38 +2304,11 @@ var init_agent_session = __esm({
1864
2304
  }));
1865
2305
  return this.history;
1866
2306
  }
1867
- /**
1868
- * 换到另一个会话(`/resume`,方案 25 §2.6)。
1869
- *
1870
- * **模型真的接上那段上下文**,不只是把历史打印到屏幕上 —— 后者在终端里
1871
- * 和真恢复长得一模一样,而用户会照着屏幕上那段去问「你刚才说的那个方案」,
1872
- * 然后收到一句完全对不上的回答。
1873
- *
1874
- * 两样都要换:历史(模型看的)和 `sessionId`(往哪儿记)。只换前者的话,
1875
- * 接下来这一轮会被记进**上一个**会话里 —— 所以这儿在
1876
- * {@link reloadHistory} 之外还多一步。
1877
- *
1878
- * 内存模式下装出来是空的,但 `sessionId` 仍然换:调用方已经决定
1879
- * 「这是另一段对话」了。
1880
- *
1881
- * @returns 恢复出来的历史,供宿主回放进屏幕
1882
- */
1883
2307
  resume(sessionId) {
1884
2308
  const restored = this.reloadHistory(sessionId);
1885
2309
  this.sessionId = sessionId;
1886
2310
  return restored;
1887
2311
  }
1888
- /**
1889
- * 对话回退(方案 27):删掉水位线之后的消息,然后把内存历史重新装一遍。
1890
- *
1891
- * **重装而不是自己在内存里 splice**:DB 是唯一真源,重装出来的历史天然和它一致。
1892
- * 自己截内存的话就有两套截断逻辑,而它们一旦分叉,症状是「屏幕上和模型看到的
1893
- * 对不上」—— `resume()` 那段注释骂过的正是这个。
1894
- *
1895
- * 没有 store(纯内存模式)就什么都不做并返回 `null`:没有 DB 就没有水位线可言。
1896
- *
1897
- * @returns 删掉几条消息;失败或不支持时 `null`
1898
- */
1899
2312
  rewindConversation(lastKeptId) {
1900
2313
  if (!this.store || lastKeptId < 0) return null;
1901
2314
  const removed = this.store.rewindTo(this.sessionId, lastKeptId);
@@ -1903,13 +2316,6 @@ var init_agent_session = __esm({
1903
2316
  this.reloadHistory(this.sessionId);
1904
2317
  return removed;
1905
2318
  }
1906
- /**
1907
- * 一条排队的请求 → 一个带答复闭包的事件。
1908
- *
1909
- * **答复一次就出账**:先从 `inFlight` 里删掉,再 resolve。重复调用 respond
1910
- * 是无害的(promise 已 settle),但漏了这一步 `finally` 会再对它补一个兜底答复,
1911
- * 于是「用户明明点了允许」和「引擎收到拒绝」同时成立。
1912
- */
1913
2319
  toEvent(pending) {
1914
2320
  if (pending.kind === "question") {
1915
2321
  return {
@@ -1924,26 +2330,12 @@ var init_agent_session = __esm({
1924
2330
  return {
1925
2331
  type: "approval-request",
1926
2332
  request: pending.req,
1927
- // 没带 `note` 时原样传裸出口而不是包成 `{outcome}`:两者语义相同,
1928
- // 但裸串是既有形态,包一层会让所有断言 `resolve` 参数的用例白红一次
1929
2333
  respond: (outcome, note) => {
1930
2334
  this.inFlight.delete(pending);
1931
2335
  pending.resolve(note === void 0 ? outcome : { outcome, note });
1932
2336
  }
1933
2337
  };
1934
2338
  }
1935
- /**
1936
- * 跑一轮对话,产出 AgentEvent 流(含 approval-request / question)。
1937
- *
1938
- * 宿主只要 for-await 这个流,把 approval-request 交给用户点选即可 ——
1939
- * 不用关心阻塞回调、不用自己拼历史、不用自己写持久化。
1940
- *
1941
- * @param opts.signal 中止信号。`AgentLoop` 一直支持 `input.signal`(轮次之间
1942
- * 和工具执行处都会检查),但这个签名以前不透传,于是宿主唯一的中止手段
1943
- * 是硬杀进程 —— `dispose()` 跑不到,SQLite 连接和子进程都留着。
1944
- * TUI 的 Esc 就靠它:只靠 `break` 退出 for-await 要等下一个事件到达
1945
- * 才生效,模型正在长考时按 Esc 完全没反应。
1946
- */
1947
2339
  async *run(userMessage, opts = {}) {
1948
2340
  let assistantText = "";
1949
2341
  const userText = contentToText(userMessage);
@@ -1955,13 +2347,11 @@ var init_agent_session = __esm({
1955
2347
  });
1956
2348
  if (userText.trim()) this.goals?.beginRound(this.sessionId);
1957
2349
  this.markRoleHandoff();
2350
+ this.markWorkspaceHandoff();
1958
2351
  const iter = this.agent.stream({
1959
2352
  userMessage,
1960
2353
  sessionId: this.sessionId,
1961
2354
  history: this.history,
1962
- // 逐轮把真实用量累进 `sessions` 表(方案 42 PR-0)。
1963
- // 收的是**本轮增量** —— 事件流里的 `usage.cumulative` 会被 BudgetStore
1964
- // 从上次会话恢复,减不出干净的增量(见 `RunInput.onTurnUsage`)
1965
2355
  ...this.store ? {
1966
2356
  onTurnUsage: (usage) => void this.store?.recordUsage(this.sessionId, usage)
1967
2357
  } : {},
@@ -1980,15 +2370,18 @@ var init_agent_session = __esm({
1980
2370
  while (this.queue.length > 0) {
1981
2371
  const pending = this.queue.shift();
1982
2372
  this.inFlight.add(pending);
1983
- yield this.toEvent(pending);
2373
+ const request = this.toEvent(pending);
2374
+ publishAgentEvent(this.sessionId, request);
2375
+ yield request;
1984
2376
  }
1985
2377
  continue;
1986
2378
  }
1987
- const { value: event, done } = await pendingNext;
1988
- if (done) break;
2379
+ const { value: event, done: done2 } = await pendingNext;
2380
+ if (done2) break;
1989
2381
  pendingNext = iter.next();
1990
2382
  if (event.type === "text-delta") assistantText += event.text;
1991
2383
  turn.observe(event);
2384
+ publishAgentEvent(this.sessionId, event);
1992
2385
  yield event;
1993
2386
  }
1994
2387
  } finally {
@@ -1997,47 +2390,20 @@ var init_agent_session = __esm({
1997
2390
  this.inFlight.clear();
1998
2391
  this.notify = null;
1999
2392
  }
2000
- this.history.push({ role: "user", content: userText, ...parts ? { parts } : {} });
2001
- if (assistantText) this.history.push({ role: "assistant", content: assistantText });
2002
2393
  const steps = turn.take();
2003
2394
  const facts = turn.facts();
2395
+ const assistantHistory = assistantTurnText(
2396
+ assistantText,
2397
+ steps.flatMap((s) => s.toolResults ?? [])
2398
+ );
2399
+ this.history.push({ role: "user", content: userText, ...parts ? { parts } : {} });
2400
+ if (assistantHistory) this.history.push({ role: "assistant", content: assistantHistory });
2004
2401
  this.store?.record(this.sessionId, userText, assistantText, {
2005
2402
  ...parts ? { parts } : {},
2006
2403
  ...steps.length > 0 ? { steps } : {},
2007
- // 这一轮的账(决定 8 第三个时刻)。不落它的话刷新之后每一轮的
2008
- // 「共 N token · 哪个模型 · 花了几秒」全部消失 —— 那是网线补不回来的
2009
2404
  ...facts ? { facts } : {}
2010
2405
  });
2011
2406
  }
2012
- /**
2013
- * 换专家的那一刻,往历史里插一条 `[系统]` 消息(方案 57 §2.3 / §3.5)。
2014
- *
2015
- * ## 为什么在这一层,不在 `loop.ts` 里
2016
- *
2017
- * 循环里那个 `messages` 是 **per-run** 的数组,往那儿 push 的东西不进 DB 历史
2018
- * (后台任务通知就是这么设计的 —— 它不该被回放)。而这一条要的**恰恰是落盘**:
2019
- * 身份行活在那一次请求里、chip 活在浏览器里,历史是唯一会落盘的那一份,
2020
- * 而「这段对话里谁干的」正是重开会话回放时才会被问的问题。
2021
- *
2022
- * ## 三条形状判据
2023
- *
2024
- * 1. **`role: 'user'` + `[系统]` 前缀,不是 `role: 'system'`** —— AI SDK v7 不许
2025
- * `messages` 里出现 system,会抛 `AI_InvalidPromptError`。这条坑 `loop.ts`
2026
- * 已经踩过一次并逐字记下来了,别重踩。⚠️ 前缀**由这里拼**({@link systemNote}),
2027
- * catalog 里 `agent_role` 那两条是光正文:前缀是全仓共用的一条契约,真源在
2028
- * `protocol/src/system-note.ts`,烤进句子里就会跟着译者走;
2029
- * 2. **两条连续的 `user` 消息是安全的** —— 后台任务通知今天就是这么挂的,
2030
- * 而它在真 provider 上跑通了。⚠️ `message-utils.ts` 里那个
2031
- * `validateAlternation()` 看起来禁止这件事,但它**全仓只有自己的用例调用,
2032
- * 生产路径上一个调用点都没有**(2026-08-17 复核)。别为了让它自洽去「修」
2033
- * 这条路,那会连后台通知一起打挂;
2034
- * 3. **只在真的换人那一条上插**(含「从有到无」)。每条都插等于往历史里灌噪音,
2035
- * 还会让压缩器把预算花在这上面。
2036
- *
2037
- * ⚠️ 落盘走 {@link SessionStore.recordNote} 而不是 `record()`:后者写的是**一轮
2038
- * 问答**(user + assistant + tool×N),拿它写一条孤零零的 user 行是在赌
2039
- * 「assistantText 为空时它恰好不写第二行」——那是一个实现细节,不是一条承诺。
2040
- */
2041
2407
  markRoleHandoff() {
2042
2408
  if (!this.currentRole) return;
2043
2409
  const now = this.currentRole() ?? null;
@@ -2048,14 +2414,24 @@ var init_agent_session = __esm({
2048
2414
  this.history.push({ role: "user", content: text });
2049
2415
  this.store?.recordNote(this.sessionId, text);
2050
2416
  }
2051
- /** 一次性执行并拿最终文本(CLI 单次问答用) */
2417
+ markWorkspaceHandoff() {
2418
+ if (!this.currentWorkDir) return;
2419
+ const now = this.currentWorkDir();
2420
+ const previous = this.lastWorkDir;
2421
+ this.lastWorkDir = now;
2422
+ if (previous === null || previous === now) return;
2423
+ if (this.history.length === 0) return;
2424
+ const text = systemNote(t("workspace.handoff", { root: now }));
2425
+ this.history.push({ role: "user", content: text });
2426
+ this.store?.recordNote(this.sessionId, text);
2427
+ }
2052
2428
  async once(userMessage, opts = {}) {
2053
2429
  let text = "";
2054
2430
  let reasoning = "";
2055
2431
  for await (const ev of this.run(userMessage, opts)) {
2056
2432
  if (ev.type === "text-delta") text += ev.text;
2057
2433
  else if (ev.type === "reasoning-delta") reasoning += ev.text;
2058
- else if (ev.type === "error") text = `\u9519\u8BEF: ${ev.message}`;
2434
+ else if (ev.type === "error") text = t("boot.error", { message: ev.message });
2059
2435
  }
2060
2436
  return { text, ...reasoning ? { reasoning } : {} };
2061
2437
  }
@@ -2065,8 +2441,6 @@ var init_agent_session = __esm({
2065
2441
  function createRoleControl(scope, roles) {
2066
2442
  return {
2067
2443
  has: (name) => findRole(roles(), name) !== void 0,
2068
- // `findRole` 找不到时回 `undefined`,而 `enter(undefined)` 的语义正好是
2069
- // 「退回会话默认角色」—— 上面那段 ⚠️ 说的就是这一格,不用再判一次
2070
2444
  enterTurn: (name) => scope.enter(findRole(roles(), name))
2071
2445
  };
2072
2446
  }
@@ -2086,7 +2460,6 @@ var init_role_scope = __esm({
2086
2460
  "src/role-scope.ts"() {
2087
2461
  }
2088
2462
  });
2089
-
2090
2463
  // src/session-goal.ts
2091
2464
  function buildGoalControl(service, sessionId) {
2092
2465
  return {
@@ -2100,41 +2473,70 @@ function buildGoalControl(service, sessionId) {
2100
2473
  clear: () => service.clear(sessionId())
2101
2474
  };
2102
2475
  }
2476
+ function buildGoalCatalog(service) {
2477
+ return {
2478
+ current: (sessionId) => service.current(sessionId),
2479
+ create: (sessionId, objective, maxRounds) => service.create(sessionId, objective, maxRounds),
2480
+ edit: (sessionId, objective) => service.edit(sessionId, objective),
2481
+ setBudget: (sessionId, maxRounds) => service.setBudget(sessionId, maxRounds),
2482
+ pause: (sessionId) => service.pause(sessionId),
2483
+ resume: (sessionId) => service.resume(sessionId),
2484
+ complete: (sessionId, evidence) => service.complete(sessionId, evidence),
2485
+ clear: (sessionId) => service.clear(sessionId)
2486
+ };
2487
+ }
2103
2488
  var init_session_goal = __esm({
2104
2489
  "src/session-goal.ts"() {
2105
2490
  }
2106
2491
  });
2107
2492
  function buildSessionGuards(input) {
2108
- const { permission, planState, managed, startLevel, requestApproval, interactive } = input;
2109
- const scopeOf = (perm, state, own) => ({
2110
- permission: perm,
2111
- permissions: perm ? buildSessionPermissions(perm, managed, state) : null,
2112
- plan: state ? buildPlanControl(state) : null,
2113
- planState: state,
2114
- // 引导会话不盖(注册表里那两个就是它的),别的会话盖上自己那一对
2115
- tools: own && state ? createPlanModeTools({ state, ...interactive ? { requestApproval } : {} }) : []
2116
- });
2493
+ const {
2494
+ permission,
2495
+ planState,
2496
+ managed,
2497
+ defaultLevel,
2498
+ requestApproval,
2499
+ interactive,
2500
+ deferredTools
2501
+ } = input;
2502
+ const bootstrapReveal = input.bootstrapReveal;
2503
+ const scopeOf = (perm, state, own) => {
2504
+ const reveal = own ? createToolRevealState() : bootstrapReveal;
2505
+ return {
2506
+ permission: perm,
2507
+ permissions: perm ? buildSessionPermissions(perm, managed, state, defaultLevel) : null,
2508
+ plan: state ? buildPlanControl(state) : null,
2509
+ planState: state,
2510
+ tools: own ? [
2511
+ ...state ? createPlanModeTools({ state, ...interactive ? { requestApproval } : {} }) : [],
2512
+ ...deferredTools().length > 0 ? [createToolSearchTool({ deferred: deferredTools, reveal })] : []
2513
+ ] : [],
2514
+ reveal
2515
+ };
2516
+ };
2117
2517
  const bootstrapScope = scopeOf(permission, planState, false);
2118
2518
  return {
2119
2519
  bootstrap: () => bootstrapScope,
2120
2520
  open: () => {
2121
2521
  if (!permission) return scopeOf(null, null, true);
2122
- const own = permission.scoped(startLevel);
2522
+ const own = permission.scoped(defaultLevel.get());
2123
2523
  return scopeOf(own, planState ? new PlanModeState(own) : null, true);
2124
2524
  }
2125
2525
  };
2126
2526
  }
2127
- function buildSessionPermissions(permission, managed, planState) {
2527
+ function buildSessionPermissions(permission, managed, planState, defaultLevel) {
2128
2528
  return {
2129
2529
  level: () => permission.getLevel(),
2130
2530
  managed: () => ({ bypassDisabled: managed.disableBypassPermissionsMode }),
2131
- setLevel: (level) => {
2531
+ setLevel: (level, opts) => {
2132
2532
  if (level === "bypass" && managed.disableBypassPermissionsMode) {
2133
2533
  return { ok: false, reason: "managed-bypass-disabled" };
2134
2534
  }
2135
2535
  permission.setLevel(level);
2136
2536
  planState?.forget();
2137
- return { ok: true };
2537
+ if (!opts.remember) return { ok: true };
2538
+ const failure = defaultLevel.remember(level);
2539
+ return failure ? { ok: true, notRemembered: failure } : { ok: true };
2138
2540
  }
2139
2541
  };
2140
2542
  }
@@ -2216,19 +2618,6 @@ var init_session_store = __esm({
2216
2618
  "src/session-store.ts"() {
2217
2619
  init_session_file_changes();
2218
2620
  SessionStore = class {
2219
- /**
2220
- * @param artifactsRoot artifact 目录的**家目录**(即 `~/.epoch` 那一层),
2221
- * 缺省按 `EPOCH_HOME` / profile 解析。只有测试和多 profile 场景要显式给。
2222
- * @param cwd 这段会话归属的**项目根**,落进 `sessions.cwd` 列(方案 29 §2.1)。
2223
- *
2224
- * `cwd` 在方案 29 之前从来没人写过:列是有的(v1 基线里就在,还带着
2225
- * `idx_sessions_cwd` 索引),但 `ensure()` 只传了 id,于是全库的 cwd 都是 NULL、
2226
- * `epoch -c`「接上本目录最近一个会话」压根无从判起。
2227
- *
2228
- * 传项目根而不是 `process.cwd()`:在子目录里敲 `epoch -c` 要接上同一个仓库的
2229
- * 会话。读取侧(`SessionManager.getLatestByCwd`)用的是同一个口径。
2230
- * 不给就仍然写 NULL —— 那段会话只是不参与 `-c` 匹配,别的一切照旧。
2231
- */
2232
2621
  constructor(sessions, artifactsRoot, cwd, source, decision) {
2233
2622
  this.sessions = sessions;
2234
2623
  this.artifactsRoot = artifactsRoot;
@@ -2241,40 +2630,10 @@ var init_session_store = __esm({
2241
2630
  cwd;
2242
2631
  source;
2243
2632
  decision;
2244
- /** 已经确认存在的会话 id,避免每轮都试着 create */
2245
2633
  known = /* @__PURE__ */ new Set();
2246
- /** 这个会话的 artifact 目录 */
2247
2634
  artifactDir(sessionId) {
2248
2635
  return this.artifactsRoot ? artifactsDir(sessionId, this.artifactsRoot) : artifactsDir(sessionId);
2249
2636
  }
2250
- /**
2251
- * 记录一轮问答。持久化失败不该影响对话本身,所以吞异常但返回是否成功。
2252
- *
2253
- * `userMessage` / `assistantText` 收的是**文本投影**:`Message.content` 是
2254
- * SQLite 里的一个 TEXT 列,把 base64 灌进去会让 sessions.db 以每张截图几百 KB
2255
- * 的速度膨胀,而且 `epoch sessions list` 的预览、标题截取、全文检索全会被那串
2256
- * 字符污染。图片本身走 `detail.parts` —— 落进 artifact 目录,列里只留路径。
2257
- *
2258
- * 落盘的行顺序是 **user →(assistant → tool×k)× 步数**,不是时间序:这是
2259
- * `EpochMessage` 的规范顺序(assistant 声明 `toolCalls`,tool 行携带
2260
- * `toolResults`)。宿主按 `toolCallId` 配对分组渲染,**不要依赖行序**去猜
2261
- * 哪个结果属于哪次调用 —— 那正是 `AgentEvent` 加 `callId` 时骂过的病
2262
- * (靠「工具名 + 调用顺序 FIFO」猜,并行工具必然串台)。
2263
- *
2264
- * ## ⚠️ 一轮不再只有**一条** assistant 行(2026-08-18)
2265
- *
2266
- * 以前是「整轮正文拼成一条 + 全部 toolCalls 挂上去 + 所有 tool 行垫在最后」。
2267
- * 那个形状丢掉了步序,直接后果是**刷新页面之后屏幕上的顺序和刚才不一样**:
2268
- * 回放只能照行序重建,于是「先调工具、后回答」被画成「先回答、后工具卡」。
2269
- *
2270
- * 现在按 {@link TurnDetail.steps} 一步一行。这不是新发明的形状 —— `loop.ts`
2271
- * 自己往上下文里 push 的就是它,只是那一份是 per-run 的、从不落盘。
2272
- *
2273
- * 两个连带的地方,改这里时别忘了:
2274
- * - {@link loadHistory} 要把连续的 assistant 行**合并回一条**再喂模型;
2275
- * - `web/src/state/replay.ts` 和假服务端的 `toReplayMessages()` 是读侧的
2276
- * 两份实现,形状变了两边都要跟。
2277
- */
2278
2637
  record(sessionId, userMessage, assistantText, detail = {}) {
2279
2638
  try {
2280
2639
  this.ensure(sessionId);
@@ -2298,8 +2657,6 @@ var init_session_store = __esm({
2298
2657
  for (const result of persistToolResults(step.toolResults ?? [])) {
2299
2658
  this.sessions.appendMessage(sessionId, {
2300
2659
  role: "tool",
2301
- // content 列存输出的文本投影:FTS 索引和 `epoch sessions search`
2302
- // 靠它才搜得到工具输出。结构化的那份在 toolResults 里
2303
2660
  content: result.output,
2304
2661
  toolCallId: result.toolCallId,
2305
2662
  toolName: result.toolName,
@@ -2312,18 +2669,6 @@ var init_session_store = __esm({
2312
2669
  return false;
2313
2670
  }
2314
2671
  }
2315
- /**
2316
- * 记一条**孤立的 `user` 行**,没有与之配对的回答(方案 57 §3.5 那条换人留痕)。
2317
- *
2318
- * 和 {@link record} 分开而不是拿 `record(id, text, '')` 顶:那个方法写的是**一轮
2319
- * 问答**,「assistantText 为空时它恰好只写一行」是一个实现细节而不是一条承诺 ——
2320
- * 哪天它改成「assistant 行无条件写」(那是一个合理的改动),这条留痕后面就会
2321
- * 凭空多出一条空回答,而没有任何东西会红。
2322
- *
2323
- * **要 `ensure()`**,判据同 `recordUsage()`:这一行写在轮次开始那一刻,
2324
- * 比 `record()`(整轮结束才写)早。不建会话行的话 `appendMessage` 会失败,
2325
- * 而这条留痕正是重开会话回放时唯一说得出「从这里起换人了」的东西。
2326
- */
2327
2672
  recordNote(sessionId, text) {
2328
2673
  try {
2329
2674
  this.ensure(sessionId);
@@ -2333,15 +2678,6 @@ var init_session_store = __esm({
2333
2678
  return false;
2334
2679
  }
2335
2680
  }
2336
- /**
2337
- * 累加一轮模型请求的真实用量(方案 42 PR-0)。持久化失败照旧不影响对话。
2338
- *
2339
- * **这里要 `ensure()`**,和 `lastMessageId()` 的克制正相反:用量是逐轮来的,
2340
- * 比 `record()`(整轮对话结束才写)早,新会话的第一条消息还没落盘。
2341
- * 不建会话行的话那条 UPDATE 影响 0 行 —— 一段全新会话的用量会**整段丢掉**。
2342
- * 而且这里的触发条件比那边硬:能走到这儿说明模型请求已经发出去、钱已经花了,
2343
- * 那就是一段值得记录的会话,不是「点开又退出」。
2344
- */
2345
2681
  recordUsage(sessionId, usage) {
2346
2682
  try {
2347
2683
  this.ensure(sessionId);
@@ -2351,17 +2687,6 @@ var init_session_store = __esm({
2351
2687
  return false;
2352
2688
  }
2353
2689
  }
2354
- /**
2355
- * 记下(或清掉)这段会话当前生效的那份已批准计划(方案 35 PR-2 验收 9)。
2356
- *
2357
- * **要 `ensure()`**,和 `recordUsage()` 同一个理由、比它更硬:计划被批准
2358
- * 通常发生在第一轮对话**结束之前**(模型 `enter_plan_mode` → 调研 →
2359
- * `exit_plan_mode` → 用户批),那时 `record()` 还一条都没写。不建会话行的话
2360
- * 那条 UPDATE 影响 0 行 —— 用户刚点完「批准并执行」,计划就已经没落盘。
2361
- *
2362
- * 传 `null` 是**清掉**(用户拒绝了计划)。这里不做「空串当没给」的归一:
2363
- * 调用方是 `PlanModeState`,它给的只会是一份正文或者 `null`。
2364
- */
2365
2690
  savePlan(sessionId, markdown) {
2366
2691
  try {
2367
2692
  this.ensure(sessionId);
@@ -2371,12 +2696,6 @@ var init_session_store = __esm({
2371
2696
  return false;
2372
2697
  }
2373
2698
  }
2374
- /**
2375
- * 读回这段会话的已批准计划;没有(或会话不存在)时 `null`。
2376
- *
2377
- * **不 `ensure()`**:这是查询。`--resume` 指着一个不存在的 id 时该回「没有」,
2378
- * 而不是顺手建一段空会话。
2379
- */
2380
2699
  loadPlan(sessionId) {
2381
2700
  try {
2382
2701
  return this.sessions.get(sessionId)?.meta.plan ?? null;
@@ -2384,13 +2703,6 @@ var init_session_store = __esm({
2384
2703
  return null;
2385
2704
  }
2386
2705
  }
2387
- /**
2388
- * 这个会话最后一条消息的 DB id(方案 27 的检查点水位线)。
2389
- *
2390
- * 会话还没建出来时是 0 —— **不 `ensure()`**:开轮是每条用户消息都要做的事,
2391
- * 为它去建一个可能马上就没有任何内容的会话行,等于让「只是聊了两句就退出」
2392
- * 也在 DB 里留一条空会话。
2393
- */
2394
2706
  lastMessageId(sessionId) {
2395
2707
  try {
2396
2708
  return this.sessions.lastMessageId(sessionId);
@@ -2398,13 +2710,6 @@ var init_session_store = __esm({
2398
2710
  return 0;
2399
2711
  }
2400
2712
  }
2401
- /**
2402
- * 对话回退:删掉水位线之后的消息(方案 27)。返回删了几条,失败时 `null`。
2403
- *
2404
- * 吞异常的口径和 `record()` 一致 —— 但这里返回 `null` 而不是 `false`:
2405
- * 调用方要据此决定还要不要重装内存历史,而「删了 0 条」和「没删成」
2406
- * 对那个决定是两回事。
2407
- */
2408
2713
  rewindTo(sessionId, lastKeptId) {
2409
2714
  try {
2410
2715
  return this.sessions.rewindTo(sessionId, lastKeptId);
@@ -2412,23 +2717,14 @@ var init_session_store = __esm({
2412
2717
  return null;
2413
2718
  }
2414
2719
  }
2415
- /**
2416
- * 会话不存在就建。create 对已存在的 id 会抛错,这里按「已存在」处理。
2417
- *
2418
- * `cwd` 只在**新建**时写,不去 update 已存在的那一行:一段会话属于哪个项目
2419
- * 是它开始那一刻的事实,`epoch -c` 之后在别的目录里接着聊也不该把它改籍。
2420
- *
2421
- * ⚠️ 工作区决定(v7 那两格)走的是同一条「只在新建时写」,但**理由不同**:
2422
- * 不是「不该改」,而是「改它的路在别处」—— 那一格的 update 归
2423
- * `SessionWorkspaces` 的 `decisions.record()`(判据在构造函数的 `decision` 上)。
2424
- */
2425
2720
  ensure(sessionId) {
2426
2721
  if (this.known.has(sessionId)) return;
2427
2722
  const decision = this.decision?.(sessionId) ?? null;
2723
+ const cwd = this.cwd?.();
2428
2724
  try {
2429
2725
  this.sessions.create({
2430
2726
  id: sessionId,
2431
- ...this.cwd ? { cwd: this.cwd } : {},
2727
+ ...cwd ? { cwd } : {},
2432
2728
  ...this.source ? { source: this.source } : {},
2433
2729
  ...decision ? {
2434
2730
  workspaceState: decision.state,
@@ -2439,61 +2735,42 @@ var init_session_store = __esm({
2439
2735
  }
2440
2736
  this.known.add(sessionId);
2441
2737
  }
2442
- /**
2443
- * 恢复一个会话的历史消息,**喂给模型用**。
2444
- *
2445
- * 只取 user / assistant:tool 行是给宿主回放的,单独一条没有配对 tool-call 的
2446
- * tool 消息会被 provider 拒掉。图片部件在这里被**读回 base64**,所以恢复出来的
2447
- * 会话里模型仍然看得见上一轮那张图 —— 这就是方案 12 留下的「resume 恢复图片」。
2448
- *
2449
- * 图片文件找不到(artifact 清理跑过、用户手删)时退化成一句占位文本,不抛。
2450
- *
2451
- * ## ⚠️ 连续的 assistant 行在这里被**合并回一条**(2026-08-18)
2452
- *
2453
- * `record()` 改成一步一行之后,一轮在 DB 里会留下多条 assistant,中间垫着
2454
- * tool 行。上面那道 user/assistant 过滤把 tool 行滤掉,剩下的就是**连着的几条
2455
- * assistant**,其中还有「只调工具没说话」那种正文为空的。
2456
- *
2457
- * 两样都是没人验过的形状:`sdk-adapter` 会把 `content: ''` 原样发出去,而连着
2458
- * 两条同角色消息在各家 provider 上的行为各不相同(仓里那个
2459
- * `validateAlternation()` 干脆直接抛 —— 它今天没有生产调用方,但它写下的
2460
- * 顾虑是真的)。
2461
- *
2462
- * 合并口径**按直播那一份来**:文本直接相接、不插分隔符(`run()` 里就是
2463
- * `assistantText += event.text`),合完是空就整条不发(那边也是
2464
- * `if (assistantText)`)。判据是 resume 回来的上下文必须和刚才在内存里的那份
2465
- * **逐字相同** —— 差一个换行,模型看到的就是另一段历史。
2466
- */
2467
2738
  loadHistory(sessionId) {
2468
2739
  const prev = this.sessions.get(sessionId);
2469
2740
  if (!prev) return [];
2470
2741
  this.known.add(sessionId);
2471
2742
  const out = [];
2743
+ let text = "";
2744
+ let results = [];
2745
+ let open = false;
2746
+ const flush = () => {
2747
+ if (!open) return;
2748
+ const content = assistantTurnText(text, results);
2749
+ if (content) out.push({ role: "assistant", content });
2750
+ text = "";
2751
+ results = [];
2752
+ open = false;
2753
+ };
2472
2754
  for (const m of prev.messages) {
2473
2755
  if (m.role === "user") {
2756
+ flush();
2474
2757
  const parts = toParts(m.parts, true);
2475
2758
  out.push({ role: "user", content: m.content, ...parts ? { parts } : {} });
2476
2759
  continue;
2477
2760
  }
2478
- if (m.role !== "assistant") continue;
2479
- const last = out[out.length - 1];
2480
- if (last?.role === "assistant") last.content += m.content;
2481
- else if (m.content) out.push({ role: "assistant", content: m.content });
2761
+ if (m.role === "assistant") {
2762
+ open = true;
2763
+ text += m.content;
2764
+ continue;
2765
+ }
2766
+ if (m.role === "tool") {
2767
+ if (open) results.push(...m.toolResults ?? []);
2768
+ continue;
2769
+ }
2482
2770
  }
2771
+ flush();
2483
2772
  return out;
2484
2773
  }
2485
- /**
2486
- * 恢复一个会话的**全部**消息,用于宿主回放(web 刷新页面、会话详情页)。
2487
- *
2488
- * 与 `loadHistory()` 的三点区别,都是「给人看」和「给模型看」的差别:
2489
- * 1. 带 tool 行 —— 工具调用和 artifact 就挂在那上面
2490
- * 2. 带 `id` / `timestamp`
2491
- * 3. **图片不内联 base64**:`EpochImagePart.image` 里是绝对文件路径。
2492
- * 宿主拿它拼自己的 artifact 端点,把几百 KB 的 base64 塞进一次 REST 响应
2493
- * 或 SSE 是纯粹的浪费。工具产物同理:`ToolArtifact` 有 `path`、**没有** `data`。
2494
- *
2495
- * ⚠️ 因此这个方法的返回值**不能**直接喂给 provider。
2496
- */
2497
2774
  loadMessages(sessionId) {
2498
2775
  const prev = this.sessions.get(sessionId);
2499
2776
  if (!prev) return [];
@@ -2504,13 +2781,8 @@ var init_session_store = __esm({
2504
2781
  id: m.id,
2505
2782
  timestamp: m.timestamp,
2506
2783
  role: m.role,
2507
- // protocol 的约定:tool 消息 content 为空串,内容在 toolResults 里,
2508
- // 「取这条消息的文本」才永远不用判类型。DB 里那份是给 FTS 用的
2509
2784
  content: m.role === "tool" ? "" : m.content,
2510
2785
  ...parts ? { parts } : {},
2511
- // 思考原文和这一轮的账(2026-08-18)。**两样都只有回放这一支带** ——
2512
- // `loadHistory()` 那份是喂模型的,模型不需要(也不该)看自己上一轮
2513
- // 想过什么、花了多少钱
2514
2786
  ...m.reasoning ? { reasoning: m.reasoning } : {},
2515
2787
  ...m.turnFacts ? { turnFacts: m.turnFacts } : {},
2516
2788
  ...m.toolCalls ? { toolCalls: m.toolCalls } : {},
@@ -2518,15 +2790,6 @@ var init_session_store = __esm({
2518
2790
  };
2519
2791
  });
2520
2792
  }
2521
- /**
2522
- * 这个会话在工作区里改过哪些文件(方案 42 PR-4)。
2523
- *
2524
- * @param workDir 相对路径按哪个根算。**这个会话绑定的工作区主根**(决定 18),
2525
- * 不是服务进程的工作目录 —— 后者在多会话下只会是其中一个的,而它不说是哪个。
2526
- *
2527
- * 读不出来(SQLite 起不来 / 会话不存在)时是空数组,不抛:这是一个展示用的
2528
- * 汇总,它自己坏了不该让整屏 500。
2529
- */
2530
2793
  fileChanges(sessionId, workDir) {
2531
2794
  try {
2532
2795
  return collectFileChanges(this.loadMessages(sessionId), workDir);
@@ -2534,6 +2797,12 @@ var init_session_store = __esm({
2534
2797
  return [];
2535
2798
  }
2536
2799
  }
2800
+ ftsHealth() {
2801
+ return this.sessions.ftsHealth();
2802
+ }
2803
+ reclaimFts(vacuum = true) {
2804
+ return this.sessions.reclaimFts(vacuum);
2805
+ }
2537
2806
  };
2538
2807
  }
2539
2808
  });
@@ -2655,8 +2924,6 @@ async function applyMcpConfig(mcp, sync, opts) {
2655
2924
  return {
2656
2925
  ok: false,
2657
2926
  reason: "no-servers",
2658
- // 只带**第一条**诊断:屏幕上那句话后面已经跟着完整的诊断列表了,
2659
- // 把十条拼成一行只会把它挤出去
2660
2927
  detail: issues[0]?.message ?? ""
2661
2928
  };
2662
2929
  }
@@ -2720,9 +2987,6 @@ var init_mcp_config_apply = __esm({
2720
2987
  init_mcp_config_file();
2721
2988
  }
2722
2989
  });
2723
- function isWritableName(name) {
2724
- return /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(name);
2725
- }
2726
2990
  function skipTrivia(raw, from) {
2727
2991
  let i = from;
2728
2992
  for (; ; ) {
@@ -2786,13 +3050,15 @@ function walkObject(raw, braceAt) {
2786
3050
  const entries = [];
2787
3051
  let i = skipTrivia(raw, braceAt + 1);
2788
3052
  while (i < raw.length && raw[i] !== "}") {
3053
+ const keyAt = i;
2789
3054
  const key = readString(raw, i);
2790
3055
  if (!key) return { entries, clean: false };
2791
3056
  const afterKey = skipTrivia(raw, key[1]);
2792
3057
  if (raw[afterKey] !== ":") return { entries, clean: false };
2793
3058
  const valueAt2 = skipTrivia(raw, afterKey + 1);
2794
- entries.push({ key: key[0], valueAt: valueAt2 });
2795
- i = skipTrivia(raw, skipValue(raw, valueAt2));
3059
+ const valueEnd = skipValue(raw, valueAt2);
3060
+ entries.push({ key: key[0], keyAt, valueAt: valueAt2, valueEnd });
3061
+ i = skipTrivia(raw, valueEnd);
2796
3062
  if (raw[i] === ",") i = skipTrivia(raw, i + 1);
2797
3063
  else if (raw[i] !== "}") return { entries, clean: false };
2798
3064
  }
@@ -2810,10 +3076,14 @@ function locate(raw) {
2810
3076
  }
2811
3077
  const inner = walkObject(raw, servers.valueAt);
2812
3078
  if (!inner.clean) return { ok: false, detail: t("mcp_write.servers_unreadable") };
2813
- const keys = inner.entries.map((e) => e.key);
2814
3079
  return {
2815
3080
  ok: true,
2816
- landing: { braceAt: servers.valueAt, hasMembers: keys.length > 0, keys, kind: "servers" }
3081
+ landing: {
3082
+ braceAt: servers.valueAt,
3083
+ hasMembers: inner.entries.length > 0,
3084
+ entries: inner.entries,
3085
+ kind: "servers"
3086
+ }
2817
3087
  };
2818
3088
  }
2819
3089
  if (top.entries.some((e) => e.key === "mcpServers")) {
@@ -2821,15 +3091,29 @@ function locate(raw) {
2821
3091
  }
2822
3092
  return {
2823
3093
  ok: true,
2824
- landing: { braceAt: start, hasMembers: top.entries.length > 0, keys: [], kind: "top" }
3094
+ landing: { braceAt: start, hasMembers: top.entries.length > 0, entries: [], kind: "top" }
2825
3095
  };
2826
3096
  }
2827
3097
  function indentOf(raw) {
2828
3098
  return /\n([ \t]+)\S/.exec(raw)?.[1] ?? " ";
2829
3099
  }
2830
- function renderEntry(input, indent) {
2831
- const pad = indent.repeat(2);
2832
- const inner = indent.repeat(3);
3100
+ function eolOf(raw) {
3101
+ const at = raw.indexOf("\n");
3102
+ if (at < 0) return "\n";
3103
+ return raw[at - 1] === "\r" ? "\r\n" : "\n";
3104
+ }
3105
+ function withEol(block, eol) {
3106
+ return eol === "\n" ? block : block.replaceAll("\n", eol);
3107
+ }
3108
+ var init_mcp_json_scan = __esm({
3109
+ "src/mcp-json-scan.ts"() {
3110
+ }
3111
+ });
3112
+ function isWritableName(name) {
3113
+ return /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(name);
3114
+ }
3115
+ function renderEntry(input, indent, pad = indent.repeat(2)) {
3116
+ const inner = pad + indent;
2833
3117
  const fields = [`${inner}"transport": ${JSON.stringify(input.transport)}`];
2834
3118
  if (input.command) fields.push(`${inner}"command": ${JSON.stringify(input.command)}`);
2835
3119
  if (input.args && input.args.length > 0) {
@@ -2853,7 +3137,7 @@ function insertServerText(raw, input) {
2853
3137
  const scan = locate(raw);
2854
3138
  if (!scan.ok) return { ok: false, reason: "unwritable", detail: scan.detail };
2855
3139
  const { landing } = scan;
2856
- if (landing.keys.includes(input.name)) {
3140
+ if (landing.entries.some((e) => e.key === input.name)) {
2857
3141
  return { ok: false, reason: "duplicate", detail: input.name };
2858
3142
  }
2859
3143
  const indent = indentOf(raw);
@@ -2865,7 +3149,8 @@ ${indent}"servers": {
2865
3149
  ${entry}
2866
3150
  ${indent}}${landing.hasMembers ? "," : "\n"}`;
2867
3151
  const at = landing.braceAt + 1;
2868
- return { ok: true, text: raw.slice(0, at) + block + raw.slice(at) };
3152
+ const eol = eolOf(raw);
3153
+ return { ok: true, text: raw.slice(0, at) + withEol(block, eol) + raw.slice(at) };
2869
3154
  }
2870
3155
  function writeMcpServer(input, opts = {}) {
2871
3156
  if (!isWritableName(input.name)) {
@@ -2874,19 +3159,8 @@ function writeMcpServer(input, opts = {}) {
2874
3159
  for (const name of opts.taken ?? []) {
2875
3160
  if (name === input.name) return { ok: false, reason: "duplicate", detail: input.name };
2876
3161
  }
2877
- const probe = parseMcpConfig(
2878
- JSON.stringify({ servers: { [input.name]: rawFields(input) } }),
2879
- "(add-form)"
2880
- );
2881
- if (probe.servers.length === 0) {
2882
- return {
2883
- ok: false,
2884
- reason: "invalid-config",
2885
- // 分隔符用 ASCII 的 `; `:这几条 message 本身是 plugin-mcp 那边的原文,
2886
- // 我们只是把它们串起来,不该在中间插一个由**我们**的语言决定的标点
2887
- detail: probe.issues.map((i) => i.message).join("; ") || t("mcp_write.invalid")
2888
- };
2889
- }
3162
+ const invalid = validateAddInput(input);
3163
+ if (invalid !== null) return { ok: false, reason: "invalid-config", detail: invalid };
2890
3164
  const configPath2 = mcpConfigPath(opts.homeDir);
2891
3165
  let raw;
2892
3166
  try {
@@ -2904,6 +3178,14 @@ function writeMcpServer(input, opts = {}) {
2904
3178
  }
2905
3179
  return { ok: true, configPath: configPath2 };
2906
3180
  }
3181
+ function validateAddInput(input, lang) {
3182
+ const probe = parseMcpConfig(
3183
+ JSON.stringify({ servers: { [input.name]: rawFields(input) } }),
3184
+ "(add-form)"
3185
+ );
3186
+ if (probe.servers.length > 0) return null;
3187
+ return probe.issues.map((i) => i.message).join("; ") || t("mcp_write.invalid", {}, lang);
3188
+ }
2907
3189
  function rawFields(input) {
2908
3190
  return {
2909
3191
  transport: input.transport,
@@ -2930,9 +3212,177 @@ function toServerConfig(input) {
2930
3212
  var EMPTY_CONFIG;
2931
3213
  var init_mcp_config_write = __esm({
2932
3214
  "src/mcp-config-write.ts"() {
3215
+ init_mcp_json_scan();
2933
3216
  EMPTY_CONFIG = '{\n "servers": {}\n}\n';
2934
3217
  }
2935
3218
  });
3219
+ function skipSpaces(raw, from) {
3220
+ let i = from;
3221
+ while (i < raw.length && /[ \t\r\n]/.test(raw[i])) i++;
3222
+ return i;
3223
+ }
3224
+ function lineStart(raw, at) {
3225
+ return raw.lastIndexOf("\n", at - 1) + 1;
3226
+ }
3227
+ function lineEnd(raw, at) {
3228
+ const nl = raw.indexOf("\n", at);
3229
+ return nl === -1 ? raw.length : nl;
3230
+ }
3231
+ function padBefore(raw, at) {
3232
+ const ls = lineStart(raw, at);
3233
+ const head = raw.slice(ls, at);
3234
+ return /^[ \t]*$/.test(head) ? head : "";
3235
+ }
3236
+ function findEntry(raw, name) {
3237
+ const scan = locate(raw);
3238
+ if (!scan.ok) return { detail: scan.detail };
3239
+ const entries = scan.landing.entries;
3240
+ const index = entries.findIndex((e) => e.key === name);
3241
+ return index === -1 ? null : { entries, index };
3242
+ }
3243
+ function replaceServerText(raw, input) {
3244
+ const found = findEntry(raw, input.name);
3245
+ if (found !== null && "detail" in found) {
3246
+ return { ok: false, reason: "unwritable", detail: found.detail };
3247
+ }
3248
+ if (found === null) return { ok: false, reason: "invalid-name", detail: input.name };
3249
+ const target = found.entries[found.index];
3250
+ const pad = padBefore(raw, target.keyAt);
3251
+ const entry = renderEntry(input, indentOf(raw), pad);
3252
+ const block = withEol(entry.slice(pad.length), eolOf(raw));
3253
+ const text = raw.slice(0, target.keyAt) + block + raw.slice(target.valueEnd);
3254
+ return { ok: true, text };
3255
+ }
3256
+ function removeCuts(raw, entries, index) {
3257
+ const self = entries[index];
3258
+ const afterValue = skipTrivia(raw, self.valueEnd);
3259
+ if (raw[afterValue] === ",") {
3260
+ return skipSpaces(raw, self.valueEnd) === afterValue ? [tidyLine(raw, self.keyAt, afterValue + 1)] : [
3261
+ [self.keyAt, self.valueEnd],
3262
+ [afterValue, afterValue + 1]
3263
+ ];
3264
+ }
3265
+ const prev = index > 0 ? entries[index - 1] : void 0;
3266
+ const prevComma = prev === void 0 ? -1 : skipTrivia(raw, prev.valueEnd);
3267
+ if (prevComma < 0 || raw[prevComma] !== ",") {
3268
+ return [tidyLine(raw, self.keyAt, self.valueEnd)];
3269
+ }
3270
+ return [
3271
+ [prevComma, prevComma + 1],
3272
+ [lineStartIfBlank(raw, self.keyAt), self.valueEnd]
3273
+ ];
3274
+ }
3275
+ function tidyLine(raw, keyAt, to) {
3276
+ const ls = lineStart(raw, keyAt);
3277
+ const le = lineEnd(raw, to);
3278
+ const headBlank = /^[ \t]*$/.test(raw.slice(ls, keyAt));
3279
+ const tailBlank = /^[ \t\r]*$/.test(raw.slice(to, le));
3280
+ if (!headBlank || !tailBlank) return [keyAt, to];
3281
+ return [ls, Math.min(le + 1, raw.length)];
3282
+ }
3283
+ function lineStartIfBlank(raw, at) {
3284
+ const ls = lineStart(raw, at);
3285
+ return /^[ \t]*$/.test(raw.slice(ls, at)) ? ls : at;
3286
+ }
3287
+ function removeServerText(raw, name) {
3288
+ const found = findEntry(raw, name);
3289
+ if (found !== null && "detail" in found) {
3290
+ return { ok: false, reason: "unwritable", detail: found.detail };
3291
+ }
3292
+ if (found === null) return { ok: false, reason: "invalid-name", detail: name };
3293
+ const cuts = [...removeCuts(raw, found.entries, found.index)].sort((a, b) => b[0] - a[0]);
3294
+ let text = raw;
3295
+ for (const [from, to] of cuts) text = text.slice(0, from) + text.slice(to);
3296
+ return { ok: true, text };
3297
+ }
3298
+ function commit(splice, expectKeys, opts) {
3299
+ const configPath2 = mcpConfigPath(opts.homeDir);
3300
+ if (!existsSync(configPath2)) return { ok: false, reason: "not-found", detail: configPath2 };
3301
+ let raw;
3302
+ try {
3303
+ raw = readFileSync(configPath2, "utf-8");
3304
+ } catch (err) {
3305
+ return { ok: false, reason: "unwritable", detail: describe5(err) };
3306
+ }
3307
+ const before = locate(raw);
3308
+ if (!before.ok) return { ok: false, reason: "unwritable", detail: before.detail };
3309
+ const spliced = splice(raw);
3310
+ if (!spliced.ok) {
3311
+ return spliced.reason === "invalid-name" ? { ok: false, reason: "not-found", detail: spliced.detail } : spliced;
3312
+ }
3313
+ const bad = checkSpliced(
3314
+ spliced.text,
3315
+ expectKeys(before.landing.entries.map((e) => e.key)),
3316
+ opts.lang
3317
+ );
3318
+ if (bad !== null) return { ok: false, reason: "unwritable", detail: bad };
3319
+ return write(configPath2, raw, spliced.text);
3320
+ }
3321
+ function checkSpliced(text, expect, lang) {
3322
+ const after = locate(text);
3323
+ if (!after.ok) return after.detail;
3324
+ const got = after.landing.entries.map((e) => e.key);
3325
+ if (got.length !== expect.length || got.some((k, i) => k !== expect[i])) {
3326
+ return `${t("mcp_write.invalid", {}, lang)} (servers: [${got.join(", ")}] != [${expect.join(", ")}])`;
3327
+ }
3328
+ return null;
3329
+ }
3330
+ function write(configPath2, expectDisk, body) {
3331
+ let disk;
3332
+ try {
3333
+ disk = readFileSync(configPath2, "utf-8");
3334
+ } catch (err) {
3335
+ return { ok: false, reason: "unwritable", detail: describe5(err) };
3336
+ }
3337
+ if (disk !== expectDisk) {
3338
+ return { ok: false, reason: "unwritable", detail: t("mcp_config.stale_disk") };
3339
+ }
3340
+ const tmp = `${configPath2}.${process.pid}.tmp`;
3341
+ try {
3342
+ mkdirSync(dirname(configPath2), { recursive: true });
3343
+ const mode = statSync(configPath2).mode & 511;
3344
+ try {
3345
+ writeFileSync(tmp, body, { encoding: "utf-8", mode });
3346
+ renameSync(tmp, configPath2);
3347
+ } catch (err) {
3348
+ rmSync(tmp, { force: true });
3349
+ throw err;
3350
+ }
3351
+ } catch (err) {
3352
+ return { ok: false, reason: "unwritable", detail: describe5(err) };
3353
+ }
3354
+ return { ok: true, configPath: configPath2 };
3355
+ }
3356
+ function updateMcpServer(input, opts = {}) {
3357
+ const invalid = validateAddInput(input, opts.lang);
3358
+ if (invalid !== null) return { ok: false, reason: "invalid-config", detail: invalid };
3359
+ return commit(
3360
+ (raw) => replaceServerText(raw, input),
3361
+ (before) => before,
3362
+ opts
3363
+ );
3364
+ }
3365
+ function removeMcpServer(name, opts = {}) {
3366
+ return commit(
3367
+ (raw) => removeServerText(raw, name),
3368
+ (before) => before.filter((k) => k !== name),
3369
+ opts
3370
+ );
3371
+ }
3372
+ function describe5(err) {
3373
+ return err instanceof Error ? err.message : String(err);
3374
+ }
3375
+ var init_mcp_config_edit = __esm({
3376
+ "src/mcp-config-edit.ts"() {
3377
+ init_mcp_config_write();
3378
+ init_mcp_json_scan();
3379
+ }
3380
+ });
3381
+ function foldToolMode(mode) {
3382
+ if (mode === "both") return "both";
3383
+ if (mode === "code") return "code";
3384
+ return "tools";
3385
+ }
2936
3386
  function applyShell(kind, diags) {
2937
3387
  if (!kind || kind === "cmd") {
2938
3388
  setShell(kind ?? null);
@@ -2945,29 +3395,40 @@ function applyShell(kind, diags) {
2945
3395
  return;
2946
3396
  }
2947
3397
  setShell(kind);
2948
- diags.ok("Shell", `${kind}\uFF08terminal \u5DE5\u5177\u7684\u547D\u4EE4\u4EA4\u7ED9\u5B83\u6267\u884C\uFF09`);
3398
+ diags.ok("Shell", t("boot.shell_kind", { kind }));
2949
3399
  }
2950
3400
  function registerLocalTools(input) {
2951
- const { trackerDb, goals, memory, sandbox, diags } = input;
2952
- const registry = new ToolRegistry();
3401
+ const { trackerDb, goals, memory, sandbox, skills, diags } = input;
3402
+ const registry = new ToolRegistry({ codeMode: input.toolMode === "code" });
2953
3403
  for (const plugin of [filePlugin, terminalPlugin, webPlugin, lspPlugin]) {
2954
3404
  try {
2955
3405
  registry.registerPlugin(plugin);
2956
3406
  } catch (err) {
2957
3407
  diags.failed(
2958
3408
  `Plugin ${plugin.name}`,
2959
- `\u6CE8\u518C\u5931\u8D25 (${err instanceof Error ? err.message : err})`,
3409
+ t("boot.plugin_register_failed", {
3410
+ reason: err instanceof Error ? err.message : String(err)
3411
+ }),
2960
3412
  "plugin-register-failed"
2961
3413
  );
2962
3414
  }
2963
3415
  }
2964
3416
  const rg = resolveRipgrep();
2965
3417
  if (rg.mode === "missing") {
2966
- diags.warn("Ripgrep", `\u672A\u5B89\u88C5\uFF0Cfile_search \u4E0D\u53EF\u7528\uFF08${rg.hint}\uFF09`, "ripgrep-missing");
3418
+ diags.warn("Ripgrep", t("boot.ripgrep_missing", { hint: rg.hint ?? "" }), "ripgrep-missing");
3419
+ } else if (rg.mode === "not-executable") {
3420
+ diags.warn(
3421
+ "Ripgrep",
3422
+ t("boot.ripgrep_not_executable", { hint: rg.hint ?? "" }),
3423
+ "ripgrep-not-executable"
3424
+ );
2967
3425
  } else if (rg.mode === "system") {
2968
- diags.ok("Ripgrep", "\u7528\u7CFB\u7EDF\u5B89\u88C5\u7684 rg");
3426
+ diags.ok("Ripgrep", t("boot.ripgrep_system"));
2969
3427
  } else {
2970
- diags.ok("Ripgrep", `\u7528\u5185\u7F6E\u7684 rg${rg.version ? ` ${rg.version}` : ""}`);
3428
+ diags.ok(
3429
+ "Ripgrep",
3430
+ t("boot.ripgrep_builtin", { version: rg.version === void 0 ? "" : ` ${rg.version}` })
3431
+ );
2971
3432
  }
2972
3433
  const search = selectSearchProvider(input.search);
2973
3434
  if (search.provider) {
@@ -2977,16 +3438,47 @@ function registerLocalTools(input) {
2977
3438
  input.search?.maxResults ? { defaultMaxResults: input.search.maxResults } : {}
2978
3439
  )
2979
3440
  );
2980
- diags.ok("Web \u641C\u7D22", search.detail);
3441
+ diags.ok("WebSearch", search.detail);
2981
3442
  } else {
2982
- diags.skipped("Web \u641C\u7D22", search.detail);
3443
+ diags.skipped("WebSearch", search.detail);
2983
3444
  }
2984
3445
  if (trackerDb) registry.registerTool(createTodoTool(trackerDb));
2985
3446
  if (goals) registry.registerTool(createGoalTool({ service: goals }));
2986
3447
  if (memory) registry.registerTool(createMemoryTool(memory));
2987
3448
  if (sandbox) registry.registerTool(createCodeExecTool(sandbox));
3449
+ if (input.toolMode === "both" || input.toolMode === "code") {
3450
+ registry.registerTool(createRunCodeTool());
3451
+ }
3452
+ if (skills) registry.registerTool(createSkillViewTool({ skills }));
3453
+ if (skills) registry.registerTools(createSkillWriteTools({ skills }));
3454
+ if (input.deferMcp === true) {
3455
+ registry.registerTool(
3456
+ createToolSearchTool({
3457
+ deferred: () => registry.byExposure(ToolExposure.Deferred),
3458
+ reveal: input.bootstrapReveal ?? createToolRevealState()
3459
+ })
3460
+ );
3461
+ }
2988
3462
  return registry;
2989
3463
  }
3464
+ function reportToolCost(registry, diags) {
3465
+ const exposed = registry.exposed();
3466
+ const mcp = exposed.filter(
3467
+ (tool) => (registry.sourceOf(tool.name) ?? "").startsWith("mcp:")
3468
+ ).length;
3469
+ const local = exposed.length - mcp;
3470
+ const tokens = Math.round(toolTableTokens(exposed).total / 100) / 10;
3471
+ const split = (lang) => mcp > 0 ? t("boot.tools_split_mixed", { local, mcp }, lang) : t("boot.tools_split_local", { local }, lang);
3472
+ const deferred = registry.byExposure(ToolExposure.Deferred).length;
3473
+ diags.ok(
3474
+ TOOLS_MODULE,
3475
+ (lang) => deferred > 0 ? t(
3476
+ "boot.tools_cost_deferred",
3477
+ { count: exposed.length, split: split(lang), tokens, deferred },
3478
+ lang
3479
+ ) : t("boot.tools_cost", { count: exposed.length, split: split(lang), tokens }, lang)
3480
+ );
3481
+ }
2990
3482
  function registerSessionSearch(registry, input) {
2991
3483
  if (!input.session) {
2992
3484
  input.diags.skipped(
@@ -3030,7 +3522,13 @@ function registerAskQuestion(registry, onQuestionRequest, interactive) {
3030
3522
  createAskQuestionTool(interactive ? { requestQuestion: onQuestionRequest } : {})
3031
3523
  );
3032
3524
  }
3033
- function createToolSync(mcp, registry) {
3525
+ function registerMcpBatch(registry, tools, server, defer) {
3526
+ registry.registerTools(
3527
+ defer ? tools.map((tool) => ({ ...tool, exposure: ToolExposure.Deferred })) : [...tools],
3528
+ `mcp:${server}`
3529
+ );
3530
+ }
3531
+ function createToolSync(mcp, registry, defer) {
3034
3532
  let queue = Promise.resolve();
3035
3533
  const replace = (server) => {
3036
3534
  queue = queue.then(async () => {
@@ -3038,9 +3536,11 @@ function createToolSync(mcp, registry) {
3038
3536
  const fresh = await withTimeout(mcp.refresh(server), MCP_LIST_BUDGET_MS, null) ?? [];
3039
3537
  const prefix = `mcp__${server}__`;
3040
3538
  registry.unregisterBySource(`mcp:${server}`);
3041
- registry.registerTools(
3042
- fresh.filter((t13) => t13.name.startsWith(prefix)),
3043
- `mcp:${server}`
3539
+ registerMcpBatch(
3540
+ registry,
3541
+ fresh.filter((t25) => t25.name.startsWith(prefix)),
3542
+ server,
3543
+ defer
3044
3544
  );
3045
3545
  } catch {
3046
3546
  }
@@ -3049,7 +3549,7 @@ function createToolSync(mcp, registry) {
3049
3549
  };
3050
3550
  return { settled: () => queue, replace };
3051
3551
  }
3052
- function wireToolRefresh(mcp, registry, sync = createToolSync(mcp, registry)) {
3552
+ function wireToolRefresh(mcp, registry, sync = createToolSync(mcp, registry, false)) {
3053
3553
  mcp.onToolsChanged = (server) => void sync.replace(server);
3054
3554
  return sync;
3055
3555
  }
@@ -3098,8 +3598,6 @@ async function addServer(mcp, sync, input, homeDir) {
3098
3598
  return {
3099
3599
  ok: true,
3100
3600
  configPath: written.configPath,
3101
- // `connectAll` 恒回一台一条,兜底那一支是给类型系统看的 —— 真走到了说明
3102
- // registry 那边变了形状,那时报一台「连不上、没说为什么」比崩掉强
3103
3601
  status: status ?? mcp.getStatus(input.name) ?? {
3104
3602
  name: input.name,
3105
3603
  connected: false,
@@ -3109,6 +3607,32 @@ async function addServer(mcp, sync, input, homeDir) {
3109
3607
  }
3110
3608
  };
3111
3609
  }
3610
+ async function updateServer(mcp, sync, input, homeDir) {
3611
+ const written = updateMcpServer(input, { homeDir });
3612
+ if (!written.ok) return written;
3613
+ await mcp.disconnect(input.name);
3614
+ const config = toServerConfig(input);
3615
+ const [status] = await mcp.connectAll([config], "user");
3616
+ await sync.replace(input.name);
3617
+ return {
3618
+ ok: true,
3619
+ configPath: written.configPath,
3620
+ status: status ?? mcp.getStatus(input.name) ?? {
3621
+ name: input.name,
3622
+ connected: false,
3623
+ toolCount: 0,
3624
+ reconnectAttempts: 0,
3625
+ source: "user"
3626
+ }
3627
+ };
3628
+ }
3629
+ async function removeServer(mcp, sync, name, homeDir) {
3630
+ const written = removeMcpServer(name, { homeDir });
3631
+ if (!written.ok) return written;
3632
+ await mcp.disconnect(name);
3633
+ await sync.replace(name);
3634
+ return { ok: true, configPath: written.configPath };
3635
+ }
3112
3636
  async function connectAllServers(mcp, batches, budgetMs = MCP_CONNECT_BUDGET_MS) {
3113
3637
  const all = await withTimeout(
3114
3638
  Promise.all(batches.map((b) => mcp.connectAll([...b.servers], b.source))),
@@ -3142,9 +3666,9 @@ function reportTaken(source, name, diags) {
3142
3666
  "host-mcp-name-taken"
3143
3667
  );
3144
3668
  }
3145
- async function registerMcpTools(registry, homeDir, diags, injected = []) {
3669
+ async function registerMcpTools(registry, homeDir, diags, injected = [], defer = false) {
3146
3670
  const mcp = new McpRegistry({ homeDir });
3147
- const sync = createToolSync(mcp, registry);
3671
+ const sync = createToolSync(mcp, registry, defer);
3148
3672
  const live = () => ({
3149
3673
  dispose: () => void mcp.disconnectAll().catch(() => {
3150
3674
  }),
@@ -3156,6 +3680,12 @@ async function registerMcpTools(registry, homeDir, diags, injected = []) {
3156
3680
  if (outcome.ok) wireToolRefresh(mcp, registry, sync);
3157
3681
  return outcome;
3158
3682
  },
3683
+ update: async (input) => {
3684
+ const outcome = await updateServer(mcp, sync, input, homeDir);
3685
+ if (outcome.ok) wireToolRefresh(mcp, registry, sync);
3686
+ return outcome;
3687
+ },
3688
+ remove: (name) => removeServer(mcp, sync, name, homeDir),
3159
3689
  readConfig: () => readMcpConfigFile({ homeDir }),
3160
3690
  writeConfig: (text, revision, lang) => writeMcpConfigFile(text, {
3161
3691
  revision,
@@ -3176,19 +3706,19 @@ async function registerMcpTools(registry, homeDir, diags, injected = []) {
3176
3706
  try {
3177
3707
  const { servers, issues } = mcp.loadFromConfig(mcpConfigPath(homeDir));
3178
3708
  for (const issue of issues) {
3179
- diags.warn(`MCP \u914D\u7F6E ${issue.path}`, issue.message, "mcp-config-invalid");
3709
+ diags.warn(`MCPConfig ${issue.path}`, issue.message, "mcp-config-invalid");
3180
3710
  }
3181
3711
  const extra = dropTakenNames(injected, new Set(servers.map((s) => s.name)), diags);
3182
3712
  const injectedCount = extra.reduce((sum, b) => sum + b.servers.length, 0);
3183
3713
  if (servers.length === 0 && injectedCount === 0) {
3184
- diags.skipped("MCP", "\u672A\u914D\u7F6E\uFF08~/.epoch/mcp.json \u4E0D\u5B58\u5728\u6216\u4E3A\u7A7A\uFF09");
3714
+ diags.skipped("MCP", t("boot.mcp_none"));
3185
3715
  return live();
3186
3716
  }
3187
3717
  const statuses = await connectAllServers(mcp, [{ source: "user", servers }, ...extra]);
3188
3718
  if (statuses === null) {
3189
3719
  diags.failed(
3190
3720
  "MCP",
3191
- `\u8FDE\u63A5\u8D85\u65F6\uFF08>${MCP_CONNECT_BUDGET_MS / 1e3}s\uFF09\uFF0C\u5DF2\u8DF3\u8FC7`,
3721
+ t("boot.mcp_connect_timeout", { seconds: MCP_CONNECT_BUDGET_MS / 1e3 }),
3192
3722
  "mcp-connect-timeout"
3193
3723
  );
3194
3724
  return live();
@@ -3198,14 +3728,22 @@ async function registerMcpTools(registry, homeDir, diags, injected = []) {
3198
3728
  if (!st.connected) {
3199
3729
  diags.failed(
3200
3730
  `MCP ${st.name}`,
3201
- `\u8FDE\u63A5\u5931\u8D25${st.lastError ? ` (${st.lastError})` : ""}`,
3731
+ t("boot.mcp_connect_failed", {
3732
+ detail: st.lastError ? t("boot.mcp_connect_failed_detail", { detail: st.lastError }) : ""
3733
+ }),
3202
3734
  "mcp-connect-failed"
3203
3735
  );
3204
3736
  continue;
3205
3737
  }
3206
- const own = mcpTools.filter((t13) => t13.name.startsWith(`mcp__${st.name}__`));
3207
- registry.registerTools(own, `mcp:${st.name}`);
3208
- diags.ok(`MCP ${st.name}`, `${own.length} \u4E2A\u5DE5\u5177`);
3738
+ const own = mcpTools.filter((t25) => t25.name.startsWith(`mcp__${st.name}__`));
3739
+ const registered = dropOverlongToolNames(
3740
+ own,
3741
+ `MCP ${st.name}`,
3742
+ "mcp-tool-name-too-long",
3743
+ diags
3744
+ );
3745
+ registerMcpBatch(registry, registered, st.name, defer);
3746
+ diags.ok(`MCP ${st.name}`, t("boot.mcp_tools", { count: registered.length }));
3209
3747
  if (st.lastError) diags.warn(`MCP ${st.name}`, st.lastError, "mcp-degraded");
3210
3748
  }
3211
3749
  wireToolRefresh(mcp, registry, sync);
@@ -3213,21 +3751,25 @@ async function registerMcpTools(registry, homeDir, diags, injected = []) {
3213
3751
  } catch (err) {
3214
3752
  diags.failed(
3215
3753
  "MCP",
3216
- `\u521D\u59CB\u5316\u5931\u8D25 (${err instanceof Error ? err.message : String(err)})`,
3754
+ t("boot.mcp_init_failed", {
3755
+ reason: err instanceof Error ? err.message : String(err)
3756
+ }),
3217
3757
  "mcp-init-failed"
3218
3758
  );
3219
3759
  return live();
3220
3760
  }
3221
3761
  }
3222
- var MCP_CONNECT_BUDGET_MS, MCP_LIST_BUDGET_MS, SESSION_SEARCH_MODULE;
3762
+ var MCP_CONNECT_BUDGET_MS, MCP_LIST_BUDGET_MS, TOOLS_MODULE, SESSION_SEARCH_MODULE;
3223
3763
  var init_tools = __esm({
3224
3764
  "src/tools.ts"() {
3225
3765
  init_host_capabilities();
3226
3766
  init_mcp_config_apply();
3767
+ init_mcp_config_edit();
3227
3768
  init_mcp_config_file();
3228
3769
  init_mcp_config_write();
3229
3770
  MCP_CONNECT_BUDGET_MS = 15e3;
3230
3771
  MCP_LIST_BUDGET_MS = 1e4;
3772
+ TOOLS_MODULE = "Tools";
3231
3773
  SESSION_SEARCH_MODULE = "SessionSearch";
3232
3774
  }
3233
3775
  });
@@ -3271,11 +3813,6 @@ var init_session_factory = __esm({
3271
3813
  SessionBridges = class {
3272
3814
  byId = /* @__PURE__ */ new Map();
3273
3815
  override;
3274
- /**
3275
- * @param override 宿主自己实现的阻塞式确认(`BuildRuntimeOptions.onApprovalRequest`)。
3276
- * 给了就**所有会话都走它**:那种宿主(CLI 的 inquirer)本来就是单会话的,
3277
- * 它的回调签名里也没有 sessionId 的位置。
3278
- */
3279
3816
  constructor(override) {
3280
3817
  this.override = override;
3281
3818
  }
@@ -3285,32 +3822,11 @@ var init_session_factory = __esm({
3285
3822
  release(sessionId) {
3286
3823
  this.byId.delete(sessionId);
3287
3824
  }
3288
- /*
3289
- * ⚠️ 这儿原来有一个 `planOwner()`(记「最后一次交计划的是谁」),
3290
- * 2026-08-16 **删掉**了。它存在的唯一理由是 `PlanModeState.onChange` 上没有
3291
- * 会话 id —— 因为那时候 plan 模式是**进程级**的一个状态对象。现在一个会话一个
3292
- * 状态,`onChange` 的宿主自己就知道自己是谁。
3293
- *
3294
- * **不是留着不调,是必须删**:两个会话各自挂着落盘 hook、而引导会话那一份还
3295
- * 按 `planOwner()` 决定写到谁名下的话,会话 B 交过一次计划之后,引导会话再批
3296
- * 一份就会把 A 的正文写到 B 名下。判据全文在 `session-guard.ts` 的文件头末节。
3297
- */
3298
- /**
3299
- * 转一条审批给它该去的那个会话。
3300
- *
3301
- * 认不出这个 sessionId(会话刚被删 / 被冷却,而它那一轮还在收尾)时回 `deny` ——
3302
- * 方向逐字同 `AgentSession` 的 `settleAbandoned`:审批的安全侧就是拒绝。
3303
- * 挂着不答的话,那个孤儿 `AgentLoop` 会永远停在 await 上,进程退不掉。
3304
- */
3305
3825
  approve(sessionId, req) {
3306
3826
  if (this.override) return this.override(req);
3307
3827
  const bridge = this.byId.get(sessionId);
3308
3828
  return bridge ? bridge.approve(req) : Promise.resolve("deny");
3309
3829
  }
3310
- /**
3311
- * 转一条提问。认不出时回 `skipped` —— 那是**实话**(确实没人答),
3312
- * 而替用户挑一个 label 才是伪造(方案 34 §2.4)。
3313
- */
3314
3830
  ask(sessionId, req) {
3315
3831
  const bridge = this.byId.get(sessionId);
3316
3832
  return bridge ? bridge.ask(req) : Promise.resolve({ answers: {}, skipped: true });
@@ -3334,82 +3850,27 @@ var init_session_factory = __esm({
3334
3850
  get(sessionId) {
3335
3851
  return this.live.get(sessionId) ?? null;
3336
3852
  }
3337
- /**
3338
- * 把**引导会话**装进表里。装配层专用,`bind()` 那两道门在这条路上不走 ——
3339
- * 它的工作区已经 `seed()` 进去了(判据见 workspace.ts 的 `seed`)。
3340
- *
3341
- * @returns 建不出 `AgentLoop`(provider 没起来)时 null。那不是错误:
3342
- * 一台还没配 API key 的机器上跑 `epoch doctor` 走的就是这条路
3343
- */
3344
3853
  adopt(sessionId, history) {
3345
3854
  this.bootstrap = sessionId;
3346
- const live = this.assemble(sessionId, this.opts.workspaces.of(sessionId), history, false);
3855
+ const live = this.assemble(sessionId, history, false);
3347
3856
  if (live) this.live.set(sessionId, live);
3348
3857
  return live;
3349
3858
  }
3350
- /**
3351
- * `/resume` 换了 id 之后把表跟着改一下(`SessionControl.resume`)。
3352
- *
3353
- * `AgentSession.resume()` 自己会把 `sessionId` 换掉,但**这张表的键不会跟着变** ——
3354
- * 不补这一步的话 `get(新 id)` 是 null,于是浏览器刚 resume 过去的那个会话
3355
- * 在这个进程眼里不存在。
3356
- *
3357
- * ## ⚠️ 搬的是**两格**,桥那一格 2026-08-18 才补上
3358
- *
3359
- * 原来这儿只有 `bridges.release(from)`,没有按新键 `register` 一次
3360
- * (`register` 只出现在建会话那一条路上,见 {@link assemble})。于是 resume
3361
- * 之后新 id 在路由表里是空的,而**后果是静默答错不是报错**:
3362
- * `ask_user_question` 回 `skipped`、plan 审批回 `deny` —— 模型问不出问题、
3363
- * 批不了计划,屏幕上只是「它没问」。
3364
- *
3365
- * **这不是 2026-08-17 那轮 `from` 取早一步的修复改出来的**:那之前
3366
- * `ToolContext.sessionId` 已经是新 id 而桥登记在旧 id 上,一样查不到。
3367
- * 两种情形下新 id 都无桥,那一轮既没让它变坏也没变好。
3368
- *
3369
- * ⚠️ **不是「只有 web 才踩」**(2026-08-18 实测修正的一处口径)。`/resume`
3370
- * 今天确实只有 TUI 走得到,而 TUI 上三条路**分成两档**:
3371
- *
3372
- * - 普通工具的权限审批 **不走**这一层(`onApprovalRequest` 直接给这个会话
3373
- * 自己那座桥,见 {@link assemble} 那条注释)—— 那一档确实没事;
3374
- * - `ask_user_question` 和 `exit_plan_mode` 交计划 **走**(`build.ts` 里
3375
- * 那两句 `(req, sid) => bridges.ask/approve(sid, req)`)—— 这两样 TUI
3376
- * `/resume` 之后一样是坏的。判据:`ToolContext.sessionId` 来自
3377
- * `AgentLoop` 的 `input.sessionId`,也就是换过之后那个新 id。
3378
- *
3379
- * ## 判了哪条治法:**把建会话那一刻那份桥原样 register 到新键上**
3380
- *
3381
- * 判据只有一条,而它是**读出来的不是推的**:{@link
3382
- * AgentSession.createApprovalBridge} 那两个回调闭的是 `target`(那个
3383
- * `AgentSession` **实例**)和一个 `buffered` 数组,**一个 id 都没闭** ——
3384
- * 没有 sessionId、没有工作区、没有 workDir。而 `AgentSession.resume()` 是
3385
- * **就地**改 `this.sessionId`(实例不换、`attach` 过的 `target` 还是它)。
3386
- * 所以「同一份桥」指的就是「用户此刻在看的那一段会话」,不是一个过期快照。
3387
- *
3388
- * 这也顺带答了「resume 过去之后这些回调该指向谁」——**照旧指向这一头**。
3389
- * 判据同 `build.ts` 那个回调里 `workspaces.seed` / `rekeyBackgroundTasks`
3390
- * 两行(2026-08-17 写的):`/resume` 只是把另一段对话接上**这个进程的这个
3391
- * 引擎**,问答通道的另一头照旧是这个进程的宿主(TUI 的屏幕 / 浏览器那条 SSE)。
3392
- * 「那段会话当初那个宿主」根本不存在 —— 那个进程早退了。
3393
- *
3394
- * **被否掉的另一条:在这里 `createApprovalBridge()` 新建一座登记上去。**
3395
- * 它比今天更坏,两处:① `attach()` 绑的是新闭包里那个 `target`,而正在跑的
3396
- * 那个 `AgentSession` 已经和旧桥绑好了 —— 新桥的请求会落进一条**没有消费者**
3397
- * 的 `buffered`,于是 `ask` 从「回 `skipped`」变成「永远不返回」,那个
3398
- * `AgentLoop` 挂在 await 上,进程退不掉;② 旧桥上**已经在飞**的请求
3399
- * (`inFlight` 里那些)连带失去归属。一句话:桥不是按 id 建的,
3400
- * 所以换 id 这件事上它压根不该重建。
3401
- */
3402
3859
  rekey(from, to) {
3403
3860
  const live = this.live.get(from);
3404
3861
  if (!live || from === to) return;
3405
3862
  this.live.delete(from);
3406
3863
  this.opts.bridges.release(from);
3864
+ const workspaces = this.opts.workspaces;
3407
3865
  const moved = {
3408
3866
  ...live,
3409
3867
  sessionId: to,
3410
- workspace: this.opts.workspaces.of(to)
3868
+ get workspace() {
3869
+ return workspaces.of(to);
3870
+ }
3411
3871
  };
3412
3872
  this.live.set(to, moved);
3873
+ this.opts.processes.claim(to);
3413
3874
  this.opts.bridges.register(to, moved.bridge);
3414
3875
  if (this.bootstrap === from) this.bootstrap = to;
3415
3876
  }
@@ -3425,15 +3886,13 @@ var init_session_factory = __esm({
3425
3886
  if (hit) return { ok: true, live: hit, created: false };
3426
3887
  }
3427
3888
  const sessionId = this.newId();
3428
- let bound = null;
3429
3889
  if (input.workspace !== null) {
3430
3890
  const outcome = this.opts.workspaces.bind(sessionId, input.workspace, input.lang);
3431
3891
  if (!outcome.ok) return { ok: false, reason: outcome.reason, detail: outcome.detail };
3432
- bound = outcome.bound;
3433
3892
  } else {
3434
3893
  this.opts.workspaces.decideNone(sessionId);
3435
3894
  }
3436
- const live = this.assemble(sessionId, bound, [], true, role);
3895
+ const live = this.assemble(sessionId, [], true, role);
3437
3896
  if (!live) {
3438
3897
  this.opts.workspaces.release(sessionId);
3439
3898
  return { ok: false, reason: "unavailable" };
@@ -3441,7 +3900,6 @@ var init_session_factory = __esm({
3441
3900
  this.live.set(sessionId, live);
3442
3901
  return { ok: true, live, created: true };
3443
3902
  }
3444
- /** 判据全文在 {@link SessionFactory.revive} 上 */
3445
3903
  revive(input) {
3446
3904
  const { sessionId } = input;
3447
3905
  const already = this.live.get(sessionId);
@@ -3451,15 +3909,13 @@ var init_session_factory = __esm({
3451
3909
  const decision = remembered ?? input.decision;
3452
3910
  const awaitingPick = this.opts.workspaces.stateOf(sessionId) === "unbound";
3453
3911
  if (!decision && !awaitingPick) return { ok: false, reason: "no-decision" };
3454
- let bound = null;
3455
3912
  if (decision?.state === "bound") {
3456
3913
  const outcome = this.opts.workspaces.bind(sessionId, decision.root, input.lang);
3457
3914
  if (!outcome.ok) return { ok: false, reason: outcome.reason, detail: outcome.detail };
3458
- bound = outcome.bound;
3459
3915
  } else if (decision) {
3460
3916
  this.opts.workspaces.decideNone(sessionId);
3461
3917
  }
3462
- const live = this.assemble(sessionId, bound, [], true);
3918
+ const live = this.assemble(sessionId, [], true);
3463
3919
  if (!live) {
3464
3920
  if (!remembered && !awaitingPick) this.opts.workspaces.release(sessionId);
3465
3921
  return { ok: false, reason: "unavailable" };
@@ -3472,67 +3928,41 @@ var init_session_factory = __esm({
3472
3928
  this.live.delete(sessionId);
3473
3929
  this.opts.bridges.release(sessionId);
3474
3930
  }
3475
- /**
3476
- * 一个 `delegate_task` 子跑手。**全进程一个**,按 `parentSessionId` 现查现配。
3477
- *
3478
- * 三处 id 必须都是**派活那个会话**的,缺一处就是 §6.3 第 3 条那个洞:
3479
- * `budgetSessionId`(钱记谁头上)、两个 `checkpoints` 回调(快照落谁的目录)、
3480
- * 以及审批弹给谁看。在这里现查而不是装配时闭包一个,正是因为那个闭包
3481
- * 捕获的是引导会话。
3482
- *
3483
- * @param provider **收参数而不是读 `this.opts.provider`**:`delegate_task`
3484
- * 只在 provider 起得来时才注册,收进来之后这条路上就没有「provider 是 null」
3485
- * 这个状态了 —— 少一个只能靠一句话解释的不可达分支。
3486
- *
3487
- * ⚠️ 它今天只是**兜底**(派活的会话已经被冷却掉了):2026-08-16 起子 agent
3488
- * 跑在**派活那个会话**的 provider 门面上,理由见下面那两行注释
3489
- */
3490
3931
  subRunner(provider) {
3491
3932
  return async (goal, context, subSessionId, roleName, parentSessionId) => {
3492
3933
  const { services, config } = this.opts;
3493
3934
  const parent = this.live.get(parentSessionId);
3494
3935
  const workDir = parent?.workspace?.workspace.root ?? this.opts.defaultWorkDir;
3495
3936
  const projectContext = parent?.workspace?.projectContext ?? services.projectContext;
3496
- const checkpoints = new CheckpointManager({ workDir, homeDir: config.homeDir });
3937
+ const checkpoints = new CheckpointManager({
3938
+ workDir: () => workDir,
3939
+ homeDir: config.homeDir
3940
+ });
3497
3941
  const role = findRole(this.opts.roles(), roleName ?? DEFAULT_AGENT_ROLE);
3498
3942
  const permission = parent?.permission ?? services.permission;
3499
3943
  const sub = new AgentLoop({
3500
- // 跑在**派活那个会话**的模型上(2026-08-16)。和下面 `budgetSessionId` /
3501
- // `checkpoints` / `onApprovalRequest` 是同一条判据:子任务是那个会话的
3502
- // 一部分。读进程那一份的话,会话 B 明明换到了别的模型,它派出去的活
3503
- // 还在打引导会话那个 —— 而账是记在 B 头上的
3504
3944
  provider: parent?.provider ?? provider,
3505
3945
  toolProvider: roleScopedProvider(this.opts.rawToolProvider, role, BLOCKED_TOOLS),
3946
+ codeModeTools: this.opts.codeModeTools,
3506
3947
  telemetry: this.opts.telemetry,
3507
3948
  workDir,
3508
- // 复用父会话那份:信任判定和指令文件已经算过、也已经进过诊断,
3509
- // 每个子 agent 再算一遍只是重复读盘和重复跑 git
3510
3949
  projectContext,
3511
- // 角色说了算,没说才回落到「父的一半」——避免单个子任务吃掉全部预算
3512
3950
  maxTurns: role?.maxTurns ?? Math.max(4, Math.floor(config.maxTurns / 2)),
3513
3951
  contextLength: services.contextLength,
3514
- // **不给 `scope`**:它缺省就是 `'delegate'`,而这条路正是那个缺省值
3515
- // 存在的理由(见 `AgentRoleScope`)。显式写一遍也对,但那会让这一行
3516
- // 和它上面十几行「原样透传」的写法长得不一样,反而像在做别的事
3517
- //
3518
- // 这条路上**没有槽**(方案 57 §3.2 只换了 `assemble()` 那一行):一个子任务
3519
- // 从派出去到跑完就是一次 run,中途没有「下一条消息」这个概念。写成函数
3520
- // 只是跟着 `AgentConfig.role` 的形状走,它每次都回同一份对象
3952
+ artifactRetention: resolveArtifactRetention(config.artifacts),
3953
+ sandboxTerminal: config.sandbox?.terminal !== false,
3954
+ compliance: this.opts.compliance ?? void 0,
3521
3955
  ...role ? { role: () => ({ name: role.name, ...role.prompt ? { prompt: role.prompt } : {} }) } : {},
3522
3956
  ...permission ? { permissionManager: permission } : {},
3523
3957
  ...services.hookManager ? { hookManager: services.hookManager } : {},
3958
+ skillSystem: services.skill ?? void 0,
3959
+ roleSkills: () => role?.skills,
3524
3960
  ...this.opts.budget,
3525
- // 子任务的花费记到**派活的那个会话**头上。不这么做的话 `delegate_task`
3526
- // 就是一条绕过成本上限的通道;而记到引导会话头上(工厂之前那样)更糟 ——
3527
- // 那是**另一个人**的预算
3528
3961
  budgetSessionId: parentSessionId,
3529
- // 同上:子 agent 改的文件要能被派活那个会话的 `/rewind` 看见
3530
3962
  checkpoints: {
3531
3963
  capture: (_sub, paths) => checkpoints.capture(parentSessionId, paths),
3532
3964
  record: (_sub, paths) => checkpoints.record(parentSessionId, paths)
3533
3965
  },
3534
- // 审批弹给派活那个会话的界面。`BLOCKED_TOOLS` 挡掉了 plan 和提问那几个,
3535
- // 剩下的写类工具照样要问人
3536
3966
  onApprovalRequest: (req) => this.opts.bridges.approve(parentSessionId, req)
3537
3967
  });
3538
3968
  let r;
@@ -3554,23 +3984,9 @@ ${context}` : goal,
3554
3984
  };
3555
3985
  };
3556
3986
  }
3557
- // -------------------------------------------------------------------------
3558
- // 私有:全仓唯一一处「主会话的 `new AgentLoop` + `new AgentSession`」
3559
- // -------------------------------------------------------------------------
3560
- /**
3561
- * @param own 这个会话要不要**自己开一份**模型 / 权限档 / plan 模式
3562
- * (2026-08-16)。引导会话是 `false` —— 它拿的就是进程那两份本体。
3563
- *
3564
- * **收一个布尔而不是在这里判 `sessionId === this.bootstrap`**:`adopt()`
3565
- * 恰好在给 `this.bootstrap` 赋值的那一行之后调进来,照那个字段判的话,
3566
- * 这段代码的正确性靠的是两行之间的时序 —— 而下一个人挪一行就会把它挪坏
3567
- * @param role **这个会话**的专家(决定 20 ②)。不给就回落到 `--agent` 那一份
3568
- * (`opts.mainRole`,进程级)—— 两者形状完全一样,走的也是同一条收窄路,
3569
- * 区别只在作用域。回落而不是二选一:`epoch web --agent explore` 之后新建的
3570
- * 会话仍然该是 explore,那是用户在命令行上说过的话。
3571
- */
3572
- assemble(sessionId, bound, history, own, role) {
3987
+ assemble(sessionId, history, own, role) {
3573
3988
  const opts = this.opts;
3989
+ opts.processes.claim(sessionId);
3574
3990
  const { services, config, provider, models } = opts;
3575
3991
  if (!provider || !models) return null;
3576
3992
  const scope = {
@@ -3579,14 +3995,26 @@ ${context}` : goal,
3579
3995
  };
3580
3996
  const active = role ?? opts.mainRole ?? void 0;
3581
3997
  const roleScope = createRoleScope(
3582
- withSessionTools(opts.toolProvider, scope.guard.tools),
3998
+ withSessionTools(
3999
+ revealScopedProvider(opts.toolProvider, opts.deferredTools, scope.guard.reveal),
4000
+ scope.guard.tools
4001
+ ),
3583
4002
  active,
3584
4003
  []
3585
4004
  );
3586
4005
  const planState = scope.guard.planState;
3587
- const workDir = bound?.workspace.root ?? opts.defaultWorkDir;
3588
- const projectContext = bound?.projectContext ?? services.projectContext;
3589
- const checkpoints = new CheckpointManager({ workDir, homeDir: config.homeDir });
4006
+ const live = {};
4007
+ const site = () => {
4008
+ const now = opts.workspaces.of(live.session?.sessionId ?? sessionId);
4009
+ return {
4010
+ workDir: now?.workspace.root ?? opts.defaultWorkDir,
4011
+ projectContext: now?.projectContext ?? services.projectContext
4012
+ };
4013
+ };
4014
+ const checkpoints = new CheckpointManager({
4015
+ workDir: () => site().workDir,
4016
+ homeDir: config.homeDir
4017
+ });
3590
4018
  const loopProvider = opts.goals ? withGoalPrompt(
3591
4019
  scope.model.provider,
3592
4020
  () => opts.goals.promptView(live.session?.sessionId ?? sessionId)
@@ -3594,59 +4022,19 @@ ${context}` : goal,
3594
4022
  const bridge = AgentSession.createApprovalBridge();
3595
4023
  const routed = { approve: bridge.onApprovalRequest, ask: bridge.onQuestionRequest };
3596
4024
  opts.bridges.register(sessionId, routed);
3597
- const live = {};
3598
4025
  const agent = safeInit(
3599
4026
  "AgentLoop",
3600
4027
  () => new AgentLoop({
3601
- // **这个会话自己那份门面**,不是 `opts.provider`(那是引导会话的)。
3602
- // 拿错的表现是:第二个会话换完模型,界面上写着新的、引擎下一轮打的
3603
- // 还是旧的 —— 判据在 `SessionModelScope.provider` 上
3604
4028
  provider: loopProvider,
3605
- // **给 provider 而不是 `exposed()` 的快照**:MCP server 发
3606
- // list_changed / 断线重连 / login 解锁新工具时,正在跑的这个 loop
3607
- // 下一轮就能看到新表(方案 15)。这里给的是**包了两层收窄的那个** ——
3608
- // 命令那一层(方案 23)在 `opts.toolProvider` 里,角色那一层是槽
3609
- // (方案 57 §3.2)。两层都是可变的,上面那条性质仍然成立。
3610
- //
3611
- // 收窄**不再按 `active` 分支**:槽在 `active` 缺席时是一层透明包装
3612
- // (`roleScopedProvider(base, undefined, [])` 一个工具都不减),
3613
- // 所以这一行的字节行为和改造前逐字相同。分支留着的话,身份那一半会在
3614
- // 建 loop 那一刻定死「有没有角色」,而工具那一半是活的 —— 那正是
3615
- // 方案 57 §1.3 那个劈叉,只是换了个更不容易看出来的位置
3616
4029
  toolProvider: roleScope.provider,
4030
+ codeModeTools: opts.codeModeTools,
3617
4031
  telemetry: opts.telemetry,
3618
- workDir,
3619
- projectContext,
3620
- // artifact 落在**这次实际生效的数据目录**下,而不是全局 `~/.epoch`
3621
- // (方案 54 §二)。给 `config.homeDir` 而不是 `opts.homeDir`:
3622
- // 不传时它是引擎探测出来的结果,只有它是真源
4032
+ site,
3623
4033
  multimodal: { homeDir: config.homeDir },
3624
- // 角色的 maxTurns 压过配置:它是「这个角色干这类活要几轮」的判断。
3625
- //
3626
- // ⚠️ **读的是 `active`(这段会话默认那个专家)而不是槽 —— 逐条换专家
3627
- // 不换轮次预算**,这是一个答案不是一笔欠账(方案 57 §3.2.1,2026-08-17)。
3628
- // 判据一句话:身份和工具必须同作用域是因为它们**同时被模型读到**、
3629
- // 劈叉了看不出来;而轮次预算是宿主侧的一个上界,它劈叉的表现是
3630
- // `finish` 上那个 `max-turns`,屏幕上说得清。真要换只能走
3631
- // `RunInput.maxTurns`,而那一格已经有调用方了(`--max-turns`),
3632
- // 「谁压过谁」是另一次判断。全文在 `agent-role/scope.ts` 的 `current` 上
4034
+ artifactRetention: resolveArtifactRetention(config.artifacts),
4035
+ sandboxTerminal: config.sandbox?.terminal !== false,
4036
+ compliance: opts.compliance ?? void 0,
3633
4037
  maxTurns: active?.maxTurns ?? config.maxTurns,
3634
- // 工具白名单**不传**:它已经被上面的 `roleScopedProvider` 消化掉了,
3635
- // loop 里不该存在「工具表和角色不一致」这种状态。
3636
- //
3637
- // `scope: 'session'` 是这一轮加的那一格,而且**必须显式给** ——
3638
- // 它缺省是 `'delegate'`(老调用方的 prompt 要逐字节不变),不给的话
3639
- // 顶层会话会收到一句「由主智能体派来完成一个独立子任务」的假话,
3640
- // 而这一条从 `--agent` 落地那天起就错着(判据见 `AgentRoleScope`)。
3641
- //
3642
- // `source` 纯展示,进不了 prompt —— 它是 `run-start` 事件上那枚来源
3643
- // 徽标的唯一来路(决定 8 第一个时刻)。这一层是全仓唯一同时握着
3644
- // 「完整 AgentRole」和「这个会话的 loop」的地方,不在这儿给就没人给得出
3645
- //
3646
- // **从槽里读,而且这一格不再按 `active` 分支**(方案 57 §3.2):
3647
- // 工具那一半已经是活的了,身份这一半再钉死在建 loop 那一刻,就是
3648
- // §1.3 那个劈叉。槽空着时函数回 `undefined`,loop 那边整个键缺席 ——
3649
- // 和改造前 `active` 缺席时**逐字节等价**
3650
4038
  role: () => {
3651
4039
  const now = roleScope.current;
3652
4040
  if (!now) return void 0;
@@ -3658,28 +4046,11 @@ ${context}` : goal,
3658
4046
  };
3659
4047
  },
3660
4048
  contextLength: services.contextLength,
3661
- // 已批准的计划(方案 35 PR-2 验收 8)。给函数不给快照 —— 计划是这一轮
3662
- // 跑到一半才被批准的,快照恒为 null。
3663
- //
3664
- // ⚠️ 读的是**这个会话自己那份** plan 状态(2026-08-16):原来这儿是一个
3665
- // 进程级闭包,于是会话 B 的 system prompt 附加段里挂的是会话 A 批准的
3666
- // 那份纲 —— 模型照着一份不属于这段对话的计划干活,而屏幕上什么都看不出来
3667
4049
  ...planState ? { approvedPlan: () => planState.approvedPlan() } : {},
3668
- // 宿主给了阻塞式确认就用它的(单会话宿主),否则走这个会话自己那座桥。
3669
- // **不绕 `bridges.approve`**:那一层是给共享工具路由用的,而 `AgentLoop`
3670
- // 本来就知道自己是谁 —— 多绕一圈只多一处能对错的地方
3671
4050
  onApprovalRequest: opts.hostApproval ?? bridge.onApprovalRequest,
3672
- // 只给**这个会话**那一批(2026-08-15)。会话 id 走 `live` 而不是入参,
3673
- // 理由见它的声明
3674
4051
  backgroundTasks: () => opts.backgroundTasks(live.session?.sessionId ?? sessionId),
3675
- // 写类工具动手前留一份旧内容(方案 27)。开轮在 `AgentSession.run()`,
3676
- // 循环自己不认识这个东西 —— 所以上面那条「同一个实例」是硬约束
3677
4052
  checkpoints,
3678
4053
  ...services.memory ? { memory: services.memory } : {},
3679
- // 压缩时把当前目标递给摘要模型(方案 52 PR-3 / 验收 3 后半句)。
3680
- // **包的是同一个进程级实例**,不是新建一个 —— 冷却期、熔断计数、
3681
- // 上一版摘要都在那份状态里,复制一份等于每个会话各有一套闸门。
3682
- // 判据全文在 `goal/engine.ts`
3683
4054
  ...services.contextEngine ? {
3684
4055
  contextEngine: opts.goals ? withGoalCompression(
3685
4056
  services.contextEngine,
@@ -3687,11 +4058,9 @@ ${context}` : goal,
3687
4058
  ) : services.contextEngine
3688
4059
  } : {},
3689
4060
  ...services.hookManager ? { hookManager: services.hookManager } : {},
3690
- ...services.skill ? { skillSystem: services.skill } : {},
4061
+ skillSystem: services.skill ?? void 0,
4062
+ roleSkills: () => roleScope.current?.skills,
3691
4063
  ...services.skillLearner ? { skillLearner: services.skillLearner } : {},
3692
- // **这个会话自己那一档**(2026-08-16)。规则表 / 审批缓存 / 审计流水
3693
- // 照旧是同一份(core 的 `PermissionManager.scoped()` 共享它们),
3694
- // 分家的只有「此刻是哪一档」
3695
4064
  ...scope.guard.permission ? { permissionManager: scope.guard.permission } : {},
3696
4065
  ...opts.budget
3697
4066
  }),
@@ -3704,7 +4073,7 @@ ${context}` : goal,
3704
4073
  const store = opts.sessions ? new SessionStore(
3705
4074
  opts.sessions,
3706
4075
  config.homeDir,
3707
- projectContext.rootDir,
4076
+ () => site().projectContext.rootDir,
3708
4077
  opts.sessionSource,
3709
4078
  (id) => opts.workspaces.decisionOf(id)
3710
4079
  ) : void 0;
@@ -3714,15 +4083,9 @@ ${context}` : goal,
3714
4083
  ...store ? { store } : {},
3715
4084
  history: [...history],
3716
4085
  checkpoints,
3717
- // 一轮开始时给目标 +1 并检查预算(方案 52 PR-2)。**接在轮次边界上,
3718
- // 而 `loop.ts` 一行没改** —— 判据在方案 52 §四:一次 `run()` 恰好是一条
3719
- // 被接纳的用户消息,而循环内部那个 `for (turn = 1..maxTurns)` 数的是模型
3720
- // 请求次数。挂在这儿和 `checkpoints` 是同一个位置、同一条理由
3721
4086
  ...opts.goals ? { goals: opts.goals } : {},
3722
- // 换人那一刻在历史里留一条 `[系统]` 消息(方案 57 §3.5)。**给函数不给名字**:
3723
- // 槽是可变的,快照拿到的是装配那一刻那个专家,于是每一条消息都会被判成
3724
- // 「没换人」—— 而那正是这条留痕唯一会坏的方式(不报错、只是永远不出现)
3725
- currentRole: () => roleScope.current?.name
4087
+ currentRole: () => roleScope.current?.name,
4088
+ currentWorkDir: () => site().workDir
3726
4089
  });
3727
4090
  bridge.attach(session);
3728
4091
  live.session = session;
@@ -3734,38 +4097,17 @@ ${context}` : goal,
3734
4097
  session,
3735
4098
  agent,
3736
4099
  model: scope.model.control,
3737
- // 这个会话那个角色槽的宿主面(方案 57 §3.4)。**在这儿包而不是把 `roleScope`
3738
- // 整个交出去**:`enter()` 收的是 `AgentRole` 对象,而服务端不许 import core
3739
- // ——它手里只有网线上那个字符串。判据全文在 `role-scope.ts` 文件头
3740
4100
  roles: createRoleControl(roleScope, opts.roles),
3741
- /*
3742
- * 会话级那一份身份(2026-08-18)。**取 `active` 而不是入参 `role`**:
3743
- * 那个变量已经把「没带就退到 `--agent`」算过一次,而上面那句「全函数只算
3744
- * 这一次」正是为了不让两处给出不同答案。判据全文在 {@link LiveSession.role}。
3745
- *
3746
- * 只带 `name` + `source` 两格(`ActiveAgentRole`),不把整个 `AgentRole`
3747
- * 交出去:服务端不许 import core,而它要的就是这两个字段
3748
- * —— 同 `roles` 那一格「包成两个动词」的判据。
3749
- */
3750
4101
  role: toActiveRole(active),
3751
4102
  provider: scope.model.provider,
3752
4103
  permission: scope.guard.permission,
3753
4104
  permissions: scope.guard.permissions,
3754
4105
  plan: scope.guard.plan,
3755
- // 会话 id 走 `session.sessionId` 而不是入参,判据同下面那个
3756
- // `buildCheckpointControl`:`/resume` 会把它换掉
3757
4106
  goals: opts.goals ? buildGoalControl(opts.goals, () => session.sessionId) : null,
3758
- // **同一个 `checkpoints` 实例**再包一层交出去,不是新建一个管理器:
3759
- // 上面那条「`AgentSession` 和它的 `AgentLoop` 必须共用同一个实例」
3760
- // 对宿主这一侧同样成立 —— 另建一个的表现是回退列表读得出、
3761
- // 而「这一轮开过没有」是另一本账。
3762
- //
3763
- // 会话 id 走 `session.sessionId` 而不是上面那个入参:`/resume` 会把它换掉
3764
- // (`rekey` 只换工厂表里的键,换不了一个拍过快照的闭包)
3765
4107
  checkpoints: buildCheckpointControl(checkpoints, session, () => session.sessionId),
3766
- workspace: bound,
3767
- // **同一个对象**,不是重新拼一遍那两个回调 —— 判据在 `rekey` 上。
3768
- // 它只在这张表里,`get()` / `list()` 的类型里没有这一格
4108
+ get workspace() {
4109
+ return opts.workspaces.of(live.session?.sessionId ?? sessionId);
4110
+ },
3769
4111
  bridge: routed
3770
4112
  };
3771
4113
  }
@@ -3804,6 +4146,9 @@ function toProvider(router) {
3804
4146
  } catch {
3805
4147
  return { supportsImages: false };
3806
4148
  }
4149
+ },
4150
+ get modelId() {
4151
+ return router.getSelection().model;
3807
4152
  }
3808
4153
  };
3809
4154
  }
@@ -3823,7 +4168,6 @@ var init_file = __esm({
3823
4168
  opts;
3824
4169
  now;
3825
4170
  ready = false;
3826
- /** 自增的 span id。同一个进程内唯一就够 —— 这文件本来就是单机看的 */
3827
4171
  seq = 0;
3828
4172
  startSpan(name, attrs, parent) {
3829
4173
  const startedAt = this.now();
@@ -3957,8 +4301,6 @@ async function tryCreateOtelTelemetry(endpoint) {
3957
4301
  };
3958
4302
  return {
3959
4303
  telemetry,
3960
- // 退出时必须 flush(批量导出器手上压着最后一轮的 span),但**必须有上限** ——
3961
- // 见 SHUTDOWN_BUDGET_MS
3962
4304
  shutdown: async () => {
3963
4305
  await withTimeout(
3964
4306
  Promise.allSettled([tracerProvider.shutdown(), meterProvider.shutdown()]).then(() => {
@@ -3982,7 +4324,7 @@ async function buildTelemetry(input) {
3982
4324
  const noop = { telemetry: NOOP_TELEMETRY, dispose: () => {
3983
4325
  } };
3984
4326
  if (!config?.enabled) {
3985
- diags.skipped("Telemetry", "\u672A\u5F00\u542F\uFF08epoch config set telemetry.enabled true \u5F00\u542F\uFF09");
4327
+ diags.skipped("Telemetry", t("boot.telemetry_off"));
3986
4328
  return noop;
3987
4329
  }
3988
4330
  const target = config.target ?? "file";
@@ -3995,7 +4337,7 @@ async function buildTelemetry(input) {
3995
4337
  }
3996
4338
  diags.warn(
3997
4339
  "Telemetry",
3998
- "target=otlp \u4F46\u6CA1\u88C5 OpenTelemetry \u4F9D\u8D56\uFF0C\u5DF2\u964D\u7EA7\u4E3A file\u3002\u88C5\u6CD5\uFF1Apnpm add @opentelemetry/api @opentelemetry/sdk-trace-base @opentelemetry/sdk-metrics @opentelemetry/exporter-trace-otlp-http @opentelemetry/exporter-metrics-otlp-http @opentelemetry/resources",
4340
+ (lang) => t("boot.telemetry_otlp_missing", { command: OTLP_INSTALL_COMMAND }, lang),
3999
4341
  "otlp-deps-missing"
4000
4342
  );
4001
4343
  }
@@ -4009,7 +4351,6 @@ async function buildTelemetry(input) {
4009
4351
  return {
4010
4352
  telemetry: new FileTelemetry({
4011
4353
  path,
4012
- // 写不进去要说出来,但只说一次 —— 每条记录报一次会把诊断刷爆
4013
4354
  onError: onceWarn(diags, path)
4014
4355
  }),
4015
4356
  dispose: () => {
@@ -4023,7 +4364,11 @@ function onceWarn(diags, path) {
4023
4364
  reported = true;
4024
4365
  diags.warn(
4025
4366
  "Telemetry",
4026
- `\u5199\u5165\u5931\u8D25\uFF08${err instanceof Error ? err.message : String(err)}\uFF09\uFF1A${path}`,
4367
+ (lang) => t(
4368
+ "boot.telemetry_write_failed",
4369
+ { reason: err instanceof Error ? err.message : String(err), path },
4370
+ lang
4371
+ ),
4027
4372
  "telemetry-write-failed"
4028
4373
  );
4029
4374
  };
@@ -4050,16 +4395,18 @@ function consoleTelemetry() {
4050
4395
  histogram: (name, value, attrs) => line({ t: "histogram", name, v: value, attrs })
4051
4396
  };
4052
4397
  }
4398
+ var OTLP_INSTALL_COMMAND;
4053
4399
  var init_telemetry = __esm({
4054
4400
  "src/telemetry/index.ts"() {
4055
4401
  init_file();
4056
4402
  init_otel();
4057
4403
  init_file();
4058
4404
  init_otel();
4405
+ OTLP_INSTALL_COMMAND = "pnpm add @opentelemetry/api @opentelemetry/sdk-trace-base @opentelemetry/sdk-metrics @opentelemetry/exporter-trace-otlp-http @opentelemetry/exporter-metrics-otlp-http @opentelemetry/resources";
4059
4406
  }
4060
4407
  });
4061
4408
  function missing(tool) {
4062
- return { output: `${tool} \u5DE5\u5177\u4E0D\u53EF\u7528\uFF08\u6A21\u5757\u6CA1\u8D77\u6765\uFF0C\u770B /diagnostics\uFF09`, ok: false };
4409
+ return { output: t("boot.tool_unavailable", { tool: `${tool} ` }), ok: false };
4063
4410
  }
4064
4411
  function toResult(result) {
4065
4412
  const output = typeof result.output === "string" ? result.output : JSON.stringify(result.output, null, 2);
@@ -4078,7 +4425,11 @@ function createUserActions(deps) {
4078
4425
  const type = mapOperationType(toolName);
4079
4426
  const decision = pm.check({ type, detail, workDir: deps.workDir, target, toolName });
4080
4427
  if (decision.allowed) return null;
4081
- if (!decision.requireApproval) return `\u64CD\u4F5C\u88AB\u62E6\u622A [\u6743\u9650]: ${decision.reason || "\u9700\u8981\u6388\u6743"}`;
4428
+ if (!decision.requireApproval) {
4429
+ return t("boot.blocked_by_permission", {
4430
+ reason: decision.reason || t("boot.needs_authorization")
4431
+ });
4432
+ }
4082
4433
  const outcome = await approve({
4083
4434
  toolName,
4084
4435
  type,
@@ -4087,7 +4438,7 @@ function createUserActions(deps) {
4087
4438
  ...decision.reason ? { reason: decision.reason } : {}
4088
4439
  });
4089
4440
  pm.auditUserDecision?.({ type, target, toolName }, outcome);
4090
- if (outcome === "deny") return "\u5DF2\u53D6\u6D88";
4441
+ if (outcome === "deny") return t("boot.cancelled");
4091
4442
  pm.recordApproval(toolName, target, outcome);
4092
4443
  return null;
4093
4444
  };
@@ -4128,6 +4479,258 @@ var init_user_shell = __esm({
4128
4479
  "src/user-shell.ts"() {
4129
4480
  }
4130
4481
  });
4482
+ function utilityBudgetKey(input) {
4483
+ const owner = input.sessionId?.trim();
4484
+ if (owner) return owner;
4485
+ const label = input.label !== void 0 && LABEL_SHAPE.test(input.label) ? input.label : ANONYMOUS;
4486
+ return `${BUCKET_PREFIX}${label}`;
4487
+ }
4488
+ function buildUtilityControl(opts) {
4489
+ const { provider, store, pricingOf } = opts;
4490
+ const record = (input, out) => {
4491
+ if (!store || !out.usage) return;
4492
+ const pricing = pricingOf?.(out.model);
4493
+ new BudgetGuard({ store, sessionId: utilityBudgetKey(input) }).add(out.usage, {
4494
+ model: out.model,
4495
+ ...pricing ? { pricing } : {}
4496
+ });
4497
+ };
4498
+ return {
4499
+ async generate(input) {
4500
+ if (input.signal?.aborted === true) throw new Error(t("utility.aborted"));
4501
+ const wanted = input.model ?? refOf(provider.getUtilityInfo());
4502
+ const out = await provider.generate({
4503
+ messages: input.messages,
4504
+ ...input.system !== void 0 ? { system: input.system } : {},
4505
+ ...input.maxTokens !== void 0 ? { maxTokens: input.maxTokens } : {},
4506
+ ...wanted.provider ? { provider: wanted.provider } : {},
4507
+ model: wanted.model,
4508
+ ...input.responseFormat ? { responseFormat: input.responseFormat } : {},
4509
+ ...input.providerOptions ? { providerOptions: input.providerOptions } : {}
4510
+ });
4511
+ record(input, out);
4512
+ return {
4513
+ text: out.text,
4514
+ ...out.usage ? { usage: out.usage } : {},
4515
+ model: out.model,
4516
+ provider: out.provider,
4517
+ ...out.responseFormatApplied === void 0 ? {} : { responseFormatApplied: out.responseFormatApplied },
4518
+ ...out.warnings ? { warnings: out.warnings } : {}
4519
+ };
4520
+ }
4521
+ };
4522
+ }
4523
+ function refOf(info) {
4524
+ return { provider: info.selection.provider, model: info.selection.model };
4525
+ }
4526
+ var BUCKET_PREFIX, ANONYMOUS, LABEL_SHAPE;
4527
+ var init_utility = __esm({
4528
+ "src/utility.ts"() {
4529
+ BUCKET_PREFIX = "utility:";
4530
+ ANONYMOUS = "anonymous";
4531
+ LABEL_SHAPE = /^[a-z0-9_-]{1,32}$/;
4532
+ }
4533
+ });
4534
+ function isSystemOpenable(path) {
4535
+ const dot = path.lastIndexOf(".");
4536
+ if (dot < 0) return false;
4537
+ return SYSTEM_OPENABLE_EXTENSIONS.has(path.slice(dot).toLowerCase());
4538
+ }
4539
+ function toWorkspaceRelative(root, userPath) {
4540
+ const trimmed = userPath.trim();
4541
+ if (trimmed === "") return null;
4542
+ if (!isAbsolute(trimmed)) return trimmed.replace(/^\.\/+/, "") || null;
4543
+ const rel = relative(resolve(root), resolve(trimmed));
4544
+ if (rel === "" || rel.startsWith("..")) return null;
4545
+ return rel.split(sep).join("/");
4546
+ }
4547
+ function insideRoot(root, abs) {
4548
+ let realRoot;
4549
+ try {
4550
+ realRoot = realpathSync(root);
4551
+ } catch {
4552
+ return false;
4553
+ }
4554
+ const rel = relative(realRoot, abs);
4555
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
4556
+ }
4557
+ function sniffKind(absPath) {
4558
+ let fd;
4559
+ try {
4560
+ fd = openSync(absPath, "r");
4561
+ const buf = Buffer.alloc(8192);
4562
+ const read = readSync(fd, buf, 0, buf.length, 0);
4563
+ return buf.subarray(0, read).includes(0) ? "binary" : "text";
4564
+ } catch {
4565
+ return null;
4566
+ } finally {
4567
+ if (fd !== void 0) {
4568
+ try {
4569
+ closeSync(fd);
4570
+ } catch {
4571
+ }
4572
+ }
4573
+ }
4574
+ }
4575
+ function withinWorkspace(resolveInWorkspace2, root, rel) {
4576
+ return resolveInWorkspace2(root, rel) !== null;
4577
+ }
4578
+ function buildWorkspaceFilesControl(deps, permission) {
4579
+ let cache = null;
4580
+ const now = () => Date.now();
4581
+ function statFileImpl(path, root) {
4582
+ const rel = toWorkspaceRelative(root, path);
4583
+ if (rel === null) return { ok: false, reason: "denied" };
4584
+ const full = deps.resolveInWorkspace(root, rel);
4585
+ if (!full) return { ok: false, reason: "denied" };
4586
+ const decision = permission?.check({
4587
+ type: "file_read",
4588
+ detail: rel,
4589
+ workDir: root,
4590
+ target: full,
4591
+ toolName: "file_read"
4592
+ });
4593
+ if (permission && !decision?.allowed) return { ok: false, reason: "denied" };
4594
+ let stat;
4595
+ try {
4596
+ stat = statSync(full);
4597
+ } catch (err) {
4598
+ const code = err.code;
4599
+ return {
4600
+ ok: false,
4601
+ reason: code === "ENOENT" || code === "ENOTDIR" ? "missing" : "unreadable"
4602
+ };
4603
+ }
4604
+ if (!stat.isFile()) return { ok: false, reason: "not-a-file" };
4605
+ let absPath;
4606
+ try {
4607
+ absPath = realpathSync(full);
4608
+ } catch {
4609
+ return { ok: false, reason: "unreadable" };
4610
+ }
4611
+ if (!insideRoot(root, absPath)) return { ok: false, reason: "denied" };
4612
+ const kind = sniffKind(absPath);
4613
+ if (kind === null) return { ok: false, reason: "unreadable" };
4614
+ return { ok: true, rel, absPath, bytes: stat.size, mtimeMs: stat.mtimeMs, kind };
4615
+ }
4616
+ function computeList(root) {
4617
+ const { files, truncated } = deps.listFiles(root);
4618
+ return { files, truncated, at: now(), root };
4619
+ }
4620
+ function listFor(root) {
4621
+ const t25 = now();
4622
+ if (!cache || cache.root !== root) cache = computeList(root);
4623
+ else if (t25 - cache.at > LIST_TTL_MS) cache = computeList(root);
4624
+ return cache;
4625
+ }
4626
+ return {
4627
+ candidates(query, limit, root) {
4628
+ const q = query.trim().toLowerCase();
4629
+ let base;
4630
+ try {
4631
+ base = listFor(root);
4632
+ } catch {
4633
+ return null;
4634
+ }
4635
+ const mentionable = [];
4636
+ let unmentionable = 0;
4637
+ for (const rel of base.files) {
4638
+ if (!withinWorkspace(deps.resolveInWorkspace, root, rel)) continue;
4639
+ const hit = q.length === 0 || rel.toLowerCase().includes(q);
4640
+ if (!hit) continue;
4641
+ if (isMentionableFilePath(rel)) {
4642
+ mentionable.push({ rel, depth: rel.split("/").length });
4643
+ } else {
4644
+ unmentionable += 1;
4645
+ }
4646
+ }
4647
+ mentionable.sort((a, b) => a.depth - b.depth || (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
4648
+ return {
4649
+ candidates: mentionable.slice(0, limit).map((row) => row.rel),
4650
+ unmentionable,
4651
+ truncated: base.truncated
4652
+ };
4653
+ },
4654
+ readFile(path, root) {
4655
+ const full = deps.resolveInWorkspace(root, path);
4656
+ if (!full) return { ok: false, reason: "denied" };
4657
+ const decision = permission?.check({
4658
+ type: "file_read",
4659
+ detail: path,
4660
+ workDir: root,
4661
+ target: full,
4662
+ toolName: "file_read"
4663
+ });
4664
+ if (permission && !decision?.allowed) return { ok: false, reason: "denied" };
4665
+ let stat;
4666
+ try {
4667
+ stat = statSync(full);
4668
+ if (!stat.isFile()) return { ok: false, reason: "unreadable" };
4669
+ } catch {
4670
+ return { ok: false, reason: "unreadable" };
4671
+ }
4672
+ let raw;
4673
+ try {
4674
+ raw = readFileSync(full);
4675
+ } catch {
4676
+ return { ok: false, reason: "unreadable" };
4677
+ }
4678
+ if (raw.subarray(0, 8192).includes(0)) {
4679
+ return { ok: false, reason: "binary", bytes: stat.size };
4680
+ }
4681
+ return { ok: true, text: raw.toString("utf8"), bytes: stat.size };
4682
+ },
4683
+ statFile: statFileImpl,
4684
+ planSystemOpen(path, root) {
4685
+ const stat = statFileImpl(path, root);
4686
+ if (!stat.ok) return { ok: false, reason: stat.reason };
4687
+ if (!isSystemOpenable(stat.absPath)) return { ok: false, reason: "extension-not-allowed" };
4688
+ const { command, args } = deps.systemOpenArgv(stat.absPath);
4689
+ return { ok: true, command, args, absPath: stat.absPath };
4690
+ }
4691
+ };
4692
+ }
4693
+ var SYSTEM_OPENABLE_EXTENSIONS, LIST_TTL_MS;
4694
+ var init_workspace_files = __esm({
4695
+ "src/workspace-files.ts"() {
4696
+ SYSTEM_OPENABLE_EXTENSIONS = /* @__PURE__ */ new Set([
4697
+ ".doc",
4698
+ ".docx",
4699
+ ".xls",
4700
+ ".xlsx",
4701
+ ".ppt",
4702
+ ".pptx",
4703
+ ".rtf",
4704
+ ".odt",
4705
+ ".ods",
4706
+ ".odp",
4707
+ ".pages",
4708
+ ".numbers",
4709
+ ".key",
4710
+ ".pdf",
4711
+ ".tif",
4712
+ ".tiff",
4713
+ ".heic",
4714
+ ".heif",
4715
+ ".psd",
4716
+ ".sketch",
4717
+ ".fig",
4718
+ ".mp4",
4719
+ ".mov",
4720
+ ".m4v",
4721
+ ".mkv",
4722
+ ".avi",
4723
+ ".webm",
4724
+ ".mp3",
4725
+ ".wav",
4726
+ ".m4a",
4727
+ ".flac",
4728
+ ".aac",
4729
+ ".ogg"
4730
+ ]);
4731
+ LIST_TTL_MS = 6e4;
4732
+ }
4733
+ });
4131
4734
  function splitDirList(value) {
4132
4735
  if (!value) return [];
4133
4736
  return value.split(delimiter).map((s) => s.trim()).filter((s) => s.length > 0);
@@ -4137,20 +4740,23 @@ function wireWorkspace(input) {
4137
4740
  input.permission?.setExtraRoots(workspace.extra);
4138
4741
  if (input.addDir.length === 0) return workspace;
4139
4742
  for (const issue of issues) {
4140
- if (issue.kind === "redundant") input.diags.skipped("\u989D\u5916\u76EE\u5F55", issue.detail);
4141
- else input.diags.warn("\u989D\u5916\u76EE\u5F55", issue.detail, `add-dir-${issue.kind}`);
4743
+ if (issue.kind === "redundant") input.diags.skipped("ExtraDirs", issue.detail);
4744
+ else input.diags.warn("ExtraDirs", issue.detail, `add-dir-${issue.kind}`);
4142
4745
  }
4143
4746
  if (workspace.extra.length > 0) {
4144
4747
  input.diags.ok(
4145
4748
  WORKSPACE_MODULE,
4146
- `${workspace.extra.length} \u4E2A\u989D\u5916\u6839: ${workspace.extra.join(", ")}`
4749
+ t("boot.workspace_extra", {
4750
+ count: workspace.extra.length,
4751
+ dirs: workspace.extra.join(", ")
4752
+ })
4147
4753
  );
4148
4754
  }
4149
4755
  const unloaded = unloadedInstructionFiles(workspace);
4150
4756
  if (unloaded.length > 0) {
4151
4757
  input.diags.skipped(
4152
- "\u989D\u5916\u76EE\u5F55\u6307\u4EE4\u6587\u4EF6",
4153
- `\u672A\u52A0\u8F7D\uFF08\u989D\u5916\u76EE\u5F55\u7684\u6307\u4EE4\u6587\u4EF6\u8981\u5355\u72EC\u8FC7\u4FE1\u4EFB\u5224\u5B9A\uFF0C\u672C\u7248\u4E0D\u652F\u6301\uFF09: ${unloaded.join(", ")}`
4758
+ "ExtraDirInstructions",
4759
+ t("boot.workspace_extra_instructions", { paths: unloaded.join(", ") })
4154
4760
  );
4155
4761
  }
4156
4762
  return workspace;
@@ -4161,10 +4767,6 @@ function workspaceDecisionLog(sessions, diags) {
4161
4767
  try {
4162
4768
  sessions.updateMeta(sessionId, {
4163
4769
  workspaceState: decision.state,
4164
- // `none` 那一档**不带路径**。写 `undefined` 在 `updateSessionMeta()` 里
4165
- // 是「这一列不进 SET」(那儿第一行就 `if (v === undefined) continue`),
4166
- // 不是「清空它」—— 而 `bound → none` 这条路被 server 那道 409 挡着,
4167
- // 所以「留着一个陈旧的 root」在今天不可达
4168
4770
  ...decision.state === "bound" ? { workspaceRoot: decision.root } : {}
4169
4771
  });
4170
4772
  } catch (err) {
@@ -4210,51 +4812,10 @@ function inspectRoot(dir, lang) {
4210
4812
  var WORKSPACE_MODULE, SessionWorkspaces;
4211
4813
  var init_workspace = __esm({
4212
4814
  "src/workspace.ts"() {
4213
- WORKSPACE_MODULE = "\u5DE5\u4F5C\u533A";
4815
+ WORKSPACE_MODULE = "Workspace";
4214
4816
  SessionWorkspaces = class {
4215
- /**
4216
- * 谁绑在哪儿。**一张内存 Map,进程退出就没了 —— 这一条 2026-08-19 之后仍然成立。**
4217
- *
4218
- * 变的是它旁边多了一份 {@link WorkspaceDecisionLog}:绑成功的那一刻**另外**
4219
- * 往会话库记一笔「用户当初选了哪儿」。两者不是一份数据的两个副本,时态不同 ——
4220
- * 这张表答的是「它**现在**绑在哪儿」(因此 {@link bind} 那道闸门:路径校验 +
4221
- * 信任判定),落盘那份答的是「用户**当初**决定了什么」(只读的事实,不带闸门,
4222
- * 因为决定本身不需要重新判定)。
4223
- *
4224
- * 于是原来那两条反对理由,一条仍然拦着、一条被那个时态差绕开了:
4225
- *
4226
- * 1. **仍然成立**:落盘成一条**自动生效**的绑定,会和 [build.ts](./build.ts) 里
4227
- * `/resume` 那一行 {@link seed} 顶上 —— 那儿的规矩是「接回来的会话跑在
4228
- * **这个进程**的工作区里」。侧栏画着 A、引擎跑在 B 就是这么来的。所以装配时
4229
- * **没有任何东西读那份记录去 `bind()`**;绑定发生在用户点开旧会话、敲下第一句
4230
- * 话那一刻(`SessionFactory.revive()`),走的是这个类完整的 {@link bind};
4231
- * 2. **不再成立**:「只读的事实不属于这张表」是对的,但结论错了 —— 它属于
4232
- * **会话库那一行**,而不是「所以谁都别记」。不记的代价是重启之后一段会话
4233
- * 说不出自己在哪儿干过活,判据全文(含哪条过期、哪条原样成立)在 web 的
4234
- * `shell/session-list.tsx` 文件头。
4235
- */
4236
4817
  bindings = /* @__PURE__ */ new Map();
4237
- /**
4238
- * 「建出来了,但还没选地盘」的那几个会话(方案 55 PR-1,2026-08-17)。
4239
- *
4240
- * **一个 `Set<sessionId>`,不是第二张绑定表**:这一档的全部内容就是「它在等
4241
- * 一次选择」,没有路径、没有信任判定、没有 `ProjectContext`。用一张 Map 存的话
4242
- * 那些格子只能是 null,而一个到处是 null 的绑定会被下一个人当成「绑了个空的」。
4243
- *
4244
- * 出集合有且只有两条路:{@link bind} 成功(转成 `bound`)、
4245
- * 以及 {@link release}(会话没了)。「明确选不使用工作区」那一下在宿主那一层
4246
- * 也走 `release` —— 判据见它。
4247
- */
4248
4818
  deferred = /* @__PURE__ */ new Set();
4249
- /**
4250
- * 明确选过「不使用工作区」的那几个(会话库 v7,2026-08-19)。
4251
- *
4252
- * **{@link stateOf} 不看它**,那一档的语义一个字没变。这个集合存在只为回答
4253
- * {@link decisionOf} 的第三种答案 `null`:`stateOf()` 把「明确不使用」和
4254
- * 「这个进程从没听说过这个 id」都报成 `none`(那条 ⚠️ 就写在它的文档上),
4255
- * 而落盘的时候这两者天差地别 —— 前者是一次决定,后者写下去就是**替用户
4256
- * 编了一个他没做过的决定**。
4257
- */
4258
4819
  noWorkspace = /* @__PURE__ */ new Set();
4259
4820
  gate;
4260
4821
  recent;
@@ -4266,51 +4827,17 @@ var init_workspace = __esm({
4266
4827
  this.decisions = opts.decisions ?? null;
4267
4828
  this.now = opts.now ?? Date.now;
4268
4829
  }
4269
- /** 这个会话绑在哪儿。没绑过 `null` —— 「不使用工作区」是真的一档,不是缺省值 */
4270
4830
  of(sessionId) {
4271
4831
  return this.bindings.get(sessionId) ?? null;
4272
4832
  }
4273
- /**
4274
- * 记一笔「建出来了、还没选地盘」。判据全文在 {@link WorkspaceControl.defer}。
4275
- *
4276
- * **已经绑上的会话调它是空操作**,不是把它退回未选:那等于绕过决定 18 的
4277
- * 「绑定后不给改」—— 一个宿主只要先 `defer()` 再 `bind()`,就能给一段跑过
4278
- * 一整轮活的会话换地盘,而 `bind()` 那道 `locked` 闸拦的正是这件事。
4279
- */
4280
4833
  defer(sessionId) {
4281
4834
  if (this.bindings.has(sessionId)) return;
4282
4835
  this.deferred.add(sessionId);
4283
4836
  }
4284
- /** 这个会话是哪一档。判据见 {@link WorkspaceBindingState} */
4285
4837
  stateOf(sessionId) {
4286
4838
  if (this.bindings.has(sessionId)) return "bound";
4287
4839
  return this.deferred.has(sessionId) ? "unbound" : "none";
4288
4840
  }
4289
- /**
4290
- * 装配时把**引导会话**的绑定记进来。
4291
- *
4292
- * 和 {@link bind} 分开是因为这一份已经算好了:`buildRuntime()` 手里的
4293
- * `workspace`(含 `--add-dir`)、`services.trust`、`services.projectContext`
4294
- * 就是引擎**真在用**的那三样。走 bind() 会重新 `discoverProject()`(要跑 git)
4295
- * 算出第二份 —— 那第二份哪怕只差一个字节,界面上写的就不是引擎真在用的那个。
4296
- *
4297
- * `unloadedInstructions` **由这里补**,不要调用方给:它是 `workspace` 的纯函数,
4298
- * 而让两个 `seed()` 调用点各算一遍的失败形态是其中一个漏了 ——
4299
- * 结果是「resume 之后那条『额外目录的 CLAUDE.md 没加载』的提示凭空消失」。
4300
- *
4301
- * @param opts.decision 顺带把这一份**落盘**成用户的工作区决定(会话库 v7)。
4302
- * **两个调用点一个给一个不给,这正是这个参数存在的全部理由**:
4303
- *
4304
- * - `buildRuntime()` 里引导会话那一句 —— **给**。用户在哪个目录里敲的
4305
- * `epoch` / `epoch web`,那就是他为这段会话选的地盘,和在 picker 里点一下
4306
- * 没有区别。不给的话「直接 `epoch web` 聊两句」这条最短路径重启后一律
4307
- * 掉进「早先的会话」,而那正是这一轮要修的;
4308
- * - `/resume` 那一句(`build.ts` 的 `SessionControl.resume` 回调)——
4309
- * **不给**。那儿的规矩是「接回来的会话跑在**这个进程**的工作区里」,
4310
- * 它是一次**临时接管**,不是那段会话的主人改了主意。落盘它的后果是:
4311
- * 随手在 `/tmp` 里 `epoch --resume` 一段三周前的会话,就把它在库里的
4312
- * 地盘永久改成了 `/tmp`。
4313
- */
4314
4841
  seed(sessionId, bound, opts) {
4315
4842
  this.bindings.set(sessionId, {
4316
4843
  ...bound,
@@ -4322,17 +4849,6 @@ var init_workspace = __esm({
4322
4849
  this.decisions?.record(sessionId, { state: "bound", root: bound.workspace.root });
4323
4850
  }
4324
4851
  }
4325
- /**
4326
- * 把一个目录绑到会话上。
4327
- *
4328
- * 顺序是**先校验路径、再看有没有被锁**:一个写错的路径不管会话锁没锁都是写错的,
4329
- * 而反过来的话「路径不存在」这条永远被 `locked` 盖住 —— 于是调用方收到的是
4330
- * 「不能改」,而他真正的问题是「这个目录我打错了」。
4331
- *
4332
- * **只认绝对路径。** 相对路径要么相对服务进程的 cwd、要么相对用户以为的某个
4333
- * 目录,两者不一致时没有任何迹象;而这条路的入口是网线(浏览器传字符串进来),
4334
- * 那儿压根没有「当前目录」这个概念。
4335
- */
4336
4852
  bind(sessionId, dir, lang) {
4337
4853
  const invalid = inspectRoot(dir, lang);
4338
4854
  if (invalid) return invalid;
@@ -4358,75 +4874,28 @@ var init_workspace = __esm({
4358
4874
  this.decisions?.record(sessionId, { state: "bound", root });
4359
4875
  return { ok: true, bound, changed: true };
4360
4876
  }
4361
- /** 记一笔「明确不使用工作区」。判据全文在 {@link WorkspaceControl.decideNone} */
4362
4877
  decideNone(sessionId) {
4363
4878
  if (this.bindings.has(sessionId)) return;
4364
4879
  this.deferred.delete(sessionId);
4365
4880
  this.noWorkspace.add(sessionId);
4366
4881
  this.decisions?.record(sessionId, { state: "none" });
4367
4882
  }
4368
- /**
4369
- * 用户为这个会话做过的工作区决定;**没做过是 `null`**。
4370
- *
4371
- * 这是 `SessionStore.ensure()` 建行那一刻要问的那一句(判据在
4372
- * {@link WorkspaceDecisionLog.record} 的「实现方不许建行」那一节)。
4373
- *
4374
- * ⚠️ 和 {@link stateOf} 的差别只有一处,但那一处是全部意义所在:
4375
- * `stateOf()` 把「明确不使用」和「没听说过这个 id」都答成 `none`,
4376
- * 这里前者是 `{state:'none'}`、后者是 `null`。
4377
- */
4378
4883
  decisionOf(sessionId) {
4379
4884
  const bound = this.bindings.get(sessionId);
4380
4885
  if (bound) return { state: "bound", root: bound.workspace.root };
4381
4886
  return this.noWorkspace.has(sessionId) ? { state: "none" } : null;
4382
4887
  }
4383
- /**
4384
- * 解绑(会话被删掉时)。
4385
- *
4386
- * **不动「已知工作区」清单**:那份记的是「我最近在哪几个目录里干过活」,
4387
- * 删掉一段会话不代表那件事没发生过。
4388
- *
4389
- * ⚠️ 2026-08-17 起它也把 {@link defer} 那一笔清掉,于是「删掉一段还没选地盘的
4390
- * 会话」不会在集合里留一个永远等不到选择的 id(方案 55 验收第 8 条)。
4391
- *
4392
- * ⚠️ **「明确选不使用工作区」那一下 2026-08-19 起不再走这里**,改走
4393
- * {@link decideNone}。原来两者在内存里等价(摘掉 `defer` 那一笔之后
4394
- * {@link stateOf} 就是 `none`),但落盘之后不再等价:这个方法**同时**是
4395
- * 建会话失败的回滚路径和删会话的路径,在它里面记一次决定,等于让一段建到
4396
- * 一半失败的会话在库里留下「用户选了不使用工作区」。判据全文在 `decideNone` 上。
4397
- *
4398
- * **它照旧把 `noWorkspace` 那一笔也摘掉** —— 会话都没了,那个决定连同它一起走。
4399
- * ⚠️ 摘的只是内存这一份:库里那一行由 `DELETE /api/sessions/:id` 整行删掉,
4400
- * 不是在这儿改字段。
4401
- */
4402
4888
  release(sessionId) {
4403
4889
  this.bindings.delete(sessionId);
4404
4890
  this.deferred.delete(sessionId);
4405
4891
  this.noWorkspace.delete(sessionId);
4406
4892
  }
4407
- /** 本机「已知工作区」清单,最近使用的在前 */
4408
4893
  known() {
4409
4894
  return this.recent.list();
4410
4895
  }
4411
- /**
4412
- * 一个目录的信任判定。
4413
- *
4414
- * 和 {@link resolveBinding} 一样先归一到**项目根**再判 —— 两处分叉的表现是
4415
- * 「清单里说未信任、绑上去之后指令文件却加载了」。
4416
- *
4417
- * 只查不写:`decide()` 是纯读(`TrustManager.check` + 降级规则),
4418
- * 不往清单里记账,也不建绑定。
4419
- */
4420
4896
  trustOf(dir) {
4421
4897
  return this.gate.decide(resolveProjectRoot(dir));
4422
4898
  }
4423
- /**
4424
- * 一个目录 → 完整的绑定。
4425
- *
4426
- * 信任按**项目根**判而不是按传进来那个目录判,和 `discoverProject()` 内部
4427
- * 保持一致(在仓库子目录里绑定会继承仓库根的信任决定)。两处判据分叉的表现是
4428
- * 「界面说未信任、指令文件却加载了」,或者反过来。
4429
- */
4430
4899
  resolveBinding(root) {
4431
4900
  const trust = this.gate.decide(resolveProjectRoot(root));
4432
4901
  const { workspace } = resolveWorkspace(root);
@@ -4434,26 +4903,25 @@ var init_workspace = __esm({
4434
4903
  workspace,
4435
4904
  trust,
4436
4905
  projectContext: discoverProject(root, trust),
4437
- // 照旧探一遍而不是写死 `[]`:今天没有额外根所以它必然是空的,
4438
- // 但「为什么是空的」这件事不该由一个字面量来记住
4439
4906
  unloadedInstructions: unloadedInstructionFiles(workspace)
4440
4907
  };
4441
4908
  }
4442
4909
  };
4443
4910
  }
4444
4911
  });
4445
-
4446
4912
  // src/build.ts
4447
4913
  var build_exports = {};
4448
4914
  __export(build_exports, {
4449
4915
  AgentRoleError: () => AgentRoleError,
4916
+ buildGoalCatalog: () => buildGoalCatalog,
4450
4917
  buildRuntime: () => buildRuntime,
4451
4918
  ensureSecretsReady: () => ensureSecretsReady
4452
4919
  });
4453
4920
  async function buildRuntime(opts = {}) {
4454
4921
  const opened = { cleanups: null };
4922
+ const processes = createProcessTable();
4455
4923
  try {
4456
- return await assembleRuntime(opts, opened);
4924
+ return await assembleRuntime(opts, opened, processes);
4457
4925
  } catch (err) {
4458
4926
  for (const fn of [...opened.cleanups ?? []].reverse()) {
4459
4927
  try {
@@ -4462,16 +4930,17 @@ async function buildRuntime(opts = {}) {
4462
4930
  }
4463
4931
  }
4464
4932
  try {
4465
- await cleanupBackgroundProcesses();
4933
+ await processes.release();
4466
4934
  } catch {
4467
4935
  }
4468
4936
  throw err;
4469
4937
  }
4470
4938
  }
4471
- async function assembleRuntime(opts, opened) {
4939
+ async function assembleRuntime(opts, opened, processes) {
4472
4940
  const startedAt = Date.now();
4473
4941
  const workDir = opts.workDir ?? process.cwd();
4474
4942
  const sessionId = opts.resumeId || randomUUID();
4943
+ processes.claim(sessionId);
4475
4944
  const diagnostics = new DiagnosticSink();
4476
4945
  if (opts.hostPreset) {
4477
4946
  await applyHostPreset({
@@ -4499,14 +4968,8 @@ async function assembleRuntime(opts, opened) {
4499
4968
  ...opts.interactive === void 0 ? {} : { interactive: opts.interactive },
4500
4969
  ...opts.logger ? { logger: opts.logger } : {},
4501
4970
  ...settingsPath ? { settingsPath } : {},
4502
- // 宿主注入的技能目录(方案 44)。空数组也照传:`resolveSkillDirs` 对空数组
4503
- // 和不给这个键的处理逐字相同,多一个分支只是多一处会走散的地方
4504
4971
  hostSkillDirs: hostCaps.skillDirs,
4505
- // 没有 `?? process.env.*` 的兜底,理由见 `BuildRuntimeOptions.managedSettingsPath`
4506
4972
  ...opts.managedSettingsPath ? { managedSettingsPath: opts.managedSettingsPath } : {},
4507
- // **这一行必须紧跟着上面那次 `loadConfig()`**:`getConfigOrigins()` 报的是
4508
- // 最近一次加载的账,中间隔一次别人的 `loadConfig()` 就串台了。递进去而不是
4509
- // 让 `buildServices` 自己去问,判据写在 `ServiceOptions.configOrigins` 上
4510
4973
  configOrigins
4511
4974
  });
4512
4975
  const config = services.config;
@@ -4545,21 +5008,25 @@ async function assembleRuntime(opts, opened) {
4545
5008
  if (sandbox) {
4546
5009
  cleanups.push(() => sandbox.destroy());
4547
5010
  isolation = sandbox.getIsolation();
4548
- diagnostics.ok("Sandbox \u9694\u79BB", isolation.detail);
5011
+ diagnostics.ok("SandboxIsolation", isolation.detail);
4549
5012
  services.permission?.setSandboxIsolation(isolation.level);
4550
5013
  }
4551
5014
  if (services.permission) {
4552
5015
  applyHeadless(services.permission, config, opts.headless, diagnostics, opts.interactive);
4553
5016
  }
4554
5017
  const budgetWiring = wireBudget(config, services.modelPricing, diagnostics);
5018
+ const bootstrapReveal = createToolRevealState();
4555
5019
  const registry = registerLocalTools({
4556
5020
  homeDir: config.homeDir,
5021
+ deferMcp: config.tools?.deferMcp === true,
5022
+ toolMode: foldToolMode(config.tools?.mode),
5023
+ bootstrapReveal,
4557
5024
  trackerDb: services.trackerDb,
4558
5025
  goals: services.goals,
4559
5026
  memory: services.memory,
4560
5027
  sandbox,
5028
+ skills: services.skill,
4561
5029
  diags: diagnostics,
4562
- // 合并后那份 config —— 项目级设置将来若放开 search 段,这里天然跟着走
4563
5030
  ...config.search ? { search: config.search } : {}
4564
5031
  });
4565
5032
  registerSessionSearch(registry, {
@@ -4568,11 +5035,20 @@ async function assembleRuntime(opts, opened) {
4568
5035
  ...config.sessionSearch ? { sessionSearch: config.sessionSearch } : {},
4569
5036
  diags: diagnostics
4570
5037
  });
4571
- const mcpWiring = await registerMcpTools(registry, config.homeDir, diagnostics, [
4572
- { source: "host", servers: hostCaps.mcpServers },
4573
- { source: "plugin", servers: wirePluginMcpServers(services.plugins.mcpPaths, diagnostics) }
4574
- ]);
5038
+ const mcpWiring = await registerMcpTools(
5039
+ registry,
5040
+ config.homeDir,
5041
+ diagnostics,
5042
+ [
5043
+ { source: "host", servers: hostCaps.mcpServers },
5044
+ { source: "plugin", servers: wirePluginMcpServers(services.plugins.mcpPaths, diagnostics) }
5045
+ ],
5046
+ config.tools?.deferMcp === true
5047
+ );
4575
5048
  cleanups.push(mcpWiring.dispose);
5049
+ registry.registerTools(
5050
+ createMcpTools({ control: mcpWiring.control, servers: () => mcpWiring.status() })
5051
+ );
4576
5052
  const bridges = new SessionBridges(opts.onApprovalRequest);
4577
5053
  const interactive = !services.permission?.isNonInteractive();
4578
5054
  const requestPlanApproval = (req, sessionId2) => bridges.approve(sessionId2, req);
@@ -4582,39 +5058,41 @@ async function assembleRuntime(opts, opened) {
4582
5058
  requestPlanApproval,
4583
5059
  interactive
4584
5060
  );
5061
+ const defaultLevel = buildDefaultLevelSlot(services);
4585
5062
  const guards = buildSessionGuards({
4586
5063
  permission: services.permission,
4587
5064
  planState,
4588
5065
  managed: services.managedPolicy,
4589
- // 「这次启动那一档」,不是「引导会话此刻那一档」——判据在 `SessionGuards.open`
4590
- startLevel: services.permissionLevel,
4591
- // **和上面那两个进程级工具收的是同一个回调**:「弹给谁看」由
4592
- // `ToolContext.sessionId` 决定,在这儿另写一个闭包等于把那条路由绕过去
5066
+ deferredTools: () => registry.byExposure(ToolExposure.Deferred),
5067
+ bootstrapReveal,
5068
+ defaultLevel,
4593
5069
  requestApproval: requestPlanApproval,
4594
5070
  interactive
4595
5071
  });
4596
5072
  const bootstrapGuard = guards.bootstrap();
4597
5073
  registerAskQuestion(registry, (req, sessionId2) => bridges.ask(sessionId2, req), interactive);
5074
+ registry.registerTools(
5075
+ dropOverlongToolNames(
5076
+ hostCaps.tools,
5077
+ HOST_CAPABILITIES_MODULE,
5078
+ "host-tool-name-too-long",
5079
+ diagnostics
5080
+ ),
5081
+ "host"
5082
+ );
4598
5083
  const commands = wireCommands({
4599
5084
  homeDir: config.homeDir,
4600
5085
  projectRoot: services.projectContext.rootDir,
4601
5086
  trusted: services.trust.trusted,
4602
5087
  pluginDirs: services.plugins.commandDirs,
4603
5088
  toolProvider: registry.asProvider(),
4604
- // 传的是**函数**:命令展开发生在用户敲下那一刻,而这之前他可能已经
4605
- // `/model` 换过了。取值钉住就等于永远解析成启动时那个模型
4606
- //
4607
- // 只递 provider + model 两段:`origin` 讲的是**当前选择**怎么来的,
4608
- // 而这里回答的是「工具模型是谁」。原样带过去,命令会展开出一个
4609
- // `origin: 'config'` 的 ref,随后 `enterTurn` 再按 `command-frontmatter`
4610
- // 盖一次 —— 同一个字段有两个说法,出事时看哪个都不对
4611
5089
  utilityModel: () => {
4612
5090
  const s = services.provider?.getUtilityInfo().selection;
4613
5091
  return s ? { provider: s.provider, model: s.model } : void 0;
4614
5092
  },
4615
5093
  diags: diagnostics
4616
5094
  });
4617
- const checkpoints = new CheckpointManager({ workDir, homeDir: config.homeDir });
5095
+ const checkpoints = new CheckpointManager({ workDir: () => workDir, homeDir: config.homeDir });
4618
5096
  const { providerBridge } = services;
4619
5097
  const agentRoles = [];
4620
5098
  const agentRoleNotices = [];
@@ -4633,8 +5111,11 @@ async function assembleRuntime(opts, opened) {
4633
5111
  mainRole = pickMainRole(opts.agent ?? process.env.EPOCH_AGENT, roles);
4634
5112
  if (mainRole) {
4635
5113
  diagnostics.ok(
4636
- "\u4E3B agent \u89D2\u8272",
4637
- `${mainRole.name}${mainRole.tools ? `\uFF08${mainRole.tools.length} \u4E2A\u5DE5\u5177\uFF09` : "\uFF08\u5DE5\u5177\u8868\u4E0D\u6536\u7A84\uFF09"}`
5114
+ "MainAgentRole",
5115
+ t("boot.main_role", {
5116
+ name: mainRole.name,
5117
+ tools: mainRole.tools ? t("boot.main_role_tools", { count: mainRole.tools.length }) : t("boot.main_role_no_narrow")
5118
+ })
4638
5119
  );
4639
5120
  }
4640
5121
  }
@@ -4652,34 +5133,25 @@ async function assembleRuntime(opts, opened) {
4652
5133
  provider: providerBridge,
4653
5134
  models,
4654
5135
  guards,
4655
- // 引导会话的工作目录。**不能拿 `projectContext.rootDir` 顶替** ——
4656
- // 在仓库子目录里启动是常态,顶替等于悄悄换了相对路径的解析基准
4657
5136
  defaultWorkDir: workDir,
4658
- // 收窄过的那一层(方案 23);未收窄那份给子 agent,判据见工厂的选项注释
4659
5137
  toolProvider: commands.toolProvider,
4660
5138
  rawToolProvider: registry.asProvider(),
5139
+ deferredTools: () => registry.byExposure(ToolExposure.Deferred),
5140
+ codeModeTools: () => registry.codeModeDeclared(),
4661
5141
  telemetry,
5142
+ compliance: services.compliance,
4662
5143
  budget: budgetWiring,
4663
5144
  ...opts.onApprovalRequest ? { hostApproval: opts.onApprovalRequest } : {},
4664
5145
  workspaces,
4665
5146
  bridges,
4666
5147
  sessions: services.session,
4667
- // 目标(方案 52)。**进程一份**,收窄成「这一段会话」发生在工厂里
4668
- // (`buildGoalControl(…, () => session.sessionId)`)
4669
5148
  goals: services.goals,
4670
5149
  ...opts.sessionSource ? { sessionSource: opts.sessionSource } : {},
4671
5150
  mainRole: mainRole ?? null,
4672
5151
  roles: () => agentRoles,
4673
- // 后台任务的状态变化(方案 36 PR-2)。给函数不给数组 —— 任务状态每时每刻
4674
- // 在变,快照拿到的是装配那一刻的。
4675
- //
4676
- // ⚠️ 2026-08-15 起它**按会话取**:工厂给每个会话拿它自己那一批(判据在
4677
- // plugin-terminal 的 `background.ts` 文件头)。收窄之前每个会话每轮都被告知
4678
- // 一批**它没起过**的命令在跑,而那条通知是直接进模型上下文的
4679
5152
  backgroundTasks: (id) => listTasks(id),
4680
- // 子任务跑完之后把它起的后台任务归还给派活的会话(判据在 `subRunner` 末尾)。
4681
- // 这里是全仓唯一一处知道「任务表由 plugin-terminal 提供」的地方
4682
5153
  rekeyBackgroundTasks: rekeyTasks,
5154
+ processes,
4683
5155
  diags: diagnostics
4684
5156
  });
4685
5157
  if (providerBridge) {
@@ -4689,19 +5161,22 @@ async function assembleRuntime(opts, opened) {
4689
5161
  for (const c of registry.getConflicts()) {
4690
5162
  diagnostics.warn(
4691
5163
  `Tool ${c.name}`,
4692
- `${c.droppedFrom} \u4E0E ${c.keptFrom} \u649E\u540D\uFF0C\u5DF2\u5FFD\u7565\u524D\u8005`,
5164
+ t("boot.tool_conflict", { dropped: c.droppedFrom, kept: c.keptFrom }),
4693
5165
  "tool-name-conflict"
4694
5166
  );
4695
5167
  }
4696
5168
  }
5169
+ reportToolCost(registry, diagnostics);
4697
5170
  const store = services.session ? new SessionStore(
4698
5171
  services.session,
4699
5172
  config.homeDir,
4700
- services.projectContext.rootDir,
5173
+ () => services.projectContext.rootDir,
4701
5174
  opts.sessionSource
4702
5175
  ) : void 0;
4703
5176
  const history = opts.resumeId && store ? store.loadHistory(opts.resumeId) : [];
4704
- if (history.length > 0) diagnostics.ok("History", `${history.length} \u6761\u6D88\u606F\u5DF2\u52A0\u8F7D`);
5177
+ if (history.length > 0) {
5178
+ diagnostics.ok("History", t("boot.history_loaded", { count: history.length }));
5179
+ }
4705
5180
  const bootstrap = factory.adopt(sessionId, history);
4706
5181
  const session = bootstrap?.session ?? null;
4707
5182
  const agent = bootstrap?.agent ?? null;
@@ -4728,7 +5203,7 @@ async function assembleRuntime(opts, opened) {
4728
5203
  }
4729
5204
  }
4730
5205
  try {
4731
- await cleanupBackgroundProcesses();
5206
+ await processes.release();
4732
5207
  } catch {
4733
5208
  }
4734
5209
  };
@@ -4737,10 +5212,6 @@ async function assembleRuntime(opts, opened) {
4737
5212
  const provider = services.provider;
4738
5213
  const listTools = () => commands.toolProvider.list().map((tool) => ({
4739
5214
  tool,
4740
- // 注册表一直记着「谁注册的这个工具」,只是以前没人往外传。能力页的
4741
- // 「内置连接器」那一组要按它分(方案 42 PR-1)—— 按工具名前缀猜
4742
- // (`file_*` 归 plugin-file)会在第一个不按前缀命名的工具上错。
4743
- // 取不到只可能是收窄层放进来一个 registry 没见过的名字,那时说 `builtin`
4744
5215
  source: registry.sourceOf(tool.name) ?? "builtin"
4745
5216
  }));
4746
5217
  const schedules = createScheduleControl({
@@ -4748,30 +5219,35 @@ async function assembleRuntime(opts, opened) {
4748
5219
  ...opts.scheduleRunner ? { runner: opts.scheduleRunner } : {}
4749
5220
  });
4750
5221
  cleanups.push(schedules.dispose);
5222
+ if (schedules.openError) {
5223
+ diagnostics.failed("Schedules", `FAILED (${schedules.openError.message})`, "init-failed");
5224
+ } else {
5225
+ diagnostics.ok("Schedules");
5226
+ }
5227
+ const hostMarketplaces = opts.hostMarketplaces;
5228
+ if (hostMarketplaces) {
5229
+ await wireHostMarketplaces({
5230
+ hostMarketplaces,
5231
+ statePath: marketplacesPath(config.homeDir),
5232
+ diagnostics
5233
+ });
5234
+ }
5235
+ reportI18nHealth(diagnostics);
4751
5236
  return {
4752
5237
  agent,
4753
5238
  session,
4754
- // 多会话工厂(方案 30 §6.3)。`SessionFactory` 是收窄过的那一面 ——
4755
- // `adopt` / `subRunner` 是装配层的特权,宿主拿到它就等于能绕过 `bind()`
4756
- // 那两道门(判据同 `WorkspaceControl` 上刻意不露 `seed` 的那一条)
4757
5239
  sessionFactory: factory,
5240
+ events: processEventBus,
4758
5241
  sessionStore: store ?? null,
4759
- // **引导会话自己那一份**,从工厂手里拿 —— 不在这儿再包一个。工厂给每个会话
4760
- // 各建了一个 `CheckpointManager`(作用域表里它是会话级),在这边照着上面那个
4761
- // 进程级管理器另包一层,等于给同一段会话的安全网第二个说法,而两者的
4762
- // `workDir` 只在「引导会话没换过工作区」时恰好相等(方案 30 §9.5)。
4763
- //
4764
- // `??` 那一支只在 provider 没起来时走得到(那时一个会话都装配不出来):
4765
- // 这个字段不可为 null,而一个读得出空列表的控制面是那时唯一诚实的答案
5242
+ sessionReferences: services.session?.references ?? null,
5243
+ workspaceFiles: buildWorkspaceFilesControl(
5244
+ { listFiles: listWorkspaceFiles, resolveInWorkspace, systemOpenArgv },
5245
+ services.permission
5246
+ ),
4766
5247
  checkpoints: bootstrap?.checkpoints ?? buildCheckpointControl(checkpoints, null, () => sessionId),
4767
- // **引导会话自己那一份**,判据逐字同上面的 `checkpoints`:从工厂手里拿,
4768
- // 不在这儿再包一个。`??` 那一支同理只在 provider 没起来时走得到 ——
4769
- // 那时候没有会话,而目标是会话的状态,`null` 是那一刻唯一诚实的答案
4770
5248
  goals: bootstrap?.goals ?? (services.goals ? buildGoalControl(services.goals, () => sessionId) : null),
5249
+ goalCatalog: services.goals ? buildGoalCatalog(services.goals) : null,
4771
5250
  config,
4772
- // 这次实际生效的数据目录,以及从它派生的 artifact 根目录(方案 54 §二)。
4773
- // **调 `paths.ts` 而不是 `join(homeDir, 'artifacts')`**:路径真源只有一处,
4774
- // 在这里拼字符串就是给同一个目录第二个说法 —— 而那正是这一节要修的病
4775
5251
  homeDir: config.homeDir,
4776
5252
  artifactsRoot: artifactsDir(void 0, config.homeDir),
4777
5253
  sessionId,
@@ -4779,22 +5255,11 @@ async function assembleRuntime(opts, opened) {
4779
5255
  schedules: schedules.control,
4780
5256
  workspace,
4781
5257
  workspaces,
4782
- // 装配过程里到处在用的那个布尔(指令文件、角色文件、自定义命令都问它),
4783
- // 原样露出来给 doctor —— 别在宿主侧重新判一遍,见字段的 JSDoc
4784
5258
  trusted: services.trust.trusted,
4785
- // 信任记录的读写口(方案 54 §五)。**零新逻辑** —— 转的就是装配时那个
4786
- // manager 实例本身,宿主 `record()` 之后本进程新绑的工作区立刻按新判定走
4787
- // (闸门每次现问 `check()`,中间没有缓存)。
4788
- //
4789
- // 转实例而不是包一层收窄:四个方法就是 protocol 的 `TrustManager` 契约,
4790
- // 包一层只会多出一处「哪天签名变了这里忘了跟」。
4791
5259
  trust: services.trustManager,
4792
5260
  isolation,
4793
5261
  policy: services.policy,
4794
- // **读收窄之后那一层**(方案 23),不是 `registry.exposed()`:这个字段的口径是
4795
- // 「模型现在看得见什么」,而一条写了 `allowed-tools` 的自定义命令正在跑的时候,
4796
- // 模型看得见的就是白名单里那几个。读原始 registry 的话 `/tools` 会在收窄期间说假话。
4797
- // getter 而不是快照:MCP 刷新(方案 15)和收窄进出都要能立刻反映出来
5262
+ hook: services.hook,
4798
5263
  get tools() {
4799
5264
  return listTools().map(({ tool, source }) => ({
4800
5265
  name: tool.name,
@@ -4804,19 +5269,10 @@ async function assembleRuntime(opts, opened) {
4804
5269
  },
4805
5270
  usageScope: budgetWiring.budgetStore ? "session" : "run",
4806
5271
  compression: compressionLine(config, services),
4807
- // **getter 而不是快照**(和上面的 `tools` 同一个理由):方案 26 之后
4808
- // `/model` 能在运行期换 provider,拍一张快照的话 `/model` 会一直显示
4809
- // 启动时那家和那把 key —— 一个专门用来"确认我现在用的是什么"的面板在说假话
4810
5272
  get providerInfo() {
4811
5273
  return provider ? { provider: provider.getProviderType(), apiKeyHint: provider.getApiKeyHint() } : null;
4812
5274
  },
4813
- // **引导会话那一份**(2026-08-16)——它就是这个进程那个 router 的出口本体,
4814
- // 所以 TUI 的 `/model`、CLI 的 `--model` 一个字节都没变。别的会话走
4815
- // `sessionFactory.get(id).model`,判据见 [session-model.ts](./session-model.ts)
4816
5275
  model: bootstrapModel?.control ?? null,
4817
- // **不吃 `provider`**,所以 provider 层起不来时它照样在(判据在字段上)。
4818
- // `configured` 写成 getter 而不是拍下来的两个值:base URL 只对配置里那一家
4819
- // 有效,而那条判断住在 `model-catalog.ts` 里,不在这儿
4820
5276
  modelCatalog: buildModelCatalogControl({
4821
5277
  homeDir: config.homeDir,
4822
5278
  configured: () => ({
@@ -4824,51 +5280,46 @@ async function assembleRuntime(opts, opened) {
4824
5280
  ...config.provider.baseUrl ? { baseUrl: config.provider.baseUrl } : {}
4825
5281
  })
4826
5282
  }),
5283
+ utility: provider ? buildUtilityControl({
5284
+ provider,
5285
+ ...budgetWiring.budgetStore ? { store: budgetWiring.budgetStore } : {},
5286
+ ...budgetWiring.pricingOf ? { pricingOf: budgetWiring.pricingOf } : {}
5287
+ }) : null,
4827
5288
  agentRoles,
4828
5289
  agentRoleNotices,
4829
5290
  get skills() {
4830
5291
  return services.skill?.list() ?? [];
4831
5292
  },
4832
- // 不计数的那条读(方案 56 §1.2)。`?? null` 而不是 `?? ''`:技能系统没起来
4833
- // 和「这个技能的正文是空的」是两件事,合成同一个值之后界面画不出前者
4834
5293
  skillBody: (name) => services.skill?.body(name) ?? null,
4835
- // 收窄过的导入口(方案 42 §六)。**转的是两个函数,不是 `services.skill`
4836
- // 本身** —— 判据在 `SkillControl` 的文档上,同 `McpControl` 只露 reconnect
4837
- skillImport: buildSkillControl(services),
4838
- // 身份那一栏的写口(2026-08-18)。**只露一个 `add`**,同上面那两条。
4839
- // ⚠️ `reload` 递的是上面那个 `reloadRoles` —— 建完当场重载,判据在
4840
- // `role-write.ts` 文件头第二节(不重载的话这一下只做了一半)
5294
+ skillIndexResidency: (role) => services.skill?.indexResidency({
5295
+ only: role === null ? void 0 : findRole(agentRoles, role)?.skills
5296
+ }) ?? /* @__PURE__ */ new Map(),
5297
+ skillWrite: buildSkillControl(services),
4841
5298
  roleWrite: createRoleWriteControl({
4842
5299
  userDir: agentsDir(config.homeDir),
4843
5300
  roles: () => agentRoles,
4844
- // 给函数不给快照:MCP 连上 / 掉线都会让工具表变,而工具名要按**用户按下
4845
- // 那一刻**的那份判(同 `mcpServers` 是个 getter 的判据)
4846
5301
  tools: () => registry.asProvider(),
4847
5302
  reload: reloadRoles
4848
5303
  }),
4849
- // getter:MCP 会掉线重连,快照就是启动那一刻的谎话
5304
+ plugins: hostMarketplaces ? createPluginControl({
5305
+ pluginsDir: pluginsDir(config.homeDir),
5306
+ statePath: pluginsStatePath(config.homeDir),
5307
+ marketplacesPath: marketplacesPath(config.homeDir),
5308
+ loaded: services.plugins.loaded,
5309
+ allowRemote: hostMarketplaces.allowRemote === true
5310
+ }) : null,
4850
5311
  get mcpServers() {
4851
5312
  return mcpWiring.status();
4852
5313
  },
4853
- // 收窄过的那一面(方案 56 §1.1)。**转的是 `McpWiring.control` 而不是
4854
- // registry 本身** —— 判据在 `McpControl` 的文档上,同 `WorkspaceControl`
4855
- // 刻意不露 `seed()` 那一条
4856
5314
  mcp: mcpWiring.control,
4857
5315
  memory: services.memory ? buildMemoryControl(services.memory) : null,
4858
- // 引导会话那一份档位(`level` / `setLevel`)+ 三样进程级的读
4859
- // (规则表 / 遮蔽 / 审计流水)。⚠️ 换档那三步(托管挡 bypass → `setLevel` →
4860
- // `plan.forget()`)**不在这个函数里**:它们住在 `session-guard.ts`,
4861
- // 因为它们现在要按会话跑 N 遍,而那条判定只能有一个实现
4862
5316
  permissions: buildPermissionsControl(services, bootstrapGuard.permissions, listTools),
4863
- // 设置页那一片。**收 `workspaces`** 是这一轮补的那笔账:③④ 跟着工作区走,
4864
- // 而工作区一个会话绑一个(决定 18)—— 判据在 `SettingsControl` 的文档上
4865
5317
  settings: buildSettingsControl(services, workspaces, {
4866
5318
  ...settingsPath ? { settingsPath } : {},
4867
5319
  ...opts.managedSettingsPath ? { managedSettingsPath: opts.managedSettingsPath } : {}
4868
5320
  }),
4869
- // 同 `model`:**引导会话那一份**,而它就是进程那个 `PlanModeState` 的出口本体
4870
5321
  plan: bootstrapGuard.plan,
4871
- sessions: services.session ? buildSessionControl(services.session, session, checkpoints, (from, id) => {
5322
+ sessions: services.session ? buildSessionControl(services.session, session, checkpoints, config.homeDir, (from, id) => {
4872
5323
  planState?.restore(store?.loadPlan(id) ?? null);
4873
5324
  workspaces.seed(id, {
4874
5325
  workspace,
@@ -4879,7 +5330,6 @@ async function assembleRuntime(opts, opened) {
4879
5330
  factory.rekey(source, id);
4880
5331
  rekeyTasks(source, id);
4881
5332
  }) : null,
4882
- // getter 而不是快照,理由同 `tools` / `providerInfo`
4883
5333
  get contextBreakdown() {
4884
5334
  if (!agent) return null;
4885
5335
  const historyText = (session?.getHistory() ?? []).map((m) => m.content).join("\n");
@@ -4889,18 +5339,12 @@ async function assembleRuntime(opts, opened) {
4889
5339
  });
4890
5340
  },
4891
5341
  commands: commands.control,
4892
- // 用 `registry` 而不是 `commands.toolProvider`:`!` 是**用户**敲的,
4893
- // 不该被一条自定义命令的 `allowed-tools` 收窄掉(那条收窄的语义是
4894
- // 「这一轮**模型**能用什么」)
4895
5342
  userActions: createUserActions({
4896
5343
  registry,
4897
5344
  permission: services.permission,
4898
5345
  workDir,
4899
5346
  sessionId
4900
5347
  }),
4901
- // 三个通道,**一个真源**(那个 sink):字符串那份和「换个语言说一遍」那份
4902
- // 都是投影,不可能漂移。前两个是快照(装配到这一步就定了),
4903
- // 第三个是函数 —— 它要等请求到了才知道用哪个语言
4904
5348
  diagnostics: diagnostics.toStrings(),
4905
5349
  diagnosticList: diagnostics.toList(),
4906
5350
  diagnosticsIn: (lang) => diagnostics.toList(lang),
@@ -4933,9 +5377,9 @@ function wireAgentRoles(config, services, registry, diags, hostRoles = []) {
4933
5377
  });
4934
5378
  const external = [...services.plugins.roles, ...hostRoles, ...load.overrides];
4935
5379
  const { roles, notices } = mergeRoles(external);
4936
- for (const n of notices) diags.warn("Agent \u89D2\u8272", n.detail, n.code);
5380
+ for (const n of notices) diags.warn(AGENT_ROLE_DIAG_MODULE, n.detail, n.code);
4937
5381
  if (!load.projectSkipped) {
4938
- diags.ok("Agent \u89D2\u8272", (lang) => {
5382
+ diags.ok(AGENT_ROLE_DIAG_MODULE, (lang) => {
4939
5383
  const parts = [
4940
5384
  t("agent_roles.tally_builtin", { count: builtinRoles().length }, lang),
4941
5385
  t("agent_roles.tally_user", { count: load.userCount }, lang),
@@ -4955,19 +5399,27 @@ function wireAgentRoles(config, services, registry, diags, hostRoles = []) {
4955
5399
  const unknown = unknownRoleTools(provider, role, BLOCKED_TOOLS);
4956
5400
  if (unknown.length > 0) {
4957
5401
  diags.warn(
4958
- "Agent \u89D2\u8272",
4959
- `${role.name} \u5F15\u7528\u4E86\u4E0D\u5B58\u5728\u7684\u5DE5\u5177 ${unknown.join(", ")}\uFF0C\u5DF2\u5FFD\u7565`,
5402
+ AGENT_ROLE_DIAG_MODULE,
5403
+ t("boot.role_unknown_tools", { role: role.name, tools: unknown.join(", ") }),
4960
5404
  "unknown-tool"
4961
5405
  );
4962
5406
  }
4963
5407
  const hitBlocked = blockedRoleTools(role, BLOCKED_TOOLS);
4964
5408
  if (hitBlocked.length > 0) {
4965
5409
  diags.warn(
4966
- "Agent \u89D2\u8272",
4967
- `${role.name} \u8981\u4E86 ${hitBlocked.join(", ")}\uFF0C\u4F46\u4EFB\u4F55\u89D2\u8272\u90FD\u62FF\u4E0D\u5230\u8FD9\u4E9B\u5DE5\u5177`,
5410
+ AGENT_ROLE_DIAG_MODULE,
5411
+ t("boot.role_blocked_tools", { role: role.name, tools: hitBlocked.join(", ") }),
4968
5412
  "role-blocked-tool"
4969
5413
  );
4970
5414
  }
5415
+ const unknownSkills = unknownRoleSkills(services.skill?.getNames() ?? [], role);
5416
+ if (unknownSkills.length > 0) {
5417
+ diags.warn(
5418
+ AGENT_ROLE_DIAG_MODULE,
5419
+ t("boot.role_unknown_skills", { role: role.name, skills: unknownSkills.join(", ") }),
5420
+ "unknown-skill"
5421
+ );
5422
+ }
4971
5423
  }
4972
5424
  return { roles, notices };
4973
5425
  }
@@ -4977,7 +5429,9 @@ function pickMainRole(wanted, roles) {
4977
5429
  const role = findRole(roles, name);
4978
5430
  if (role) return role;
4979
5431
  const available = roles.map((r) => r.name).join(", ");
4980
- throw new AgentRoleError(`\u6CA1\u6709\u540D\u4E3A "${name}" \u7684 agent \u89D2\u8272\u3002\u53EF\u7528\uFF1A${available || "\uFF08\u65E0\uFF09"}`);
5432
+ throw new AgentRoleError(
5433
+ t("boot.role_not_found", { name, available: available || t("boot.role_none") })
5434
+ );
4981
5435
  }
4982
5436
  function makePricingLookup(homeDir) {
4983
5437
  const cache = /* @__PURE__ */ new Map();
@@ -5005,18 +5459,18 @@ function wireBudget(config, modelPricing, diagnostics) {
5005
5459
  );
5006
5460
  const { maxCostUsd, maxTokens } = config.budget ?? {};
5007
5461
  if (maxCostUsd === void 0 && maxTokens === void 0) {
5008
- diagnostics.skipped("Budget", "\u672A\u914D\u7F6E\u4E0A\u9650\uFF08epoch config set budget.maxCostUsd <\u91D1\u989D> \u5F00\u542F\uFF09");
5462
+ diagnostics.skipped("Budget", t("boot.budget_none"));
5009
5463
  } else {
5010
5464
  const limits = [
5011
- maxCostUsd !== void 0 ? `\u6210\u672C $${maxCostUsd}` : "",
5465
+ maxCostUsd !== void 0 ? t("boot.budget_cost", { amount: maxCostUsd }) : "",
5012
5466
  maxTokens !== void 0 ? `token ${maxTokens}` : ""
5013
5467
  ].filter(Boolean).join(" / ");
5014
- diagnostics.ok("Budget", `\u4F1A\u8BDD\u4E0A\u9650 ${limits}`);
5468
+ diagnostics.ok("Budget", t("boot.budget_limits", { limits }));
5015
5469
  }
5016
5470
  if (maxCostUsd !== void 0 && !modelPricing) {
5017
5471
  diagnostics.warn(
5018
5472
  "Budget",
5019
- `${config.model} \u6CA1\u6709\u5B9A\u4EF7\u6570\u636E\uFF0C\u6210\u672C\u4E0A\u9650\u4E0D\u751F\u6548\uFF08token \u4E0A\u9650\u4ECD\u6709\u6548\uFF09`,
5473
+ t("boot.budget_no_pricing", { model: config.model }),
5020
5474
  "no-pricing-data"
5021
5475
  );
5022
5476
  }
@@ -5024,8 +5478,6 @@ function wireBudget(config, modelPricing, diagnostics) {
5024
5478
  ...config.budget ? { budget: config.budget } : {},
5025
5479
  ...modelPricing ? { modelPricing } : {},
5026
5480
  ...budgetStore ? { budgetStore } : {},
5027
- // **无条件给**,不看有没有配上限:闸门就算不拦截也要算钱(usage 事件里的
5028
- // costUsd 有消费方),算错了一样是错的
5029
5481
  pricingOf: makePricingLookup(config.homeDir)
5030
5482
  };
5031
5483
  }
@@ -5037,7 +5489,7 @@ function applyHeadless(permission, config, override, diagnostics, interactive) {
5037
5489
  if (nonInteractive) {
5038
5490
  diagnostics.warn(
5039
5491
  "Headless",
5040
- `\u975E\u4EA4\u4E92\u73AF\u5883\uFF0C\u9884\u6388\u6743 ${describeHeadlessPolicy(merged)}`,
5492
+ t("boot.headless_preauth", { policy: describeHeadlessPolicy(merged) }),
5041
5493
  "headless-preauthorized"
5042
5494
  );
5043
5495
  }
@@ -5062,16 +5514,17 @@ async function migrateOnce(store, diagnostics, override) {
5062
5514
  envFilePath: envPath(homeDir),
5063
5515
  store,
5064
5516
  names: API_KEY_ENV_VARS,
5065
- // 只到天:同一天多跑几次不该产生一堆备份文件,真重名时由 migrate.ts 自己让路
5066
5517
  stamp: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10).replace(/-/g, "")
5067
5518
  });
5068
5519
  if (result.warning) diagnostics.warn("Secret", result.warning, "secret-migrate");
5069
5520
  if (result.migrated.length > 0) {
5070
5521
  diagnostics.ok(
5071
5522
  "Secret",
5072
- // `store.detail` 是「Windows DPAPI(已加密,绑定当前用户账户)」这种整句描述,
5073
- // 「迁入」和它之间要留一个空格 —— 少了就是「迁入Windows DPAPI」(2026-08-19 实测)
5074
- `\u5DF2\u628A ${result.migrated.length} \u4E2A key \u8FC1\u5165 ${store.detail}` + (result.backupPath ? `\uFF0C\u539F\u6587\u4EF6\u5907\u4EFD\u4E3A ${result.backupPath}` : "")
5523
+ t("boot.secret_migrated", {
5524
+ count: result.migrated.length,
5525
+ backend: store.detail,
5526
+ backup: result.backupPath ? t("boot.secret_migrated_backup", { path: result.backupPath }) : ""
5527
+ })
5075
5528
  );
5076
5529
  }
5077
5530
  }
@@ -5093,7 +5546,7 @@ async function setupMcpDataKey(store, diagnostics) {
5093
5546
  const why = err instanceof Error ? err.message : String(err);
5094
5547
  diagnostics.warn(
5095
5548
  "Secret",
5096
- `WARN MCP \u6570\u636E\u5BC6\u94A5\u53D6\u4E0D\u5230\uFF0Cmcp-auth.json \u4ECD\u662F\u660E\u6587\uFF08${why}\uFF09`,
5549
+ t("boot.mcp_key_unavailable", { reason: why }),
5097
5550
  "mcp-key-unavailable"
5098
5551
  );
5099
5552
  }
@@ -5129,6 +5582,34 @@ function buildSkillControl(services) {
5129
5582
  const outcome = await importSkills(source, { ...opts, token });
5130
5583
  if (outcome.ok && outcome.imported.length > 0) services.skill?.invalidateCache();
5131
5584
  return outcome;
5585
+ },
5586
+ remove: async (name, lang) => {
5587
+ const skill = services.skill;
5588
+ if (!skill) {
5589
+ return {
5590
+ ok: false,
5591
+ reason: "missing",
5592
+ detail: t("skill.remove_no_system", void 0, lang)
5593
+ };
5594
+ }
5595
+ const found = skill.list().find((s) => s.name === name);
5596
+ if (!found) {
5597
+ return {
5598
+ ok: false,
5599
+ reason: "not-found",
5600
+ detail: t("skill.remove_unknown", { name }, lang)
5601
+ };
5602
+ }
5603
+ try {
5604
+ const path = await skill.delete(name);
5605
+ return { ok: true, name, path };
5606
+ } catch (err) {
5607
+ return {
5608
+ ok: false,
5609
+ reason: found.scope === "user" ? "failed" : "readonly",
5610
+ detail: err instanceof Error ? err.message : String(err)
5611
+ };
5612
+ }
5132
5613
  }
5133
5614
  };
5134
5615
  }
@@ -5136,19 +5617,25 @@ function buildMemoryControl(memory) {
5136
5617
  return {
5137
5618
  list: () => memory.getAllEntries().map((e) => ({
5138
5619
  content: e.content,
5139
- // `getAllEntries` 不带 target,而这一层要显示「记在哪一级」。
5140
- // 拿不到就说 unknown,不猜 —— 猜错会让用户去错的文件里找
5141
5620
  target: e.target ?? "unknown"
5142
5621
  })),
5143
5622
  remove: async (content) => {
5144
5623
  const trimmed = content.trim();
5145
5624
  const hit = memory.getAllEntries().find((e) => e.content.includes(trimmed));
5146
- if (!hit) return { ok: false, message: `\u6CA1\u6709\u627E\u5230\u5305\u542B\u300C${trimmed}\u300D\u7684\u8BB0\u5FC6` };
5625
+ if (!hit) return { ok: false, message: t("boot.memory_not_found", { query: trimmed }) };
5147
5626
  try {
5148
5627
  await memory.remove("memory", hit.content);
5149
- return { ok: true, message: `\u5DF2\u5220\u9664\uFF1A${hit.content.slice(0, 60)}` };
5628
+ return {
5629
+ ok: true,
5630
+ message: t("boot.memory_removed", { content: hit.content.slice(0, 60) })
5631
+ };
5150
5632
  } catch (err) {
5151
- return { ok: false, message: `\u5220\u9664\u5931\u8D25\uFF1A${err instanceof Error ? err.message : err}` };
5633
+ return {
5634
+ ok: false,
5635
+ message: t("boot.memory_remove_failed", {
5636
+ reason: err instanceof Error ? err.message : String(err)
5637
+ })
5638
+ };
5152
5639
  }
5153
5640
  }
5154
5641
  };
@@ -5163,14 +5650,11 @@ function toSessionSummary(meta) {
5163
5650
  messageCount: meta.messageCount,
5164
5651
  model: meta.model,
5165
5652
  costUsd: meta.estimatedCostUsd,
5166
- // 两格原样转发,这一层**不判也不补**:库里是 NULL 就是 `undefined`
5167
- // (`rowToMeta()` 在那两格上专门做了 `?? undefined` 的归一,判据写在那儿),
5168
- // 而「没记过」和「明确不使用」在宿主那一侧是两个组
5169
5653
  workspaceState: meta.workspaceState,
5170
5654
  workspaceRoot: meta.workspaceRoot
5171
5655
  };
5172
5656
  }
5173
- function buildSessionControl(sessions, session, checkpoints, onResumed) {
5657
+ function buildSessionControl(sessions, session, checkpoints, homeDir, onResumed) {
5174
5658
  return {
5175
5659
  resume: (sessionId) => {
5176
5660
  const from = session?.sessionId ?? null;
@@ -5178,10 +5662,6 @@ function buildSessionControl(sessions, session, checkpoints, onResumed) {
5178
5662
  onResumed?.(from, sessionId);
5179
5663
  return restored;
5180
5664
  },
5181
- // ⚠️ `sortKey: 'updatedAt'` 这一行**没改,但含义变了**(会话库 v4):它以前
5182
- // 在 `SessionDB.listSessions` 里静默退化成 `started_at`,现在真的按最后活动排。
5183
- // 于是这个 `limit` 截出来的那批也变了 —— 一段两年前建、今天还在聊的会话
5184
- // 从此进得了列表,而它以前必然被一批「新建但没说过话」的会话挤掉
5185
5665
  list: (limit = 20) => sessions.list({ limit, sortKey: "updatedAt", direction: "desc" }).sessions.map(toSessionSummary),
5186
5666
  get: (sessionId) => {
5187
5667
  const found = sessions.get(sessionId);
@@ -5201,7 +5681,13 @@ function buildSessionControl(sessions, session, checkpoints, onResumed) {
5201
5681
  } catch {
5202
5682
  checkpointsRemoved = false;
5203
5683
  }
5204
- return { deleted, checkpointsRemoved };
5684
+ let artifactsRemoved = true;
5685
+ try {
5686
+ await removeSessionArtifacts(sessionId, homeDir);
5687
+ } catch {
5688
+ artifactsRemoved = false;
5689
+ }
5690
+ return { deleted, checkpointsRemoved, artifactsRemoved };
5205
5691
  },
5206
5692
  search: (query, limit = 20) => sessions.search({ query, limit, scope: "all" }).results.flatMap(
5207
5693
  (r) => r.matches.map((m) => ({
@@ -5238,15 +5724,17 @@ function buildPermissionsControl(services, bootstrap, tools) {
5238
5724
  rules: effectiveRules,
5239
5725
  shadows: () => checkShadowing(compileRuleLists(services.config.permissions)),
5240
5726
  level: () => bootstrap?.level() ?? services.config.permission,
5241
- // 配置里那一档 + 它赢在哪一层(方案 56 §1.3)。**和上面那一格是两个值** ——
5242
- // 判据在接口上。读 `baseConfig` 是因为 `configOrigins` 记的就是它那一份
5243
- // (而 `permission` 不在 `SettingsFileSchema` 白名单里,两份在这个键上恒等)
5244
5727
  configured: () => ({
5245
5728
  level: services.baseConfig.permission,
5246
5729
  layer: originOf(services.configOrigins, "permission")
5247
5730
  }),
5248
- // 权限层起不来 = 什么都没判过,回空账而不是 null(见接口上那段注释)
5249
5731
  audit: (scope) => services.permission?.getAudit(scope) ?? { entries: [], dropped: 0 },
5732
+ approvals: () => services.permission?.listApprovals() ?? [],
5733
+ revokeApproval: (id) => {
5734
+ const perm = services.permission;
5735
+ if (!perm) return { ok: false, reason: "no-permission-layer" };
5736
+ return perm.revokeApproval(id) ? { ok: true } : { ok: false, reason: "unknown-id" };
5737
+ },
5250
5738
  managed: () => {
5251
5739
  const p = services.managedPolicy;
5252
5740
  return {
@@ -5257,10 +5745,7 @@ function buildPermissionsControl(services, bootstrap, tools) {
5257
5745
  bypassDisabled: p.disableBypassPermissionsMode
5258
5746
  };
5259
5747
  },
5260
- // 三步判定**整个转发**,一行都不在这里重写(判据见上面那个参数的注释)。
5261
- // 权限层没起来时回 `{ok:true}` 而不是拒绝:那时 `level()` 读的是
5262
- // `config.permission`,一个纯展示值,改它没有任何意义也没有任何危害
5263
- setLevel: (level) => bootstrap?.setLevel(level) ?? { ok: true },
5748
+ setLevel: (level, opts) => bootstrap?.setLevel(level, opts) ?? { ok: true },
5264
5749
  gates: (level) => {
5265
5750
  const rules = compileRuleLists(services.config.permissions);
5266
5751
  const isolation = services.permission?.getSandboxIsolation() ?? "process";
@@ -5283,12 +5768,7 @@ function compressionLine(config, services) {
5283
5768
  const resolved = compressionOptions(config);
5284
5769
  return {
5285
5770
  contextLength: services.contextLength,
5286
- // `.parse({})` 而不是 `defaultConfig()`:后者的返回类型标注成 `EpochConfig`,
5287
- // 而那上面 `compression` 是可选的(嵌入宿主手搓那一份走的就是这条),
5288
- // 于是这个回落自己又需要一个回落。schema 的产出类型上它是必有的(`.prefault({})`)
5289
5771
  threshold: resolved.threshold ?? EpochConfigSchema.parse({}).compression.threshold,
5290
- // `!== false` 而不是裸真值:判据同 `compressionOptions()` 里那一行 ——
5291
- // 只有显式的 false 才关得掉,而这一条要写在脸上
5292
5772
  auto: services.contextEngine !== null && resolved.enabled !== false
5293
5773
  };
5294
5774
  }
@@ -5310,20 +5790,20 @@ function buildSettingsControl(services, workspaces, opts) {
5310
5790
  row.scalar ? { key: row.scalar, layers: scope.layers } : void 0
5311
5791
  );
5312
5792
  const overridable = row.scalar !== void 0;
5793
+ const writes = scope.paths === null ? [] : settingWriteTargets({
5794
+ key: row.key,
5795
+ winner: chain.layer,
5796
+ paths: scope.paths,
5797
+ trusted: scope.trusted
5798
+ });
5799
+ const kind = writes.length === 0 ? null : settingValueKind(row.key);
5800
+ const choices = writes.length === 0 ? null : settingValueChoices(row.key);
5313
5801
  return {
5314
5802
  ...chain,
5315
5803
  overridable,
5316
- // 「写进去会不会生效」整个判断在 core,这一层只递参数 —— 它必须和
5317
- // `write()` 里那一次用同一个函数算,否则界面警告和落盘结论会分叉。
5318
- //
5319
- // ⚠️ `paths` 和 `trusted` **来自同一个 `scope`**,那是硬约束不是巧合:
5320
- // 判据在 `SettingsControl` 的文档和 core 的 `config/write.ts` 第四节
5321
- writes: scope.paths === null ? [] : settingWriteTargets({
5322
- winner: chain.layer,
5323
- overridable,
5324
- paths: scope.paths,
5325
- trusted: scope.trusted
5326
- })
5804
+ writes,
5805
+ ...kind === null ? {} : { valueKind: kind },
5806
+ ...choices === null ? {} : { choices }
5327
5807
  };
5328
5808
  });
5329
5809
  };
@@ -5342,7 +5822,7 @@ function buildSettingsControl(services, workspaces, opts) {
5342
5822
  value: input.value
5343
5823
  });
5344
5824
  if (!outcome.ok) return { ok: false, reason: outcome.reason, path };
5345
- const target = rows(input.sessionId).find((row) => row.key === input.key)?.writes.find((write) => write.layer === input.layer);
5825
+ const target = rows(input.sessionId).find((row) => row.key === input.key)?.writes.find((write2) => write2.layer === input.layer);
5346
5826
  return {
5347
5827
  ok: true,
5348
5828
  key: input.key,
@@ -5393,6 +5873,31 @@ function writePathsOf(layers) {
5393
5873
  }
5394
5874
  return paths;
5395
5875
  }
5876
+ function buildDefaultLevelSlot(services) {
5877
+ let current = services.permissionLevel;
5878
+ return {
5879
+ get: () => current,
5880
+ remember: (level) => {
5881
+ current = level;
5882
+ const paths = writePathsOf(services.settingsLayers);
5883
+ if (!paths) return { reason: "no-user-config" };
5884
+ const outcome = writeSettingValue({
5885
+ layer: "user",
5886
+ path: paths.user,
5887
+ key: "permission",
5888
+ value: level
5889
+ });
5890
+ if (!outcome.ok) {
5891
+ const reason2 = outcome.reason === "unwritable-file" ? "unwritable" : "io";
5892
+ return { reason: reason2, path: paths.user };
5893
+ }
5894
+ if (originOf(services.configOrigins, "permission") === "env") {
5895
+ return { reason: "env-pinned", path: paths.user };
5896
+ }
5897
+ return void 0;
5898
+ }
5899
+ };
5900
+ }
5396
5901
  function valueAt(config, key) {
5397
5902
  let cursor = config;
5398
5903
  for (const segment of key.split(".")) {
@@ -5409,13 +5914,26 @@ function isSettingValue(value) {
5409
5914
  function maybeValue(key, value) {
5410
5915
  return value === void 0 ? {} : { [key]: value };
5411
5916
  }
5917
+ function reportI18nHealth(diagnostics) {
5918
+ void t("i18n.locales_missing");
5919
+ const issues = i18nDiagnostics();
5920
+ if (issues.length === 0) {
5921
+ diagnostics.ok("I18n", "OK");
5922
+ return;
5923
+ }
5924
+ for (const issue of issues) diagnostics.warn("I18n", issue.detail, issue.code);
5925
+ }
5412
5926
  var AgentRoleError, SETTING_ROWS;
5413
5927
  var init_build = __esm({
5414
5928
  "src/build.ts"() {
5415
5929
  init_commands();
5930
+ init_events();
5416
5931
  init_host_capabilities();
5932
+ init_host_marketplaces();
5417
5933
  init_host_preset();
5934
+ init_mcp_tools();
5418
5935
  init_model_catalog();
5936
+ init_plugin_control();
5419
5937
  init_plugin_mcp_servers();
5420
5938
  init_role_write();
5421
5939
  init_control();
@@ -5428,7 +5946,10 @@ var init_build = __esm({
5428
5946
  init_telemetry();
5429
5947
  init_tools();
5430
5948
  init_user_shell();
5949
+ init_utility();
5950
+ init_workspace_files();
5431
5951
  init_workspace();
5952
+ init_session_goal();
5432
5953
  AgentRoleError = class extends Error {
5433
5954
  constructor(message) {
5434
5955
  super(message);
@@ -5442,15 +5963,6 @@ var init_build = __esm({
5442
5963
  { key: "fallback.providers" },
5443
5964
  { key: "contextLength" },
5444
5965
  { key: "maxTurns", scalar: "maxTurns" },
5445
- // 2026-08-16 合并时收的一笔(`web/src/settings/context-memory.tsx` 文件头那张 📮)。
5446
- // 数组顺序就是界面上的顺序,所以紧挨着 `contextLength` —— 那一块要拿这三行
5447
- // 算「压缩在多少 token 上开始」,缺一行整块不画。
5448
- //
5449
- // ⚠️ **两行都不带 `scalar`**,不是漏了:带 `scalar` 的含义是「这个键在
5450
- // `SettingsFileSchema` 白名单里、项目级压得回去」,而 `compression.*` 判过
5451
- // 不进那张白名单(三条判据在 `core/src/config/sources.ts` 文件头)。
5452
- // 📮 那条还提醒了:顺手写 `scalar: 'compression.enabled'` 编译不过,
5453
- // `ScalarKey` 是从 `SCALAR_KEYS` 派生的闭集合。那道闸是对的。
5454
5966
  { key: "compression.enabled" },
5455
5967
  { key: "compression.threshold" },
5456
5968
  { key: "budget.maxCostUsd" },
@@ -5460,12 +5972,12 @@ var init_build = __esm({
5460
5972
  ];
5461
5973
  }
5462
5974
  });
5463
-
5464
5975
  // src/index.ts
5465
5976
  init_build();
5466
5977
  init_host_capabilities();
5467
5978
  init_host_preset();
5468
5979
  init_workspace();
5980
+ init_workspace_files();
5469
5981
  init_executor();
5470
5982
  init_control();
5471
5983
  init_agent_session();
@@ -5475,11 +5987,12 @@ init_services();
5475
5987
  init_user_shell();
5476
5988
  init_commands();
5477
5989
  init_role_scope();
5990
+ init_host_marketplaces();
5478
5991
  init_serialize();
5479
5992
  init_tools();
5480
5993
  init_model_catalog();
5994
+ init_utility();
5481
5995
  init_plugin_mcp_servers();
5482
-
5483
5996
  // src/mcp-admin.ts
5484
5997
  init_build();
5485
5998
  init_mcp_config_write();
@@ -5498,7 +6011,7 @@ async function listMcpServers(opts = {}) {
5498
6011
  async function mcpLogin(name, opts = {}) {
5499
6012
  const config = await requireServer(name, opts);
5500
6013
  if (!config.url) {
5501
- throw new Error(`MCP server "${name}" \u662F stdio \u4F20\u8F93\uFF0C\u672C\u5730\u5B50\u8FDB\u7A0B\u4E0D\u9700\u8981 OAuth \u767B\u5F55`);
6014
+ throw new Error(t("boot.mcp_stdio_no_oauth", { name: `MCP server "${name}` }));
5502
6015
  }
5503
6016
  const result = await loginToMcpServer({
5504
6017
  server: name,
@@ -5522,7 +6035,7 @@ async function probeMcpServer(name, opts = {}) {
5522
6035
  const tools = status?.connected ? await registry.getAllTools() : [];
5523
6036
  return {
5524
6037
  connected: status?.connected ?? false,
5525
- toolNames: tools.map((t13) => t13.name),
6038
+ toolNames: tools.map((t25) => t25.name),
5526
6039
  ...status?.lastError ? { error: status.lastError } : {},
5527
6040
  needsLogin: status?.needsLogin ?? false
5528
6041
  };
@@ -5535,14 +6048,15 @@ async function requireServer(name, opts) {
5535
6048
  const { servers, configPath: configPath2 } = await listMcpServers(opts);
5536
6049
  const found = servers.find((s) => s.config.name === name);
5537
6050
  if (!found) {
5538
- const known = servers.map((s) => s.config.name).join(", ") || "(\u7A7A)";
5539
- throw new Error(`${configPath2} \u91CC\u6CA1\u6709\u540D\u4E3A "${name}" \u7684 MCP server\u3002\u5DF2\u914D\u7F6E\uFF1A${known}`);
6051
+ const known = servers.map((s) => s.config.name).join(", ") || t("boot.mcp_list_empty");
6052
+ throw new Error(t("boot.mcp_no_such_server", { path: configPath2, name, known }));
5540
6053
  }
5541
6054
  return found.config;
5542
6055
  }
5543
-
5544
6056
  // src/index.ts
5545
6057
  init_mcp_config_file();
5546
6058
  init_mcp_config_apply();
6059
+ init_mcp_config_edit();
6060
+ init_mcp_tools();
5547
6061
 
5548
- export { AgentRoleError, AgentSession, ApprovalRelay, HOST_CAPABILITIES_MODULE, HOST_PRESET_MODULE, QuestionRelay, SCHEDULE_EXIT_CODES, SessionStore, SessionWorkspaces, applyHostPreset, applyMcpConfig, buildModelCatalogControl, buildRuntime, buildServices, collectFileChanges, createScheduleControl, createUserActions, ensureSecretsReady, exitCodeFor, fireSchedule, listMcpServers, mcpConfigRevision, mcpLogin, mcpLogout, probeMcpServer, readMcpConfigFile, registerAskQuestion, registerLocalTools, registerMcpTools, serializeStream, splitDirList, toSerializable, wireCommands, wireHostCapabilities, wirePluginMcpServers, wireWorkspace, withModelScope, withRoleScope, withToolScope, writeMcpConfigFile, writeMcpServer };
6062
+ export { AgentRoleError, AgentSession, ApprovalRelay, HOST_CAPABILITIES_MODULE, HOST_MARKETPLACE_MODULE, HOST_PRESET_MODULE, MCP_ADD_TOOL, MCP_LIST_TOOL, MCP_REMOVE_TOOL, MCP_UPDATE_TOOL, QuestionRelay, SCHEDULE_EXIT_CODES, SessionStore, SessionWorkspaces, applyHostPreset, applyMcpConfig, buildModelCatalogControl, buildRuntime, buildServices, buildWorkspaceFilesControl, collectFileChanges, createMcpTools, createScheduleControl, createUserActions, ensureSecretsReady, exitCodeFor, fireSchedule, isSystemOpenable, listMcpServers, mcpConfigRevision, mcpLogin, mcpLogout, probeMcpServer, providerLabel, readMcpConfigFile, registerAskQuestion, registerLocalTools, registerMcpTools, removeMcpServer, removeServerText, replaceServerText, serializeStream, splitDirList, toSerializable, updateMcpServer, utilityBudgetKey, wireCommands, wireHostCapabilities, wirePluginMcpServers, wireWorkspace, withModelScope, withRoleScope, withToolScope, writeMcpConfigFile, writeMcpServer };