@epoch-agent/runtime 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +118 -70
  2. package/dist/index.d.ts +1532 -49
  3. package/dist/index.js +996 -1180
  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, mapOperationType, affectedPaths, workspaceDisplayPath, persistParts, persistToolResults, ToolRegistry, createTodoTool, createGoalTool, createMemoryTool, createCodeExecTool, createRunCodeTool, createSkillViewTool, createToolSearchTool, createToolRevealState, createAskQuestionTool, resolveWorkspace, unloadedInstructionFiles, resolveProjectRoot, loadConfig, CodeSandbox, CheckpointManager, createDelegateTool, findRole, 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, 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, dbPath, automationLogsDir, pluginsStatePath, safeInit, memoriesDir, skillsDir, claudeUserSettingsPath, hooksPath, artifactsDir, resolveRipgrep, withTimeout, createProcessTable, marketplacesPath, pluginsDir, agentsDir, budgetStatePath, createSecretStore, setSecretStore, prefetchProviderSecrets, setSecretValues, migrateEnvSecrets, envPath, setDataKey, parseDataKey, generateDataKey, i18nDiagnostics, automationWorkDir, trustPath, trustedImportsPath, setShell, probeShell } from '@epoch-agent/infra';
4
4
  export { t } from '@epoch-agent/infra';
5
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';
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
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';
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((t23) => t23.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,16 @@ var init_host_preset = __esm({
441
589
  HOST_PRESET_MODULE = "HostPreset";
442
590
  }
443
591
  });
592
+ function providerLabel(info, lang) {
593
+ switch (info.type) {
594
+ case "ollama":
595
+ return t("provider.label_ollama", void 0, lang);
596
+ case "openai-compatible":
597
+ return t("provider.label_openai_compatible", void 0, lang);
598
+ default:
599
+ return info.label;
600
+ }
601
+ }
444
602
  function buildModelCatalogControl(opts) {
445
603
  const { homeDir, configured } = opts;
446
604
  const keyOf = (envVar) => {
@@ -453,7 +611,6 @@ function buildModelCatalogControl(opts) {
453
611
  type: p.type,
454
612
  label: p.label,
455
613
  envVar: p.apiKeyEnv,
456
- // 不需要 key 的那家(Ollama)恒 true —— 对它说「没配 key」是假话
457
614
  hasKey: p.apiKeyEnv === null || keyOf(p.apiKeyEnv) !== ""
458
615
  }));
459
616
  },
@@ -488,6 +645,209 @@ var init_model_catalog = __esm({
488
645
  "src/model-catalog.ts"() {
489
646
  }
490
647
  });
648
+ function createInstallFlow(deps) {
649
+ const locations = () => ({
650
+ pluginsDir: deps.pluginsDir,
651
+ statePath: deps.statePath,
652
+ epochVersion: currentEpochVersion()
653
+ });
654
+ return {
655
+ preview: async (source, extra, lang) => {
656
+ let seen;
657
+ const outcome = await installPlugin(source, {
658
+ ...locations(),
659
+ ...extra,
660
+ confirm: async (preview) => {
661
+ seen = preview;
662
+ return false;
663
+ }
664
+ });
665
+ if (seen) return { ok: true, preview: seen, token: previewToken(seen) };
666
+ return refuse(outcome, lang);
667
+ },
668
+ install: async (source, token, extra, lang) => {
669
+ let matched;
670
+ const outcome = await installPlugin(source, {
671
+ ...locations(),
672
+ ...extra,
673
+ confirm: async (preview) => {
674
+ matched = previewToken(preview) === token;
675
+ return matched;
676
+ }
677
+ });
678
+ if (outcome.ok) return { ok: true, record: outcome.record, preview: outcome.preview };
679
+ if (matched === false) {
680
+ return {
681
+ ok: false,
682
+ reason: "stale-token",
683
+ detail: t("plugin_control.stale_token", void 0, lang),
684
+ ...outcome.preview ? { preview: outcome.preview } : {}
685
+ };
686
+ }
687
+ return refuse(outcome, lang);
688
+ }
689
+ };
690
+ }
691
+ function replaceOpts(record) {
692
+ return {
693
+ replace: record.name,
694
+ ...record.marketplace ? { marketplace: record.marketplace } : {}
695
+ };
696
+ }
697
+ function refuse(outcome, lang) {
698
+ if (outcome.ok) {
699
+ return {
700
+ ok: false,
701
+ reason: "failed",
702
+ detail: t("plugin_control.unexpected_commit", { name: outcome.record.name }, lang)
703
+ };
704
+ }
705
+ return {
706
+ ok: false,
707
+ reason: outcome.preview?.conflict ? "conflict" : "failed",
708
+ detail: outcome.reason,
709
+ ...outcome.preview ? { preview: outcome.preview } : {}
710
+ };
711
+ }
712
+ function previewToken(preview) {
713
+ const inv = preview.inventory;
714
+ const hash = createHash("sha256");
715
+ hash.update(`${preview.manifest.name}@${preview.manifest.version}
716
+ `);
717
+ hash.update(`${preview.source.type} ${preview.source.raw}
718
+ `);
719
+ for (const list of [inv.commands, inv.roles, inv.skills, inv.mcpServers, inv.ignoredBuckets]) {
720
+ hash.update(`${[...list].sort().join(",")}
721
+ `);
722
+ }
723
+ for (const tally of [...inv.hooks].sort((a, b) => a.type.localeCompare(b.type))) {
724
+ hash.update(`${tally.type}=${tally.count};`);
725
+ }
726
+ hash.update(`
727
+ ${inv.denyRules}|${inv.jsTools ? 1 : 0}
728
+ `);
729
+ hash.update(`${preview.versionUnmet?.required ?? "-"}
730
+ `);
731
+ return hash.digest("hex").slice(0, 32);
732
+ }
733
+ var init_plugin_install = __esm({
734
+ "src/plugin-install.ts"() {
735
+ }
736
+ });
737
+ function createPluginControl(deps) {
738
+ let pendingRestart = false;
739
+ const live = new Map(deps.loaded.map((p) => [p.name, p.counts]));
740
+ const flow = createInstallFlow({ pluginsDir: deps.pluginsDir, statePath: deps.statePath });
741
+ const mark = (outcome) => {
742
+ if (outcome.ok) pendingRestart = true;
743
+ return outcome;
744
+ };
745
+ const resolveRef = (ref, lang) => {
746
+ const hit = resolveMarketplaceRef(ref, deps.marketplacesPath);
747
+ if (!hit) {
748
+ return {
749
+ ok: false,
750
+ reason: "unknown-ref",
751
+ detail: t("plugin_control.not_a_ref", { ref }, lang)
752
+ };
753
+ }
754
+ if (!hit.ok) return { ok: false, reason: "unknown-ref", detail: hit.reason };
755
+ if (!deps.allowRemote && !isLocalSource(hit.ref.source)) {
756
+ return {
757
+ ok: false,
758
+ reason: "remote-disabled",
759
+ detail: t("plugin_control.entry_remote_off", { ref, source: hit.ref.source }, lang)
760
+ };
761
+ }
762
+ return { ok: true, source: hit.ref.source, marketplace: hit.ref.marketplace };
763
+ };
764
+ const recordOf = (name, lang) => {
765
+ const record = readPluginRecords(deps.statePath).records.find((r) => r.name === name);
766
+ if (!record) {
767
+ return {
768
+ ok: false,
769
+ reason: "not-installed",
770
+ detail: t("plugin_control.not_installed", { name }, lang)
771
+ };
772
+ }
773
+ if (record.linked) {
774
+ return {
775
+ ok: false,
776
+ reason: "nothing-to-do",
777
+ detail: t("plugin_control.linked_no_update", { name, source: record.source }, lang)
778
+ };
779
+ }
780
+ if (!deps.allowRemote && !isLocalSource(record.source)) {
781
+ return {
782
+ ok: false,
783
+ reason: "remote-disabled",
784
+ detail: t("plugin_control.update_remote_off", { name, source: record.source }, lang)
785
+ };
786
+ }
787
+ return { ok: true, record };
788
+ };
789
+ return {
790
+ list: () => readPluginRecords(deps.statePath).records.map((r) => ({
791
+ ...r,
792
+ active: live.has(r.name),
793
+ ...live.get(r.name) ? { counts: live.get(r.name) } : {}
794
+ })),
795
+ search: (keyword = "") => {
796
+ const markets = new Map(
797
+ readMarketplaces(deps.marketplacesPath).records.map((r) => [r.name, r.source])
798
+ );
799
+ return searchMarketplaces(keyword, deps.marketplacesPath).map((hit) => {
800
+ const market = markets.get(hit.marketplace);
801
+ const absolute = market === void 0 ? void 0 : resolveEntrySource(hit.entry.source, market);
802
+ const kind = absolute === void 0 ? void 0 : resolveSource(absolute)?.type;
803
+ return {
804
+ marketplace: hit.marketplace,
805
+ entry: hit.entry,
806
+ ref: hit.ref,
807
+ ...kind ? { sourceType: kind } : {},
808
+ installable: deps.allowRemote ? kind !== void 0 : kind === "local"
809
+ };
810
+ });
811
+ },
812
+ preview: async (ref, lang) => {
813
+ const target = resolveRef(ref, lang);
814
+ return target.ok ? flow.preview(target.source, { marketplace: target.marketplace }, lang) : target;
815
+ },
816
+ install: async (ref, token, lang) => {
817
+ const target = resolveRef(ref, lang);
818
+ return target.ok ? mark(await flow.install(target.source, token, { marketplace: target.marketplace }, lang)) : target;
819
+ },
820
+ previewUpdate: async (name, lang) => {
821
+ const found = recordOf(name, lang);
822
+ return found.ok ? flow.preview(found.record.source, replaceOpts(found.record), lang) : found;
823
+ },
824
+ update: async (name, token, lang) => {
825
+ const found = recordOf(name, lang);
826
+ return found.ok ? mark(await flow.install(found.record.source, token, replaceOpts(found.record), lang)) : found;
827
+ },
828
+ uninstall: async (name, lang) => {
829
+ const outcome = await uninstallPlugin(name, { statePath: deps.statePath });
830
+ if (!outcome.ok) {
831
+ return {
832
+ ok: false,
833
+ reason: "not-installed",
834
+ detail: t("plugin_control.not_installed", { name }, lang)
835
+ };
836
+ }
837
+ pendingRestart = true;
838
+ return { ok: true, detail: outcome.message };
839
+ },
840
+ get pendingRestart() {
841
+ return pendingRestart;
842
+ }
843
+ };
844
+ }
845
+ var init_plugin_control = __esm({
846
+ "src/plugin-control.ts"() {
847
+ init_host_marketplaces();
848
+ init_plugin_install();
849
+ }
850
+ });
491
851
  function wirePluginMcpServers(mcpPaths, diags) {
492
852
  const out = [];
493
853
  for (const ref of mcpPaths) {
@@ -550,9 +910,6 @@ function createRoleWriteControl(deps) {
550
910
  return {
551
911
  ok: false,
552
912
  reason: "duplicate",
553
- // 把**哪一层**占着这个名字说出来:内置和项目级的出路不同(前者只能换名字,
554
- // 后者他还可以去改自己仓库里那一份)。只说「重名了」的话,一个撞上
555
- // `general` 的人会以为自己的 `~/.epoch/agents/` 里躺着一份看不见的文件
556
913
  detail: t(
557
914
  "role_write.dup_in_table",
558
915
  { name, layer: t(LAYER_KEY[clash.source], void 0, lang) },
@@ -588,6 +945,7 @@ function createRoleWriteControl(deps) {
588
945
  description: input.description,
589
946
  ...input.prompt === void 0 ? {} : { prompt: input.prompt },
590
947
  ...input.tools === void 0 ? {} : { tools: input.tools },
948
+ ...input.skills === void 0 ? {} : { skills: input.skills },
591
949
  ...input.maxTurns === void 0 ? {} : { maxTurns: input.maxTurns }
592
950
  },
593
951
  { dir: deps.userDir, source: "user" }
@@ -597,10 +955,6 @@ function createRoleWriteControl(deps) {
597
955
  return {
598
956
  ok: true,
599
957
  path: written.path,
600
- // 回**重载之后那张表**里的那一份,不是 core 刚从文件里读出来的那个:
601
- // 两者在 `tools` 上真会不同(`mergeRoles` 的交集语义)。撞名已经在上面
602
- // 拦掉了,所以今天这两份恒等 —— `??` 那一支是给「没有 provider、
603
- // 角色表恒空」的那种进程用的,那时表里找不到它,而文件确实写了
604
958
  role: findRole(deps.roles(), name) ?? written.role
605
959
  };
606
960
  }
@@ -645,32 +999,11 @@ var ApprovalRelay, QuestionRelay;
645
999
  var init_serialize = __esm({
646
1000
  "src/serialize.ts"() {
647
1001
  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
1002
  pendingMap = /* @__PURE__ */ new Map();
659
- /** 登记时刻从哪儿读。见 {@link RelayClock} */
660
1003
  clock;
661
1004
  constructor(clock = Date.now) {
662
1005
  this.clock = clock;
663
1006
  }
664
- /**
665
- * 登记一个刚发给 renderer 的审批。
666
- *
667
- * 直接吃 `toSerializable()` 的产出:非审批事件(没有 `respond`)传进来是空操作,
668
- * 于是宿主的消费循环里不用先判类型。
669
- *
670
- * ⚠️ 时钟**只在真的要插一条时才读**(在 `return` 后面)。写在函数头上的话,
671
- * 每一条流过的事件都会读一次挂钟 —— 而这张表是被 `serializeStream()` 就着
672
- * **每一个**事件调的,注入过时钟的宿主那边会当场看见自己的脚本被走空。
673
- */
674
1007
  track(serialized) {
675
1008
  const { event, respond } = serialized;
676
1009
  if (event.type !== "approval-request" || !respond) return;
@@ -680,13 +1013,6 @@ var init_serialize = __esm({
680
1013
  since: this.clock()
681
1014
  });
682
1015
  }
683
- /**
684
- * 答复。
685
- *
686
- * @returns 是否真的接回了引擎。`false` 意味着这个 `requestId` 不认识 ——
687
- * 重复答复、或者 renderer 拿着一个上一轮的 ID 回来了。宿主该把它
688
- * 当成日志而不是错误:用户在窗口上双击一下审批按钮就会走到这里。
689
- */
690
1016
  respond(requestId, outcome, note) {
691
1017
  const pending = this.pendingMap.get(requestId);
692
1018
  if (!pending) return false;
@@ -694,52 +1020,13 @@ var init_serialize = __esm({
694
1020
  pending.respond(outcome, note);
695
1021
  return true;
696
1022
  }
697
- /** 还有多少个审批挂在 renderer 那边没回来 */
698
1023
  get pendingCount() {
699
1024
  return this.pendingMap.size;
700
1025
  }
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
1026
  get pendingSince() {
727
1027
  const first = this.pendingMap.values().next();
728
1028
  return first.done ? null : first.value.since;
729
1029
  }
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
1030
  listPending() {
744
1031
  return [...this.pendingMap].map(([requestId, { request }]) => ({
745
1032
  type: "approval-request",
@@ -747,16 +1034,6 @@ var init_serialize = __esm({
747
1034
  request
748
1035
  }));
749
1036
  }
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
1037
  abandon() {
761
1038
  const count = this.pendingMap.size;
762
1039
  this.pendingMap.clear();
@@ -764,24 +1041,11 @@ var init_serialize = __esm({
764
1041
  }
765
1042
  };
766
1043
  QuestionRelay = class {
767
- /**
768
- * `requestId` → 请求内容 + 答复闭包 + 登记那一刻。
769
- * Map 的插入序 = 提问发生的时间序。`since` 见 {@link QuestionRelay.pendingSince}
770
- */
771
1044
  pendingMap = /* @__PURE__ */ new Map();
772
- /** 登记时刻从哪儿读。见 {@link RelayClock} */
773
1045
  clock;
774
1046
  constructor(clock = Date.now) {
775
1047
  this.clock = clock;
776
1048
  }
777
- /**
778
- * 登记一个刚发给宿主的提问。
779
- *
780
- * 直接吃 `toSerializable()` 的产出:非提问事件传进来是空操作,
781
- * 于是宿主的消费循环里不用先判类型(同 `ApprovalRelay.track`)。
782
- *
783
- * 时钟同样只在真要插一条时才读,理由逐字同 `ApprovalRelay.track`。
784
- */
785
1049
  track(serialized) {
786
1050
  const { event, respondQuestion } = serialized;
787
1051
  if (event.type !== "question" || !respondQuestion) return;
@@ -791,13 +1055,6 @@ var init_serialize = __esm({
791
1055
  since: this.clock()
792
1056
  });
793
1057
  }
794
- /**
795
- * 答复。
796
- *
797
- * @returns 是否真的接回了引擎。`false` = 这个 `requestId` 不认识
798
- * (重复答复、上一轮的 ID、或者已经被 `abandon()` 掉了)。
799
- * 调用方该把它当日志而不是错误。
800
- */
801
1058
  respond(requestId, answer) {
802
1059
  const pending = this.pendingMap.get(requestId);
803
1060
  if (!pending) return false;
@@ -805,25 +1062,13 @@ var init_serialize = __esm({
805
1062
  pending.respond(answer);
806
1063
  return true;
807
1064
  }
808
- /** 还有多少个提问挂在宿主那边没回来 */
809
1065
  get pendingCount() {
810
1066
  return this.pendingMap.size;
811
1067
  }
812
- /**
813
- * **最早**那一条还挂着的提问是什么时候登记的;一条都没挂着时 null。
814
- *
815
- * 与 {@link ApprovalRelay.pendingSince} 逐字同构、同三条判据(最早不是最近、
816
- * 取表里第一条、只读)。写在那一个上,别在这儿再判一遍。
817
- *
818
- * ⚠️ **两张表各给一个,网线上却只有一格**:合并成「这个会话从什么时候起被拦住」
819
- * 是消费方(Hub)的事,不是这一层的 —— 这一层只知道自己这张表。
820
- * 判据在 `WireSessionSummary.pendingSince` 的第一节。
821
- */
822
1068
  get pendingSince() {
823
1069
  const first = this.pendingMap.values().next();
824
1070
  return first.done ? null : first.value.since;
825
1071
  }
826
- /** 列出还挂着的提问,按发生的先后顺序。给「新连上来的宿主要补拉」用(验收 9) */
827
1072
  listPending() {
828
1073
  return [...this.pendingMap].map(([requestId, { request }]) => ({
829
1074
  type: "question",
@@ -831,13 +1076,6 @@ var init_serialize = __esm({
831
1076
  request
832
1077
  }));
833
1078
  }
834
- /**
835
- * 界面没了:把挂起的提问全部**放弃**,返回放弃的个数(验收 8)。
836
- *
837
- * 刻意**不** resolve 任何 promise —— 那会变成一串编出来的答案。调用方拿到非 0
838
- * 返回值后该 `abort()` 掉这一轮:流一结束,`AgentSession` 的 finally 会把剩下的
839
- * 请求兜底成 `skipped`(那是实话:确实没人答),引擎不会永久挂住。
840
- */
841
1079
  abandon() {
842
1080
  const count = this.pendingMap.size;
843
1081
  this.pendingMap.clear();
@@ -947,12 +1185,7 @@ async function runOnce(store, def, opts, startedAt, now) {
947
1185
  const runtime = await build({
948
1186
  homeDir: opts.homeDir,
949
1187
  workDir: def.workDir,
950
- // **显式 false**:不靠 TTY 探测。OS 调度器起的进程天然没有 TTY,
951
- // 但嵌入宿主那条路(`headless.js` 由宿主起)可能有,而那时按交互处理
952
- // 会让整个「没人可问」的语义塌掉 —— 预授权清单不生效、审批挂着等一个不在的人
953
1188
  interactive: false,
954
- // 任务级清单是**取代**不是叠加:一个任务的授权面必须只由任务自己说清,
955
- // 否则用户改一次 `~/.epoch/config.yaml` 就静默放宽了全部十个定时任务
956
1189
  headless: { allowTools: [...def.allowTools], allowOperations: [...def.allowOperations] },
957
1190
  sessionSource: "schedule",
958
1191
  installSignalHandlers: true
@@ -1137,37 +1370,50 @@ var init_executor = __esm({
1137
1370
  }
1138
1371
  });
1139
1372
  function createScheduleControl(opts) {
1140
- const store = new ScheduleStore(dbPath(opts.homeDir));
1373
+ let opened = null;
1374
+ let openError = null;
1375
+ try {
1376
+ opened = new ScheduleStore(dbPath(opts.homeDir));
1377
+ } catch (err) {
1378
+ openError = err instanceof Error ? err : new Error(String(err));
1379
+ }
1380
+ const store = () => {
1381
+ if (!opened) {
1382
+ throw new Error(
1383
+ `schedule store unavailable: ${openError?.message ?? "unknown"} (see ScheduleControl.storeAvailable)`
1384
+ );
1385
+ }
1386
+ return opened;
1387
+ };
1141
1388
  let backendPromise = null;
1142
1389
  const backendOf = () => {
1143
1390
  backendPromise ??= resolveScheduleBackend(opts.platform ?? process.platform);
1144
1391
  return backendPromise;
1145
1392
  };
1146
1393
  const registrarOf = async () => new ScheduleRegistrar({
1147
- store,
1394
+ store: store(),
1148
1395
  backend: await backendOf(),
1149
1396
  homeDir: opts.homeDir,
1150
1397
  ...opts.runner ? { runner: opts.runner } : {}
1151
- // ⚠️ **刻意不给 `cli`**:这一层不知道宿主怎么被唤起,猜的代价见文件头
1152
1398
  });
1153
1399
  const save = async (def, register) => {
1154
1400
  if (!register) return { schedule: def, issues: [] };
1155
1401
  const registrar = await registrarOf();
1156
1402
  const registration = await registrar.register(def);
1157
- return { schedule: store.get(def.id) ?? def, issues: [], registration };
1403
+ return { schedule: store().get(def.id) ?? def, issues: [], registration };
1158
1404
  };
1159
1405
  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),
1406
+ storeAvailable: opened !== null,
1407
+ list: () => store().list(),
1408
+ get: (id) => store().get(id),
1409
+ runs: (id, limit) => store().listRuns(id, limit),
1410
+ recentRuns: (limit) => store().listRecentRuns(limit),
1164
1411
  capability: async () => {
1165
1412
  const backend = await backendOf();
1166
1413
  return {
1167
1414
  backend: backend?.kind ?? null,
1168
- // 没有后端(Linux)或者没有无界面入口(嵌入宿主没给 runner)都注册不了。
1169
- // 两者的措辞不一样,所以宿主还要看 `backend` 那一格
1170
- canRegister: backend !== null && opts.runner !== void 0
1415
+ canRegister: backend !== null && opts.runner !== void 0,
1416
+ storeAvailable: opened !== null
1171
1417
  };
1172
1418
  },
1173
1419
  create: async (input, saveOpts) => {
@@ -1188,12 +1434,12 @@ function createScheduleControl(opts) {
1188
1434
  homeDir: opts.homeDir
1189
1435
  }).issues;
1190
1436
  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;
1437
+ const created = store().create({ ...input, workDir: workDir || opts.homeDir });
1438
+ const def = workDir ? created : store().update(created.id, { workDir: automationWorkDir(created.id, opts.homeDir) }) ?? created;
1193
1439
  return save(def, saveOpts?.register !== false);
1194
1440
  },
1195
1441
  update: async (id, patch, saveOpts) => {
1196
- const current = store.get(id);
1442
+ const current = store().get(id);
1197
1443
  if (!current) return { issues: [] };
1198
1444
  const merged = { ...current, ...patch };
1199
1445
  const issues = validateSchedule({
@@ -1212,18 +1458,18 @@ function createScheduleControl(opts) {
1212
1458
  homeDir: opts.homeDir
1213
1459
  }).issues;
1214
1460
  if (issues.length > 0) return { issues };
1215
- const next = store.update(id, patch);
1461
+ const next = store().update(id, patch);
1216
1462
  if (!next) return { issues: [] };
1217
1463
  return save(next, saveOpts?.register !== false);
1218
1464
  },
1219
1465
  remove: async (id) => {
1220
- const def = store.get(id);
1466
+ const def = store().get(id);
1221
1467
  if (!def) return false;
1222
1468
  if (def.osTaskId) await (await registrarOf()).unregister(def);
1223
- return store.remove(id);
1469
+ return store().remove(id);
1224
1470
  },
1225
1471
  setEnabled: async (id, enabled) => {
1226
- const def = store.get(id);
1472
+ const def = store().get(id);
1227
1473
  if (!def) return { ok: false, reason: "backend-error", detail: "schedule-not-found" };
1228
1474
  return (await registrarOf()).setEnabled(def, enabled);
1229
1475
  },
@@ -1235,7 +1481,7 @@ function createScheduleControl(opts) {
1235
1481
  }),
1236
1482
  needsAllowlist
1237
1483
  };
1238
- return { control, dispose: () => store.close() };
1484
+ return { control, dispose: () => opened?.close(), openError };
1239
1485
  }
1240
1486
  var init_control = __esm({
1241
1487
  "src/schedule/control.ts"() {
@@ -1244,7 +1490,7 @@ var init_control = __esm({
1244
1490
  });
1245
1491
  function resolvePermissionLevel(raw, diags) {
1246
1492
  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");
1493
+ diags.warn("Perm", t("boot.bad_permission_level", { raw }), "bad-permission-level");
1248
1494
  return "default";
1249
1495
  }
1250
1496
  function resolveModelCapability(config, diags) {
@@ -1258,7 +1504,7 @@ function resolveModelCapability(config, diags) {
1258
1504
  } catch {
1259
1505
  diags.warn(
1260
1506
  "Capability",
1261
- `${config.model} \u5143\u6570\u636E\u7F3A\u5931\uFF0CcontextLength \u7528\u914D\u7F6E\u503C`,
1507
+ t("boot.no_model_metadata", { model: config.model }),
1262
1508
  "no-model-metadata"
1263
1509
  );
1264
1510
  return { contextLength: config.contextLength, supportsImages: false };
@@ -1266,21 +1512,26 @@ function resolveModelCapability(config, diags) {
1266
1512
  }
1267
1513
  function reportUtilityModel(info, diags) {
1268
1514
  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");
1515
+ diags.skipped("UtilityModel", (lang) => t("boot.utility_model_none", void 0, lang));
1270
1516
  return;
1271
1517
  }
1272
1518
  if (info.issue) {
1273
- diags.warn("Utility \u6A21\u578B", info.issue, "bad-model-ref");
1519
+ diags.warn("UtilityModel", info.issue, "bad-model-ref");
1274
1520
  return;
1275
1521
  }
1276
- diags.ok("Utility \u6A21\u578B", `\u5185\u90E8\u4EFB\u52A1\u8D70 ${info.selection.provider}/${info.selection.model}`);
1522
+ diags.ok(
1523
+ "UtilityModel",
1524
+ (lang) => t(
1525
+ "boot.utility_model_selected",
1526
+ { provider: info.selection.provider, model: info.selection.model },
1527
+ lang
1528
+ )
1529
+ );
1277
1530
  }
1278
1531
  function buildTrust(config, workDir, diags, interactive) {
1279
1532
  const root = resolveProjectRoot(workDir);
1280
1533
  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
- );
1534
+ const gate2 = createAllowAllGate(t("boot.trust_disabled"));
1284
1535
  setTrustGate(gate2);
1285
1536
  const trust2 = gate2.decide(workDir);
1286
1537
  diags.warn("Trust", trust2.reason, "trust-disabled");
@@ -1290,7 +1541,7 @@ function buildTrust(config, workDir, diags, interactive) {
1290
1541
  }
1291
1542
  const manager = safeInit("Trust", () => new TrustManager(trustPath(config.homeDir)), diags);
1292
1543
  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");
1544
+ const gate = manager ? createTrustGate({ manager, interactive }) : createDenyAllGate(t("boot.trust_unavailable"));
1294
1545
  setTrustGate(gate);
1295
1546
  const trust = gate.decide(root);
1296
1547
  if (trust.trusted) diags.ok("Trust", trust.reason);
@@ -1299,11 +1550,11 @@ function buildTrust(config, workDir, diags, interactive) {
1299
1550
  }
1300
1551
  function buildImportGate(config, diags) {
1301
1552
  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");
1553
+ diags.ok("Instructions", t("boot.import_host_gate"));
1303
1554
  return;
1304
1555
  }
1305
1556
  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"));
1557
+ setImportGate(createAllowAllImportGate(t("boot.import_trust_disabled")));
1307
1558
  return;
1308
1559
  }
1309
1560
  const store = safeInit(
@@ -1312,7 +1563,7 @@ function buildImportGate(config, diags) {
1312
1563
  diags
1313
1564
  );
1314
1565
  if (!store) {
1315
- setImportGate(createDenyAllImportGate("import \u653E\u884C\u8BB0\u5F55\u4E0D\u53EF\u7528\uFF0C\u9879\u76EE\u5916\u7684 @import \u4E00\u5F8B\u4E0D\u52A0\u8F7D"));
1566
+ setImportGate(createDenyAllImportGate(t("boot.import_store_unavailable")));
1316
1567
  return;
1317
1568
  }
1318
1569
  for (const err of store.loadErrors) diags.warn("Instructions", err, "import-trust-load-error");
@@ -1359,7 +1610,7 @@ function buildServices(baseConfig, diags, workDir = process.cwd(), opts = {}) {
1359
1610
  for (const p of projectContext.skippedInstructions) {
1360
1611
  diags.warn(
1361
1612
  "Instructions",
1362
- `${p} \u672A\u52A0\u8F7D\uFF08\u5DE5\u4F5C\u533A\u672A\u4FE1\u4EFB\uFF0Cepoch trust add \u53EF\u653E\u884C\uFF09`,
1613
+ t("boot.instructions_untrusted", { path: p }),
1363
1614
  "workspace-untrusted"
1364
1615
  );
1365
1616
  }
@@ -1369,7 +1620,7 @@ function buildServices(baseConfig, diags, workDir = process.cwd(), opts = {}) {
1369
1620
  diags.ok("Provider");
1370
1621
  } catch (err) {
1371
1622
  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`);
1623
+ diags.skipped("Provider", t("boot.provider_no_credential", { message: err.message }));
1373
1624
  } else {
1374
1625
  diags.failed(
1375
1626
  "Provider",
@@ -1406,19 +1657,20 @@ function buildServices(baseConfig, diags, workDir = process.cwd(), opts = {}) {
1406
1657
  }
1407
1658
  const skillDirs = resolveSkillDirs({
1408
1659
  userDir: skillsDir(config.homeDir),
1409
- // 插件技能排在最前(优先级最低)且全部带前缀,见 core 的 skill/sources.ts
1410
1660
  pluginDirs: plugins.skillDirs,
1411
- // 宿主注入的紧跟在插件之后(方案 44 §2.1),同样全部带前缀、同样不过工作区
1412
- // 闸门 —— 它的信任来自「宿主就是跑我们这个库的那个进程」,和 secretStore 同一档
1413
1661
  ...opts.hostSkillDirs ? { hostDirs: opts.hostSkillDirs } : {},
1414
1662
  projectRoot: projectContext.rootDir,
1415
1663
  trusted: trust.trusted,
1416
1664
  diags
1417
1665
  });
1418
- const skill = safeInit("Skill", () => new SkillSystem(skillDirs.dirs), diags);
1666
+ const skill = safeInit(
1667
+ "Skill",
1668
+ () => new SkillSystem(skillDirs.dirs, { maxIndexed: config.skills?.maxIndexed }),
1669
+ diags
1670
+ );
1419
1671
  if (skill) {
1420
1672
  for (const detail of issueDetails(skill.getIssues())) {
1421
- diags.warn("Skill", `\u6280\u80FD ${detail}`, "invalid-skill-file");
1673
+ diags.warn("Skill", t("boot.skill_issue", { detail }), "invalid-skill-file");
1422
1674
  }
1423
1675
  }
1424
1676
  let skillLearner;
@@ -1442,15 +1694,17 @@ function buildServices(baseConfig, diags, workDir = process.cwd(), opts = {}) {
1442
1694
  () => new HookManager(opts.logger ? { log: opts.logger } : {}),
1443
1695
  diags
1444
1696
  );
1697
+ const hookStatus = { sources: [], projectSkipped: false };
1445
1698
  if (hookManager && managedPolicy.allowManagedHooksOnly) {
1446
1699
  diags.warn(
1447
1700
  "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`,
1701
+ t("boot.managed_hooks_only", { path: managedPolicy.path }),
1449
1702
  "managed-hooks-only"
1450
1703
  );
1451
1704
  } else if (hookManager) {
1452
1705
  const hooks = loadHookSources({
1453
1706
  userPath: hooksPath(config.homeDir),
1707
+ claudeUserPath: claudeUserSettingsPath(),
1454
1708
  pluginPaths: plugins.hookPaths,
1455
1709
  ...projectContext.rootDir ? { projectRoot: projectContext.rootDir } : {},
1456
1710
  trusted: trust.trusted,
@@ -1458,6 +1712,8 @@ function buildServices(baseConfig, diags, workDir = process.cwd(), opts = {}) {
1458
1712
  diags
1459
1713
  });
1460
1714
  hookManager.loadEntries(hooks.entries);
1715
+ hookStatus.sources = [...hooks.sources];
1716
+ hookStatus.projectSkipped = hooks.projectSkipped;
1461
1717
  }
1462
1718
  const cache = new ApprovalCache(config.homeDir);
1463
1719
  const permission = safeInit("Perm", () => new PermissionManager(permLevel, cache), diags);
@@ -1484,7 +1740,7 @@ function buildServices(baseConfig, diags, workDir = process.cwd(), opts = {}) {
1484
1740
  policyStatus.projectSkipped = policyDirs.projectSkipped;
1485
1741
  policyStatus.skippedDir = policyDirs.skippedDir ?? null;
1486
1742
  } catch {
1487
- diags.warn("Policy", "\u52A0\u8F7D\u5931\u8D25\uFF08\u8DF3\u8FC7\uFF09", "policy-load-failed");
1743
+ diags.warn("Policy", t("boot.policy_load_failed"), "policy-load-failed");
1488
1744
  }
1489
1745
  const lists = config.permissions;
1490
1746
  const counts = {
@@ -1497,7 +1753,12 @@ function buildServices(baseConfig, diags, workDir = process.cwd(), opts = {}) {
1497
1753
  permission.setPermissionRules(lists);
1498
1754
  diags.ok(
1499
1755
  "Perm",
1500
- `\u6743\u9650\u89C4\u5219 ${ruleCount} \u6761\uFF08allow ${counts.allow} / ask ${counts.ask} / deny ${counts.deny}\uFF09`
1756
+ t("boot.perm_rules", {
1757
+ total: ruleCount,
1758
+ allow: counts.allow,
1759
+ ask: counts.ask,
1760
+ deny: counts.deny
1761
+ })
1501
1762
  );
1502
1763
  for (const finding of checkShadowing(compileRuleLists(lists))) {
1503
1764
  diags.warn("Perm", finding.message, `rule-${finding.kind}`);
@@ -1510,10 +1771,6 @@ function buildServices(baseConfig, diags, workDir = process.cwd(), opts = {}) {
1510
1771
  contextEngine = safeInit(
1511
1772
  "Context",
1512
1773
  () => new ContextCompressor({
1513
- // 2026-08-16 合并时收的一笔(compressor.ts 文件头那张 📮 的第一条):
1514
- // `compression.enabled` / `compression.threshold` 两个键的装配点在这儿,
1515
- // 而写它们的那一轮禁区正是 `runtime/**`。不接这一行的后果不是少一个功能,
1516
- // 是**一句谎话** —— 配得进、设置页印得出来、还标着「② 用户级」,而压缩照压。
1517
1774
  ...compressionOptions(config),
1518
1775
  summarize: async (prompt) => {
1519
1776
  const utility = provider.getUtilityInfo().selection;
@@ -1534,8 +1791,6 @@ function buildServices(baseConfig, diags, workDir = process.cwd(), opts = {}) {
1534
1791
  const capability = resolveModelCapability(config, diags);
1535
1792
  return {
1536
1793
  config,
1537
- // 合并**之前**那一份,连同「哪个键是谁写的」那本账 —— 两样都只给设置页的
1538
- // 来源标注用(判据在 `Services.baseConfig` 上)
1539
1794
  baseConfig,
1540
1795
  configOrigins: opts.configOrigins ?? getConfigOrigins(),
1541
1796
  provider,
@@ -1559,6 +1814,7 @@ function buildServices(baseConfig, diags, workDir = process.cwd(), opts = {}) {
1559
1814
  settingsLayers: merged.layers,
1560
1815
  managedPolicy,
1561
1816
  policy: policyStatus,
1817
+ hook: hookStatus,
1562
1818
  plugins,
1563
1819
  cleanups
1564
1820
  };
@@ -1567,7 +1823,6 @@ var init_services = __esm({
1567
1823
  "src/services.ts"() {
1568
1824
  }
1569
1825
  });
1570
-
1571
1826
  // src/turn-steps.ts
1572
1827
  function draft() {
1573
1828
  return { text: "", reasoning: "", toolCalls: [], toolResults: [] };
@@ -1586,12 +1841,6 @@ var TurnStepCollector;
1586
1841
  var init_turn_steps = __esm({
1587
1842
  "src/turn-steps.ts"() {
1588
1843
  TurnStepCollector = class {
1589
- /**
1590
- * @param startedAt 这一轮开跑的毫秒时间戳,用来算 `durationMs`。
1591
- * @param now 取当下时间。**注进来只为让用例能钉一个确定的耗时** ——
1592
- * 生产路径上就是 `Date.now()`,而这一层是服务端,用本机钟是对的
1593
- * (web 那边不许用 `Date.now()`,因为它得用服务端信封上的 `ts`)。
1594
- */
1595
1844
  constructor(startedAt, now = Date.now) {
1596
1845
  this.startedAt = startedAt;
1597
1846
  this.now = now;
@@ -1600,11 +1849,8 @@ var init_turn_steps = __esm({
1600
1849
  now;
1601
1850
  steps = [];
1602
1851
  current = null;
1603
- /** 当前这一步已经收口,下一笔内容另开一步。见文件头那条 ⚠️ */
1604
1852
  sealed = false;
1605
- /** 这一轮发过几次工具调用。**跨步累计**,`toolCalls` 那一格要的是整轮的数 */
1606
1853
  calls = 0;
1607
- /** `finish` 那一帧带回来的账。没收到那一帧就没有 —— 中止 / 报错时就是这样 */
1608
1854
  finish = null;
1609
1855
  observe(event) {
1610
1856
  switch (event.type) {
@@ -1627,8 +1873,6 @@ var init_turn_steps = __esm({
1627
1873
  toolCallId: event.callId,
1628
1874
  toolName: event.toolName,
1629
1875
  output: event.output,
1630
- // artifact 原样带走(含 `data`)。剥 base64 是落盘那一层的事
1631
- // (`persistToolResults()`)—— 内存里的宿主要那份数据渲染缩略图
1632
1876
  ...event.artifacts ? { artifacts: event.artifacts } : {}
1633
1877
  });
1634
1878
  return;
@@ -1646,27 +1890,10 @@ var init_turn_steps = __esm({
1646
1890
  return;
1647
1891
  }
1648
1892
  }
1649
- /** 冲掉最后一步,交出整轮。空步已经在 {@link seal} 里滤掉 */
1650
1893
  take() {
1651
1894
  this.flush();
1652
1895
  return this.steps;
1653
1896
  }
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
1897
  facts() {
1671
1898
  if (!this.finish) return void 0;
1672
1899
  const durationMs = this.startedAt === void 0 ? void 0 : Math.max(0, this.finish.at - this.startedAt);
@@ -1700,46 +1927,20 @@ function settleAbandoned(pending) {
1700
1927
  var AgentSession;
1701
1928
  var init_agent_session = __esm({
1702
1929
  "src/agent-session.ts"() {
1930
+ init_events();
1703
1931
  init_turn_steps();
1704
1932
  AgentSession = class {
1705
- /**
1706
- * 当前会话 id。
1707
- *
1708
- * **不再是 `readonly`**(方案 25 PR-4 的 `/resume`):换会话时它必须跟着换,
1709
- * 否则接下来 `store.record()` 会把新对话记到**旧会话**名下 ——
1710
- * 症状是「我明明 resume 了另一段,怎么两段混在一起」。
1711
- * 换的入口只有 `resume()` 一个,不要在别处赋值。
1712
- */
1713
1933
  sessionId;
1714
1934
  agent;
1715
1935
  store;
1716
1936
  checkpoints;
1717
1937
  goals;
1718
1938
  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
1939
  lastRole;
1733
- /** 不是 `readonly` 的数组内容 —— `compact()` 和 `resume()` 会整体换掉它 */
1940
+ currentWorkDir;
1941
+ lastWorkDir = null;
1734
1942
  history;
1735
- /** 待交给宿主的请求队列(审批 + 提问)+ 「有新请求」的唤醒信号 */
1736
1943
  queue = [];
1737
- /**
1738
- * 已经交给宿主、但还没答复的请求。
1739
- *
1740
- * 必须单独记:请求一旦 yield 给宿主就已经出了 queue,如果宿主提前 break
1741
- * 走了,只看 queue 是空的,那个挂在 await 上的引擎就永久等下去。
1742
- */
1743
1944
  inFlight = /* @__PURE__ */ new Set();
1744
1945
  notify = null;
1745
1946
  constructor(opts) {
@@ -1749,15 +1950,10 @@ var init_agent_session = __esm({
1749
1950
  this.checkpoints = opts.checkpoints;
1750
1951
  this.goals = opts.goals;
1751
1952
  this.currentRole = opts.currentRole;
1953
+ this.currentWorkDir = opts.currentWorkDir;
1752
1954
  this.lastRole = opts.currentRole?.() ?? null;
1753
1955
  this.history = [...opts.history ?? []];
1754
1956
  }
1755
- /**
1756
- * 传给 AgentLoop 的审批回调。
1757
- *
1758
- * 必须在构造 AgentLoop **之前**拿到(它是构造参数),所以这里做成静态工厂:
1759
- * 先建 pending 队列,再建 AgentLoop,最后建 AgentSession。
1760
- */
1761
1957
  static createApprovalBridge() {
1762
1958
  let target = null;
1763
1959
  const buffered = [];
@@ -1783,49 +1979,24 @@ var init_agent_session = __esm({
1783
1979
  }
1784
1980
  };
1785
1981
  }
1786
- /** 当前累积的历史(只读快照) */
1787
1982
  getHistory() {
1788
1983
  return this.history;
1789
1984
  }
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
1985
  note(text) {
1803
1986
  if (!text.trim()) return;
1804
1987
  this.history.push({ role: "user", content: text });
1805
1988
  }
1806
- /**
1807
- * 手动压缩一次上下文(`/compact`,方案 25 §2.6)。
1808
- *
1809
- * 和自动压缩共用引擎里同一段实现,区别只有两个:**不看阈值**(用户敲这条命令
1810
- * 就是要它现在压),以及可以带一句「这次帮我留住什么」。
1811
- *
1812
- * 压缩本身是一次真实的 LLM 请求,那笔钱进的是同一个会话预算 —— 账单由
1813
- * `AgentLoop` 抛出来,这里原样转给调用方,别在这一层吞掉。
1814
- *
1815
- * 历史只有一条(或空)时直接不压:压一条消息拿不到任何收益,
1816
- * 却要花一次 utility 模型的请求。
1817
- */
1818
1989
  async compact(instruction) {
1819
1990
  const before = this.history.length;
1820
1991
  if (before <= 1) {
1821
- return { ran: false, before, after: before, reason: "\u5386\u53F2\u592A\u77ED\uFF0C\u6CA1\u4EC0\u4E48\u53EF\u538B\u7684" };
1992
+ return { ran: false, before, after: before, reason: t("boot.compact_too_short") };
1822
1993
  }
1823
1994
  const result = await this.agent.compactNow(
1824
1995
  this.history.map((m) => ({ role: m.role, content: m.content })),
1825
1996
  instruction
1826
1997
  );
1827
1998
  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" };
1999
+ return { ran: false, before, after: before, reason: t("boot.compact_unavailable") };
1829
2000
  }
1830
2001
  this.history = result.messages.map((m) => ({
1831
2002
  role: m.role === "assistant" ? "assistant" : "user",
@@ -1838,23 +2009,6 @@ var init_agent_session = __esm({
1838
2009
  ...result.usage ? { usage: result.usage } : {}
1839
2010
  };
1840
2011
  }
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
2012
  reloadHistory(sessionId) {
1859
2013
  const restored = this.store?.loadHistory(sessionId) ?? [];
1860
2014
  this.history = restored.map((m) => ({
@@ -1864,38 +2018,11 @@ var init_agent_session = __esm({
1864
2018
  }));
1865
2019
  return this.history;
1866
2020
  }
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
2021
  resume(sessionId) {
1884
2022
  const restored = this.reloadHistory(sessionId);
1885
2023
  this.sessionId = sessionId;
1886
2024
  return restored;
1887
2025
  }
1888
- /**
1889
- * 对话回退(方案 27):删掉水位线之后的消息,然后把内存历史重新装一遍。
1890
- *
1891
- * **重装而不是自己在内存里 splice**:DB 是唯一真源,重装出来的历史天然和它一致。
1892
- * 自己截内存的话就有两套截断逻辑,而它们一旦分叉,症状是「屏幕上和模型看到的
1893
- * 对不上」—— `resume()` 那段注释骂过的正是这个。
1894
- *
1895
- * 没有 store(纯内存模式)就什么都不做并返回 `null`:没有 DB 就没有水位线可言。
1896
- *
1897
- * @returns 删掉几条消息;失败或不支持时 `null`
1898
- */
1899
2026
  rewindConversation(lastKeptId) {
1900
2027
  if (!this.store || lastKeptId < 0) return null;
1901
2028
  const removed = this.store.rewindTo(this.sessionId, lastKeptId);
@@ -1903,13 +2030,6 @@ var init_agent_session = __esm({
1903
2030
  this.reloadHistory(this.sessionId);
1904
2031
  return removed;
1905
2032
  }
1906
- /**
1907
- * 一条排队的请求 → 一个带答复闭包的事件。
1908
- *
1909
- * **答复一次就出账**:先从 `inFlight` 里删掉,再 resolve。重复调用 respond
1910
- * 是无害的(promise 已 settle),但漏了这一步 `finally` 会再对它补一个兜底答复,
1911
- * 于是「用户明明点了允许」和「引擎收到拒绝」同时成立。
1912
- */
1913
2033
  toEvent(pending) {
1914
2034
  if (pending.kind === "question") {
1915
2035
  return {
@@ -1924,26 +2044,12 @@ var init_agent_session = __esm({
1924
2044
  return {
1925
2045
  type: "approval-request",
1926
2046
  request: pending.req,
1927
- // 没带 `note` 时原样传裸出口而不是包成 `{outcome}`:两者语义相同,
1928
- // 但裸串是既有形态,包一层会让所有断言 `resolve` 参数的用例白红一次
1929
2047
  respond: (outcome, note) => {
1930
2048
  this.inFlight.delete(pending);
1931
2049
  pending.resolve(note === void 0 ? outcome : { outcome, note });
1932
2050
  }
1933
2051
  };
1934
2052
  }
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
2053
  async *run(userMessage, opts = {}) {
1948
2054
  let assistantText = "";
1949
2055
  const userText = contentToText(userMessage);
@@ -1955,13 +2061,11 @@ var init_agent_session = __esm({
1955
2061
  });
1956
2062
  if (userText.trim()) this.goals?.beginRound(this.sessionId);
1957
2063
  this.markRoleHandoff();
2064
+ this.markWorkspaceHandoff();
1958
2065
  const iter = this.agent.stream({
1959
2066
  userMessage,
1960
2067
  sessionId: this.sessionId,
1961
2068
  history: this.history,
1962
- // 逐轮把真实用量累进 `sessions` 表(方案 42 PR-0)。
1963
- // 收的是**本轮增量** —— 事件流里的 `usage.cumulative` 会被 BudgetStore
1964
- // 从上次会话恢复,减不出干净的增量(见 `RunInput.onTurnUsage`)
1965
2069
  ...this.store ? {
1966
2070
  onTurnUsage: (usage) => void this.store?.recordUsage(this.sessionId, usage)
1967
2071
  } : {},
@@ -1980,7 +2084,9 @@ var init_agent_session = __esm({
1980
2084
  while (this.queue.length > 0) {
1981
2085
  const pending = this.queue.shift();
1982
2086
  this.inFlight.add(pending);
1983
- yield this.toEvent(pending);
2087
+ const request = this.toEvent(pending);
2088
+ publishAgentEvent(this.sessionId, request);
2089
+ yield request;
1984
2090
  }
1985
2091
  continue;
1986
2092
  }
@@ -1989,6 +2095,7 @@ var init_agent_session = __esm({
1989
2095
  pendingNext = iter.next();
1990
2096
  if (event.type === "text-delta") assistantText += event.text;
1991
2097
  turn.observe(event);
2098
+ publishAgentEvent(this.sessionId, event);
1992
2099
  yield event;
1993
2100
  }
1994
2101
  } finally {
@@ -1997,47 +2104,20 @@ var init_agent_session = __esm({
1997
2104
  this.inFlight.clear();
1998
2105
  this.notify = null;
1999
2106
  }
2000
- this.history.push({ role: "user", content: userText, ...parts ? { parts } : {} });
2001
- if (assistantText) this.history.push({ role: "assistant", content: assistantText });
2002
2107
  const steps = turn.take();
2003
2108
  const facts = turn.facts();
2109
+ const assistantHistory = assistantTurnText(
2110
+ assistantText,
2111
+ steps.flatMap((s) => s.toolResults ?? [])
2112
+ );
2113
+ this.history.push({ role: "user", content: userText, ...parts ? { parts } : {} });
2114
+ if (assistantHistory) this.history.push({ role: "assistant", content: assistantHistory });
2004
2115
  this.store?.record(this.sessionId, userText, assistantText, {
2005
2116
  ...parts ? { parts } : {},
2006
2117
  ...steps.length > 0 ? { steps } : {},
2007
- // 这一轮的账(决定 8 第三个时刻)。不落它的话刷新之后每一轮的
2008
- // 「共 N token · 哪个模型 · 花了几秒」全部消失 —— 那是网线补不回来的
2009
2118
  ...facts ? { facts } : {}
2010
2119
  });
2011
2120
  }
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
2121
  markRoleHandoff() {
2042
2122
  if (!this.currentRole) return;
2043
2123
  const now = this.currentRole() ?? null;
@@ -2048,14 +2128,24 @@ var init_agent_session = __esm({
2048
2128
  this.history.push({ role: "user", content: text });
2049
2129
  this.store?.recordNote(this.sessionId, text);
2050
2130
  }
2051
- /** 一次性执行并拿最终文本(CLI 单次问答用) */
2131
+ markWorkspaceHandoff() {
2132
+ if (!this.currentWorkDir) return;
2133
+ const now = this.currentWorkDir();
2134
+ const previous = this.lastWorkDir;
2135
+ this.lastWorkDir = now;
2136
+ if (previous === null || previous === now) return;
2137
+ if (this.history.length === 0) return;
2138
+ const text = systemNote(t("workspace.handoff", { root: now }));
2139
+ this.history.push({ role: "user", content: text });
2140
+ this.store?.recordNote(this.sessionId, text);
2141
+ }
2052
2142
  async once(userMessage, opts = {}) {
2053
2143
  let text = "";
2054
2144
  let reasoning = "";
2055
2145
  for await (const ev of this.run(userMessage, opts)) {
2056
2146
  if (ev.type === "text-delta") text += ev.text;
2057
2147
  else if (ev.type === "reasoning-delta") reasoning += ev.text;
2058
- else if (ev.type === "error") text = `\u9519\u8BEF: ${ev.message}`;
2148
+ else if (ev.type === "error") text = t("boot.error", { message: ev.message });
2059
2149
  }
2060
2150
  return { text, ...reasoning ? { reasoning } : {} };
2061
2151
  }
@@ -2065,8 +2155,6 @@ var init_agent_session = __esm({
2065
2155
  function createRoleControl(scope, roles) {
2066
2156
  return {
2067
2157
  has: (name) => findRole(roles(), name) !== void 0,
2068
- // `findRole` 找不到时回 `undefined`,而 `enter(undefined)` 的语义正好是
2069
- // 「退回会话默认角色」—— 上面那段 ⚠️ 说的就是这一格,不用再判一次
2070
2158
  enterTurn: (name) => scope.enter(findRole(roles(), name))
2071
2159
  };
2072
2160
  }
@@ -2086,7 +2174,6 @@ var init_role_scope = __esm({
2086
2174
  "src/role-scope.ts"() {
2087
2175
  }
2088
2176
  });
2089
-
2090
2177
  // src/session-goal.ts
2091
2178
  function buildGoalControl(service, sessionId) {
2092
2179
  return {
@@ -2100,20 +2187,47 @@ function buildGoalControl(service, sessionId) {
2100
2187
  clear: () => service.clear(sessionId())
2101
2188
  };
2102
2189
  }
2190
+ function buildGoalCatalog(service) {
2191
+ return {
2192
+ current: (sessionId) => service.current(sessionId),
2193
+ create: (sessionId, objective, maxRounds) => service.create(sessionId, objective, maxRounds),
2194
+ edit: (sessionId, objective) => service.edit(sessionId, objective),
2195
+ setBudget: (sessionId, maxRounds) => service.setBudget(sessionId, maxRounds),
2196
+ pause: (sessionId) => service.pause(sessionId),
2197
+ resume: (sessionId) => service.resume(sessionId),
2198
+ complete: (sessionId, evidence) => service.complete(sessionId, evidence),
2199
+ clear: (sessionId) => service.clear(sessionId)
2200
+ };
2201
+ }
2103
2202
  var init_session_goal = __esm({
2104
2203
  "src/session-goal.ts"() {
2105
2204
  }
2106
2205
  });
2107
2206
  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
- });
2207
+ const {
2208
+ permission,
2209
+ planState,
2210
+ managed,
2211
+ startLevel,
2212
+ requestApproval,
2213
+ interactive,
2214
+ deferredTools
2215
+ } = input;
2216
+ const bootstrapReveal = input.bootstrapReveal;
2217
+ const scopeOf = (perm, state, own) => {
2218
+ const reveal = own ? createToolRevealState() : bootstrapReveal;
2219
+ return {
2220
+ permission: perm,
2221
+ permissions: perm ? buildSessionPermissions(perm, managed, state) : null,
2222
+ plan: state ? buildPlanControl(state) : null,
2223
+ planState: state,
2224
+ tools: own ? [
2225
+ ...state ? createPlanModeTools({ state, ...interactive ? { requestApproval } : {} }) : [],
2226
+ ...deferredTools().length > 0 ? [createToolSearchTool({ deferred: deferredTools, reveal })] : []
2227
+ ] : [],
2228
+ reveal
2229
+ };
2230
+ };
2117
2231
  const bootstrapScope = scopeOf(permission, planState, false);
2118
2232
  return {
2119
2233
  bootstrap: () => bootstrapScope,
@@ -2216,19 +2330,6 @@ var init_session_store = __esm({
2216
2330
  "src/session-store.ts"() {
2217
2331
  init_session_file_changes();
2218
2332
  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
2333
  constructor(sessions, artifactsRoot, cwd, source, decision) {
2233
2334
  this.sessions = sessions;
2234
2335
  this.artifactsRoot = artifactsRoot;
@@ -2241,40 +2342,10 @@ var init_session_store = __esm({
2241
2342
  cwd;
2242
2343
  source;
2243
2344
  decision;
2244
- /** 已经确认存在的会话 id,避免每轮都试着 create */
2245
2345
  known = /* @__PURE__ */ new Set();
2246
- /** 这个会话的 artifact 目录 */
2247
2346
  artifactDir(sessionId) {
2248
2347
  return this.artifactsRoot ? artifactsDir(sessionId, this.artifactsRoot) : artifactsDir(sessionId);
2249
2348
  }
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
2349
  record(sessionId, userMessage, assistantText, detail = {}) {
2279
2350
  try {
2280
2351
  this.ensure(sessionId);
@@ -2298,8 +2369,6 @@ var init_session_store = __esm({
2298
2369
  for (const result of persistToolResults(step.toolResults ?? [])) {
2299
2370
  this.sessions.appendMessage(sessionId, {
2300
2371
  role: "tool",
2301
- // content 列存输出的文本投影:FTS 索引和 `epoch sessions search`
2302
- // 靠它才搜得到工具输出。结构化的那份在 toolResults 里
2303
2372
  content: result.output,
2304
2373
  toolCallId: result.toolCallId,
2305
2374
  toolName: result.toolName,
@@ -2312,18 +2381,6 @@ var init_session_store = __esm({
2312
2381
  return false;
2313
2382
  }
2314
2383
  }
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
2384
  recordNote(sessionId, text) {
2328
2385
  try {
2329
2386
  this.ensure(sessionId);
@@ -2333,15 +2390,6 @@ var init_session_store = __esm({
2333
2390
  return false;
2334
2391
  }
2335
2392
  }
2336
- /**
2337
- * 累加一轮模型请求的真实用量(方案 42 PR-0)。持久化失败照旧不影响对话。
2338
- *
2339
- * **这里要 `ensure()`**,和 `lastMessageId()` 的克制正相反:用量是逐轮来的,
2340
- * 比 `record()`(整轮对话结束才写)早,新会话的第一条消息还没落盘。
2341
- * 不建会话行的话那条 UPDATE 影响 0 行 —— 一段全新会话的用量会**整段丢掉**。
2342
- * 而且这里的触发条件比那边硬:能走到这儿说明模型请求已经发出去、钱已经花了,
2343
- * 那就是一段值得记录的会话,不是「点开又退出」。
2344
- */
2345
2393
  recordUsage(sessionId, usage) {
2346
2394
  try {
2347
2395
  this.ensure(sessionId);
@@ -2351,17 +2399,6 @@ var init_session_store = __esm({
2351
2399
  return false;
2352
2400
  }
2353
2401
  }
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
2402
  savePlan(sessionId, markdown) {
2366
2403
  try {
2367
2404
  this.ensure(sessionId);
@@ -2371,12 +2408,6 @@ var init_session_store = __esm({
2371
2408
  return false;
2372
2409
  }
2373
2410
  }
2374
- /**
2375
- * 读回这段会话的已批准计划;没有(或会话不存在)时 `null`。
2376
- *
2377
- * **不 `ensure()`**:这是查询。`--resume` 指着一个不存在的 id 时该回「没有」,
2378
- * 而不是顺手建一段空会话。
2379
- */
2380
2411
  loadPlan(sessionId) {
2381
2412
  try {
2382
2413
  return this.sessions.get(sessionId)?.meta.plan ?? null;
@@ -2384,13 +2415,6 @@ var init_session_store = __esm({
2384
2415
  return null;
2385
2416
  }
2386
2417
  }
2387
- /**
2388
- * 这个会话最后一条消息的 DB id(方案 27 的检查点水位线)。
2389
- *
2390
- * 会话还没建出来时是 0 —— **不 `ensure()`**:开轮是每条用户消息都要做的事,
2391
- * 为它去建一个可能马上就没有任何内容的会话行,等于让「只是聊了两句就退出」
2392
- * 也在 DB 里留一条空会话。
2393
- */
2394
2418
  lastMessageId(sessionId) {
2395
2419
  try {
2396
2420
  return this.sessions.lastMessageId(sessionId);
@@ -2398,13 +2422,6 @@ var init_session_store = __esm({
2398
2422
  return 0;
2399
2423
  }
2400
2424
  }
2401
- /**
2402
- * 对话回退:删掉水位线之后的消息(方案 27)。返回删了几条,失败时 `null`。
2403
- *
2404
- * 吞异常的口径和 `record()` 一致 —— 但这里返回 `null` 而不是 `false`:
2405
- * 调用方要据此决定还要不要重装内存历史,而「删了 0 条」和「没删成」
2406
- * 对那个决定是两回事。
2407
- */
2408
2425
  rewindTo(sessionId, lastKeptId) {
2409
2426
  try {
2410
2427
  return this.sessions.rewindTo(sessionId, lastKeptId);
@@ -2412,23 +2429,14 @@ var init_session_store = __esm({
2412
2429
  return null;
2413
2430
  }
2414
2431
  }
2415
- /**
2416
- * 会话不存在就建。create 对已存在的 id 会抛错,这里按「已存在」处理。
2417
- *
2418
- * `cwd` 只在**新建**时写,不去 update 已存在的那一行:一段会话属于哪个项目
2419
- * 是它开始那一刻的事实,`epoch -c` 之后在别的目录里接着聊也不该把它改籍。
2420
- *
2421
- * ⚠️ 工作区决定(v7 那两格)走的是同一条「只在新建时写」,但**理由不同**:
2422
- * 不是「不该改」,而是「改它的路在别处」—— 那一格的 update 归
2423
- * `SessionWorkspaces` 的 `decisions.record()`(判据在构造函数的 `decision` 上)。
2424
- */
2425
2432
  ensure(sessionId) {
2426
2433
  if (this.known.has(sessionId)) return;
2427
2434
  const decision = this.decision?.(sessionId) ?? null;
2435
+ const cwd = this.cwd?.();
2428
2436
  try {
2429
2437
  this.sessions.create({
2430
2438
  id: sessionId,
2431
- ...this.cwd ? { cwd: this.cwd } : {},
2439
+ ...cwd ? { cwd } : {},
2432
2440
  ...this.source ? { source: this.source } : {},
2433
2441
  ...decision ? {
2434
2442
  workspaceState: decision.state,
@@ -2439,61 +2447,42 @@ var init_session_store = __esm({
2439
2447
  }
2440
2448
  this.known.add(sessionId);
2441
2449
  }
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
2450
  loadHistory(sessionId) {
2468
2451
  const prev = this.sessions.get(sessionId);
2469
2452
  if (!prev) return [];
2470
2453
  this.known.add(sessionId);
2471
2454
  const out = [];
2455
+ let text = "";
2456
+ let results = [];
2457
+ let open = false;
2458
+ const flush = () => {
2459
+ if (!open) return;
2460
+ const content = assistantTurnText(text, results);
2461
+ if (content) out.push({ role: "assistant", content });
2462
+ text = "";
2463
+ results = [];
2464
+ open = false;
2465
+ };
2472
2466
  for (const m of prev.messages) {
2473
2467
  if (m.role === "user") {
2468
+ flush();
2474
2469
  const parts = toParts(m.parts, true);
2475
2470
  out.push({ role: "user", content: m.content, ...parts ? { parts } : {} });
2476
2471
  continue;
2477
2472
  }
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 });
2473
+ if (m.role === "assistant") {
2474
+ open = true;
2475
+ text += m.content;
2476
+ continue;
2477
+ }
2478
+ if (m.role === "tool") {
2479
+ if (open) results.push(...m.toolResults ?? []);
2480
+ continue;
2481
+ }
2482
2482
  }
2483
+ flush();
2483
2484
  return out;
2484
2485
  }
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
2486
  loadMessages(sessionId) {
2498
2487
  const prev = this.sessions.get(sessionId);
2499
2488
  if (!prev) return [];
@@ -2504,13 +2493,8 @@ var init_session_store = __esm({
2504
2493
  id: m.id,
2505
2494
  timestamp: m.timestamp,
2506
2495
  role: m.role,
2507
- // protocol 的约定:tool 消息 content 为空串,内容在 toolResults 里,
2508
- // 「取这条消息的文本」才永远不用判类型。DB 里那份是给 FTS 用的
2509
2496
  content: m.role === "tool" ? "" : m.content,
2510
2497
  ...parts ? { parts } : {},
2511
- // 思考原文和这一轮的账(2026-08-18)。**两样都只有回放这一支带** ——
2512
- // `loadHistory()` 那份是喂模型的,模型不需要(也不该)看自己上一轮
2513
- // 想过什么、花了多少钱
2514
2498
  ...m.reasoning ? { reasoning: m.reasoning } : {},
2515
2499
  ...m.turnFacts ? { turnFacts: m.turnFacts } : {},
2516
2500
  ...m.toolCalls ? { toolCalls: m.toolCalls } : {},
@@ -2518,15 +2502,6 @@ var init_session_store = __esm({
2518
2502
  };
2519
2503
  });
2520
2504
  }
2521
- /**
2522
- * 这个会话在工作区里改过哪些文件(方案 42 PR-4)。
2523
- *
2524
- * @param workDir 相对路径按哪个根算。**这个会话绑定的工作区主根**(决定 18),
2525
- * 不是服务进程的工作目录 —— 后者在多会话下只会是其中一个的,而它不说是哪个。
2526
- *
2527
- * 读不出来(SQLite 起不来 / 会话不存在)时是空数组,不抛:这是一个展示用的
2528
- * 汇总,它自己坏了不该让整屏 500。
2529
- */
2530
2505
  fileChanges(sessionId, workDir) {
2531
2506
  try {
2532
2507
  return collectFileChanges(this.loadMessages(sessionId), workDir);
@@ -2534,6 +2509,12 @@ var init_session_store = __esm({
2534
2509
  return [];
2535
2510
  }
2536
2511
  }
2512
+ ftsHealth() {
2513
+ return this.sessions.ftsHealth();
2514
+ }
2515
+ reclaimFts(vacuum = true) {
2516
+ return this.sessions.reclaimFts(vacuum);
2517
+ }
2537
2518
  };
2538
2519
  }
2539
2520
  });
@@ -2655,8 +2636,6 @@ async function applyMcpConfig(mcp, sync, opts) {
2655
2636
  return {
2656
2637
  ok: false,
2657
2638
  reason: "no-servers",
2658
- // 只带**第一条**诊断:屏幕上那句话后面已经跟着完整的诊断列表了,
2659
- // 把十条拼成一行只会把它挤出去
2660
2639
  detail: issues[0]?.message ?? ""
2661
2640
  };
2662
2641
  }
@@ -2882,8 +2861,6 @@ function writeMcpServer(input, opts = {}) {
2882
2861
  return {
2883
2862
  ok: false,
2884
2863
  reason: "invalid-config",
2885
- // 分隔符用 ASCII 的 `; `:这几条 message 本身是 plugin-mcp 那边的原文,
2886
- // 我们只是把它们串起来,不该在中间插一个由**我们**的语言决定的标点
2887
2864
  detail: probe.issues.map((i) => i.message).join("; ") || t("mcp_write.invalid")
2888
2865
  };
2889
2866
  }
@@ -2933,6 +2910,11 @@ var init_mcp_config_write = __esm({
2933
2910
  EMPTY_CONFIG = '{\n "servers": {}\n}\n';
2934
2911
  }
2935
2912
  });
2913
+ function foldToolMode(mode) {
2914
+ if (mode === "both") return "both";
2915
+ if (mode === "code") return "code";
2916
+ return "tools";
2917
+ }
2936
2918
  function applyShell(kind, diags) {
2937
2919
  if (!kind || kind === "cmd") {
2938
2920
  setShell(kind ?? null);
@@ -2945,29 +2927,34 @@ function applyShell(kind, diags) {
2945
2927
  return;
2946
2928
  }
2947
2929
  setShell(kind);
2948
- diags.ok("Shell", `${kind}\uFF08terminal \u5DE5\u5177\u7684\u547D\u4EE4\u4EA4\u7ED9\u5B83\u6267\u884C\uFF09`);
2930
+ diags.ok("Shell", t("boot.shell_kind", { kind }));
2949
2931
  }
2950
2932
  function registerLocalTools(input) {
2951
- const { trackerDb, goals, memory, sandbox, diags } = input;
2952
- const registry = new ToolRegistry();
2933
+ const { trackerDb, goals, memory, sandbox, skills, diags } = input;
2934
+ const registry = new ToolRegistry({ codeMode: input.toolMode === "code" });
2953
2935
  for (const plugin of [filePlugin, terminalPlugin, webPlugin, lspPlugin]) {
2954
2936
  try {
2955
2937
  registry.registerPlugin(plugin);
2956
2938
  } catch (err) {
2957
2939
  diags.failed(
2958
2940
  `Plugin ${plugin.name}`,
2959
- `\u6CE8\u518C\u5931\u8D25 (${err instanceof Error ? err.message : err})`,
2941
+ t("boot.plugin_register_failed", {
2942
+ reason: err instanceof Error ? err.message : String(err)
2943
+ }),
2960
2944
  "plugin-register-failed"
2961
2945
  );
2962
2946
  }
2963
2947
  }
2964
2948
  const rg = resolveRipgrep();
2965
2949
  if (rg.mode === "missing") {
2966
- diags.warn("Ripgrep", `\u672A\u5B89\u88C5\uFF0Cfile_search \u4E0D\u53EF\u7528\uFF08${rg.hint}\uFF09`, "ripgrep-missing");
2950
+ diags.warn("Ripgrep", t("boot.ripgrep_missing", { hint: rg.hint ?? "" }), "ripgrep-missing");
2967
2951
  } else if (rg.mode === "system") {
2968
- diags.ok("Ripgrep", "\u7528\u7CFB\u7EDF\u5B89\u88C5\u7684 rg");
2952
+ diags.ok("Ripgrep", t("boot.ripgrep_system"));
2969
2953
  } else {
2970
- diags.ok("Ripgrep", `\u7528\u5185\u7F6E\u7684 rg${rg.version ? ` ${rg.version}` : ""}`);
2954
+ diags.ok(
2955
+ "Ripgrep",
2956
+ t("boot.ripgrep_builtin", { version: rg.version === void 0 ? "" : ` ${rg.version}` })
2957
+ );
2971
2958
  }
2972
2959
  const search = selectSearchProvider(input.search);
2973
2960
  if (search.provider) {
@@ -2977,16 +2964,46 @@ function registerLocalTools(input) {
2977
2964
  input.search?.maxResults ? { defaultMaxResults: input.search.maxResults } : {}
2978
2965
  )
2979
2966
  );
2980
- diags.ok("Web \u641C\u7D22", search.detail);
2967
+ diags.ok("WebSearch", search.detail);
2981
2968
  } else {
2982
- diags.skipped("Web \u641C\u7D22", search.detail);
2969
+ diags.skipped("WebSearch", search.detail);
2983
2970
  }
2984
2971
  if (trackerDb) registry.registerTool(createTodoTool(trackerDb));
2985
2972
  if (goals) registry.registerTool(createGoalTool({ service: goals }));
2986
2973
  if (memory) registry.registerTool(createMemoryTool(memory));
2987
2974
  if (sandbox) registry.registerTool(createCodeExecTool(sandbox));
2975
+ if (input.toolMode === "both" || input.toolMode === "code") {
2976
+ registry.registerTool(createRunCodeTool());
2977
+ }
2978
+ if (skills) registry.registerTool(createSkillViewTool({ skills }));
2979
+ if (input.deferMcp === true) {
2980
+ registry.registerTool(
2981
+ createToolSearchTool({
2982
+ deferred: () => registry.byExposure(ToolExposure.Deferred),
2983
+ reveal: input.bootstrapReveal ?? createToolRevealState()
2984
+ })
2985
+ );
2986
+ }
2988
2987
  return registry;
2989
2988
  }
2989
+ function reportToolCost(registry, diags) {
2990
+ const exposed = registry.exposed();
2991
+ const mcp = exposed.filter(
2992
+ (tool) => (registry.sourceOf(tool.name) ?? "").startsWith("mcp:")
2993
+ ).length;
2994
+ const local = exposed.length - mcp;
2995
+ const tokens = Math.round(toolTableTokens(exposed).total / 100) / 10;
2996
+ const split = (lang) => mcp > 0 ? t("boot.tools_split_mixed", { local, mcp }, lang) : t("boot.tools_split_local", { local }, lang);
2997
+ const deferred = registry.byExposure(ToolExposure.Deferred).length;
2998
+ diags.ok(
2999
+ TOOLS_MODULE,
3000
+ (lang) => deferred > 0 ? t(
3001
+ "boot.tools_cost_deferred",
3002
+ { count: exposed.length, split: split(lang), tokens, deferred },
3003
+ lang
3004
+ ) : t("boot.tools_cost", { count: exposed.length, split: split(lang), tokens }, lang)
3005
+ );
3006
+ }
2990
3007
  function registerSessionSearch(registry, input) {
2991
3008
  if (!input.session) {
2992
3009
  input.diags.skipped(
@@ -3030,7 +3047,13 @@ function registerAskQuestion(registry, onQuestionRequest, interactive) {
3030
3047
  createAskQuestionTool(interactive ? { requestQuestion: onQuestionRequest } : {})
3031
3048
  );
3032
3049
  }
3033
- function createToolSync(mcp, registry) {
3050
+ function registerMcpBatch(registry, tools, server, defer) {
3051
+ registry.registerTools(
3052
+ defer ? tools.map((tool) => ({ ...tool, exposure: ToolExposure.Deferred })) : [...tools],
3053
+ `mcp:${server}`
3054
+ );
3055
+ }
3056
+ function createToolSync(mcp, registry, defer) {
3034
3057
  let queue = Promise.resolve();
3035
3058
  const replace = (server) => {
3036
3059
  queue = queue.then(async () => {
@@ -3038,9 +3061,11 @@ function createToolSync(mcp, registry) {
3038
3061
  const fresh = await withTimeout(mcp.refresh(server), MCP_LIST_BUDGET_MS, null) ?? [];
3039
3062
  const prefix = `mcp__${server}__`;
3040
3063
  registry.unregisterBySource(`mcp:${server}`);
3041
- registry.registerTools(
3042
- fresh.filter((t13) => t13.name.startsWith(prefix)),
3043
- `mcp:${server}`
3064
+ registerMcpBatch(
3065
+ registry,
3066
+ fresh.filter((t23) => t23.name.startsWith(prefix)),
3067
+ server,
3068
+ defer
3044
3069
  );
3045
3070
  } catch {
3046
3071
  }
@@ -3049,7 +3074,7 @@ function createToolSync(mcp, registry) {
3049
3074
  };
3050
3075
  return { settled: () => queue, replace };
3051
3076
  }
3052
- function wireToolRefresh(mcp, registry, sync = createToolSync(mcp, registry)) {
3077
+ function wireToolRefresh(mcp, registry, sync = createToolSync(mcp, registry, false)) {
3053
3078
  mcp.onToolsChanged = (server) => void sync.replace(server);
3054
3079
  return sync;
3055
3080
  }
@@ -3098,8 +3123,6 @@ async function addServer(mcp, sync, input, homeDir) {
3098
3123
  return {
3099
3124
  ok: true,
3100
3125
  configPath: written.configPath,
3101
- // `connectAll` 恒回一台一条,兜底那一支是给类型系统看的 —— 真走到了说明
3102
- // registry 那边变了形状,那时报一台「连不上、没说为什么」比崩掉强
3103
3126
  status: status ?? mcp.getStatus(input.name) ?? {
3104
3127
  name: input.name,
3105
3128
  connected: false,
@@ -3142,9 +3165,9 @@ function reportTaken(source, name, diags) {
3142
3165
  "host-mcp-name-taken"
3143
3166
  );
3144
3167
  }
3145
- async function registerMcpTools(registry, homeDir, diags, injected = []) {
3168
+ async function registerMcpTools(registry, homeDir, diags, injected = [], defer = false) {
3146
3169
  const mcp = new McpRegistry({ homeDir });
3147
- const sync = createToolSync(mcp, registry);
3170
+ const sync = createToolSync(mcp, registry, defer);
3148
3171
  const live = () => ({
3149
3172
  dispose: () => void mcp.disconnectAll().catch(() => {
3150
3173
  }),
@@ -3176,19 +3199,19 @@ async function registerMcpTools(registry, homeDir, diags, injected = []) {
3176
3199
  try {
3177
3200
  const { servers, issues } = mcp.loadFromConfig(mcpConfigPath(homeDir));
3178
3201
  for (const issue of issues) {
3179
- diags.warn(`MCP \u914D\u7F6E ${issue.path}`, issue.message, "mcp-config-invalid");
3202
+ diags.warn(`MCPConfig ${issue.path}`, issue.message, "mcp-config-invalid");
3180
3203
  }
3181
3204
  const extra = dropTakenNames(injected, new Set(servers.map((s) => s.name)), diags);
3182
3205
  const injectedCount = extra.reduce((sum, b) => sum + b.servers.length, 0);
3183
3206
  if (servers.length === 0 && injectedCount === 0) {
3184
- diags.skipped("MCP", "\u672A\u914D\u7F6E\uFF08~/.epoch/mcp.json \u4E0D\u5B58\u5728\u6216\u4E3A\u7A7A\uFF09");
3207
+ diags.skipped("MCP", t("boot.mcp_none"));
3185
3208
  return live();
3186
3209
  }
3187
3210
  const statuses = await connectAllServers(mcp, [{ source: "user", servers }, ...extra]);
3188
3211
  if (statuses === null) {
3189
3212
  diags.failed(
3190
3213
  "MCP",
3191
- `\u8FDE\u63A5\u8D85\u65F6\uFF08>${MCP_CONNECT_BUDGET_MS / 1e3}s\uFF09\uFF0C\u5DF2\u8DF3\u8FC7`,
3214
+ t("boot.mcp_connect_timeout", { seconds: MCP_CONNECT_BUDGET_MS / 1e3 }),
3192
3215
  "mcp-connect-timeout"
3193
3216
  );
3194
3217
  return live();
@@ -3198,14 +3221,22 @@ async function registerMcpTools(registry, homeDir, diags, injected = []) {
3198
3221
  if (!st.connected) {
3199
3222
  diags.failed(
3200
3223
  `MCP ${st.name}`,
3201
- `\u8FDE\u63A5\u5931\u8D25${st.lastError ? ` (${st.lastError})` : ""}`,
3224
+ t("boot.mcp_connect_failed", {
3225
+ detail: st.lastError ? t("boot.mcp_connect_failed_detail", { detail: st.lastError }) : ""
3226
+ }),
3202
3227
  "mcp-connect-failed"
3203
3228
  );
3204
3229
  continue;
3205
3230
  }
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`);
3231
+ const own = mcpTools.filter((t23) => t23.name.startsWith(`mcp__${st.name}__`));
3232
+ const registered = dropOverlongToolNames(
3233
+ own,
3234
+ `MCP ${st.name}`,
3235
+ "mcp-tool-name-too-long",
3236
+ diags
3237
+ );
3238
+ registerMcpBatch(registry, registered, st.name, defer);
3239
+ diags.ok(`MCP ${st.name}`, t("boot.mcp_tools", { count: registered.length }));
3209
3240
  if (st.lastError) diags.warn(`MCP ${st.name}`, st.lastError, "mcp-degraded");
3210
3241
  }
3211
3242
  wireToolRefresh(mcp, registry, sync);
@@ -3213,13 +3244,15 @@ async function registerMcpTools(registry, homeDir, diags, injected = []) {
3213
3244
  } catch (err) {
3214
3245
  diags.failed(
3215
3246
  "MCP",
3216
- `\u521D\u59CB\u5316\u5931\u8D25 (${err instanceof Error ? err.message : String(err)})`,
3247
+ t("boot.mcp_init_failed", {
3248
+ reason: err instanceof Error ? err.message : String(err)
3249
+ }),
3217
3250
  "mcp-init-failed"
3218
3251
  );
3219
3252
  return live();
3220
3253
  }
3221
3254
  }
3222
- var MCP_CONNECT_BUDGET_MS, MCP_LIST_BUDGET_MS, SESSION_SEARCH_MODULE;
3255
+ var MCP_CONNECT_BUDGET_MS, MCP_LIST_BUDGET_MS, TOOLS_MODULE, SESSION_SEARCH_MODULE;
3223
3256
  var init_tools = __esm({
3224
3257
  "src/tools.ts"() {
3225
3258
  init_host_capabilities();
@@ -3228,6 +3261,7 @@ var init_tools = __esm({
3228
3261
  init_mcp_config_write();
3229
3262
  MCP_CONNECT_BUDGET_MS = 15e3;
3230
3263
  MCP_LIST_BUDGET_MS = 1e4;
3264
+ TOOLS_MODULE = "Tools";
3231
3265
  SESSION_SEARCH_MODULE = "SessionSearch";
3232
3266
  }
3233
3267
  });
@@ -3271,11 +3305,6 @@ var init_session_factory = __esm({
3271
3305
  SessionBridges = class {
3272
3306
  byId = /* @__PURE__ */ new Map();
3273
3307
  override;
3274
- /**
3275
- * @param override 宿主自己实现的阻塞式确认(`BuildRuntimeOptions.onApprovalRequest`)。
3276
- * 给了就**所有会话都走它**:那种宿主(CLI 的 inquirer)本来就是单会话的,
3277
- * 它的回调签名里也没有 sessionId 的位置。
3278
- */
3279
3308
  constructor(override) {
3280
3309
  this.override = override;
3281
3310
  }
@@ -3285,32 +3314,11 @@ var init_session_factory = __esm({
3285
3314
  release(sessionId) {
3286
3315
  this.byId.delete(sessionId);
3287
3316
  }
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
3317
  approve(sessionId, req) {
3306
3318
  if (this.override) return this.override(req);
3307
3319
  const bridge = this.byId.get(sessionId);
3308
3320
  return bridge ? bridge.approve(req) : Promise.resolve("deny");
3309
3321
  }
3310
- /**
3311
- * 转一条提问。认不出时回 `skipped` —— 那是**实话**(确实没人答),
3312
- * 而替用户挑一个 label 才是伪造(方案 34 §2.4)。
3313
- */
3314
3322
  ask(sessionId, req) {
3315
3323
  const bridge = this.byId.get(sessionId);
3316
3324
  return bridge ? bridge.ask(req) : Promise.resolve({ answers: {}, skipped: true });
@@ -3334,82 +3342,27 @@ var init_session_factory = __esm({
3334
3342
  get(sessionId) {
3335
3343
  return this.live.get(sessionId) ?? null;
3336
3344
  }
3337
- /**
3338
- * 把**引导会话**装进表里。装配层专用,`bind()` 那两道门在这条路上不走 ——
3339
- * 它的工作区已经 `seed()` 进去了(判据见 workspace.ts 的 `seed`)。
3340
- *
3341
- * @returns 建不出 `AgentLoop`(provider 没起来)时 null。那不是错误:
3342
- * 一台还没配 API key 的机器上跑 `epoch doctor` 走的就是这条路
3343
- */
3344
3345
  adopt(sessionId, history) {
3345
3346
  this.bootstrap = sessionId;
3346
- const live = this.assemble(sessionId, this.opts.workspaces.of(sessionId), history, false);
3347
+ const live = this.assemble(sessionId, history, false);
3347
3348
  if (live) this.live.set(sessionId, live);
3348
3349
  return live;
3349
3350
  }
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
3351
  rekey(from, to) {
3403
3352
  const live = this.live.get(from);
3404
3353
  if (!live || from === to) return;
3405
3354
  this.live.delete(from);
3406
3355
  this.opts.bridges.release(from);
3356
+ const workspaces = this.opts.workspaces;
3407
3357
  const moved = {
3408
3358
  ...live,
3409
3359
  sessionId: to,
3410
- workspace: this.opts.workspaces.of(to)
3360
+ get workspace() {
3361
+ return workspaces.of(to);
3362
+ }
3411
3363
  };
3412
3364
  this.live.set(to, moved);
3365
+ this.opts.processes.claim(to);
3413
3366
  this.opts.bridges.register(to, moved.bridge);
3414
3367
  if (this.bootstrap === from) this.bootstrap = to;
3415
3368
  }
@@ -3425,15 +3378,13 @@ var init_session_factory = __esm({
3425
3378
  if (hit) return { ok: true, live: hit, created: false };
3426
3379
  }
3427
3380
  const sessionId = this.newId();
3428
- let bound = null;
3429
3381
  if (input.workspace !== null) {
3430
3382
  const outcome = this.opts.workspaces.bind(sessionId, input.workspace, input.lang);
3431
3383
  if (!outcome.ok) return { ok: false, reason: outcome.reason, detail: outcome.detail };
3432
- bound = outcome.bound;
3433
3384
  } else {
3434
3385
  this.opts.workspaces.decideNone(sessionId);
3435
3386
  }
3436
- const live = this.assemble(sessionId, bound, [], true, role);
3387
+ const live = this.assemble(sessionId, [], true, role);
3437
3388
  if (!live) {
3438
3389
  this.opts.workspaces.release(sessionId);
3439
3390
  return { ok: false, reason: "unavailable" };
@@ -3441,7 +3392,6 @@ var init_session_factory = __esm({
3441
3392
  this.live.set(sessionId, live);
3442
3393
  return { ok: true, live, created: true };
3443
3394
  }
3444
- /** 判据全文在 {@link SessionFactory.revive} 上 */
3445
3395
  revive(input) {
3446
3396
  const { sessionId } = input;
3447
3397
  const already = this.live.get(sessionId);
@@ -3451,15 +3401,13 @@ var init_session_factory = __esm({
3451
3401
  const decision = remembered ?? input.decision;
3452
3402
  const awaitingPick = this.opts.workspaces.stateOf(sessionId) === "unbound";
3453
3403
  if (!decision && !awaitingPick) return { ok: false, reason: "no-decision" };
3454
- let bound = null;
3455
3404
  if (decision?.state === "bound") {
3456
3405
  const outcome = this.opts.workspaces.bind(sessionId, decision.root, input.lang);
3457
3406
  if (!outcome.ok) return { ok: false, reason: outcome.reason, detail: outcome.detail };
3458
- bound = outcome.bound;
3459
3407
  } else if (decision) {
3460
3408
  this.opts.workspaces.decideNone(sessionId);
3461
3409
  }
3462
- const live = this.assemble(sessionId, bound, [], true);
3410
+ const live = this.assemble(sessionId, [], true);
3463
3411
  if (!live) {
3464
3412
  if (!remembered && !awaitingPick) this.opts.workspaces.release(sessionId);
3465
3413
  return { ok: false, reason: "unavailable" };
@@ -3472,67 +3420,40 @@ var init_session_factory = __esm({
3472
3420
  this.live.delete(sessionId);
3473
3421
  this.opts.bridges.release(sessionId);
3474
3422
  }
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
3423
  subRunner(provider) {
3491
3424
  return async (goal, context, subSessionId, roleName, parentSessionId) => {
3492
3425
  const { services, config } = this.opts;
3493
3426
  const parent = this.live.get(parentSessionId);
3494
3427
  const workDir = parent?.workspace?.workspace.root ?? this.opts.defaultWorkDir;
3495
3428
  const projectContext = parent?.workspace?.projectContext ?? services.projectContext;
3496
- const checkpoints = new CheckpointManager({ workDir, homeDir: config.homeDir });
3429
+ const checkpoints = new CheckpointManager({
3430
+ workDir: () => workDir,
3431
+ homeDir: config.homeDir
3432
+ });
3497
3433
  const role = findRole(this.opts.roles(), roleName ?? DEFAULT_AGENT_ROLE);
3498
3434
  const permission = parent?.permission ?? services.permission;
3499
3435
  const sub = new AgentLoop({
3500
- // 跑在**派活那个会话**的模型上(2026-08-16)。和下面 `budgetSessionId` /
3501
- // `checkpoints` / `onApprovalRequest` 是同一条判据:子任务是那个会话的
3502
- // 一部分。读进程那一份的话,会话 B 明明换到了别的模型,它派出去的活
3503
- // 还在打引导会话那个 —— 而账是记在 B 头上的
3504
3436
  provider: parent?.provider ?? provider,
3505
3437
  toolProvider: roleScopedProvider(this.opts.rawToolProvider, role, BLOCKED_TOOLS),
3438
+ codeModeTools: this.opts.codeModeTools,
3506
3439
  telemetry: this.opts.telemetry,
3507
3440
  workDir,
3508
- // 复用父会话那份:信任判定和指令文件已经算过、也已经进过诊断,
3509
- // 每个子 agent 再算一遍只是重复读盘和重复跑 git
3510
3441
  projectContext,
3511
- // 角色说了算,没说才回落到「父的一半」——避免单个子任务吃掉全部预算
3512
3442
  maxTurns: role?.maxTurns ?? Math.max(4, Math.floor(config.maxTurns / 2)),
3513
3443
  contextLength: services.contextLength,
3514
- // **不给 `scope`**:它缺省就是 `'delegate'`,而这条路正是那个缺省值
3515
- // 存在的理由(见 `AgentRoleScope`)。显式写一遍也对,但那会让这一行
3516
- // 和它上面十几行「原样透传」的写法长得不一样,反而像在做别的事
3517
- //
3518
- // 这条路上**没有槽**(方案 57 §3.2 只换了 `assemble()` 那一行):一个子任务
3519
- // 从派出去到跑完就是一次 run,中途没有「下一条消息」这个概念。写成函数
3520
- // 只是跟着 `AgentConfig.role` 的形状走,它每次都回同一份对象
3444
+ artifactRetention: resolveArtifactRetention(config.artifacts),
3445
+ sandboxTerminal: config.sandbox?.terminal !== false,
3521
3446
  ...role ? { role: () => ({ name: role.name, ...role.prompt ? { prompt: role.prompt } : {} }) } : {},
3522
3447
  ...permission ? { permissionManager: permission } : {},
3523
3448
  ...services.hookManager ? { hookManager: services.hookManager } : {},
3449
+ skillSystem: services.skill ?? void 0,
3450
+ roleSkills: () => role?.skills,
3524
3451
  ...this.opts.budget,
3525
- // 子任务的花费记到**派活的那个会话**头上。不这么做的话 `delegate_task`
3526
- // 就是一条绕过成本上限的通道;而记到引导会话头上(工厂之前那样)更糟 ——
3527
- // 那是**另一个人**的预算
3528
3452
  budgetSessionId: parentSessionId,
3529
- // 同上:子 agent 改的文件要能被派活那个会话的 `/rewind` 看见
3530
3453
  checkpoints: {
3531
3454
  capture: (_sub, paths) => checkpoints.capture(parentSessionId, paths),
3532
3455
  record: (_sub, paths) => checkpoints.record(parentSessionId, paths)
3533
3456
  },
3534
- // 审批弹给派活那个会话的界面。`BLOCKED_TOOLS` 挡掉了 plan 和提问那几个,
3535
- // 剩下的写类工具照样要问人
3536
3457
  onApprovalRequest: (req) => this.opts.bridges.approve(parentSessionId, req)
3537
3458
  });
3538
3459
  let r;
@@ -3554,23 +3475,9 @@ ${context}` : goal,
3554
3475
  };
3555
3476
  };
3556
3477
  }
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) {
3478
+ assemble(sessionId, history, own, role) {
3573
3479
  const opts = this.opts;
3480
+ opts.processes.claim(sessionId);
3574
3481
  const { services, config, provider, models } = opts;
3575
3482
  if (!provider || !models) return null;
3576
3483
  const scope = {
@@ -3579,14 +3486,26 @@ ${context}` : goal,
3579
3486
  };
3580
3487
  const active = role ?? opts.mainRole ?? void 0;
3581
3488
  const roleScope = createRoleScope(
3582
- withSessionTools(opts.toolProvider, scope.guard.tools),
3489
+ withSessionTools(
3490
+ revealScopedProvider(opts.toolProvider, opts.deferredTools, scope.guard.reveal),
3491
+ scope.guard.tools
3492
+ ),
3583
3493
  active,
3584
3494
  []
3585
3495
  );
3586
3496
  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 });
3497
+ const live = {};
3498
+ const site = () => {
3499
+ const now = opts.workspaces.of(live.session?.sessionId ?? sessionId);
3500
+ return {
3501
+ workDir: now?.workspace.root ?? opts.defaultWorkDir,
3502
+ projectContext: now?.projectContext ?? services.projectContext
3503
+ };
3504
+ };
3505
+ const checkpoints = new CheckpointManager({
3506
+ workDir: () => site().workDir,
3507
+ homeDir: config.homeDir
3508
+ });
3590
3509
  const loopProvider = opts.goals ? withGoalPrompt(
3591
3510
  scope.model.provider,
3592
3511
  () => opts.goals.promptView(live.session?.sessionId ?? sessionId)
@@ -3594,59 +3513,18 @@ ${context}` : goal,
3594
3513
  const bridge = AgentSession.createApprovalBridge();
3595
3514
  const routed = { approve: bridge.onApprovalRequest, ask: bridge.onQuestionRequest };
3596
3515
  opts.bridges.register(sessionId, routed);
3597
- const live = {};
3598
3516
  const agent = safeInit(
3599
3517
  "AgentLoop",
3600
3518
  () => new AgentLoop({
3601
- // **这个会话自己那份门面**,不是 `opts.provider`(那是引导会话的)。
3602
- // 拿错的表现是:第二个会话换完模型,界面上写着新的、引擎下一轮打的
3603
- // 还是旧的 —— 判据在 `SessionModelScope.provider` 上
3604
3519
  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
3520
  toolProvider: roleScope.provider,
3521
+ codeModeTools: opts.codeModeTools,
3617
3522
  telemetry: opts.telemetry,
3618
- workDir,
3619
- projectContext,
3620
- // artifact 落在**这次实际生效的数据目录**下,而不是全局 `~/.epoch`
3621
- // (方案 54 §二)。给 `config.homeDir` 而不是 `opts.homeDir`:
3622
- // 不传时它是引擎探测出来的结果,只有它是真源
3523
+ site,
3623
3524
  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` 上
3525
+ artifactRetention: resolveArtifactRetention(config.artifacts),
3526
+ sandboxTerminal: config.sandbox?.terminal !== false,
3633
3527
  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
3528
  role: () => {
3651
3529
  const now = roleScope.current;
3652
3530
  if (!now) return void 0;
@@ -3658,28 +3536,11 @@ ${context}` : goal,
3658
3536
  };
3659
3537
  },
3660
3538
  contextLength: services.contextLength,
3661
- // 已批准的计划(方案 35 PR-2 验收 8)。给函数不给快照 —— 计划是这一轮
3662
- // 跑到一半才被批准的,快照恒为 null。
3663
- //
3664
- // ⚠️ 读的是**这个会话自己那份** plan 状态(2026-08-16):原来这儿是一个
3665
- // 进程级闭包,于是会话 B 的 system prompt 附加段里挂的是会话 A 批准的
3666
- // 那份纲 —— 模型照着一份不属于这段对话的计划干活,而屏幕上什么都看不出来
3667
3539
  ...planState ? { approvedPlan: () => planState.approvedPlan() } : {},
3668
- // 宿主给了阻塞式确认就用它的(单会话宿主),否则走这个会话自己那座桥。
3669
- // **不绕 `bridges.approve`**:那一层是给共享工具路由用的,而 `AgentLoop`
3670
- // 本来就知道自己是谁 —— 多绕一圈只多一处能对错的地方
3671
3540
  onApprovalRequest: opts.hostApproval ?? bridge.onApprovalRequest,
3672
- // 只给**这个会话**那一批(2026-08-15)。会话 id 走 `live` 而不是入参,
3673
- // 理由见它的声明
3674
3541
  backgroundTasks: () => opts.backgroundTasks(live.session?.sessionId ?? sessionId),
3675
- // 写类工具动手前留一份旧内容(方案 27)。开轮在 `AgentSession.run()`,
3676
- // 循环自己不认识这个东西 —— 所以上面那条「同一个实例」是硬约束
3677
3542
  checkpoints,
3678
3543
  ...services.memory ? { memory: services.memory } : {},
3679
- // 压缩时把当前目标递给摘要模型(方案 52 PR-3 / 验收 3 后半句)。
3680
- // **包的是同一个进程级实例**,不是新建一个 —— 冷却期、熔断计数、
3681
- // 上一版摘要都在那份状态里,复制一份等于每个会话各有一套闸门。
3682
- // 判据全文在 `goal/engine.ts`
3683
3544
  ...services.contextEngine ? {
3684
3545
  contextEngine: opts.goals ? withGoalCompression(
3685
3546
  services.contextEngine,
@@ -3687,11 +3548,9 @@ ${context}` : goal,
3687
3548
  ) : services.contextEngine
3688
3549
  } : {},
3689
3550
  ...services.hookManager ? { hookManager: services.hookManager } : {},
3690
- ...services.skill ? { skillSystem: services.skill } : {},
3551
+ skillSystem: services.skill ?? void 0,
3552
+ roleSkills: () => roleScope.current?.skills,
3691
3553
  ...services.skillLearner ? { skillLearner: services.skillLearner } : {},
3692
- // **这个会话自己那一档**(2026-08-16)。规则表 / 审批缓存 / 审计流水
3693
- // 照旧是同一份(core 的 `PermissionManager.scoped()` 共享它们),
3694
- // 分家的只有「此刻是哪一档」
3695
3554
  ...scope.guard.permission ? { permissionManager: scope.guard.permission } : {},
3696
3555
  ...opts.budget
3697
3556
  }),
@@ -3704,7 +3563,7 @@ ${context}` : goal,
3704
3563
  const store = opts.sessions ? new SessionStore(
3705
3564
  opts.sessions,
3706
3565
  config.homeDir,
3707
- projectContext.rootDir,
3566
+ () => site().projectContext.rootDir,
3708
3567
  opts.sessionSource,
3709
3568
  (id) => opts.workspaces.decisionOf(id)
3710
3569
  ) : void 0;
@@ -3714,15 +3573,9 @@ ${context}` : goal,
3714
3573
  ...store ? { store } : {},
3715
3574
  history: [...history],
3716
3575
  checkpoints,
3717
- // 一轮开始时给目标 +1 并检查预算(方案 52 PR-2)。**接在轮次边界上,
3718
- // 而 `loop.ts` 一行没改** —— 判据在方案 52 §四:一次 `run()` 恰好是一条
3719
- // 被接纳的用户消息,而循环内部那个 `for (turn = 1..maxTurns)` 数的是模型
3720
- // 请求次数。挂在这儿和 `checkpoints` 是同一个位置、同一条理由
3721
3576
  ...opts.goals ? { goals: opts.goals } : {},
3722
- // 换人那一刻在历史里留一条 `[系统]` 消息(方案 57 §3.5)。**给函数不给名字**:
3723
- // 槽是可变的,快照拿到的是装配那一刻那个专家,于是每一条消息都会被判成
3724
- // 「没换人」—— 而那正是这条留痕唯一会坏的方式(不报错、只是永远不出现)
3725
- currentRole: () => roleScope.current?.name
3577
+ currentRole: () => roleScope.current?.name,
3578
+ currentWorkDir: () => site().workDir
3726
3579
  });
3727
3580
  bridge.attach(session);
3728
3581
  live.session = session;
@@ -3734,38 +3587,17 @@ ${context}` : goal,
3734
3587
  session,
3735
3588
  agent,
3736
3589
  model: scope.model.control,
3737
- // 这个会话那个角色槽的宿主面(方案 57 §3.4)。**在这儿包而不是把 `roleScope`
3738
- // 整个交出去**:`enter()` 收的是 `AgentRole` 对象,而服务端不许 import core
3739
- // ——它手里只有网线上那个字符串。判据全文在 `role-scope.ts` 文件头
3740
3590
  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
3591
  role: toActiveRole(active),
3751
3592
  provider: scope.model.provider,
3752
3593
  permission: scope.guard.permission,
3753
3594
  permissions: scope.guard.permissions,
3754
3595
  plan: scope.guard.plan,
3755
- // 会话 id 走 `session.sessionId` 而不是入参,判据同下面那个
3756
- // `buildCheckpointControl`:`/resume` 会把它换掉
3757
3596
  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
3597
  checkpoints: buildCheckpointControl(checkpoints, session, () => session.sessionId),
3766
- workspace: bound,
3767
- // **同一个对象**,不是重新拼一遍那两个回调 —— 判据在 `rekey` 上。
3768
- // 它只在这张表里,`get()` / `list()` 的类型里没有这一格
3598
+ get workspace() {
3599
+ return opts.workspaces.of(live.session?.sessionId ?? sessionId);
3600
+ },
3769
3601
  bridge: routed
3770
3602
  };
3771
3603
  }
@@ -3823,7 +3655,6 @@ var init_file = __esm({
3823
3655
  opts;
3824
3656
  now;
3825
3657
  ready = false;
3826
- /** 自增的 span id。同一个进程内唯一就够 —— 这文件本来就是单机看的 */
3827
3658
  seq = 0;
3828
3659
  startSpan(name, attrs, parent) {
3829
3660
  const startedAt = this.now();
@@ -3957,8 +3788,6 @@ async function tryCreateOtelTelemetry(endpoint) {
3957
3788
  };
3958
3789
  return {
3959
3790
  telemetry,
3960
- // 退出时必须 flush(批量导出器手上压着最后一轮的 span),但**必须有上限** ——
3961
- // 见 SHUTDOWN_BUDGET_MS
3962
3791
  shutdown: async () => {
3963
3792
  await withTimeout(
3964
3793
  Promise.allSettled([tracerProvider.shutdown(), meterProvider.shutdown()]).then(() => {
@@ -3982,7 +3811,7 @@ async function buildTelemetry(input) {
3982
3811
  const noop = { telemetry: NOOP_TELEMETRY, dispose: () => {
3983
3812
  } };
3984
3813
  if (!config?.enabled) {
3985
- diags.skipped("Telemetry", "\u672A\u5F00\u542F\uFF08epoch config set telemetry.enabled true \u5F00\u542F\uFF09");
3814
+ diags.skipped("Telemetry", t("boot.telemetry_off"));
3986
3815
  return noop;
3987
3816
  }
3988
3817
  const target = config.target ?? "file";
@@ -3995,7 +3824,7 @@ async function buildTelemetry(input) {
3995
3824
  }
3996
3825
  diags.warn(
3997
3826
  "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",
3827
+ (lang) => t("boot.telemetry_otlp_missing", { command: OTLP_INSTALL_COMMAND }, lang),
3999
3828
  "otlp-deps-missing"
4000
3829
  );
4001
3830
  }
@@ -4009,7 +3838,6 @@ async function buildTelemetry(input) {
4009
3838
  return {
4010
3839
  telemetry: new FileTelemetry({
4011
3840
  path,
4012
- // 写不进去要说出来,但只说一次 —— 每条记录报一次会把诊断刷爆
4013
3841
  onError: onceWarn(diags, path)
4014
3842
  }),
4015
3843
  dispose: () => {
@@ -4023,7 +3851,11 @@ function onceWarn(diags, path) {
4023
3851
  reported = true;
4024
3852
  diags.warn(
4025
3853
  "Telemetry",
4026
- `\u5199\u5165\u5931\u8D25\uFF08${err instanceof Error ? err.message : String(err)}\uFF09\uFF1A${path}`,
3854
+ (lang) => t(
3855
+ "boot.telemetry_write_failed",
3856
+ { reason: err instanceof Error ? err.message : String(err), path },
3857
+ lang
3858
+ ),
4027
3859
  "telemetry-write-failed"
4028
3860
  );
4029
3861
  };
@@ -4050,16 +3882,18 @@ function consoleTelemetry() {
4050
3882
  histogram: (name, value, attrs) => line({ t: "histogram", name, v: value, attrs })
4051
3883
  };
4052
3884
  }
3885
+ var OTLP_INSTALL_COMMAND;
4053
3886
  var init_telemetry = __esm({
4054
3887
  "src/telemetry/index.ts"() {
4055
3888
  init_file();
4056
3889
  init_otel();
4057
3890
  init_file();
4058
3891
  init_otel();
3892
+ 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
3893
  }
4060
3894
  });
4061
3895
  function missing(tool) {
4062
- return { output: `${tool} \u5DE5\u5177\u4E0D\u53EF\u7528\uFF08\u6A21\u5757\u6CA1\u8D77\u6765\uFF0C\u770B /diagnostics\uFF09`, ok: false };
3896
+ return { output: t("boot.tool_unavailable", { tool: `${tool} ` }), ok: false };
4063
3897
  }
4064
3898
  function toResult(result) {
4065
3899
  const output = typeof result.output === "string" ? result.output : JSON.stringify(result.output, null, 2);
@@ -4078,7 +3912,11 @@ function createUserActions(deps) {
4078
3912
  const type = mapOperationType(toolName);
4079
3913
  const decision = pm.check({ type, detail, workDir: deps.workDir, target, toolName });
4080
3914
  if (decision.allowed) return null;
4081
- if (!decision.requireApproval) return `\u64CD\u4F5C\u88AB\u62E6\u622A [\u6743\u9650]: ${decision.reason || "\u9700\u8981\u6388\u6743"}`;
3915
+ if (!decision.requireApproval) {
3916
+ return t("boot.blocked_by_permission", {
3917
+ reason: decision.reason || t("boot.needs_authorization")
3918
+ });
3919
+ }
4082
3920
  const outcome = await approve({
4083
3921
  toolName,
4084
3922
  type,
@@ -4087,7 +3925,7 @@ function createUserActions(deps) {
4087
3925
  ...decision.reason ? { reason: decision.reason } : {}
4088
3926
  });
4089
3927
  pm.auditUserDecision?.({ type, target, toolName }, outcome);
4090
- if (outcome === "deny") return "\u5DF2\u53D6\u6D88";
3928
+ if (outcome === "deny") return t("boot.cancelled");
4091
3929
  pm.recordApproval(toolName, target, outcome);
4092
3930
  return null;
4093
3931
  };
@@ -4128,6 +3966,139 @@ var init_user_shell = __esm({
4128
3966
  "src/user-shell.ts"() {
4129
3967
  }
4130
3968
  });
3969
+ function utilityBudgetKey(input) {
3970
+ const owner = input.sessionId?.trim();
3971
+ if (owner) return owner;
3972
+ const label = input.label !== void 0 && LABEL_SHAPE.test(input.label) ? input.label : ANONYMOUS;
3973
+ return `${BUCKET_PREFIX}${label}`;
3974
+ }
3975
+ function buildUtilityControl(opts) {
3976
+ const { provider, store, pricingOf } = opts;
3977
+ const record = (input, out) => {
3978
+ if (!store || !out.usage) return;
3979
+ const pricing = pricingOf?.(out.model);
3980
+ new BudgetGuard({ store, sessionId: utilityBudgetKey(input) }).add(out.usage, {
3981
+ model: out.model,
3982
+ ...pricing ? { pricing } : {}
3983
+ });
3984
+ };
3985
+ return {
3986
+ async generate(input) {
3987
+ if (input.signal?.aborted === true) throw new Error(t("utility.aborted"));
3988
+ const wanted = input.model ?? refOf(provider.getUtilityInfo());
3989
+ const out = await provider.generate({
3990
+ messages: input.messages,
3991
+ ...input.system !== void 0 ? { system: input.system } : {},
3992
+ ...input.maxTokens !== void 0 ? { maxTokens: input.maxTokens } : {},
3993
+ ...wanted.provider ? { provider: wanted.provider } : {},
3994
+ model: wanted.model,
3995
+ ...input.responseFormat ? { responseFormat: input.responseFormat } : {},
3996
+ ...input.providerOptions ? { providerOptions: input.providerOptions } : {}
3997
+ });
3998
+ record(input, out);
3999
+ return {
4000
+ text: out.text,
4001
+ ...out.usage ? { usage: out.usage } : {},
4002
+ model: out.model,
4003
+ provider: out.provider,
4004
+ ...out.responseFormatApplied === void 0 ? {} : { responseFormatApplied: out.responseFormatApplied },
4005
+ ...out.warnings ? { warnings: out.warnings } : {}
4006
+ };
4007
+ }
4008
+ };
4009
+ }
4010
+ function refOf(info) {
4011
+ return { provider: info.selection.provider, model: info.selection.model };
4012
+ }
4013
+ var BUCKET_PREFIX, ANONYMOUS, LABEL_SHAPE;
4014
+ var init_utility = __esm({
4015
+ "src/utility.ts"() {
4016
+ BUCKET_PREFIX = "utility:";
4017
+ ANONYMOUS = "anonymous";
4018
+ LABEL_SHAPE = /^[a-z0-9_-]{1,32}$/;
4019
+ }
4020
+ });
4021
+ function withinWorkspace(resolveInWorkspace2, root, rel) {
4022
+ return resolveInWorkspace2(root, rel) !== null;
4023
+ }
4024
+ function buildWorkspaceFilesControl(deps, permission) {
4025
+ let cache = null;
4026
+ const now = () => Date.now();
4027
+ function computeList(root) {
4028
+ const { files, truncated } = deps.listFiles(root);
4029
+ return { files, truncated, at: now(), root };
4030
+ }
4031
+ function listFor(root) {
4032
+ const t23 = now();
4033
+ if (!cache || cache.root !== root) cache = computeList(root);
4034
+ else if (t23 - cache.at > LIST_TTL_MS) cache = computeList(root);
4035
+ return cache;
4036
+ }
4037
+ return {
4038
+ candidates(query, limit, root) {
4039
+ const q = query.trim().toLowerCase();
4040
+ let base;
4041
+ try {
4042
+ base = listFor(root);
4043
+ } catch {
4044
+ return null;
4045
+ }
4046
+ const mentionable = [];
4047
+ let unmentionable = 0;
4048
+ for (const rel of base.files) {
4049
+ if (!withinWorkspace(deps.resolveInWorkspace, root, rel)) continue;
4050
+ const hit = q.length === 0 || rel.toLowerCase().includes(q);
4051
+ if (!hit) continue;
4052
+ if (isMentionableFilePath(rel)) {
4053
+ mentionable.push({ rel, depth: rel.split("/").length });
4054
+ } else {
4055
+ unmentionable += 1;
4056
+ }
4057
+ }
4058
+ mentionable.sort((a, b) => a.depth - b.depth || (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
4059
+ return {
4060
+ candidates: mentionable.slice(0, limit).map((row) => row.rel),
4061
+ unmentionable,
4062
+ truncated: base.truncated
4063
+ };
4064
+ },
4065
+ readFile(path, root) {
4066
+ const full = deps.resolveInWorkspace(root, path);
4067
+ if (!full) return { ok: false, reason: "denied" };
4068
+ const decision = permission?.check({
4069
+ type: "file_read",
4070
+ detail: path,
4071
+ workDir: root,
4072
+ target: full,
4073
+ toolName: "file_read"
4074
+ });
4075
+ if (permission && !decision?.allowed) return { ok: false, reason: "denied" };
4076
+ let stat;
4077
+ try {
4078
+ stat = statSync(full);
4079
+ if (!stat.isFile()) return { ok: false, reason: "unreadable" };
4080
+ } catch {
4081
+ return { ok: false, reason: "unreadable" };
4082
+ }
4083
+ let raw;
4084
+ try {
4085
+ raw = readFileSync(full);
4086
+ } catch {
4087
+ return { ok: false, reason: "unreadable" };
4088
+ }
4089
+ if (raw.subarray(0, 8192).includes(0)) {
4090
+ return { ok: false, reason: "binary", bytes: stat.size };
4091
+ }
4092
+ return { ok: true, text: raw.toString("utf8"), bytes: stat.size };
4093
+ }
4094
+ };
4095
+ }
4096
+ var LIST_TTL_MS;
4097
+ var init_workspace_files = __esm({
4098
+ "src/workspace-files.ts"() {
4099
+ LIST_TTL_MS = 6e4;
4100
+ }
4101
+ });
4131
4102
  function splitDirList(value) {
4132
4103
  if (!value) return [];
4133
4104
  return value.split(delimiter).map((s) => s.trim()).filter((s) => s.length > 0);
@@ -4137,20 +4108,23 @@ function wireWorkspace(input) {
4137
4108
  input.permission?.setExtraRoots(workspace.extra);
4138
4109
  if (input.addDir.length === 0) return workspace;
4139
4110
  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}`);
4111
+ if (issue.kind === "redundant") input.diags.skipped("ExtraDirs", issue.detail);
4112
+ else input.diags.warn("ExtraDirs", issue.detail, `add-dir-${issue.kind}`);
4142
4113
  }
4143
4114
  if (workspace.extra.length > 0) {
4144
4115
  input.diags.ok(
4145
4116
  WORKSPACE_MODULE,
4146
- `${workspace.extra.length} \u4E2A\u989D\u5916\u6839: ${workspace.extra.join(", ")}`
4117
+ t("boot.workspace_extra", {
4118
+ count: workspace.extra.length,
4119
+ dirs: workspace.extra.join(", ")
4120
+ })
4147
4121
  );
4148
4122
  }
4149
4123
  const unloaded = unloadedInstructionFiles(workspace);
4150
4124
  if (unloaded.length > 0) {
4151
4125
  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(", ")}`
4126
+ "ExtraDirInstructions",
4127
+ t("boot.workspace_extra_instructions", { paths: unloaded.join(", ") })
4154
4128
  );
4155
4129
  }
4156
4130
  return workspace;
@@ -4161,10 +4135,6 @@ function workspaceDecisionLog(sessions, diags) {
4161
4135
  try {
4162
4136
  sessions.updateMeta(sessionId, {
4163
4137
  workspaceState: decision.state,
4164
- // `none` 那一档**不带路径**。写 `undefined` 在 `updateSessionMeta()` 里
4165
- // 是「这一列不进 SET」(那儿第一行就 `if (v === undefined) continue`),
4166
- // 不是「清空它」—— 而 `bound → none` 这条路被 server 那道 409 挡着,
4167
- // 所以「留着一个陈旧的 root」在今天不可达
4168
4138
  ...decision.state === "bound" ? { workspaceRoot: decision.root } : {}
4169
4139
  });
4170
4140
  } catch (err) {
@@ -4210,51 +4180,10 @@ function inspectRoot(dir, lang) {
4210
4180
  var WORKSPACE_MODULE, SessionWorkspaces;
4211
4181
  var init_workspace = __esm({
4212
4182
  "src/workspace.ts"() {
4213
- WORKSPACE_MODULE = "\u5DE5\u4F5C\u533A";
4183
+ WORKSPACE_MODULE = "Workspace";
4214
4184
  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
4185
  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
4186
  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
4187
  noWorkspace = /* @__PURE__ */ new Set();
4259
4188
  gate;
4260
4189
  recent;
@@ -4266,51 +4195,17 @@ var init_workspace = __esm({
4266
4195
  this.decisions = opts.decisions ?? null;
4267
4196
  this.now = opts.now ?? Date.now;
4268
4197
  }
4269
- /** 这个会话绑在哪儿。没绑过 `null` —— 「不使用工作区」是真的一档,不是缺省值 */
4270
4198
  of(sessionId) {
4271
4199
  return this.bindings.get(sessionId) ?? null;
4272
4200
  }
4273
- /**
4274
- * 记一笔「建出来了、还没选地盘」。判据全文在 {@link WorkspaceControl.defer}。
4275
- *
4276
- * **已经绑上的会话调它是空操作**,不是把它退回未选:那等于绕过决定 18 的
4277
- * 「绑定后不给改」—— 一个宿主只要先 `defer()` 再 `bind()`,就能给一段跑过
4278
- * 一整轮活的会话换地盘,而 `bind()` 那道 `locked` 闸拦的正是这件事。
4279
- */
4280
4201
  defer(sessionId) {
4281
4202
  if (this.bindings.has(sessionId)) return;
4282
4203
  this.deferred.add(sessionId);
4283
4204
  }
4284
- /** 这个会话是哪一档。判据见 {@link WorkspaceBindingState} */
4285
4205
  stateOf(sessionId) {
4286
4206
  if (this.bindings.has(sessionId)) return "bound";
4287
4207
  return this.deferred.has(sessionId) ? "unbound" : "none";
4288
4208
  }
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
4209
  seed(sessionId, bound, opts) {
4315
4210
  this.bindings.set(sessionId, {
4316
4211
  ...bound,
@@ -4322,17 +4217,6 @@ var init_workspace = __esm({
4322
4217
  this.decisions?.record(sessionId, { state: "bound", root: bound.workspace.root });
4323
4218
  }
4324
4219
  }
4325
- /**
4326
- * 把一个目录绑到会话上。
4327
- *
4328
- * 顺序是**先校验路径、再看有没有被锁**:一个写错的路径不管会话锁没锁都是写错的,
4329
- * 而反过来的话「路径不存在」这条永远被 `locked` 盖住 —— 于是调用方收到的是
4330
- * 「不能改」,而他真正的问题是「这个目录我打错了」。
4331
- *
4332
- * **只认绝对路径。** 相对路径要么相对服务进程的 cwd、要么相对用户以为的某个
4333
- * 目录,两者不一致时没有任何迹象;而这条路的入口是网线(浏览器传字符串进来),
4334
- * 那儿压根没有「当前目录」这个概念。
4335
- */
4336
4220
  bind(sessionId, dir, lang) {
4337
4221
  const invalid = inspectRoot(dir, lang);
4338
4222
  if (invalid) return invalid;
@@ -4358,75 +4242,28 @@ var init_workspace = __esm({
4358
4242
  this.decisions?.record(sessionId, { state: "bound", root });
4359
4243
  return { ok: true, bound, changed: true };
4360
4244
  }
4361
- /** 记一笔「明确不使用工作区」。判据全文在 {@link WorkspaceControl.decideNone} */
4362
4245
  decideNone(sessionId) {
4363
4246
  if (this.bindings.has(sessionId)) return;
4364
4247
  this.deferred.delete(sessionId);
4365
4248
  this.noWorkspace.add(sessionId);
4366
4249
  this.decisions?.record(sessionId, { state: "none" });
4367
4250
  }
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
4251
  decisionOf(sessionId) {
4379
4252
  const bound = this.bindings.get(sessionId);
4380
4253
  if (bound) return { state: "bound", root: bound.workspace.root };
4381
4254
  return this.noWorkspace.has(sessionId) ? { state: "none" } : null;
4382
4255
  }
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
4256
  release(sessionId) {
4403
4257
  this.bindings.delete(sessionId);
4404
4258
  this.deferred.delete(sessionId);
4405
4259
  this.noWorkspace.delete(sessionId);
4406
4260
  }
4407
- /** 本机「已知工作区」清单,最近使用的在前 */
4408
4261
  known() {
4409
4262
  return this.recent.list();
4410
4263
  }
4411
- /**
4412
- * 一个目录的信任判定。
4413
- *
4414
- * 和 {@link resolveBinding} 一样先归一到**项目根**再判 —— 两处分叉的表现是
4415
- * 「清单里说未信任、绑上去之后指令文件却加载了」。
4416
- *
4417
- * 只查不写:`decide()` 是纯读(`TrustManager.check` + 降级规则),
4418
- * 不往清单里记账,也不建绑定。
4419
- */
4420
4264
  trustOf(dir) {
4421
4265
  return this.gate.decide(resolveProjectRoot(dir));
4422
4266
  }
4423
- /**
4424
- * 一个目录 → 完整的绑定。
4425
- *
4426
- * 信任按**项目根**判而不是按传进来那个目录判,和 `discoverProject()` 内部
4427
- * 保持一致(在仓库子目录里绑定会继承仓库根的信任决定)。两处判据分叉的表现是
4428
- * 「界面说未信任、指令文件却加载了」,或者反过来。
4429
- */
4430
4267
  resolveBinding(root) {
4431
4268
  const trust = this.gate.decide(resolveProjectRoot(root));
4432
4269
  const { workspace } = resolveWorkspace(root);
@@ -4434,26 +4271,25 @@ var init_workspace = __esm({
4434
4271
  workspace,
4435
4272
  trust,
4436
4273
  projectContext: discoverProject(root, trust),
4437
- // 照旧探一遍而不是写死 `[]`:今天没有额外根所以它必然是空的,
4438
- // 但「为什么是空的」这件事不该由一个字面量来记住
4439
4274
  unloadedInstructions: unloadedInstructionFiles(workspace)
4440
4275
  };
4441
4276
  }
4442
4277
  };
4443
4278
  }
4444
4279
  });
4445
-
4446
4280
  // src/build.ts
4447
4281
  var build_exports = {};
4448
4282
  __export(build_exports, {
4449
4283
  AgentRoleError: () => AgentRoleError,
4284
+ buildGoalCatalog: () => buildGoalCatalog,
4450
4285
  buildRuntime: () => buildRuntime,
4451
4286
  ensureSecretsReady: () => ensureSecretsReady
4452
4287
  });
4453
4288
  async function buildRuntime(opts = {}) {
4454
4289
  const opened = { cleanups: null };
4290
+ const processes = createProcessTable();
4455
4291
  try {
4456
- return await assembleRuntime(opts, opened);
4292
+ return await assembleRuntime(opts, opened, processes);
4457
4293
  } catch (err) {
4458
4294
  for (const fn of [...opened.cleanups ?? []].reverse()) {
4459
4295
  try {
@@ -4462,16 +4298,17 @@ async function buildRuntime(opts = {}) {
4462
4298
  }
4463
4299
  }
4464
4300
  try {
4465
- await cleanupBackgroundProcesses();
4301
+ await processes.release();
4466
4302
  } catch {
4467
4303
  }
4468
4304
  throw err;
4469
4305
  }
4470
4306
  }
4471
- async function assembleRuntime(opts, opened) {
4307
+ async function assembleRuntime(opts, opened, processes) {
4472
4308
  const startedAt = Date.now();
4473
4309
  const workDir = opts.workDir ?? process.cwd();
4474
4310
  const sessionId = opts.resumeId || randomUUID();
4311
+ processes.claim(sessionId);
4475
4312
  const diagnostics = new DiagnosticSink();
4476
4313
  if (opts.hostPreset) {
4477
4314
  await applyHostPreset({
@@ -4499,14 +4336,8 @@ async function assembleRuntime(opts, opened) {
4499
4336
  ...opts.interactive === void 0 ? {} : { interactive: opts.interactive },
4500
4337
  ...opts.logger ? { logger: opts.logger } : {},
4501
4338
  ...settingsPath ? { settingsPath } : {},
4502
- // 宿主注入的技能目录(方案 44)。空数组也照传:`resolveSkillDirs` 对空数组
4503
- // 和不给这个键的处理逐字相同,多一个分支只是多一处会走散的地方
4504
4339
  hostSkillDirs: hostCaps.skillDirs,
4505
- // 没有 `?? process.env.*` 的兜底,理由见 `BuildRuntimeOptions.managedSettingsPath`
4506
4340
  ...opts.managedSettingsPath ? { managedSettingsPath: opts.managedSettingsPath } : {},
4507
- // **这一行必须紧跟着上面那次 `loadConfig()`**:`getConfigOrigins()` 报的是
4508
- // 最近一次加载的账,中间隔一次别人的 `loadConfig()` 就串台了。递进去而不是
4509
- // 让 `buildServices` 自己去问,判据写在 `ServiceOptions.configOrigins` 上
4510
4341
  configOrigins
4511
4342
  });
4512
4343
  const config = services.config;
@@ -4545,21 +4376,25 @@ async function assembleRuntime(opts, opened) {
4545
4376
  if (sandbox) {
4546
4377
  cleanups.push(() => sandbox.destroy());
4547
4378
  isolation = sandbox.getIsolation();
4548
- diagnostics.ok("Sandbox \u9694\u79BB", isolation.detail);
4379
+ diagnostics.ok("SandboxIsolation", isolation.detail);
4549
4380
  services.permission?.setSandboxIsolation(isolation.level);
4550
4381
  }
4551
4382
  if (services.permission) {
4552
4383
  applyHeadless(services.permission, config, opts.headless, diagnostics, opts.interactive);
4553
4384
  }
4554
4385
  const budgetWiring = wireBudget(config, services.modelPricing, diagnostics);
4386
+ const bootstrapReveal = createToolRevealState();
4555
4387
  const registry = registerLocalTools({
4556
4388
  homeDir: config.homeDir,
4389
+ deferMcp: config.tools?.deferMcp === true,
4390
+ toolMode: foldToolMode(config.tools?.mode),
4391
+ bootstrapReveal,
4557
4392
  trackerDb: services.trackerDb,
4558
4393
  goals: services.goals,
4559
4394
  memory: services.memory,
4560
4395
  sandbox,
4396
+ skills: services.skill,
4561
4397
  diags: diagnostics,
4562
- // 合并后那份 config —— 项目级设置将来若放开 search 段,这里天然跟着走
4563
4398
  ...config.search ? { search: config.search } : {}
4564
4399
  });
4565
4400
  registerSessionSearch(registry, {
@@ -4568,10 +4403,16 @@ async function assembleRuntime(opts, opened) {
4568
4403
  ...config.sessionSearch ? { sessionSearch: config.sessionSearch } : {},
4569
4404
  diags: diagnostics
4570
4405
  });
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
- ]);
4406
+ const mcpWiring = await registerMcpTools(
4407
+ registry,
4408
+ config.homeDir,
4409
+ diagnostics,
4410
+ [
4411
+ { source: "host", servers: hostCaps.mcpServers },
4412
+ { source: "plugin", servers: wirePluginMcpServers(services.plugins.mcpPaths, diagnostics) }
4413
+ ],
4414
+ config.tools?.deferMcp === true
4415
+ );
4575
4416
  cleanups.push(mcpWiring.dispose);
4576
4417
  const bridges = new SessionBridges(opts.onApprovalRequest);
4577
4418
  const interactive = !services.permission?.isNonInteractive();
@@ -4586,35 +4427,36 @@ async function assembleRuntime(opts, opened) {
4586
4427
  permission: services.permission,
4587
4428
  planState,
4588
4429
  managed: services.managedPolicy,
4589
- // 「这次启动那一档」,不是「引导会话此刻那一档」——判据在 `SessionGuards.open`
4430
+ deferredTools: () => registry.byExposure(ToolExposure.Deferred),
4431
+ bootstrapReveal,
4590
4432
  startLevel: services.permissionLevel,
4591
- // **和上面那两个进程级工具收的是同一个回调**:「弹给谁看」由
4592
- // `ToolContext.sessionId` 决定,在这儿另写一个闭包等于把那条路由绕过去
4593
4433
  requestApproval: requestPlanApproval,
4594
4434
  interactive
4595
4435
  });
4596
4436
  const bootstrapGuard = guards.bootstrap();
4597
4437
  registerAskQuestion(registry, (req, sessionId2) => bridges.ask(sessionId2, req), interactive);
4438
+ registry.registerTools(
4439
+ dropOverlongToolNames(
4440
+ hostCaps.tools,
4441
+ HOST_CAPABILITIES_MODULE,
4442
+ "host-tool-name-too-long",
4443
+ diagnostics
4444
+ ),
4445
+ "host"
4446
+ );
4598
4447
  const commands = wireCommands({
4599
4448
  homeDir: config.homeDir,
4600
4449
  projectRoot: services.projectContext.rootDir,
4601
4450
  trusted: services.trust.trusted,
4602
4451
  pluginDirs: services.plugins.commandDirs,
4603
4452
  toolProvider: registry.asProvider(),
4604
- // 传的是**函数**:命令展开发生在用户敲下那一刻,而这之前他可能已经
4605
- // `/model` 换过了。取值钉住就等于永远解析成启动时那个模型
4606
- //
4607
- // 只递 provider + model 两段:`origin` 讲的是**当前选择**怎么来的,
4608
- // 而这里回答的是「工具模型是谁」。原样带过去,命令会展开出一个
4609
- // `origin: 'config'` 的 ref,随后 `enterTurn` 再按 `command-frontmatter`
4610
- // 盖一次 —— 同一个字段有两个说法,出事时看哪个都不对
4611
4453
  utilityModel: () => {
4612
4454
  const s = services.provider?.getUtilityInfo().selection;
4613
4455
  return s ? { provider: s.provider, model: s.model } : void 0;
4614
4456
  },
4615
4457
  diags: diagnostics
4616
4458
  });
4617
- const checkpoints = new CheckpointManager({ workDir, homeDir: config.homeDir });
4459
+ const checkpoints = new CheckpointManager({ workDir: () => workDir, homeDir: config.homeDir });
4618
4460
  const { providerBridge } = services;
4619
4461
  const agentRoles = [];
4620
4462
  const agentRoleNotices = [];
@@ -4633,8 +4475,11 @@ async function assembleRuntime(opts, opened) {
4633
4475
  mainRole = pickMainRole(opts.agent ?? process.env.EPOCH_AGENT, roles);
4634
4476
  if (mainRole) {
4635
4477
  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"}`
4478
+ "MainAgentRole",
4479
+ t("boot.main_role", {
4480
+ name: mainRole.name,
4481
+ tools: mainRole.tools ? t("boot.main_role_tools", { count: mainRole.tools.length }) : t("boot.main_role_no_narrow")
4482
+ })
4638
4483
  );
4639
4484
  }
4640
4485
  }
@@ -4652,34 +4497,24 @@ async function assembleRuntime(opts, opened) {
4652
4497
  provider: providerBridge,
4653
4498
  models,
4654
4499
  guards,
4655
- // 引导会话的工作目录。**不能拿 `projectContext.rootDir` 顶替** ——
4656
- // 在仓库子目录里启动是常态,顶替等于悄悄换了相对路径的解析基准
4657
4500
  defaultWorkDir: workDir,
4658
- // 收窄过的那一层(方案 23);未收窄那份给子 agent,判据见工厂的选项注释
4659
4501
  toolProvider: commands.toolProvider,
4660
4502
  rawToolProvider: registry.asProvider(),
4503
+ deferredTools: () => registry.byExposure(ToolExposure.Deferred),
4504
+ codeModeTools: () => registry.codeModeDeclared(),
4661
4505
  telemetry,
4662
4506
  budget: budgetWiring,
4663
4507
  ...opts.onApprovalRequest ? { hostApproval: opts.onApprovalRequest } : {},
4664
4508
  workspaces,
4665
4509
  bridges,
4666
4510
  sessions: services.session,
4667
- // 目标(方案 52)。**进程一份**,收窄成「这一段会话」发生在工厂里
4668
- // (`buildGoalControl(…, () => session.sessionId)`)
4669
4511
  goals: services.goals,
4670
4512
  ...opts.sessionSource ? { sessionSource: opts.sessionSource } : {},
4671
4513
  mainRole: mainRole ?? null,
4672
4514
  roles: () => agentRoles,
4673
- // 后台任务的状态变化(方案 36 PR-2)。给函数不给数组 —— 任务状态每时每刻
4674
- // 在变,快照拿到的是装配那一刻的。
4675
- //
4676
- // ⚠️ 2026-08-15 起它**按会话取**:工厂给每个会话拿它自己那一批(判据在
4677
- // plugin-terminal 的 `background.ts` 文件头)。收窄之前每个会话每轮都被告知
4678
- // 一批**它没起过**的命令在跑,而那条通知是直接进模型上下文的
4679
4515
  backgroundTasks: (id) => listTasks(id),
4680
- // 子任务跑完之后把它起的后台任务归还给派活的会话(判据在 `subRunner` 末尾)。
4681
- // 这里是全仓唯一一处知道「任务表由 plugin-terminal 提供」的地方
4682
4516
  rekeyBackgroundTasks: rekeyTasks,
4517
+ processes,
4683
4518
  diags: diagnostics
4684
4519
  });
4685
4520
  if (providerBridge) {
@@ -4689,19 +4524,22 @@ async function assembleRuntime(opts, opened) {
4689
4524
  for (const c of registry.getConflicts()) {
4690
4525
  diagnostics.warn(
4691
4526
  `Tool ${c.name}`,
4692
- `${c.droppedFrom} \u4E0E ${c.keptFrom} \u649E\u540D\uFF0C\u5DF2\u5FFD\u7565\u524D\u8005`,
4527
+ t("boot.tool_conflict", { dropped: c.droppedFrom, kept: c.keptFrom }),
4693
4528
  "tool-name-conflict"
4694
4529
  );
4695
4530
  }
4696
4531
  }
4532
+ reportToolCost(registry, diagnostics);
4697
4533
  const store = services.session ? new SessionStore(
4698
4534
  services.session,
4699
4535
  config.homeDir,
4700
- services.projectContext.rootDir,
4536
+ () => services.projectContext.rootDir,
4701
4537
  opts.sessionSource
4702
4538
  ) : void 0;
4703
4539
  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`);
4540
+ if (history.length > 0) {
4541
+ diagnostics.ok("History", t("boot.history_loaded", { count: history.length }));
4542
+ }
4705
4543
  const bootstrap = factory.adopt(sessionId, history);
4706
4544
  const session = bootstrap?.session ?? null;
4707
4545
  const agent = bootstrap?.agent ?? null;
@@ -4728,7 +4566,7 @@ async function assembleRuntime(opts, opened) {
4728
4566
  }
4729
4567
  }
4730
4568
  try {
4731
- await cleanupBackgroundProcesses();
4569
+ await processes.release();
4732
4570
  } catch {
4733
4571
  }
4734
4572
  };
@@ -4737,10 +4575,6 @@ async function assembleRuntime(opts, opened) {
4737
4575
  const provider = services.provider;
4738
4576
  const listTools = () => commands.toolProvider.list().map((tool) => ({
4739
4577
  tool,
4740
- // 注册表一直记着「谁注册的这个工具」,只是以前没人往外传。能力页的
4741
- // 「内置连接器」那一组要按它分(方案 42 PR-1)—— 按工具名前缀猜
4742
- // (`file_*` 归 plugin-file)会在第一个不按前缀命名的工具上错。
4743
- // 取不到只可能是收窄层放进来一个 registry 没见过的名字,那时说 `builtin`
4744
4578
  source: registry.sourceOf(tool.name) ?? "builtin"
4745
4579
  }));
4746
4580
  const schedules = createScheduleControl({
@@ -4748,30 +4582,35 @@ async function assembleRuntime(opts, opened) {
4748
4582
  ...opts.scheduleRunner ? { runner: opts.scheduleRunner } : {}
4749
4583
  });
4750
4584
  cleanups.push(schedules.dispose);
4585
+ if (schedules.openError) {
4586
+ diagnostics.failed("Schedules", `FAILED (${schedules.openError.message})`, "init-failed");
4587
+ } else {
4588
+ diagnostics.ok("Schedules");
4589
+ }
4590
+ const hostMarketplaces = opts.hostMarketplaces;
4591
+ if (hostMarketplaces) {
4592
+ await wireHostMarketplaces({
4593
+ hostMarketplaces,
4594
+ statePath: marketplacesPath(config.homeDir),
4595
+ diagnostics
4596
+ });
4597
+ }
4598
+ reportI18nHealth(diagnostics);
4751
4599
  return {
4752
4600
  agent,
4753
4601
  session,
4754
- // 多会话工厂(方案 30 §6.3)。`SessionFactory` 是收窄过的那一面 ——
4755
- // `adopt` / `subRunner` 是装配层的特权,宿主拿到它就等于能绕过 `bind()`
4756
- // 那两道门(判据同 `WorkspaceControl` 上刻意不露 `seed` 的那一条)
4757
4602
  sessionFactory: factory,
4603
+ events: processEventBus,
4758
4604
  sessionStore: store ?? null,
4759
- // **引导会话自己那一份**,从工厂手里拿 —— 不在这儿再包一个。工厂给每个会话
4760
- // 各建了一个 `CheckpointManager`(作用域表里它是会话级),在这边照着上面那个
4761
- // 进程级管理器另包一层,等于给同一段会话的安全网第二个说法,而两者的
4762
- // `workDir` 只在「引导会话没换过工作区」时恰好相等(方案 30 §9.5)。
4763
- //
4764
- // `??` 那一支只在 provider 没起来时走得到(那时一个会话都装配不出来):
4765
- // 这个字段不可为 null,而一个读得出空列表的控制面是那时唯一诚实的答案
4605
+ sessionReferences: services.session?.references ?? null,
4606
+ workspaceFiles: buildWorkspaceFilesControl(
4607
+ { listFiles: listWorkspaceFiles, resolveInWorkspace },
4608
+ services.permission
4609
+ ),
4766
4610
  checkpoints: bootstrap?.checkpoints ?? buildCheckpointControl(checkpoints, null, () => sessionId),
4767
- // **引导会话自己那一份**,判据逐字同上面的 `checkpoints`:从工厂手里拿,
4768
- // 不在这儿再包一个。`??` 那一支同理只在 provider 没起来时走得到 ——
4769
- // 那时候没有会话,而目标是会话的状态,`null` 是那一刻唯一诚实的答案
4770
4611
  goals: bootstrap?.goals ?? (services.goals ? buildGoalControl(services.goals, () => sessionId) : null),
4612
+ goalCatalog: services.goals ? buildGoalCatalog(services.goals) : null,
4771
4613
  config,
4772
- // 这次实际生效的数据目录,以及从它派生的 artifact 根目录(方案 54 §二)。
4773
- // **调 `paths.ts` 而不是 `join(homeDir, 'artifacts')`**:路径真源只有一处,
4774
- // 在这里拼字符串就是给同一个目录第二个说法 —— 而那正是这一节要修的病
4775
4614
  homeDir: config.homeDir,
4776
4615
  artifactsRoot: artifactsDir(void 0, config.homeDir),
4777
4616
  sessionId,
@@ -4779,22 +4618,11 @@ async function assembleRuntime(opts, opened) {
4779
4618
  schedules: schedules.control,
4780
4619
  workspace,
4781
4620
  workspaces,
4782
- // 装配过程里到处在用的那个布尔(指令文件、角色文件、自定义命令都问它),
4783
- // 原样露出来给 doctor —— 别在宿主侧重新判一遍,见字段的 JSDoc
4784
4621
  trusted: services.trust.trusted,
4785
- // 信任记录的读写口(方案 54 §五)。**零新逻辑** —— 转的就是装配时那个
4786
- // manager 实例本身,宿主 `record()` 之后本进程新绑的工作区立刻按新判定走
4787
- // (闸门每次现问 `check()`,中间没有缓存)。
4788
- //
4789
- // 转实例而不是包一层收窄:四个方法就是 protocol 的 `TrustManager` 契约,
4790
- // 包一层只会多出一处「哪天签名变了这里忘了跟」。
4791
4622
  trust: services.trustManager,
4792
4623
  isolation,
4793
4624
  policy: services.policy,
4794
- // **读收窄之后那一层**(方案 23),不是 `registry.exposed()`:这个字段的口径是
4795
- // 「模型现在看得见什么」,而一条写了 `allowed-tools` 的自定义命令正在跑的时候,
4796
- // 模型看得见的就是白名单里那几个。读原始 registry 的话 `/tools` 会在收窄期间说假话。
4797
- // getter 而不是快照:MCP 刷新(方案 15)和收窄进出都要能立刻反映出来
4625
+ hook: services.hook,
4798
4626
  get tools() {
4799
4627
  return listTools().map(({ tool, source }) => ({
4800
4628
  name: tool.name,
@@ -4804,19 +4632,10 @@ async function assembleRuntime(opts, opened) {
4804
4632
  },
4805
4633
  usageScope: budgetWiring.budgetStore ? "session" : "run",
4806
4634
  compression: compressionLine(config, services),
4807
- // **getter 而不是快照**(和上面的 `tools` 同一个理由):方案 26 之后
4808
- // `/model` 能在运行期换 provider,拍一张快照的话 `/model` 会一直显示
4809
- // 启动时那家和那把 key —— 一个专门用来"确认我现在用的是什么"的面板在说假话
4810
4635
  get providerInfo() {
4811
4636
  return provider ? { provider: provider.getProviderType(), apiKeyHint: provider.getApiKeyHint() } : null;
4812
4637
  },
4813
- // **引导会话那一份**(2026-08-16)——它就是这个进程那个 router 的出口本体,
4814
- // 所以 TUI 的 `/model`、CLI 的 `--model` 一个字节都没变。别的会话走
4815
- // `sessionFactory.get(id).model`,判据见 [session-model.ts](./session-model.ts)
4816
4638
  model: bootstrapModel?.control ?? null,
4817
- // **不吃 `provider`**,所以 provider 层起不来时它照样在(判据在字段上)。
4818
- // `configured` 写成 getter 而不是拍下来的两个值:base URL 只对配置里那一家
4819
- // 有效,而那条判断住在 `model-catalog.ts` 里,不在这儿
4820
4639
  modelCatalog: buildModelCatalogControl({
4821
4640
  homeDir: config.homeDir,
4822
4641
  configured: () => ({
@@ -4824,51 +4643,46 @@ async function assembleRuntime(opts, opened) {
4824
4643
  ...config.provider.baseUrl ? { baseUrl: config.provider.baseUrl } : {}
4825
4644
  })
4826
4645
  }),
4646
+ utility: provider ? buildUtilityControl({
4647
+ provider,
4648
+ ...budgetWiring.budgetStore ? { store: budgetWiring.budgetStore } : {},
4649
+ ...budgetWiring.pricingOf ? { pricingOf: budgetWiring.pricingOf } : {}
4650
+ }) : null,
4827
4651
  agentRoles,
4828
4652
  agentRoleNotices,
4829
4653
  get skills() {
4830
4654
  return services.skill?.list() ?? [];
4831
4655
  },
4832
- // 不计数的那条读(方案 56 §1.2)。`?? null` 而不是 `?? ''`:技能系统没起来
4833
- // 和「这个技能的正文是空的」是两件事,合成同一个值之后界面画不出前者
4834
4656
  skillBody: (name) => services.skill?.body(name) ?? null,
4835
- // 收窄过的导入口(方案 42 §六)。**转的是两个函数,不是 `services.skill`
4836
- // 本身** —— 判据在 `SkillControl` 的文档上,同 `McpControl` 只露 reconnect
4657
+ skillIndexResidency: (role) => services.skill?.indexResidency({
4658
+ only: role === null ? void 0 : findRole(agentRoles, role)?.skills
4659
+ }) ?? /* @__PURE__ */ new Map(),
4837
4660
  skillImport: buildSkillControl(services),
4838
- // 身份那一栏的写口(2026-08-18)。**只露一个 `add`**,同上面那两条。
4839
- // ⚠️ `reload` 递的是上面那个 `reloadRoles` —— 建完当场重载,判据在
4840
- // `role-write.ts` 文件头第二节(不重载的话这一下只做了一半)
4841
4661
  roleWrite: createRoleWriteControl({
4842
4662
  userDir: agentsDir(config.homeDir),
4843
4663
  roles: () => agentRoles,
4844
- // 给函数不给快照:MCP 连上 / 掉线都会让工具表变,而工具名要按**用户按下
4845
- // 那一刻**的那份判(同 `mcpServers` 是个 getter 的判据)
4846
4664
  tools: () => registry.asProvider(),
4847
4665
  reload: reloadRoles
4848
4666
  }),
4849
- // getter:MCP 会掉线重连,快照就是启动那一刻的谎话
4667
+ plugins: hostMarketplaces ? createPluginControl({
4668
+ pluginsDir: pluginsDir(config.homeDir),
4669
+ statePath: pluginsStatePath(config.homeDir),
4670
+ marketplacesPath: marketplacesPath(config.homeDir),
4671
+ loaded: services.plugins.loaded,
4672
+ allowRemote: hostMarketplaces.allowRemote === true
4673
+ }) : null,
4850
4674
  get mcpServers() {
4851
4675
  return mcpWiring.status();
4852
4676
  },
4853
- // 收窄过的那一面(方案 56 §1.1)。**转的是 `McpWiring.control` 而不是
4854
- // registry 本身** —— 判据在 `McpControl` 的文档上,同 `WorkspaceControl`
4855
- // 刻意不露 `seed()` 那一条
4856
4677
  mcp: mcpWiring.control,
4857
4678
  memory: services.memory ? buildMemoryControl(services.memory) : null,
4858
- // 引导会话那一份档位(`level` / `setLevel`)+ 三样进程级的读
4859
- // (规则表 / 遮蔽 / 审计流水)。⚠️ 换档那三步(托管挡 bypass → `setLevel` →
4860
- // `plan.forget()`)**不在这个函数里**:它们住在 `session-guard.ts`,
4861
- // 因为它们现在要按会话跑 N 遍,而那条判定只能有一个实现
4862
4679
  permissions: buildPermissionsControl(services, bootstrapGuard.permissions, listTools),
4863
- // 设置页那一片。**收 `workspaces`** 是这一轮补的那笔账:③④ 跟着工作区走,
4864
- // 而工作区一个会话绑一个(决定 18)—— 判据在 `SettingsControl` 的文档上
4865
4680
  settings: buildSettingsControl(services, workspaces, {
4866
4681
  ...settingsPath ? { settingsPath } : {},
4867
4682
  ...opts.managedSettingsPath ? { managedSettingsPath: opts.managedSettingsPath } : {}
4868
4683
  }),
4869
- // 同 `model`:**引导会话那一份**,而它就是进程那个 `PlanModeState` 的出口本体
4870
4684
  plan: bootstrapGuard.plan,
4871
- sessions: services.session ? buildSessionControl(services.session, session, checkpoints, (from, id) => {
4685
+ sessions: services.session ? buildSessionControl(services.session, session, checkpoints, config.homeDir, (from, id) => {
4872
4686
  planState?.restore(store?.loadPlan(id) ?? null);
4873
4687
  workspaces.seed(id, {
4874
4688
  workspace,
@@ -4879,7 +4693,6 @@ async function assembleRuntime(opts, opened) {
4879
4693
  factory.rekey(source, id);
4880
4694
  rekeyTasks(source, id);
4881
4695
  }) : null,
4882
- // getter 而不是快照,理由同 `tools` / `providerInfo`
4883
4696
  get contextBreakdown() {
4884
4697
  if (!agent) return null;
4885
4698
  const historyText = (session?.getHistory() ?? []).map((m) => m.content).join("\n");
@@ -4889,18 +4702,12 @@ async function assembleRuntime(opts, opened) {
4889
4702
  });
4890
4703
  },
4891
4704
  commands: commands.control,
4892
- // 用 `registry` 而不是 `commands.toolProvider`:`!` 是**用户**敲的,
4893
- // 不该被一条自定义命令的 `allowed-tools` 收窄掉(那条收窄的语义是
4894
- // 「这一轮**模型**能用什么」)
4895
4705
  userActions: createUserActions({
4896
4706
  registry,
4897
4707
  permission: services.permission,
4898
4708
  workDir,
4899
4709
  sessionId
4900
4710
  }),
4901
- // 三个通道,**一个真源**(那个 sink):字符串那份和「换个语言说一遍」那份
4902
- // 都是投影,不可能漂移。前两个是快照(装配到这一步就定了),
4903
- // 第三个是函数 —— 它要等请求到了才知道用哪个语言
4904
4711
  diagnostics: diagnostics.toStrings(),
4905
4712
  diagnosticList: diagnostics.toList(),
4906
4713
  diagnosticsIn: (lang) => diagnostics.toList(lang),
@@ -4933,9 +4740,9 @@ function wireAgentRoles(config, services, registry, diags, hostRoles = []) {
4933
4740
  });
4934
4741
  const external = [...services.plugins.roles, ...hostRoles, ...load.overrides];
4935
4742
  const { roles, notices } = mergeRoles(external);
4936
- for (const n of notices) diags.warn("Agent \u89D2\u8272", n.detail, n.code);
4743
+ for (const n of notices) diags.warn(AGENT_ROLE_DIAG_MODULE, n.detail, n.code);
4937
4744
  if (!load.projectSkipped) {
4938
- diags.ok("Agent \u89D2\u8272", (lang) => {
4745
+ diags.ok(AGENT_ROLE_DIAG_MODULE, (lang) => {
4939
4746
  const parts = [
4940
4747
  t("agent_roles.tally_builtin", { count: builtinRoles().length }, lang),
4941
4748
  t("agent_roles.tally_user", { count: load.userCount }, lang),
@@ -4955,19 +4762,27 @@ function wireAgentRoles(config, services, registry, diags, hostRoles = []) {
4955
4762
  const unknown = unknownRoleTools(provider, role, BLOCKED_TOOLS);
4956
4763
  if (unknown.length > 0) {
4957
4764
  diags.warn(
4958
- "Agent \u89D2\u8272",
4959
- `${role.name} \u5F15\u7528\u4E86\u4E0D\u5B58\u5728\u7684\u5DE5\u5177 ${unknown.join(", ")}\uFF0C\u5DF2\u5FFD\u7565`,
4765
+ AGENT_ROLE_DIAG_MODULE,
4766
+ t("boot.role_unknown_tools", { role: role.name, tools: unknown.join(", ") }),
4960
4767
  "unknown-tool"
4961
4768
  );
4962
4769
  }
4963
4770
  const hitBlocked = blockedRoleTools(role, BLOCKED_TOOLS);
4964
4771
  if (hitBlocked.length > 0) {
4965
4772
  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`,
4773
+ AGENT_ROLE_DIAG_MODULE,
4774
+ t("boot.role_blocked_tools", { role: role.name, tools: hitBlocked.join(", ") }),
4968
4775
  "role-blocked-tool"
4969
4776
  );
4970
4777
  }
4778
+ const unknownSkills = unknownRoleSkills(services.skill?.getNames() ?? [], role);
4779
+ if (unknownSkills.length > 0) {
4780
+ diags.warn(
4781
+ AGENT_ROLE_DIAG_MODULE,
4782
+ t("boot.role_unknown_skills", { role: role.name, skills: unknownSkills.join(", ") }),
4783
+ "unknown-skill"
4784
+ );
4785
+ }
4971
4786
  }
4972
4787
  return { roles, notices };
4973
4788
  }
@@ -4977,7 +4792,9 @@ function pickMainRole(wanted, roles) {
4977
4792
  const role = findRole(roles, name);
4978
4793
  if (role) return role;
4979
4794
  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"}`);
4795
+ throw new AgentRoleError(
4796
+ t("boot.role_not_found", { name, available: available || t("boot.role_none") })
4797
+ );
4981
4798
  }
4982
4799
  function makePricingLookup(homeDir) {
4983
4800
  const cache = /* @__PURE__ */ new Map();
@@ -5005,18 +4822,18 @@ function wireBudget(config, modelPricing, diagnostics) {
5005
4822
  );
5006
4823
  const { maxCostUsd, maxTokens } = config.budget ?? {};
5007
4824
  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");
4825
+ diagnostics.skipped("Budget", t("boot.budget_none"));
5009
4826
  } else {
5010
4827
  const limits = [
5011
- maxCostUsd !== void 0 ? `\u6210\u672C $${maxCostUsd}` : "",
4828
+ maxCostUsd !== void 0 ? t("boot.budget_cost", { amount: maxCostUsd }) : "",
5012
4829
  maxTokens !== void 0 ? `token ${maxTokens}` : ""
5013
4830
  ].filter(Boolean).join(" / ");
5014
- diagnostics.ok("Budget", `\u4F1A\u8BDD\u4E0A\u9650 ${limits}`);
4831
+ diagnostics.ok("Budget", t("boot.budget_limits", { limits }));
5015
4832
  }
5016
4833
  if (maxCostUsd !== void 0 && !modelPricing) {
5017
4834
  diagnostics.warn(
5018
4835
  "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`,
4836
+ t("boot.budget_no_pricing", { model: config.model }),
5020
4837
  "no-pricing-data"
5021
4838
  );
5022
4839
  }
@@ -5024,8 +4841,6 @@ function wireBudget(config, modelPricing, diagnostics) {
5024
4841
  ...config.budget ? { budget: config.budget } : {},
5025
4842
  ...modelPricing ? { modelPricing } : {},
5026
4843
  ...budgetStore ? { budgetStore } : {},
5027
- // **无条件给**,不看有没有配上限:闸门就算不拦截也要算钱(usage 事件里的
5028
- // costUsd 有消费方),算错了一样是错的
5029
4844
  pricingOf: makePricingLookup(config.homeDir)
5030
4845
  };
5031
4846
  }
@@ -5037,7 +4852,7 @@ function applyHeadless(permission, config, override, diagnostics, interactive) {
5037
4852
  if (nonInteractive) {
5038
4853
  diagnostics.warn(
5039
4854
  "Headless",
5040
- `\u975E\u4EA4\u4E92\u73AF\u5883\uFF0C\u9884\u6388\u6743 ${describeHeadlessPolicy(merged)}`,
4855
+ t("boot.headless_preauth", { policy: describeHeadlessPolicy(merged) }),
5041
4856
  "headless-preauthorized"
5042
4857
  );
5043
4858
  }
@@ -5062,16 +4877,17 @@ async function migrateOnce(store, diagnostics, override) {
5062
4877
  envFilePath: envPath(homeDir),
5063
4878
  store,
5064
4879
  names: API_KEY_ENV_VARS,
5065
- // 只到天:同一天多跑几次不该产生一堆备份文件,真重名时由 migrate.ts 自己让路
5066
4880
  stamp: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10).replace(/-/g, "")
5067
4881
  });
5068
4882
  if (result.warning) diagnostics.warn("Secret", result.warning, "secret-migrate");
5069
4883
  if (result.migrated.length > 0) {
5070
4884
  diagnostics.ok(
5071
4885
  "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}` : "")
4886
+ t("boot.secret_migrated", {
4887
+ count: result.migrated.length,
4888
+ backend: store.detail,
4889
+ backup: result.backupPath ? t("boot.secret_migrated_backup", { path: result.backupPath }) : ""
4890
+ })
5075
4891
  );
5076
4892
  }
5077
4893
  }
@@ -5093,7 +4909,7 @@ async function setupMcpDataKey(store, diagnostics) {
5093
4909
  const why = err instanceof Error ? err.message : String(err);
5094
4910
  diagnostics.warn(
5095
4911
  "Secret",
5096
- `WARN MCP \u6570\u636E\u5BC6\u94A5\u53D6\u4E0D\u5230\uFF0Cmcp-auth.json \u4ECD\u662F\u660E\u6587\uFF08${why}\uFF09`,
4912
+ t("boot.mcp_key_unavailable", { reason: why }),
5097
4913
  "mcp-key-unavailable"
5098
4914
  );
5099
4915
  }
@@ -5136,19 +4952,25 @@ function buildMemoryControl(memory) {
5136
4952
  return {
5137
4953
  list: () => memory.getAllEntries().map((e) => ({
5138
4954
  content: e.content,
5139
- // `getAllEntries` 不带 target,而这一层要显示「记在哪一级」。
5140
- // 拿不到就说 unknown,不猜 —— 猜错会让用户去错的文件里找
5141
4955
  target: e.target ?? "unknown"
5142
4956
  })),
5143
4957
  remove: async (content) => {
5144
4958
  const trimmed = content.trim();
5145
4959
  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` };
4960
+ if (!hit) return { ok: false, message: t("boot.memory_not_found", { query: trimmed }) };
5147
4961
  try {
5148
4962
  await memory.remove("memory", hit.content);
5149
- return { ok: true, message: `\u5DF2\u5220\u9664\uFF1A${hit.content.slice(0, 60)}` };
4963
+ return {
4964
+ ok: true,
4965
+ message: t("boot.memory_removed", { content: hit.content.slice(0, 60) })
4966
+ };
5150
4967
  } catch (err) {
5151
- return { ok: false, message: `\u5220\u9664\u5931\u8D25\uFF1A${err instanceof Error ? err.message : err}` };
4968
+ return {
4969
+ ok: false,
4970
+ message: t("boot.memory_remove_failed", {
4971
+ reason: err instanceof Error ? err.message : String(err)
4972
+ })
4973
+ };
5152
4974
  }
5153
4975
  }
5154
4976
  };
@@ -5163,14 +4985,11 @@ function toSessionSummary(meta) {
5163
4985
  messageCount: meta.messageCount,
5164
4986
  model: meta.model,
5165
4987
  costUsd: meta.estimatedCostUsd,
5166
- // 两格原样转发,这一层**不判也不补**:库里是 NULL 就是 `undefined`
5167
- // (`rowToMeta()` 在那两格上专门做了 `?? undefined` 的归一,判据写在那儿),
5168
- // 而「没记过」和「明确不使用」在宿主那一侧是两个组
5169
4988
  workspaceState: meta.workspaceState,
5170
4989
  workspaceRoot: meta.workspaceRoot
5171
4990
  };
5172
4991
  }
5173
- function buildSessionControl(sessions, session, checkpoints, onResumed) {
4992
+ function buildSessionControl(sessions, session, checkpoints, homeDir, onResumed) {
5174
4993
  return {
5175
4994
  resume: (sessionId) => {
5176
4995
  const from = session?.sessionId ?? null;
@@ -5178,10 +4997,6 @@ function buildSessionControl(sessions, session, checkpoints, onResumed) {
5178
4997
  onResumed?.(from, sessionId);
5179
4998
  return restored;
5180
4999
  },
5181
- // ⚠️ `sortKey: 'updatedAt'` 这一行**没改,但含义变了**(会话库 v4):它以前
5182
- // 在 `SessionDB.listSessions` 里静默退化成 `started_at`,现在真的按最后活动排。
5183
- // 于是这个 `limit` 截出来的那批也变了 —— 一段两年前建、今天还在聊的会话
5184
- // 从此进得了列表,而它以前必然被一批「新建但没说过话」的会话挤掉
5185
5000
  list: (limit = 20) => sessions.list({ limit, sortKey: "updatedAt", direction: "desc" }).sessions.map(toSessionSummary),
5186
5001
  get: (sessionId) => {
5187
5002
  const found = sessions.get(sessionId);
@@ -5201,7 +5016,13 @@ function buildSessionControl(sessions, session, checkpoints, onResumed) {
5201
5016
  } catch {
5202
5017
  checkpointsRemoved = false;
5203
5018
  }
5204
- return { deleted, checkpointsRemoved };
5019
+ let artifactsRemoved = true;
5020
+ try {
5021
+ await removeSessionArtifacts(sessionId, homeDir);
5022
+ } catch {
5023
+ artifactsRemoved = false;
5024
+ }
5025
+ return { deleted, checkpointsRemoved, artifactsRemoved };
5205
5026
  },
5206
5027
  search: (query, limit = 20) => sessions.search({ query, limit, scope: "all" }).results.flatMap(
5207
5028
  (r) => r.matches.map((m) => ({
@@ -5238,15 +5059,17 @@ function buildPermissionsControl(services, bootstrap, tools) {
5238
5059
  rules: effectiveRules,
5239
5060
  shadows: () => checkShadowing(compileRuleLists(services.config.permissions)),
5240
5061
  level: () => bootstrap?.level() ?? services.config.permission,
5241
- // 配置里那一档 + 它赢在哪一层(方案 56 §1.3)。**和上面那一格是两个值** ——
5242
- // 判据在接口上。读 `baseConfig` 是因为 `configOrigins` 记的就是它那一份
5243
- // (而 `permission` 不在 `SettingsFileSchema` 白名单里,两份在这个键上恒等)
5244
5062
  configured: () => ({
5245
5063
  level: services.baseConfig.permission,
5246
5064
  layer: originOf(services.configOrigins, "permission")
5247
5065
  }),
5248
- // 权限层起不来 = 什么都没判过,回空账而不是 null(见接口上那段注释)
5249
5066
  audit: (scope) => services.permission?.getAudit(scope) ?? { entries: [], dropped: 0 },
5067
+ approvals: () => services.permission?.listApprovals() ?? [],
5068
+ revokeApproval: (id) => {
5069
+ const perm = services.permission;
5070
+ if (!perm) return { ok: false, reason: "no-permission-layer" };
5071
+ return perm.revokeApproval(id) ? { ok: true } : { ok: false, reason: "unknown-id" };
5072
+ },
5250
5073
  managed: () => {
5251
5074
  const p = services.managedPolicy;
5252
5075
  return {
@@ -5257,9 +5080,6 @@ function buildPermissionsControl(services, bootstrap, tools) {
5257
5080
  bypassDisabled: p.disableBypassPermissionsMode
5258
5081
  };
5259
5082
  },
5260
- // 三步判定**整个转发**,一行都不在这里重写(判据见上面那个参数的注释)。
5261
- // 权限层没起来时回 `{ok:true}` 而不是拒绝:那时 `level()` 读的是
5262
- // `config.permission`,一个纯展示值,改它没有任何意义也没有任何危害
5263
5083
  setLevel: (level) => bootstrap?.setLevel(level) ?? { ok: true },
5264
5084
  gates: (level) => {
5265
5085
  const rules = compileRuleLists(services.config.permissions);
@@ -5283,12 +5103,7 @@ function compressionLine(config, services) {
5283
5103
  const resolved = compressionOptions(config);
5284
5104
  return {
5285
5105
  contextLength: services.contextLength,
5286
- // `.parse({})` 而不是 `defaultConfig()`:后者的返回类型标注成 `EpochConfig`,
5287
- // 而那上面 `compression` 是可选的(嵌入宿主手搓那一份走的就是这条),
5288
- // 于是这个回落自己又需要一个回落。schema 的产出类型上它是必有的(`.prefault({})`)
5289
5106
  threshold: resolved.threshold ?? EpochConfigSchema.parse({}).compression.threshold,
5290
- // `!== false` 而不是裸真值:判据同 `compressionOptions()` 里那一行 ——
5291
- // 只有显式的 false 才关得掉,而这一条要写在脸上
5292
5107
  auto: services.contextEngine !== null && resolved.enabled !== false
5293
5108
  };
5294
5109
  }
@@ -5313,11 +5128,6 @@ function buildSettingsControl(services, workspaces, opts) {
5313
5128
  return {
5314
5129
  ...chain,
5315
5130
  overridable,
5316
- // 「写进去会不会生效」整个判断在 core,这一层只递参数 —— 它必须和
5317
- // `write()` 里那一次用同一个函数算,否则界面警告和落盘结论会分叉。
5318
- //
5319
- // ⚠️ `paths` 和 `trusted` **来自同一个 `scope`**,那是硬约束不是巧合:
5320
- // 判据在 `SettingsControl` 的文档和 core 的 `config/write.ts` 第四节
5321
5131
  writes: scope.paths === null ? [] : settingWriteTargets({
5322
5132
  winner: chain.layer,
5323
5133
  overridable,
@@ -5409,13 +5219,25 @@ function isSettingValue(value) {
5409
5219
  function maybeValue(key, value) {
5410
5220
  return value === void 0 ? {} : { [key]: value };
5411
5221
  }
5222
+ function reportI18nHealth(diagnostics) {
5223
+ void t("i18n.locales_missing");
5224
+ const issues = i18nDiagnostics();
5225
+ if (issues.length === 0) {
5226
+ diagnostics.ok("I18n", "OK");
5227
+ return;
5228
+ }
5229
+ for (const issue of issues) diagnostics.warn("I18n", issue.detail, issue.code);
5230
+ }
5412
5231
  var AgentRoleError, SETTING_ROWS;
5413
5232
  var init_build = __esm({
5414
5233
  "src/build.ts"() {
5415
5234
  init_commands();
5235
+ init_events();
5416
5236
  init_host_capabilities();
5237
+ init_host_marketplaces();
5417
5238
  init_host_preset();
5418
5239
  init_model_catalog();
5240
+ init_plugin_control();
5419
5241
  init_plugin_mcp_servers();
5420
5242
  init_role_write();
5421
5243
  init_control();
@@ -5428,7 +5250,10 @@ var init_build = __esm({
5428
5250
  init_telemetry();
5429
5251
  init_tools();
5430
5252
  init_user_shell();
5253
+ init_utility();
5254
+ init_workspace_files();
5431
5255
  init_workspace();
5256
+ init_session_goal();
5432
5257
  AgentRoleError = class extends Error {
5433
5258
  constructor(message) {
5434
5259
  super(message);
@@ -5442,15 +5267,6 @@ var init_build = __esm({
5442
5267
  { key: "fallback.providers" },
5443
5268
  { key: "contextLength" },
5444
5269
  { 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
5270
  { key: "compression.enabled" },
5455
5271
  { key: "compression.threshold" },
5456
5272
  { key: "budget.maxCostUsd" },
@@ -5460,12 +5276,12 @@ var init_build = __esm({
5460
5276
  ];
5461
5277
  }
5462
5278
  });
5463
-
5464
5279
  // src/index.ts
5465
5280
  init_build();
5466
5281
  init_host_capabilities();
5467
5282
  init_host_preset();
5468
5283
  init_workspace();
5284
+ init_workspace_files();
5469
5285
  init_executor();
5470
5286
  init_control();
5471
5287
  init_agent_session();
@@ -5475,11 +5291,12 @@ init_services();
5475
5291
  init_user_shell();
5476
5292
  init_commands();
5477
5293
  init_role_scope();
5294
+ init_host_marketplaces();
5478
5295
  init_serialize();
5479
5296
  init_tools();
5480
5297
  init_model_catalog();
5298
+ init_utility();
5481
5299
  init_plugin_mcp_servers();
5482
-
5483
5300
  // src/mcp-admin.ts
5484
5301
  init_build();
5485
5302
  init_mcp_config_write();
@@ -5498,7 +5315,7 @@ async function listMcpServers(opts = {}) {
5498
5315
  async function mcpLogin(name, opts = {}) {
5499
5316
  const config = await requireServer(name, opts);
5500
5317
  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`);
5318
+ throw new Error(t("boot.mcp_stdio_no_oauth", { name: `MCP server "${name}` }));
5502
5319
  }
5503
5320
  const result = await loginToMcpServer({
5504
5321
  server: name,
@@ -5522,7 +5339,7 @@ async function probeMcpServer(name, opts = {}) {
5522
5339
  const tools = status?.connected ? await registry.getAllTools() : [];
5523
5340
  return {
5524
5341
  connected: status?.connected ?? false,
5525
- toolNames: tools.map((t13) => t13.name),
5342
+ toolNames: tools.map((t23) => t23.name),
5526
5343
  ...status?.lastError ? { error: status.lastError } : {},
5527
5344
  needsLogin: status?.needsLogin ?? false
5528
5345
  };
@@ -5535,14 +5352,13 @@ async function requireServer(name, opts) {
5535
5352
  const { servers, configPath: configPath2 } = await listMcpServers(opts);
5536
5353
  const found = servers.find((s) => s.config.name === name);
5537
5354
  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}`);
5355
+ const known = servers.map((s) => s.config.name).join(", ") || t("boot.mcp_list_empty");
5356
+ throw new Error(t("boot.mcp_no_such_server", { path: configPath2, name, known }));
5540
5357
  }
5541
5358
  return found.config;
5542
5359
  }
5543
-
5544
5360
  // src/index.ts
5545
5361
  init_mcp_config_file();
5546
5362
  init_mcp_config_apply();
5547
5363
 
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 };
5364
+ export { AgentRoleError, AgentSession, ApprovalRelay, HOST_CAPABILITIES_MODULE, HOST_MARKETPLACE_MODULE, HOST_PRESET_MODULE, QuestionRelay, SCHEDULE_EXIT_CODES, SessionStore, SessionWorkspaces, applyHostPreset, applyMcpConfig, buildModelCatalogControl, buildRuntime, buildServices, buildWorkspaceFilesControl, collectFileChanges, createScheduleControl, createUserActions, ensureSecretsReady, exitCodeFor, fireSchedule, listMcpServers, mcpConfigRevision, mcpLogin, mcpLogout, probeMcpServer, providerLabel, readMcpConfigFile, registerAskQuestion, registerLocalTools, registerMcpTools, serializeStream, splitDirList, toSerializable, utilityBudgetKey, wireCommands, wireHostCapabilities, wirePluginMcpServers, wireWorkspace, withModelScope, withRoleScope, withToolScope, writeMcpConfigFile, writeMcpServer };