@jack200714/mafw 4.5.2 → 4.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +27 -3
  2. package/gateway/dist/core/manager/goal-snapshot.js +2 -2
  3. package/gateway/dist/core/manager/manager-session-runtime.js +59 -0
  4. package/gateway/dist/core/manager/milestone-push.js +25 -10
  5. package/gateway/dist/index.js +315 -139
  6. package/gateway/dist/media/media-plugin-loader.js +25 -13
  7. package/gateway/dist/media/resolve-prompt.js +20 -0
  8. package/gateway/dist/memory/gateway-db.js +23 -0
  9. package/gateway/dist/opencode-adapter.js +34 -0
  10. package/gateway/dist/plugins/hub.js +153 -19
  11. package/gateway/dist/plugins/package-context.js +24 -0
  12. package/gateway/dist/plugins/package-host.js +331 -0
  13. package/gateway/dist/plugins/package-types.js +2 -0
  14. package/gateway/dist/recall/gateway-db-migrate.js +5 -2
  15. package/gateway/dist/recall/redact.js +53 -0
  16. package/gateway/dist/recall/turn-pipeline.js +2 -0
  17. package/gateway/dist/routes/event-publish.js +44 -0
  18. package/gateway/dist/routes/plugins.js +19 -6
  19. package/gateway/dist/routes/waitwhat-command.js +43 -0
  20. package/gateway/dist/runtime/contract.js +4 -1
  21. package/gateway/dist/runtime/event-broadcast.js +41 -0
  22. package/gateway/dist/runtime/loader.js +45 -14
  23. package/gateway/dist/runtime/normalize.js +13 -0
  24. package/gateway/dist/runtime/pi/pi-approval-bridge.js +12 -2
  25. package/gateway/dist/runtime/pi/pi-approval-extension.js +11 -3
  26. package/gateway/dist/runtime/pi/pi-session.js +21 -3
  27. package/gateway/dist/runtime/plugins/pi-runtime.js +6 -3
  28. package/gateway/dist/runtime/serve-sidecar.js +4 -1
  29. package/gateway/dist/runtime/serve-supervisor.js +12 -0
  30. package/gateway/dist/runtime/validate.js +39 -0
  31. package/gateway/dist/skills/manager-identity.js +6 -1
  32. package/gateway/dist/usage/builtin-plugins/gateway.js +103 -26
  33. package/gateway/dist/usage/plugin-context.js +42 -2
  34. package/gateway/dist/usage/plugin-loader.js +32 -13
  35. package/gateway/package.json +2 -2
  36. package/package.json +3 -1
  37. package/packages/tui/dist/cli.js +28 -5
@@ -63,6 +63,8 @@ const stale_verify_1 = require("./recall/stale-verify");
63
63
  const session_worker_pool_1 = require("./recall/session-worker-pool");
64
64
  const reflect_cursor_1 = require("./recall/reflect-cursor");
65
65
  const index_scan_1 = require("./recall/index-scan");
66
+ const redact_1 = require("./recall/redact");
67
+ const waitwhat_command_1 = require("./routes/waitwhat-command");
66
68
  const consolidation_service_1 = require("./memory/consolidation-service");
67
69
  const auth_2 = require("./runtime/auth");
68
70
  const harmonic_file_store_2 = require("./memory/harmonic-file-store");
@@ -72,6 +74,7 @@ const media_service_1 = require("./media/media-service");
72
74
  const media_agent_1 = require("./media/media-agent");
73
75
  const pi_adapter_1 = require("./media/pi-adapter");
74
76
  const media_runtime_executor_1 = require("./media/media-runtime-executor");
77
+ const resolve_prompt_1 = require("./media/resolve-prompt");
75
78
  const media_plugin_loader_1 = require("./media/media-plugin-loader");
76
79
  const tts_service_1 = require("./media/tts-service");
77
80
  const eval_endpoint_1 = require("./eval-endpoint");
@@ -99,14 +102,20 @@ const self_update_1 = require("./self-update");
99
102
  const step_inject_1 = require("./recall/step-inject");
100
103
  const inject_format_1 = require("./recall/inject-format");
101
104
  const normalize_1 = require("./runtime/normalize");
105
+ const event_broadcast_1 = require("./runtime/event-broadcast");
102
106
  const budget_guard_1 = require("./core/budget-guard");
103
107
  const goal_budget_1 = require("./core/goal-budget");
104
108
  const contract_1 = require("./runtime/contract");
109
+ const validate_1 = require("./runtime/validate");
105
110
  const loader_1 = require("./runtime/loader");
106
111
  const pi_runtime_1 = require("./runtime/plugins/pi-runtime");
107
112
  const permission_1 = require("./routes/permission");
113
+ const event_publish_1 = require("./routes/event-publish");
108
114
  const runtime_switch_1 = require("./routes/runtime-switch");
109
115
  const plugins_1 = require("./routes/plugins");
116
+ const package_host_1 = require("./plugins/package-host");
117
+ const package_context_1 = require("./plugins/package-context");
118
+ const hub_1 = require("./plugins/hub");
110
119
  const restart_agent_1 = require("./routes/restart-agent");
111
120
  const session_mutations_1 = require("./routes/session-mutations");
112
121
  const serve_supervisor_1 = require("./runtime/serve-supervisor");
@@ -144,6 +153,8 @@ async function isPortHealthy(port) {
144
153
  return false;
145
154
  }
146
155
  }
156
+ const manager_session_runtime_1 = require("./core/manager/manager-session-runtime");
157
+ const INTERNAL_SESSION_TTL_DAYS = 7;
147
158
  class MafwScheduler {
148
159
  serveInstance;
149
160
  serveUrl;
@@ -157,6 +168,10 @@ class MafwScheduler {
157
168
  serveOwned = false;
158
169
  serveExitStreak = 0;
159
170
  serveRecovering = false;
171
+ // External runtimes (pi) do not own serve: the watchdog can only warn and
172
+ // reconnect the event stream. Flag keeps that warning a one-shot ERROR per
173
+ // outage instead of an unbounded WARN loop.
174
+ serveExternalDownNotified = false;
160
175
  serveWatchdogTimer = null;
161
176
  serveRetryTimer = null;
162
177
  serveStableTimer = null;
@@ -169,6 +184,8 @@ class MafwScheduler {
169
184
  lastActiveBySession = new Map();
170
185
  lastWriteBySession = new Map();
171
186
  tokenWriterSession = null;
187
+ /** 畸形事件 warn 限频(每 runtime 30s 一次) */
188
+ malformedEventWarnAt = new Map();
172
189
  stopTokenWatcher = null;
173
190
  // Path 1 step-injection state (mark-before-async + dedup + queue). All
174
191
  // per-session entries expire via TtlMap.
@@ -206,8 +223,6 @@ class MafwScheduler {
206
223
  registeredProjects = new Map();
207
224
  registryPath;
208
225
  registryWriteQueue = Promise.resolve();
209
- configPath;
210
- configWriteQueue = Promise.resolve();
211
226
  running = true;
212
227
  // private dashboard?: DashboardServer;
213
228
  mcpEndpoint;
@@ -232,6 +247,8 @@ class MafwScheduler {
232
247
  mediaPluginLoader;
233
248
  mediaRuntimeExecutor;
234
249
  mediaRuntimeExecutorRt;
250
+ pluginHost;
251
+ usageStatsProvider;
235
252
  ttsService;
236
253
  kernels;
237
254
  automationEngine;
@@ -252,7 +269,6 @@ class MafwScheduler {
252
269
  this.serveUrl = config_1.config.server.serveUrl;
253
270
  this.apiPort = config_1.config.server.apiPort;
254
271
  this.pollInterval = config_1.config.timeouts.backupPollInterval;
255
- this.configPath = config_1.config.paths.globalConfig;
256
272
  this.registryPath = config_1.config.paths.registryFile;
257
273
  this.chatSessions = new chat_sessions_1.ChatSessionManager();
258
274
  this.serveSupervisor = (0, serve_supervisor_1.createServeSupervisor)({
@@ -357,6 +373,16 @@ class MafwScheduler {
357
373
  await this.runtimeLoader.init();
358
374
  // 内置插件注册:pi-coding-agent runtime(进程内 SDK 嵌入)
359
375
  this.runtimeLoader.registerBuiltin('pi', pi_runtime_1.createPiRuntime, pi_runtime_1.PI_CAPABILITIES, true);
376
+ // 2b. 统一插件包宿主(必须在 createRuntime 之前 init——包 runtime 贡献要先注册)
377
+ this.pluginHost = new package_host_1.PluginHost(process.env.MAFW_PLUGINS_DIR || config_1.config.resolvePath('plugins'), (name) => (0, package_context_1.createPluginPackageContext)(name, {
378
+ getCredentials: () => this.opencodeClient?.credentials ?? undefined,
379
+ usageStats: () => this.usageStatsProvider,
380
+ projectDir: this.projectDir,
381
+ gatewayPort: config_1.config.server.apiPort,
382
+ emit: (event) => this.broadcast(event),
383
+ }));
384
+ this.pluginHost.bindRuntime((entries) => this.runtimeLoader?.setPackageEntries(entries));
385
+ await this.pluginHost.init();
360
386
  // 3. 创建 SDK 客户端(auth),用于健康检查和后续通信
361
387
  const sdkConfig = {
362
388
  baseUrl: this.serveUrl,
@@ -367,6 +393,12 @@ class MafwScheduler {
367
393
  if (opencodePassword) {
368
394
  sdkConfig.headers = { Authorization: 'Basic ' + Buffer.from(`opencode:${opencodePassword}`).toString('base64') };
369
395
  }
396
+ // opencode 注册为正式内置插件(与用户插件同一接口;同名文件/包可覆盖)
397
+ const sdkSnapshot = { ...sdkConfig, headers: { ...sdkConfig.headers } };
398
+ this.runtimeLoader.registerBuiltin('opencode', async () => (await import('./runtime/opencode-runtime.js')).createOpencodeRuntime({
399
+ ...sdkSnapshot,
400
+ headers: { ...sdkSnapshot.headers },
401
+ }), (0, contract_1.fullCapabilities)(), !!process.env.MAFW_SERVER_SERVE_URL);
370
402
  const runtime = await this.createRuntime(sdkConfig);
371
403
  this.opencodeClient = runtime;
372
404
  this.runtimeCaps = runtime.capabilities;
@@ -423,8 +455,7 @@ class MafwScheduler {
423
455
  // 4. Dashboard is now served via the API server on the same port
424
456
  // this.dashboard = new DashboardServer(3001, this.projectDir, this);
425
457
  // this.dashboard.start();
426
- // 5. 恢复配置和注册表
427
- await this.recoverConfig();
458
+ // 5. 恢复注册表(权威源:gateway DB kv_store + legacy 文件兜底)
428
459
  await this.recoverRegistry();
429
460
  // 5.0 Data-directory migration: move memory store + pipeline files from
430
461
  // the previously-fixed gateway package .mafw (and any project-relative
@@ -598,7 +629,7 @@ class MafwScheduler {
598
629
  }
599
630
  else {
600
631
  try {
601
- const ms = this.getGatewayDb().kvGet('manager-session', this.projectDir);
632
+ const ms = this.readManagerSessionEntry(this.projectDir);
602
633
  if (ms?.sessionId)
603
634
  targets.add(ms.sessionId);
604
635
  }
@@ -642,8 +673,10 @@ class MafwScheduler {
642
673
  if (changed.includes('runtime')) {
643
674
  const newPlugin = config_1.config.runtime?.plugin;
644
675
  if (newPlugin !== prevPlugin && !this.switchingRuntime && !this.serveRecovering) {
645
- logger_1.log.info(`[Scheduler] Runtime plugin changed: '${prevPlugin ?? 'builtin'}' → '${newPlugin ?? 'builtin'}'; hot-switching...`);
676
+ this.switchingRuntime = true;
677
+ const prev = this.opencodeClient;
646
678
  try {
679
+ logger_1.log.info(`[Scheduler] Runtime plugin changed: '${prevPlugin ?? 'builtin'}' → '${newPlugin ?? 'builtin'}'; hot-switching...`);
647
680
  const sdkConfig = {
648
681
  baseUrl: this.serveUrl,
649
682
  directory: this.projectDir,
@@ -668,11 +701,28 @@ class MafwScheduler {
668
701
  if (this.automationEngine)
669
702
  this.automationEngine.setRuntimeClient(runtime);
670
703
  await this.resubscribeEvents(`config hot-reload runtime plugin changed to '${newPlugin ?? 'builtin'}'`);
704
+ // Same desktop hint as the route path: hand-edited config.yaml
705
+ // switches must also refresh the renderer (menus/tabs/manager kv).
706
+ this.broadcast({ type: 'runtime_switched', runtime: runtime.name, previous: prevPlugin ?? null });
707
+ // Dispose the previous runtime AFTER the new one is fully wired
708
+ // (mirror of the route path) — without this, switching away from
709
+ // pi leaks its AgentSessions/ApprovalBridges/event stream.
710
+ if (prev && prev.dispose) {
711
+ try {
712
+ await prev.dispose();
713
+ }
714
+ catch (err) {
715
+ logger_1.log.warn(`[Scheduler] dispose previous runtime failed: ${err.message}`);
716
+ }
717
+ }
671
718
  logger_1.log.info(`[Scheduler] Runtime hot-switched to '${runtime.name}'`);
672
719
  }
673
720
  catch (err) {
674
721
  logger_1.log.warn(`[Scheduler] Runtime hot-switch failed (non-fatal): ${err.message}`);
675
722
  }
723
+ finally {
724
+ this.switchingRuntime = false;
725
+ }
676
726
  }
677
727
  }
678
728
  }, 300);
@@ -773,7 +823,8 @@ class MafwScheduler {
773
823
  registeredAt: new Date().toISOString()
774
824
  });
775
825
  this.persistRegistry();
776
- this.persistConfig();
826
+ // 桌面 renderer 依此事件刷新 Rail 项目列表(否则只在 gateway ready 时拉一次)。
827
+ this.broadcast((0, event_broadcast_1.projectRegisteredEvent)(projectDir));
777
828
  logger_1.log.info(`[Scheduler] Project registered via filesystem: ${projectDir}`);
778
829
  }
779
830
  catch {
@@ -846,6 +897,23 @@ class MafwScheduler {
846
897
  }
847
898
  handleOpencodeEvent(evt) {
848
899
  const f = (0, normalize_1.normalizeOpencodeEvent)(evt);
900
+ // 畸形事件诊断(限频):runtime 插件发来的事件 type/properties 全空时
901
+ // 静默穿过会污染 trajectory 与桌面 SSE——这里给可定位诊断。
902
+ if ((0, normalize_1.isMalformedEvent)(evt)) {
903
+ const now = Date.now();
904
+ const last = this.malformedEventWarnAt.get(this.runtimeName) ?? 0;
905
+ if (now - last > 30_000) {
906
+ this.malformedEventWarnAt.set(this.runtimeName, now);
907
+ let summary;
908
+ try {
909
+ summary = JSON.stringify(evt)?.slice(0, 200) ?? '(unserializable)';
910
+ }
911
+ catch {
912
+ summary = '(unserializable)';
913
+ }
914
+ logger_1.log.warn(`[SSE] malformed event from runtime '${this.runtimeName}' (no type/properties) — dropped consumers may misbehave; payload: ${summary}`);
915
+ }
916
+ }
849
917
  const { type, properties: props, sessionID } = f;
850
918
  // Only memory-system sessions (index-scan / extract / reflect workers) are
851
919
  // internal: their token-level deltas flooded the desktop renderer (per-delta
@@ -952,13 +1020,15 @@ class MafwScheduler {
952
1020
  catch (err) {
953
1021
  logger_1.log.warn(`[Trajectory] idle aggregation failed (non-fatal): ${err.message}`);
954
1022
  }
955
- this.broadcast({ type: 'opencode_event', data: { type: 'message.complete', sessionID, ...(memoryWorker ? { internal: true } : {}) } });
1023
+ this.broadcast((0, event_broadcast_1.opencodeBroadcast)({ type: 'message.complete', sessionID }, memoryWorker));
956
1024
  }
957
1025
  else if (f.broadcast === 'error') {
958
- this.broadcast({ type: 'opencode_event', data: { type: 'message.error', sessionID, error: props?.error instanceof Error ? props.error.message : String(props?.error ?? 'Unknown error'), ...(memoryWorker ? { internal: true } : {}) } });
1026
+ this.broadcast((0, event_broadcast_1.opencodeBroadcast)({ type: 'message.error', sessionID, error: props?.error instanceof Error ? props.error.message : String(props?.error ?? 'Unknown error') }, memoryWorker));
959
1027
  }
960
1028
  else {
961
- this.broadcast({ type: 'opencode_event', data: { type, properties: props, sessionID, ...(memoryWorker ? { internal: true } : {}) } });
1029
+ // directory 透传:session.created/updated/deleted 的消费方(桌面 Rail)
1030
+ // 据此定位所属项目做定向刷新(normalize 已从 GlobalEvent 信封提取)。
1031
+ this.broadcast((0, event_broadcast_1.opencodeBroadcast)({ type, properties: props, sessionID, directory: f.directory }, memoryWorker));
962
1032
  }
963
1033
  }
964
1034
  /** 能力门:runtime 未声明该能力时以 503 显式拒绝(fail-open 的声明式降级)。 */
@@ -988,7 +1058,18 @@ class MafwScheduler {
988
1058
  if (plugin) {
989
1059
  try {
990
1060
  const creds = await this.runtimeCredentialsForPlugin();
991
- const rt = await plugin.createRuntime((0, loader_1.createRuntimePluginContext)(creds));
1061
+ const rt = await plugin.createRuntime((0, loader_1.createRuntimePluginContext)(creds, {
1062
+ projectDir: this.projectDir,
1063
+ gatewayPort: config_1.config.server.apiPort,
1064
+ }));
1065
+ // 声明 vs 实现一致性校验:错位插件在切换/启动期给出字段级诊断并回退,
1066
+ // 而不是运行时深处才炸。
1067
+ const issues = (0, validate_1.validateRuntimeShape)(rt, pluginName);
1068
+ if (issues.length > 0) {
1069
+ for (const issue of issues)
1070
+ logger_1.log.error(`[Runtime] shape violation: ${issue}`);
1071
+ throw new Error(`runtime '${rt.name}' failed shape validation (${issues.length} issue(s)) — see logs`);
1072
+ }
992
1073
  logger_1.log.info(`[Runtime] using plugin runtime '${rt.name}' (capabilities: ${JSON.stringify(rt.capabilities)})`);
993
1074
  return rt;
994
1075
  }
@@ -1000,6 +1081,11 @@ class MafwScheduler {
1000
1081
  logger_1.log.warn(`[Runtime] plugin '${pluginName}' not found — falling back to opencode`);
1001
1082
  }
1002
1083
  }
1084
+ const builtin = this.runtimeLoader?.get('opencode');
1085
+ if (builtin) {
1086
+ return builtin.createRuntime((0, loader_1.createRuntimePluginContext)(undefined));
1087
+ }
1088
+ // 最终安全网:loader 未注册时直连(不应发生)
1003
1089
  const { createOpencodeRuntime } = await import('./runtime/opencode-runtime.js');
1004
1090
  return createOpencodeRuntime({
1005
1091
  ...sdkConfig,
@@ -1706,6 +1792,7 @@ class MafwScheduler {
1706
1792
  }
1707
1793
  this.pluginLoader?.stop();
1708
1794
  this.mediaPluginLoader?.stop();
1795
+ this.pluginHost?.stop();
1709
1796
  if (this.opencodeClient && typeof this.opencodeClient.dispose === 'function') {
1710
1797
  void this.opencodeClient.dispose().catch((err) => {
1711
1798
  logger_1.log.warn(`[Scheduler] runtime dispose error: ${err?.message ?? String(err)}`);
@@ -1719,32 +1806,6 @@ class MafwScheduler {
1719
1806
  // }
1720
1807
  logger_1.log.info('[Scheduler] Stopping...');
1721
1808
  }
1722
- // Proxy a native opencode request by trying every registered workspace.
1723
- // Native reply/reject routes are workspace-scoped (WorkspaceRoutingMiddleware),
1724
- // but the gateway's own projectDir is its cwd — the request may belong to any
1725
- // registered project. GET list endpoints are cross-workspace and unaffected.
1726
- async proxyNativeWorkspaces(path, method, body) {
1727
- const dirs = new Set([this.projectDir || '.']);
1728
- for (const key of this.registeredProjects.keys())
1729
- dirs.add(key);
1730
- let lastStatus = 502;
1731
- for (const dir of dirs) {
1732
- try {
1733
- const r = await fetch(`${this.serveUrl}${path}?directory=${encodeURIComponent(dir)}`, {
1734
- method,
1735
- headers: { 'content-type': 'application/json', 'x-opencode-directory': encodeURIComponent(dir) },
1736
- ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
1737
- });
1738
- if (r.ok)
1739
- return { ok: true, status: r.status };
1740
- lastStatus = r.status;
1741
- }
1742
- catch (e) {
1743
- logger_1.log.error(`[Native proxy] ${path} failed for ${dir}: ${e.message}`);
1744
- }
1745
- }
1746
- return { ok: false, status: lastStatus };
1747
- }
1748
1809
  // 鈹€鈹€ Services & Event Bus 鈹€鈹€
1749
1810
  async initServices() {
1750
1811
  const projectDir = this.projectDir;
@@ -1769,6 +1830,9 @@ class MafwScheduler {
1769
1830
  getCredentials: () => this.opencodeClient?.credentials ?? undefined,
1770
1831
  });
1771
1832
  await this.mediaPluginLoader.init();
1833
+ this.pluginHost?.bindMedia((entries) => this.mediaPluginLoader?.setPackageEntries(entries));
1834
+ // 内置 pi 引擎登记(可被用户同名包/文件覆盖——resolveMediaPrompt 语义)
1835
+ this.mediaPluginLoader.registerBuiltinEngine('pi', ['image', 'video', 'audio']);
1772
1836
  this.mediaService = new media_service_1.MediaService({
1773
1837
  prompt: (0, pi_adapter_1.createPiPromptAdapter)({
1774
1838
  getApiKey: (provider) => {
@@ -1778,31 +1842,24 @@ class MafwScheduler {
1778
1842
  config: () => config_1.config.raw.media,
1779
1843
  resolvePrompt: (kind, cfg) => {
1780
1844
  const engineName = cfg[kind]?.engine ?? cfg.engine ?? 'pi';
1781
- // pi runtime 激活时:图片走 AgentRuntime 会话(MediaRuntimeExecutor),
1782
- // video/audio executor 内部回退到 complete 路径
1783
- if (engineName === 'pi' && this.opencodeClient?.name === 'pi') {
1784
- // runtime 热切换后 opencodeClient 实例更换——旧 executor 持有 stale
1785
- // runtime 引用,必须重建(dispose 尽力而为,不阻塞 prompt)。
1786
- if (!this.mediaRuntimeExecutor || this.mediaRuntimeExecutorRt !== this.opencodeClient) {
1787
- void this.mediaRuntimeExecutor?.dispose().catch(() => { });
1788
- this.mediaRuntimeExecutor = (0, media_runtime_executor_1.createMediaRuntimeExecutor)(this.opencodeClient);
1789
- this.mediaRuntimeExecutorRt = this.opencodeClient;
1790
- }
1791
- return this.mediaRuntimeExecutor.prompt;
1792
- }
1793
- if (engineName === 'pi')
1794
- return undefined;
1795
- const engines = this.mediaPluginLoader?.getEngines();
1796
- const engine = engines?.get(engineName);
1797
- if (!engine) {
1798
- logger_1.log.warn(`[MediaService] engine '${engineName}' not found, falling back to pi`);
1799
- return undefined;
1800
- }
1801
- if (!engine.modalities.includes(kind)) {
1802
- logger_1.log.warn(`[MediaService] engine '${engineName}' does not support modality '${kind}', falling back to pi`);
1803
- return undefined;
1804
- }
1805
- return engine.prompt;
1845
+ return (0, resolve_prompt_1.resolveMediaPrompt)(engineName, kind, {
1846
+ engines: this.mediaPluginLoader?.getEngines() ?? new Map(),
1847
+ builtinPi: () => {
1848
+ // pi runtime 激活时:图片走 AgentRuntime 会话(MediaRuntimeExecutor),
1849
+ // video/audio executor 内部回退到 complete 路径
1850
+ if (this.opencodeClient?.name === 'pi') {
1851
+ // runtime 热切换后 opencodeClient 实例更换——旧 executor 持有 stale
1852
+ // runtime 引用,必须重建(dispose 尽力而为,不阻塞 prompt)。
1853
+ if (!this.mediaRuntimeExecutor || this.mediaRuntimeExecutorRt !== this.opencodeClient) {
1854
+ void this.mediaRuntimeExecutor?.dispose().catch(() => { });
1855
+ this.mediaRuntimeExecutor = (0, media_runtime_executor_1.createMediaRuntimeExecutor)(this.opencodeClient);
1856
+ this.mediaRuntimeExecutorRt = this.opencodeClient;
1857
+ }
1858
+ return this.mediaRuntimeExecutor.prompt;
1859
+ }
1860
+ return undefined;
1861
+ },
1862
+ });
1806
1863
  },
1807
1864
  });
1808
1865
  // Determine workspace name: if projectDir resolves to a 'gateway' subdirectory,
@@ -1911,6 +1968,9 @@ class MafwScheduler {
1911
1968
  const collector = new TrajectoryCollector(trajStore, this.getGatewayDb(), projectDir, () => config_1.config.trajectory.retentionDays);
1912
1969
  collector.setRoleFor((sid) => this.internalSessionRoles.get(sid) ?? null);
1913
1970
  this.trajectoryCollector = collector;
1971
+ const pruned = this.getGatewayDb().kvPruneOlderThan('internal-session', INTERNAL_SESSION_TTL_DAYS);
1972
+ if (pruned > 0)
1973
+ logger_1.log.info(`[Scheduler] pruned ${pruned} stale internal-session kv entries (> ${INTERNAL_SESSION_TTL_DAYS}d)`);
1914
1974
  const restored = this.getGatewayDb().kvAll('internal-session');
1915
1975
  for (const { key: sid, value } of restored) {
1916
1976
  if (value?.role && !this.internalSessionRoles.has(sid)) {
@@ -1927,10 +1987,12 @@ class MafwScheduler {
1927
1987
  const pluginsDir = path.join(os.homedir(), '.mafw', 'usage-plugins');
1928
1988
  const builtinPluginsDir = path.join(__dirname, 'usage', 'builtin-plugins');
1929
1989
  const disabledPlugins = Array.isArray(config_1.config.usage?.disabledPlugins) ? config_1.config.usage.disabledPlugins : [];
1990
+ const statsProvider = createUsageStatsProvider(trajStore);
1991
+ this.usageStatsProvider = statsProvider;
1930
1992
  const pluginLoader = new PluginLoader(pluginsDir, [], {
1931
1993
  builtinPluginsDir,
1932
1994
  disabledPlugins,
1933
- usageStats: createUsageStatsProvider(trajStore),
1995
+ usageStats: statsProvider,
1934
1996
  // inline provider key 兜底(auth.json 无条目的自建 provider,如 gateway)——
1935
1997
  // thunk 惰性求值,opencodeClient 此时尚未初始化也不影响。
1936
1998
  resolveInlineApiKey: async (providerID) => {
@@ -1946,6 +2008,7 @@ class MafwScheduler {
1946
2008
  });
1947
2009
  await pluginLoader.init();
1948
2010
  this.pluginLoader = pluginLoader;
2011
+ this.pluginHost?.bindUsage((entries) => pluginLoader.setPackageEntries(entries));
1949
2012
  const { UsagePoller } = require('./usage/usage-poller');
1950
2013
  this.usagePoller = new UsagePoller(trajStore, () => config_1.config.usage.limits, () => config_1.config.usage.budgets, pluginLoader);
1951
2014
  logger_1.log.info('[Trajectory] store initialized');
@@ -2050,6 +2113,18 @@ class MafwScheduler {
2050
2113
  handleServeExit(code) {
2051
2114
  if (!this.serveOwned || this.serveRecovering)
2052
2115
  return;
2116
+ // After a hot-switch to an in-process/external runtime, the (still owned)
2117
+ // opencode serve is no longer the active backend: its exit must NOT trigger
2118
+ // recovery — pi has no spawnServe, so the orchestrator would fall into the
2119
+ // supervisor's kill+spawn path and retry forever (killing whatever listens
2120
+ // on the serve port each cycle). A later switch back re-ensures via
2121
+ // ensureServeForBuiltinRuntime (probe → adopt or spawn).
2122
+ const rt = this.opencodeClient;
2123
+ if (rt?.external || !rt?.agentProcess?.spawnServe) {
2124
+ this.serveOwned = false;
2125
+ logger_1.log.info('[Scheduler] owned serve exited after runtime switch; recovery skipped (active runtime does not own serve)');
2126
+ return;
2127
+ }
2053
2128
  this.serveInstance = undefined;
2054
2129
  if (this.serveStableTimer) {
2055
2130
  clearTimeout(this.serveStableTimer);
@@ -2122,6 +2197,10 @@ class MafwScheduler {
2122
2197
  if (!this.running || this.serveRecovering)
2123
2198
  return;
2124
2199
  if (await this.isServeHealthy()) {
2200
+ if (failures > 0 || this.serveExternalDownNotified) {
2201
+ logger_1.log.info('[Scheduler] Serve back online');
2202
+ }
2203
+ this.serveExternalDownNotified = false;
2125
2204
  failures = 0;
2126
2205
  return;
2127
2206
  }
@@ -2130,7 +2209,10 @@ class MafwScheduler {
2130
2209
  if (failures >= this.serveWatchdogFailures) {
2131
2210
  failures = 0;
2132
2211
  if (this.opencodeClient?.external) {
2133
- logger_1.log.warn('[Scheduler] External serve unreachable; reconnecting event stream only (not killing external process)');
2212
+ if (!this.serveExternalDownNotified) {
2213
+ this.serveExternalDownNotified = true;
2214
+ logger_1.log.error('[Scheduler] External serve unreachable — the active runtime does not own the serve process, so it will NOT be respawned. Serve-dependent features (provider list, sessions, approvals) are degraded.');
2215
+ }
2134
2216
  try {
2135
2217
  await this.subscribeToEvents();
2136
2218
  }
@@ -3129,6 +3211,32 @@ class MafwScheduler {
3129
3211
  res.end(JSON.stringify({ ok: true, text: answer }));
3130
3212
  return;
3131
3213
  }
3214
+ if (cmd === "waitwhat") {
3215
+ if (!sessionID) {
3216
+ res.writeHead(400);
3217
+ res.end(JSON.stringify({ ok: false, error: "sessionID required" }));
3218
+ return;
3219
+ }
3220
+ if (!this.opencodeClient) {
3221
+ res.writeHead(503);
3222
+ res.end(JSON.stringify({ ok: false, error: "LLM client not available" }));
3223
+ return;
3224
+ }
3225
+ const result = await (0, waitwhat_command_1.runWaitwhat)(String(sessionID), {
3226
+ listMessages: async (sid) => {
3227
+ const r = await this.opencodeClient.session.messages({ sessionID: sid, limit: 50 });
3228
+ return (r?.data || []);
3229
+ },
3230
+ promptAsync: async (sid, text) => {
3231
+ await this.opencodeClient.session.promptAsync({ sessionID: sid, parts: [{ type: "text", text }] });
3232
+ },
3233
+ });
3234
+ res.writeHead(result.ok ? 200 : 400, { 'Content-Type': 'application/json' });
3235
+ res.end(JSON.stringify(result.ok
3236
+ ? { ok: true, message: '重述请求已发送到当前会话' }
3237
+ : { ok: false, error: result.error }));
3238
+ return;
3239
+ }
3132
3240
  if (cmd === "status") {
3133
3241
  const statusPath = path.join(this.mafwDir, 'STATUS.md');
3134
3242
  const text = fs.existsSync(statusPath) ? fs.readFileSync(statusPath, 'utf-8') : 'No active Goals. Use /goal to create one.';
@@ -3600,7 +3708,8 @@ class MafwScheduler {
3600
3708
  });
3601
3709
  // 持久化到磁盘(写队列防并发覆盖)
3602
3710
  await this.persistRegistry();
3603
- await this.persistConfig();
3711
+ // 桌面 renderer 依此事件刷新 Rail 项目列表(否则只在 gateway ready 时拉一次)。
3712
+ this.broadcast((0, event_broadcast_1.projectRegisteredEvent)(projectDir));
3604
3713
  if (this.opencodeClient) {
3605
3714
  try {
3606
3715
  await this.ensureManagerSession(projectDir, mafwDir);
@@ -3737,8 +3846,14 @@ class MafwScheduler {
3737
3846
  const filter = parsedUrl.searchParams.get('projectDir') || '';
3738
3847
  try {
3739
3848
  const all = this.getGatewayDb().kvAll('manager-session');
3849
+ // Per-runtime slots: surface only the ACTIVE runtime's manager
3850
+ // slot per project. Other runtimes' slots stay dormant (resumed
3851
+ // when switching back) — nothing is deleted here.
3852
+ const live = all
3853
+ .map((e) => ({ key: e.key, value: (0, manager_session_runtime_1.readManagerSlot)(e.value, this.runtimeName) }))
3854
+ .filter((e) => e.value !== null);
3740
3855
  if (filter) {
3741
- const found = all.find((e) => e.key === filter);
3856
+ const found = live.find((e) => e.key === filter);
3742
3857
  if (!found) {
3743
3858
  res.writeHead(404);
3744
3859
  res.end(JSON.stringify({ error: 'No manager session for project' }));
@@ -3752,13 +3867,13 @@ class MafwScheduler {
3752
3867
  }));
3753
3868
  return;
3754
3869
  }
3755
- if (all.length === 0) {
3870
+ if (live.length === 0) {
3756
3871
  res.writeHead(404);
3757
3872
  res.end(JSON.stringify({ error: 'No manager session' }));
3758
3873
  return;
3759
3874
  }
3760
3875
  res.writeHead(200);
3761
- res.end(JSON.stringify(all.map((e) => ({
3876
+ res.end(JSON.stringify(live.map((e) => ({
3762
3877
  projectDir: e.key,
3763
3878
  sessionId: e.value.sessionId,
3764
3879
  createdAt: e.value.createdAt || null,
@@ -3969,17 +4084,15 @@ class MafwScheduler {
3969
4084
  return;
3970
4085
  }
3971
4086
  // 鈹€鈹€ Question endpoints (AskCard 鈹€ proxy to native opencode Question API) 鈹€鈹€
3972
- // GET /api/questions 鈹€ list pending questions
4087
+ // GET /api/questions ── list pending questions(契约:session.question.list)
3973
4088
  if (req.url?.match(/^\/api\/questions(?:\?|$)/) && req.method === 'GET') {
3974
4089
  if (this.capGuardQuestion(res))
3975
4090
  return;
3976
4091
  try {
3977
- const dir = new URL(req.url, this.serveUrl).searchParams.get('directory') || this.projectDir || '.';
3978
- const r = await fetch(`${this.serveUrl}/question?directory=${encodeURIComponent(dir)}`, {
3979
- headers: { 'x-opencode-directory': encodeURIComponent(dir) },
3980
- });
3981
- const items = await r.json();
3982
- res.end(JSON.stringify({ items }));
4092
+ const dir = new URL(req.url, this.serveUrl).searchParams.get('directory') || this.projectDir || undefined;
4093
+ const question = this.opencodeClient?.session?.question;
4094
+ const items = question ? await question.list(dir ? { directory: dir } : undefined) : [];
4095
+ res.end(JSON.stringify({ items: items ?? [] }));
3983
4096
  }
3984
4097
  catch (err) {
3985
4098
  logger_1.log.error('[Question] list error:', err.message);
@@ -3987,20 +4100,34 @@ class MafwScheduler {
3987
4100
  }
3988
4101
  return;
3989
4102
  }
3990
- // POST /api/questions/{id}/reply 鈹€ { answers: string[][] }
4103
+ // POST /api/questions/{id}/reply ── { answers: string[][] }(契约 + workspace 重试)
3991
4104
  const qReplyMatch = req.url?.match(/^\/api\/questions\/([^/]+)\/reply(?:\?|$)/);
3992
4105
  if (qReplyMatch && req.method === 'POST') {
3993
4106
  if (this.capGuardQuestion(res))
3994
4107
  return;
4108
+ const question = this.opencodeClient?.session?.question;
4109
+ if (!question) {
4110
+ res.writeHead(503);
4111
+ res.end(JSON.stringify({ status: 'error', error: 'question API not available on this runtime' }));
4112
+ return;
4113
+ }
3995
4114
  try {
3996
4115
  const body = JSON.parse(await readBody(req));
3997
- const r = await this.proxyNativeWorkspaces(`/question/${qReplyMatch[1]}/reply`, 'POST', { answers: body.answers });
3998
- if (!r.ok) {
3999
- res.writeHead(r.status);
4000
- res.end(JSON.stringify({ status: 'error', code: r.status }));
4001
- return;
4116
+ // workspace 路由:默认(SDK 自带 projectDir)→ 各注册项目逐试
4117
+ const dirs = [undefined, this.projectDir, ...this.registeredProjects.keys()];
4118
+ let lastErr = null;
4119
+ for (const dir of [...new Set(dirs)]) {
4120
+ try {
4121
+ await question.reply({ requestID: qReplyMatch[1], answers: body.answers, ...(dir ? { directory: dir } : {}) });
4122
+ res.end(JSON.stringify({ status: 'ok' }));
4123
+ return;
4124
+ }
4125
+ catch (err) {
4126
+ lastErr = err;
4127
+ }
4002
4128
  }
4003
- res.end(JSON.stringify({ status: 'ok' }));
4129
+ res.writeHead(400);
4130
+ res.end(JSON.stringify({ status: 'error', error: lastErr?.message ?? 'question reply failed on all workspaces' }));
4004
4131
  }
4005
4132
  catch (err) {
4006
4133
  logger_1.log.error('[Question] reply error:', err.message);
@@ -4009,19 +4136,32 @@ class MafwScheduler {
4009
4136
  }
4010
4137
  return;
4011
4138
  }
4012
- // POST /api/questions/{id}/reject
4139
+ // POST /api/questions/{id}/reject(契约 + workspace 重试)
4013
4140
  const qRejectMatch = req.url?.match(/^\/api\/questions\/([^/]+)\/reject(?:\?|$)/);
4014
4141
  if (qRejectMatch && req.method === 'POST') {
4015
4142
  if (this.capGuardQuestion(res))
4016
4143
  return;
4144
+ const question = this.opencodeClient?.session?.question;
4145
+ if (!question) {
4146
+ res.writeHead(503);
4147
+ res.end(JSON.stringify({ status: 'error', error: 'question API not available on this runtime' }));
4148
+ return;
4149
+ }
4017
4150
  try {
4018
- const r = await this.proxyNativeWorkspaces(`/question/${qRejectMatch[1]}/reject`, 'POST');
4019
- if (!r.ok) {
4020
- res.writeHead(r.status);
4021
- res.end(JSON.stringify({ status: 'error', code: r.status }));
4022
- return;
4151
+ const dirs = [undefined, this.projectDir, ...this.registeredProjects.keys()];
4152
+ let lastErr = null;
4153
+ for (const dir of [...new Set(dirs)]) {
4154
+ try {
4155
+ await question.reject({ requestID: qRejectMatch[1], ...(dir ? { directory: dir } : {}) });
4156
+ res.end(JSON.stringify({ status: 'ok' }));
4157
+ return;
4158
+ }
4159
+ catch (err) {
4160
+ lastErr = err;
4161
+ }
4023
4162
  }
4024
- res.end(JSON.stringify({ status: 'ok' }));
4163
+ res.writeHead(400);
4164
+ res.end(JSON.stringify({ status: 'error', error: lastErr?.message ?? 'question reject failed on all workspaces' }));
4025
4165
  }
4026
4166
  catch (err) {
4027
4167
  logger_1.log.error('[Question] reject error:', err.message);
@@ -4031,17 +4171,16 @@ class MafwScheduler {
4031
4171
  return;
4032
4172
  }
4033
4173
  // 鈹€鈹€ Permission endpoints (PermissionCard 鈹€ proxy to native opencode Permission API) 鈹€鈹€
4034
- // GET /api/permissions 鈹€ list pending permission requests
4174
+ // GET /api/permissions ── list pending permission requests(契约:session.permissionList)
4035
4175
  if (req.url?.match(/^\/api\/permissions(?:\?|$)/) && req.method === 'GET') {
4036
4176
  if (this.capGuard(res, 'nativeApprovals'))
4037
4177
  return;
4038
4178
  try {
4039
- const dir = new URL(req.url, this.serveUrl).searchParams.get('directory') || this.projectDir || '.';
4040
- const r = await fetch(`${this.serveUrl}/permission?directory=${encodeURIComponent(dir)}`, {
4041
- headers: { 'x-opencode-directory': encodeURIComponent(dir) },
4042
- });
4043
- const items = await r.json();
4044
- res.end(JSON.stringify({ items }));
4179
+ const dir = new URL(req.url, this.serveUrl).searchParams.get('directory') || this.projectDir || undefined;
4180
+ const items = this.opencodeClient?.session?.permissionList
4181
+ ? await this.opencodeClient.session.permissionList(dir ? { directory: dir } : undefined)
4182
+ : [];
4183
+ res.end(JSON.stringify({ items: items ?? [] }));
4045
4184
  }
4046
4185
  catch (err) {
4047
4186
  logger_1.log.error('[Permission] list error:', err.message);
@@ -4089,6 +4228,9 @@ class MafwScheduler {
4089
4228
  if (this.automationEngine)
4090
4229
  this.automationEngine.setRuntimeClient(rt);
4091
4230
  await this.resubscribeEvents(`runtime switched to '${rt.name}'`);
4231
+ // Desktop hint: a runtime switch swaps the session storage backend
4232
+ // (opencode SQLite vs pi), so cached session lists are stale.
4233
+ this.broadcast({ type: 'runtime_switched', runtime: rt.name, previous: prev?.name ?? null });
4092
4234
  if (prev && prev.dispose) {
4093
4235
  try {
4094
4236
  await prev.dispose();
@@ -4144,6 +4286,23 @@ class MafwScheduler {
4144
4286
  usage: config_1.config.resolvePath('usage-plugins'),
4145
4287
  ui: process.env.MAFW_UI_PLUGINS_DIR || path.join(os.homedir(), '.mafw', 'ui-plugins'),
4146
4288
  },
4289
+ builtinEntries: () => {
4290
+ const entries = [];
4291
+ const rt = (name) => ({ type: 'runtime', name, file: '(builtin)', status: 'enabled', size: 0, mtime: '' });
4292
+ // opencode 经 registerBuiltin 注册,getBuiltinNames 已含——不再手工 push(防重复)
4293
+ for (const name of this.runtimeLoader?.getBuiltinNames?.() ?? [])
4294
+ entries.push(rt(name));
4295
+ for (const name of this.mediaPluginLoader?.getBuiltinEngineNames?.() ?? []) {
4296
+ entries.push({ type: 'media', name, file: '(builtin)', status: 'enabled', size: 0, mtime: '' });
4297
+ }
4298
+ const usageState = this.pluginLoader?.getState?.() ?? [];
4299
+ for (const s of usageState) {
4300
+ if (s.builtin && s.status === 'ok' && s.name) {
4301
+ entries.push({ type: 'usage', name: s.name, file: s.file, status: 'enabled', size: 0, mtime: '', pluginType: s.pluginType });
4302
+ }
4303
+ }
4304
+ return entries;
4305
+ },
4147
4306
  getErrors: (type) => {
4148
4307
  const stateOf = (loader) => (loader && typeof loader.getState === 'function' ? loader.getState() : []);
4149
4308
  const source = type === 'runtime' ? this.runtimeLoader : type === 'media' ? this.mediaPluginLoader : type === 'usage' ? this.pluginLoader : null;
@@ -4164,8 +4323,19 @@ class MafwScheduler {
4164
4323
  await this.pluginLoader?.reload();
4165
4324
  // ui: desktop main fs.watch picks it up automatically
4166
4325
  },
4326
+ getPackages: () => this.pluginHost?.getState() ?? [],
4167
4327
  },
4168
4328
  };
4329
+ try {
4330
+ const cleaned = (0, hub_1.cleanupExamples)(pluginHubDeps.hub);
4331
+ if (cleaned.removed.length)
4332
+ logger_1.log.info(`[PluginsHub] removed stale examples: ${cleaned.removed.length}`);
4333
+ if (cleaned.failed.length)
4334
+ logger_1.log.warn(`[PluginsHub] cleanupExamples failed: ${cleaned.failed.join(', ')}`);
4335
+ }
4336
+ catch (err) {
4337
+ logger_1.log.warn(`[PluginsHub] cleanupExamples error: ${err.message}`);
4338
+ }
4169
4339
  if (req.method === 'GET' && req.url?.match(/^\/api\/plugins(?:\?|$)/)) {
4170
4340
  await (0, plugins_1.handlePluginsList)(req, res, pluginHubDeps);
4171
4341
  return;
@@ -4281,20 +4451,37 @@ class MafwScheduler {
4281
4451
  }
4282
4452
  return;
4283
4453
  }
4284
- // POST /api/permissions/{id}/reply 鈹€ { reply: 'once'|'always'|'reject', message?: string }
4454
+ // POST /api/permissions/{id}/reply ── { reply, message? }(契约:列表反查 sessionID → permissionReply)
4285
4455
  const pReplyMatch = req.url?.match(/^\/api\/permissions\/([^/]+)\/reply(?:\?|$)/);
4286
4456
  if (pReplyMatch && req.method === 'POST') {
4287
4457
  if (this.capGuard(res, 'nativeApprovals'))
4288
4458
  return;
4459
+ const runtime = this.opencodeClient;
4460
+ if (!runtime?.session?.permissionList || !runtime?.session?.permissionReply) {
4461
+ res.writeHead(503);
4462
+ res.end(JSON.stringify({ status: 'error', error: 'permission API not available on this runtime' }));
4463
+ return;
4464
+ }
4289
4465
  try {
4290
4466
  const body = JSON.parse(await readBody(req));
4291
- const payload = { reply: body.reply };
4292
- if (body.message)
4293
- payload.message = body.message;
4294
- const r = await this.proxyNativeWorkspaces(`/permission/${pReplyMatch[1]}/reply`, 'POST', payload);
4295
- if (!r.ok) {
4296
- res.writeHead(r.status);
4297
- res.end(JSON.stringify({ status: 'error', code: r.status }));
4467
+ const reply = body.reply;
4468
+ if (reply !== 'once' && reply !== 'always' && reply !== 'reject') {
4469
+ res.writeHead(400);
4470
+ res.end(JSON.stringify({ status: 'error', error: "reply must be 'once'|'always'|'reject'" }));
4471
+ return;
4472
+ }
4473
+ // 反查 sessionID:pending 列表项携带(跨 runtime 形状一致)
4474
+ const pending = await runtime.session.permissionList();
4475
+ const found = (Array.isArray(pending) ? pending : []).find((p) => p?.id === pReplyMatch[1]);
4476
+ if (!found?.sessionID) {
4477
+ res.writeHead(404);
4478
+ res.end(JSON.stringify({ status: 'error', error: 'permission request not found' }));
4479
+ return;
4480
+ }
4481
+ const ok = await runtime.session.permissionReply(found.sessionID, pReplyMatch[1], reply, body.message);
4482
+ if (!ok) {
4483
+ res.writeHead(404);
4484
+ res.end(JSON.stringify({ status: 'error', error: 'permission request not found' }));
4298
4485
  return;
4299
4486
  }
4300
4487
  res.end(JSON.stringify({ status: 'ok' }));
@@ -5142,7 +5329,10 @@ class MafwScheduler {
5142
5329
  session_id: sessionID,
5143
5330
  turn_id: turnId,
5144
5331
  source: source,
5145
- content: content.slice(0, 100_000),
5332
+ // Redact secrets once at capture so every downstream consumer
5333
+ // (turnCompress transcripts, worker prompts, archive) only
5334
+ // ever sees redacted content.
5335
+ content: (0, redact_1.redactSecrets)(content).slice(0, 100_000),
5146
5336
  failure,
5147
5337
  });
5148
5338
  // Async scan prefetch: precompute semantic recall for this turn
@@ -5162,6 +5352,11 @@ class MafwScheduler {
5162
5352
  });
5163
5353
  return;
5164
5354
  }
5355
+ // POST /api/events —— 事件发布(插件/外部程序 → 全 UI 通道;契约见 routes/event-publish.ts)
5356
+ if (req.url && req.url.startsWith('/api/events') && req.method === 'POST') {
5357
+ await (0, event_publish_1.handleEventPublish)({ broadcast: (e) => this.broadcast(e) }, req, res);
5358
+ return;
5359
+ }
5165
5360
  // SSE 事件(→ Dashboard / Chat)
5166
5361
  if (req.url && req.url.startsWith('/api/events') && req.method === 'GET') {
5167
5362
  const parsedUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
@@ -5433,33 +5628,6 @@ class MafwScheduler {
5433
5628
  logger_1.log.info(`[Scheduler] Recovered ${filtered.length} registered projects`);
5434
5629
  }
5435
5630
  }
5436
- async persistConfig() {
5437
- this.configWriteQueue = this.configWriteQueue.then(async () => {
5438
- const dir = path.dirname(this.configPath);
5439
- if (!fs.existsSync(dir))
5440
- fs.mkdirSync(dir, { recursive: true });
5441
- let config = {};
5442
- if (fs.existsSync(this.configPath)) {
5443
- config = JSON.parse(fs.readFileSync(this.configPath, 'utf-8'));
5444
- }
5445
- config.projects = Object.fromEntries(this.registeredProjects);
5446
- fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2));
5447
- });
5448
- await this.configWriteQueue;
5449
- }
5450
- async recoverConfig() {
5451
- if (fs.existsSync(this.configPath)) {
5452
- try {
5453
- const config = JSON.parse(fs.readFileSync(this.configPath, 'utf-8'));
5454
- const entries = Object.entries((config.projects || {}))
5455
- .filter(([dir]) => !this.isUserDataDir(dir));
5456
- this.registeredProjects = new Map(entries);
5457
- }
5458
- catch (err) {
5459
- logger_1.log.error(`[Scheduler] Failed to recover config: ${err.message}`);
5460
- }
5461
- }
5462
- }
5463
5631
  // ── 4. 轮询(降级兜底 + autoresume) ──
5464
5632
  startBackupPolling() {
5465
5633
  const interval = config_1.config.timeouts.backupPollInterval;
@@ -6190,6 +6358,14 @@ ${observations.map((o, i) => `[${i + 1}] ${o}`).join('\n')}`;
6190
6358
  });
6191
6359
  return run;
6192
6360
  }
6361
+ // Slot-based read of a manager-session kv entry: each runtime owns a slot
6362
+ // in the value (byRuntime), so switching runtimes never drops a topic —
6363
+ // switching back resumes the previous manager. Legacy v1 entries are
6364
+ // inferred by session id prefix and only surface under their own runtime.
6365
+ // See manager-session-runtime.ts.
6366
+ readManagerSessionEntry(projectDir) {
6367
+ return (0, manager_session_runtime_1.readManagerSlot)(this.getGatewayDb().kvGet('manager-session', projectDir), this.runtimeName);
6368
+ }
6193
6369
  // Serialized rotate: joins any in-flight ensure/create for the same project
6194
6370
  // so rotate and lazy-init never double-create (spec §4).
6195
6371
  async runManagerExclusive(projectDir, fn) {
@@ -6206,7 +6382,7 @@ ${observations.map((o, i) => `[${i + 1}] ${o}`).join('\n')}`;
6206
6382
  // The inflight map is shared with ensureManagerSession, whose joiners
6207
6383
  // expect the promise to resolve to a session id string — map the rotate
6208
6384
  // result onto the resulting kv entry.
6209
- const mapped = run.then(() => this.getGatewayDb().kvGet('manager-session', projectDir)?.sessionId ?? '', () => '');
6385
+ const mapped = run.then(() => this.readManagerSessionEntry(projectDir)?.sessionId ?? '', () => '');
6210
6386
  this.managerSessionInflight.set(projectDir, mapped);
6211
6387
  try {
6212
6388
  return await run;
@@ -6221,7 +6397,7 @@ ${observations.map((o, i) => `[${i + 1}] ${o}`).join('\n')}`;
6221
6397
  throw new Error('opencodeClient not available');
6222
6398
  // Manager identity lives in the gateway DB (kv_store), so it survives
6223
6399
  // project-directory churn and never gets orphaned by directory moves.
6224
- const existing = this.getGatewayDb().kvGet('manager-session', projectDir);
6400
+ const existing = this.readManagerSessionEntry(projectDir);
6225
6401
  if (existing?.sessionId) {
6226
6402
  await this.sdkSession.registerExternal(existing.sessionId, projectDir, {
6227
6403
  mafw: { role: 'manager', pinned: true, exemptFromTrim: true, exemptFromEvict: true, exemptFromArchive: true },
@@ -6237,7 +6413,7 @@ ${observations.map((o, i) => `[${i + 1}] ${o}`).join('\n')}`;
6237
6413
  throw new Error('Failed to create manager session: no id returned');
6238
6414
  }
6239
6415
  const createdAt = new Date().toISOString();
6240
- this.getGatewayDb().kvSet('manager-session', projectDir, { sessionId, createdAt });
6416
+ this.getGatewayDb().kvSet('manager-session', projectDir, (0, manager_session_runtime_1.writeManagerSlot)(this.getGatewayDb().kvGet('manager-session', projectDir), this.runtimeName, { sessionId, createdAt }));
6241
6417
  this.registerInternalSession(sessionId, 'manager');
6242
6418
  try {
6243
6419
  await this.sdkSession.registerExternal(sessionId, projectDir, {
@@ -6269,7 +6445,7 @@ ${observations.map((o, i) => `[${i + 1}] ${o}`).join('\n')}`;
6269
6445
  }
6270
6446
  rotateDeps() {
6271
6447
  return {
6272
- getManagerSession: (pd) => this.getGatewayDb().kvGet('manager-session', pd),
6448
+ getManagerSession: (pd) => this.readManagerSessionEntry(pd),
6273
6449
  lock: (pd, fn) => this.runManagerExclusive(pd, fn),
6274
6450
  ensure: (pd) => this.ensureManagerSession(pd, this.mafwDirFor(pd)),
6275
6451
  downgrade: async (sid) => {
@@ -6316,7 +6492,7 @@ ${observations.map((o, i) => `[${i + 1}] ${o}`).join('\n')}`;
6316
6492
  return undefined;
6317
6493
  if (!this.milestonePush) {
6318
6494
  this.milestonePush = new milestone_push_1.MilestonePushNotifier({
6319
- getManagerSession: (pd) => this.getGatewayDb().kvGet('manager-session', pd),
6495
+ getManagerSession: (pd) => this.readManagerSessionEntry(pd),
6320
6496
  wasNotified: (key) => !!this.getGatewayDb().kvGet('milestone-notified', key),
6321
6497
  markNotified: (key) => this.getGatewayDb().kvSet('milestone-notified', key, { at: new Date().toISOString() }),
6322
6498
  readGoalState: (goalId, pd) => {
@@ -6345,7 +6521,7 @@ ${observations.map((o, i) => `[${i + 1}] ${o}`).join('\n')}`;
6345
6521
  const sessionId = session.id;
6346
6522
  if (!sessionId)
6347
6523
  throw new Error('Failed to create manager session: no id returned');
6348
- this.getGatewayDb().kvSet('manager-session', projectDir, { sessionId, createdAt: new Date().toISOString() });
6524
+ this.getGatewayDb().kvSet('manager-session', projectDir, (0, manager_session_runtime_1.writeManagerSlot)(this.getGatewayDb().kvGet('manager-session', projectDir), this.runtimeName, { sessionId, createdAt: new Date().toISOString() }));
6349
6525
  this.registerInternalSession(sessionId, 'manager');
6350
6526
  await this.sdkSession.registerExternal(sessionId, projectDir, {
6351
6527
  mafw: { role: 'manager', pinned: true, exemptFromTrim: true, exemptFromEvict: true, exemptFromArchive: true },