@jack200714/mafw 4.8.0 → 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 (36) 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 +265 -103
  6. package/gateway/dist/media/media-plugin-loader.js +25 -1
  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/package-context.js +24 -0
  11. package/gateway/dist/plugins/package-host.js +331 -0
  12. package/gateway/dist/plugins/package-types.js +2 -0
  13. package/gateway/dist/recall/gateway-db-migrate.js +5 -2
  14. package/gateway/dist/recall/redact.js +53 -0
  15. package/gateway/dist/recall/turn-pipeline.js +2 -0
  16. package/gateway/dist/routes/event-publish.js +44 -0
  17. package/gateway/dist/routes/plugins.js +4 -1
  18. package/gateway/dist/routes/waitwhat-command.js +43 -0
  19. package/gateway/dist/runtime/contract.js +4 -1
  20. package/gateway/dist/runtime/event-broadcast.js +8 -0
  21. package/gateway/dist/runtime/loader.js +22 -1
  22. package/gateway/dist/runtime/normalize.js +13 -0
  23. package/gateway/dist/runtime/pi/pi-approval-bridge.js +12 -2
  24. package/gateway/dist/runtime/pi/pi-approval-extension.js +11 -3
  25. package/gateway/dist/runtime/pi/pi-session.js +21 -3
  26. package/gateway/dist/runtime/plugins/pi-runtime.js +6 -3
  27. package/gateway/dist/runtime/serve-sidecar.js +4 -1
  28. package/gateway/dist/runtime/serve-supervisor.js +12 -0
  29. package/gateway/dist/runtime/validate.js +39 -0
  30. package/gateway/dist/skills/manager-identity.js +6 -1
  31. package/gateway/dist/usage/builtin-plugins/gateway.js +91 -36
  32. package/gateway/dist/usage/plugin-context.js +42 -2
  33. package/gateway/dist/usage/plugin-loader.js +32 -2
  34. package/gateway/package.json +1 -1
  35. package/package.json +3 -1
  36. package/packages/tui/dist/cli.js +20 -2
@@ -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");
@@ -103,11 +106,15 @@ const event_broadcast_1 = require("./runtime/event-broadcast");
103
106
  const budget_guard_1 = require("./core/budget-guard");
104
107
  const goal_budget_1 = require("./core/goal-budget");
105
108
  const contract_1 = require("./runtime/contract");
109
+ const validate_1 = require("./runtime/validate");
106
110
  const loader_1 = require("./runtime/loader");
107
111
  const pi_runtime_1 = require("./runtime/plugins/pi-runtime");
108
112
  const permission_1 = require("./routes/permission");
113
+ const event_publish_1 = require("./routes/event-publish");
109
114
  const runtime_switch_1 = require("./routes/runtime-switch");
110
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");
111
118
  const hub_1 = require("./plugins/hub");
112
119
  const restart_agent_1 = require("./routes/restart-agent");
113
120
  const session_mutations_1 = require("./routes/session-mutations");
@@ -146,6 +153,8 @@ async function isPortHealthy(port) {
146
153
  return false;
147
154
  }
148
155
  }
156
+ const manager_session_runtime_1 = require("./core/manager/manager-session-runtime");
157
+ const INTERNAL_SESSION_TTL_DAYS = 7;
149
158
  class MafwScheduler {
150
159
  serveInstance;
151
160
  serveUrl;
@@ -175,6 +184,8 @@ class MafwScheduler {
175
184
  lastActiveBySession = new Map();
176
185
  lastWriteBySession = new Map();
177
186
  tokenWriterSession = null;
187
+ /** 畸形事件 warn 限频(每 runtime 30s 一次) */
188
+ malformedEventWarnAt = new Map();
178
189
  stopTokenWatcher = null;
179
190
  // Path 1 step-injection state (mark-before-async + dedup + queue). All
180
191
  // per-session entries expire via TtlMap.
@@ -236,6 +247,8 @@ class MafwScheduler {
236
247
  mediaPluginLoader;
237
248
  mediaRuntimeExecutor;
238
249
  mediaRuntimeExecutorRt;
250
+ pluginHost;
251
+ usageStatsProvider;
239
252
  ttsService;
240
253
  kernels;
241
254
  automationEngine;
@@ -360,6 +373,16 @@ class MafwScheduler {
360
373
  await this.runtimeLoader.init();
361
374
  // 内置插件注册:pi-coding-agent runtime(进程内 SDK 嵌入)
362
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();
363
386
  // 3. 创建 SDK 客户端(auth),用于健康检查和后续通信
364
387
  const sdkConfig = {
365
388
  baseUrl: this.serveUrl,
@@ -370,6 +393,12 @@ class MafwScheduler {
370
393
  if (opencodePassword) {
371
394
  sdkConfig.headers = { Authorization: 'Basic ' + Buffer.from(`opencode:${opencodePassword}`).toString('base64') };
372
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);
373
402
  const runtime = await this.createRuntime(sdkConfig);
374
403
  this.opencodeClient = runtime;
375
404
  this.runtimeCaps = runtime.capabilities;
@@ -600,7 +629,7 @@ class MafwScheduler {
600
629
  }
601
630
  else {
602
631
  try {
603
- const ms = this.getGatewayDb().kvGet('manager-session', this.projectDir);
632
+ const ms = this.readManagerSessionEntry(this.projectDir);
604
633
  if (ms?.sessionId)
605
634
  targets.add(ms.sessionId);
606
635
  }
@@ -644,8 +673,10 @@ class MafwScheduler {
644
673
  if (changed.includes('runtime')) {
645
674
  const newPlugin = config_1.config.runtime?.plugin;
646
675
  if (newPlugin !== prevPlugin && !this.switchingRuntime && !this.serveRecovering) {
647
- logger_1.log.info(`[Scheduler] Runtime plugin changed: '${prevPlugin ?? 'builtin'}' → '${newPlugin ?? 'builtin'}'; hot-switching...`);
676
+ this.switchingRuntime = true;
677
+ const prev = this.opencodeClient;
648
678
  try {
679
+ logger_1.log.info(`[Scheduler] Runtime plugin changed: '${prevPlugin ?? 'builtin'}' → '${newPlugin ?? 'builtin'}'; hot-switching...`);
649
680
  const sdkConfig = {
650
681
  baseUrl: this.serveUrl,
651
682
  directory: this.projectDir,
@@ -670,11 +701,28 @@ class MafwScheduler {
670
701
  if (this.automationEngine)
671
702
  this.automationEngine.setRuntimeClient(runtime);
672
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
+ }
673
718
  logger_1.log.info(`[Scheduler] Runtime hot-switched to '${runtime.name}'`);
674
719
  }
675
720
  catch (err) {
676
721
  logger_1.log.warn(`[Scheduler] Runtime hot-switch failed (non-fatal): ${err.message}`);
677
722
  }
723
+ finally {
724
+ this.switchingRuntime = false;
725
+ }
678
726
  }
679
727
  }
680
728
  }, 300);
@@ -849,6 +897,23 @@ class MafwScheduler {
849
897
  }
850
898
  handleOpencodeEvent(evt) {
851
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
+ }
852
917
  const { type, properties: props, sessionID } = f;
853
918
  // Only memory-system sessions (index-scan / extract / reflect workers) are
854
919
  // internal: their token-level deltas flooded the desktop renderer (per-delta
@@ -993,7 +1058,18 @@ class MafwScheduler {
993
1058
  if (plugin) {
994
1059
  try {
995
1060
  const creds = await this.runtimeCredentialsForPlugin();
996
- 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
+ }
997
1073
  logger_1.log.info(`[Runtime] using plugin runtime '${rt.name}' (capabilities: ${JSON.stringify(rt.capabilities)})`);
998
1074
  return rt;
999
1075
  }
@@ -1005,6 +1081,11 @@ class MafwScheduler {
1005
1081
  logger_1.log.warn(`[Runtime] plugin '${pluginName}' not found — falling back to opencode`);
1006
1082
  }
1007
1083
  }
1084
+ const builtin = this.runtimeLoader?.get('opencode');
1085
+ if (builtin) {
1086
+ return builtin.createRuntime((0, loader_1.createRuntimePluginContext)(undefined));
1087
+ }
1088
+ // 最终安全网:loader 未注册时直连(不应发生)
1008
1089
  const { createOpencodeRuntime } = await import('./runtime/opencode-runtime.js');
1009
1090
  return createOpencodeRuntime({
1010
1091
  ...sdkConfig,
@@ -1711,6 +1792,7 @@ class MafwScheduler {
1711
1792
  }
1712
1793
  this.pluginLoader?.stop();
1713
1794
  this.mediaPluginLoader?.stop();
1795
+ this.pluginHost?.stop();
1714
1796
  if (this.opencodeClient && typeof this.opencodeClient.dispose === 'function') {
1715
1797
  void this.opencodeClient.dispose().catch((err) => {
1716
1798
  logger_1.log.warn(`[Scheduler] runtime dispose error: ${err?.message ?? String(err)}`);
@@ -1724,32 +1806,6 @@ class MafwScheduler {
1724
1806
  // }
1725
1807
  logger_1.log.info('[Scheduler] Stopping...');
1726
1808
  }
1727
- // Proxy a native opencode request by trying every registered workspace.
1728
- // Native reply/reject routes are workspace-scoped (WorkspaceRoutingMiddleware),
1729
- // but the gateway's own projectDir is its cwd — the request may belong to any
1730
- // registered project. GET list endpoints are cross-workspace and unaffected.
1731
- async proxyNativeWorkspaces(path, method, body) {
1732
- const dirs = new Set([this.projectDir || '.']);
1733
- for (const key of this.registeredProjects.keys())
1734
- dirs.add(key);
1735
- let lastStatus = 502;
1736
- for (const dir of dirs) {
1737
- try {
1738
- const r = await fetch(`${this.serveUrl}${path}?directory=${encodeURIComponent(dir)}`, {
1739
- method,
1740
- headers: { 'content-type': 'application/json', 'x-opencode-directory': encodeURIComponent(dir) },
1741
- ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
1742
- });
1743
- if (r.ok)
1744
- return { ok: true, status: r.status };
1745
- lastStatus = r.status;
1746
- }
1747
- catch (e) {
1748
- logger_1.log.error(`[Native proxy] ${path} failed for ${dir}: ${e.message}`);
1749
- }
1750
- }
1751
- return { ok: false, status: lastStatus };
1752
- }
1753
1809
  // 鈹€鈹€ Services & Event Bus 鈹€鈹€
1754
1810
  async initServices() {
1755
1811
  const projectDir = this.projectDir;
@@ -1774,6 +1830,9 @@ class MafwScheduler {
1774
1830
  getCredentials: () => this.opencodeClient?.credentials ?? undefined,
1775
1831
  });
1776
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']);
1777
1836
  this.mediaService = new media_service_1.MediaService({
1778
1837
  prompt: (0, pi_adapter_1.createPiPromptAdapter)({
1779
1838
  getApiKey: (provider) => {
@@ -1783,31 +1842,24 @@ class MafwScheduler {
1783
1842
  config: () => config_1.config.raw.media,
1784
1843
  resolvePrompt: (kind, cfg) => {
1785
1844
  const engineName = cfg[kind]?.engine ?? cfg.engine ?? 'pi';
1786
- // pi runtime 激活时:图片走 AgentRuntime 会话(MediaRuntimeExecutor),
1787
- // video/audio executor 内部回退到 complete 路径
1788
- if (engineName === 'pi' && this.opencodeClient?.name === 'pi') {
1789
- // runtime 热切换后 opencodeClient 实例更换——旧 executor 持有 stale
1790
- // runtime 引用,必须重建(dispose 尽力而为,不阻塞 prompt)。
1791
- if (!this.mediaRuntimeExecutor || this.mediaRuntimeExecutorRt !== this.opencodeClient) {
1792
- void this.mediaRuntimeExecutor?.dispose().catch(() => { });
1793
- this.mediaRuntimeExecutor = (0, media_runtime_executor_1.createMediaRuntimeExecutor)(this.opencodeClient);
1794
- this.mediaRuntimeExecutorRt = this.opencodeClient;
1795
- }
1796
- return this.mediaRuntimeExecutor.prompt;
1797
- }
1798
- if (engineName === 'pi')
1799
- return undefined;
1800
- const engines = this.mediaPluginLoader?.getEngines();
1801
- const engine = engines?.get(engineName);
1802
- if (!engine) {
1803
- logger_1.log.warn(`[MediaService] engine '${engineName}' not found, falling back to pi`);
1804
- return undefined;
1805
- }
1806
- if (!engine.modalities.includes(kind)) {
1807
- logger_1.log.warn(`[MediaService] engine '${engineName}' does not support modality '${kind}', falling back to pi`);
1808
- return undefined;
1809
- }
1810
- 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
+ });
1811
1863
  },
1812
1864
  });
1813
1865
  // Determine workspace name: if projectDir resolves to a 'gateway' subdirectory,
@@ -1916,6 +1968,9 @@ class MafwScheduler {
1916
1968
  const collector = new TrajectoryCollector(trajStore, this.getGatewayDb(), projectDir, () => config_1.config.trajectory.retentionDays);
1917
1969
  collector.setRoleFor((sid) => this.internalSessionRoles.get(sid) ?? null);
1918
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)`);
1919
1974
  const restored = this.getGatewayDb().kvAll('internal-session');
1920
1975
  for (const { key: sid, value } of restored) {
1921
1976
  if (value?.role && !this.internalSessionRoles.has(sid)) {
@@ -1932,10 +1987,12 @@ class MafwScheduler {
1932
1987
  const pluginsDir = path.join(os.homedir(), '.mafw', 'usage-plugins');
1933
1988
  const builtinPluginsDir = path.join(__dirname, 'usage', 'builtin-plugins');
1934
1989
  const disabledPlugins = Array.isArray(config_1.config.usage?.disabledPlugins) ? config_1.config.usage.disabledPlugins : [];
1990
+ const statsProvider = createUsageStatsProvider(trajStore);
1991
+ this.usageStatsProvider = statsProvider;
1935
1992
  const pluginLoader = new PluginLoader(pluginsDir, [], {
1936
1993
  builtinPluginsDir,
1937
1994
  disabledPlugins,
1938
- usageStats: createUsageStatsProvider(trajStore),
1995
+ usageStats: statsProvider,
1939
1996
  // inline provider key 兜底(auth.json 无条目的自建 provider,如 gateway)——
1940
1997
  // thunk 惰性求值,opencodeClient 此时尚未初始化也不影响。
1941
1998
  resolveInlineApiKey: async (providerID) => {
@@ -1951,6 +2008,7 @@ class MafwScheduler {
1951
2008
  });
1952
2009
  await pluginLoader.init();
1953
2010
  this.pluginLoader = pluginLoader;
2011
+ this.pluginHost?.bindUsage((entries) => pluginLoader.setPackageEntries(entries));
1954
2012
  const { UsagePoller } = require('./usage/usage-poller');
1955
2013
  this.usagePoller = new UsagePoller(trajStore, () => config_1.config.usage.limits, () => config_1.config.usage.budgets, pluginLoader);
1956
2014
  logger_1.log.info('[Trajectory] store initialized');
@@ -2055,6 +2113,18 @@ class MafwScheduler {
2055
2113
  handleServeExit(code) {
2056
2114
  if (!this.serveOwned || this.serveRecovering)
2057
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
+ }
2058
2128
  this.serveInstance = undefined;
2059
2129
  if (this.serveStableTimer) {
2060
2130
  clearTimeout(this.serveStableTimer);
@@ -3141,6 +3211,32 @@ class MafwScheduler {
3141
3211
  res.end(JSON.stringify({ ok: true, text: answer }));
3142
3212
  return;
3143
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
+ }
3144
3240
  if (cmd === "status") {
3145
3241
  const statusPath = path.join(this.mafwDir, 'STATUS.md');
3146
3242
  const text = fs.existsSync(statusPath) ? fs.readFileSync(statusPath, 'utf-8') : 'No active Goals. Use /goal to create one.';
@@ -3750,8 +3846,14 @@ class MafwScheduler {
3750
3846
  const filter = parsedUrl.searchParams.get('projectDir') || '';
3751
3847
  try {
3752
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);
3753
3855
  if (filter) {
3754
- const found = all.find((e) => e.key === filter);
3856
+ const found = live.find((e) => e.key === filter);
3755
3857
  if (!found) {
3756
3858
  res.writeHead(404);
3757
3859
  res.end(JSON.stringify({ error: 'No manager session for project' }));
@@ -3765,13 +3867,13 @@ class MafwScheduler {
3765
3867
  }));
3766
3868
  return;
3767
3869
  }
3768
- if (all.length === 0) {
3870
+ if (live.length === 0) {
3769
3871
  res.writeHead(404);
3770
3872
  res.end(JSON.stringify({ error: 'No manager session' }));
3771
3873
  return;
3772
3874
  }
3773
3875
  res.writeHead(200);
3774
- res.end(JSON.stringify(all.map((e) => ({
3876
+ res.end(JSON.stringify(live.map((e) => ({
3775
3877
  projectDir: e.key,
3776
3878
  sessionId: e.value.sessionId,
3777
3879
  createdAt: e.value.createdAt || null,
@@ -3982,17 +4084,15 @@ class MafwScheduler {
3982
4084
  return;
3983
4085
  }
3984
4086
  // 鈹€鈹€ Question endpoints (AskCard 鈹€ proxy to native opencode Question API) 鈹€鈹€
3985
- // GET /api/questions 鈹€ list pending questions
4087
+ // GET /api/questions ── list pending questions(契约:session.question.list)
3986
4088
  if (req.url?.match(/^\/api\/questions(?:\?|$)/) && req.method === 'GET') {
3987
4089
  if (this.capGuardQuestion(res))
3988
4090
  return;
3989
4091
  try {
3990
- const dir = new URL(req.url, this.serveUrl).searchParams.get('directory') || this.projectDir || '.';
3991
- const r = await fetch(`${this.serveUrl}/question?directory=${encodeURIComponent(dir)}`, {
3992
- headers: { 'x-opencode-directory': encodeURIComponent(dir) },
3993
- });
3994
- const items = await r.json();
3995
- 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 ?? [] }));
3996
4096
  }
3997
4097
  catch (err) {
3998
4098
  logger_1.log.error('[Question] list error:', err.message);
@@ -4000,20 +4100,34 @@ class MafwScheduler {
4000
4100
  }
4001
4101
  return;
4002
4102
  }
4003
- // POST /api/questions/{id}/reply 鈹€ { answers: string[][] }
4103
+ // POST /api/questions/{id}/reply ── { answers: string[][] }(契约 + workspace 重试)
4004
4104
  const qReplyMatch = req.url?.match(/^\/api\/questions\/([^/]+)\/reply(?:\?|$)/);
4005
4105
  if (qReplyMatch && req.method === 'POST') {
4006
4106
  if (this.capGuardQuestion(res))
4007
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
+ }
4008
4114
  try {
4009
4115
  const body = JSON.parse(await readBody(req));
4010
- const r = await this.proxyNativeWorkspaces(`/question/${qReplyMatch[1]}/reply`, 'POST', { answers: body.answers });
4011
- if (!r.ok) {
4012
- res.writeHead(r.status);
4013
- res.end(JSON.stringify({ status: 'error', code: r.status }));
4014
- 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
+ }
4015
4128
  }
4016
- 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' }));
4017
4131
  }
4018
4132
  catch (err) {
4019
4133
  logger_1.log.error('[Question] reply error:', err.message);
@@ -4022,19 +4136,32 @@ class MafwScheduler {
4022
4136
  }
4023
4137
  return;
4024
4138
  }
4025
- // POST /api/questions/{id}/reject
4139
+ // POST /api/questions/{id}/reject(契约 + workspace 重试)
4026
4140
  const qRejectMatch = req.url?.match(/^\/api\/questions\/([^/]+)\/reject(?:\?|$)/);
4027
4141
  if (qRejectMatch && req.method === 'POST') {
4028
4142
  if (this.capGuardQuestion(res))
4029
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
+ }
4030
4150
  try {
4031
- const r = await this.proxyNativeWorkspaces(`/question/${qRejectMatch[1]}/reject`, 'POST');
4032
- if (!r.ok) {
4033
- res.writeHead(r.status);
4034
- res.end(JSON.stringify({ status: 'error', code: r.status }));
4035
- 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
+ }
4036
4162
  }
4037
- 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' }));
4038
4165
  }
4039
4166
  catch (err) {
4040
4167
  logger_1.log.error('[Question] reject error:', err.message);
@@ -4044,17 +4171,16 @@ class MafwScheduler {
4044
4171
  return;
4045
4172
  }
4046
4173
  // 鈹€鈹€ Permission endpoints (PermissionCard 鈹€ proxy to native opencode Permission API) 鈹€鈹€
4047
- // GET /api/permissions 鈹€ list pending permission requests
4174
+ // GET /api/permissions ── list pending permission requests(契约:session.permissionList)
4048
4175
  if (req.url?.match(/^\/api\/permissions(?:\?|$)/) && req.method === 'GET') {
4049
4176
  if (this.capGuard(res, 'nativeApprovals'))
4050
4177
  return;
4051
4178
  try {
4052
- const dir = new URL(req.url, this.serveUrl).searchParams.get('directory') || this.projectDir || '.';
4053
- const r = await fetch(`${this.serveUrl}/permission?directory=${encodeURIComponent(dir)}`, {
4054
- headers: { 'x-opencode-directory': encodeURIComponent(dir) },
4055
- });
4056
- const items = await r.json();
4057
- 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 ?? [] }));
4058
4184
  }
4059
4185
  catch (err) {
4060
4186
  logger_1.log.error('[Permission] list error:', err.message);
@@ -4163,10 +4289,12 @@ class MafwScheduler {
4163
4289
  builtinEntries: () => {
4164
4290
  const entries = [];
4165
4291
  const rt = (name) => ({ type: 'runtime', name, file: '(builtin)', status: 'enabled', size: 0, mtime: '' });
4166
- entries.push(rt('opencode'));
4292
+ // opencode 经 registerBuiltin 注册,getBuiltinNames 已含——不再手工 push(防重复)
4167
4293
  for (const name of this.runtimeLoader?.getBuiltinNames?.() ?? [])
4168
4294
  entries.push(rt(name));
4169
- entries.push({ type: 'media', name: 'pi', file: '(builtin)', status: 'enabled', size: 0, mtime: '' });
4295
+ for (const name of this.mediaPluginLoader?.getBuiltinEngineNames?.() ?? []) {
4296
+ entries.push({ type: 'media', name, file: '(builtin)', status: 'enabled', size: 0, mtime: '' });
4297
+ }
4170
4298
  const usageState = this.pluginLoader?.getState?.() ?? [];
4171
4299
  for (const s of usageState) {
4172
4300
  if (s.builtin && s.status === 'ok' && s.name) {
@@ -4195,6 +4323,7 @@ class MafwScheduler {
4195
4323
  await this.pluginLoader?.reload();
4196
4324
  // ui: desktop main fs.watch picks it up automatically
4197
4325
  },
4326
+ getPackages: () => this.pluginHost?.getState() ?? [],
4198
4327
  },
4199
4328
  };
4200
4329
  try {
@@ -4322,20 +4451,37 @@ class MafwScheduler {
4322
4451
  }
4323
4452
  return;
4324
4453
  }
4325
- // POST /api/permissions/{id}/reply 鈹€ { reply: 'once'|'always'|'reject', message?: string }
4454
+ // POST /api/permissions/{id}/reply ── { reply, message? }(契约:列表反查 sessionID → permissionReply)
4326
4455
  const pReplyMatch = req.url?.match(/^\/api\/permissions\/([^/]+)\/reply(?:\?|$)/);
4327
4456
  if (pReplyMatch && req.method === 'POST') {
4328
4457
  if (this.capGuard(res, 'nativeApprovals'))
4329
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
+ }
4330
4465
  try {
4331
4466
  const body = JSON.parse(await readBody(req));
4332
- const payload = { reply: body.reply };
4333
- if (body.message)
4334
- payload.message = body.message;
4335
- const r = await this.proxyNativeWorkspaces(`/permission/${pReplyMatch[1]}/reply`, 'POST', payload);
4336
- if (!r.ok) {
4337
- res.writeHead(r.status);
4338
- 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' }));
4339
4485
  return;
4340
4486
  }
4341
4487
  res.end(JSON.stringify({ status: 'ok' }));
@@ -5183,7 +5329,10 @@ class MafwScheduler {
5183
5329
  session_id: sessionID,
5184
5330
  turn_id: turnId,
5185
5331
  source: source,
5186
- 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),
5187
5336
  failure,
5188
5337
  });
5189
5338
  // Async scan prefetch: precompute semantic recall for this turn
@@ -5203,6 +5352,11 @@ class MafwScheduler {
5203
5352
  });
5204
5353
  return;
5205
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
+ }
5206
5360
  // SSE 事件(→ Dashboard / Chat)
5207
5361
  if (req.url && req.url.startsWith('/api/events') && req.method === 'GET') {
5208
5362
  const parsedUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
@@ -6204,6 +6358,14 @@ ${observations.map((o, i) => `[${i + 1}] ${o}`).join('\n')}`;
6204
6358
  });
6205
6359
  return run;
6206
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
+ }
6207
6369
  // Serialized rotate: joins any in-flight ensure/create for the same project
6208
6370
  // so rotate and lazy-init never double-create (spec §4).
6209
6371
  async runManagerExclusive(projectDir, fn) {
@@ -6220,7 +6382,7 @@ ${observations.map((o, i) => `[${i + 1}] ${o}`).join('\n')}`;
6220
6382
  // The inflight map is shared with ensureManagerSession, whose joiners
6221
6383
  // expect the promise to resolve to a session id string — map the rotate
6222
6384
  // result onto the resulting kv entry.
6223
- const mapped = run.then(() => this.getGatewayDb().kvGet('manager-session', projectDir)?.sessionId ?? '', () => '');
6385
+ const mapped = run.then(() => this.readManagerSessionEntry(projectDir)?.sessionId ?? '', () => '');
6224
6386
  this.managerSessionInflight.set(projectDir, mapped);
6225
6387
  try {
6226
6388
  return await run;
@@ -6235,7 +6397,7 @@ ${observations.map((o, i) => `[${i + 1}] ${o}`).join('\n')}`;
6235
6397
  throw new Error('opencodeClient not available');
6236
6398
  // Manager identity lives in the gateway DB (kv_store), so it survives
6237
6399
  // project-directory churn and never gets orphaned by directory moves.
6238
- const existing = this.getGatewayDb().kvGet('manager-session', projectDir);
6400
+ const existing = this.readManagerSessionEntry(projectDir);
6239
6401
  if (existing?.sessionId) {
6240
6402
  await this.sdkSession.registerExternal(existing.sessionId, projectDir, {
6241
6403
  mafw: { role: 'manager', pinned: true, exemptFromTrim: true, exemptFromEvict: true, exemptFromArchive: true },
@@ -6251,7 +6413,7 @@ ${observations.map((o, i) => `[${i + 1}] ${o}`).join('\n')}`;
6251
6413
  throw new Error('Failed to create manager session: no id returned');
6252
6414
  }
6253
6415
  const createdAt = new Date().toISOString();
6254
- 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 }));
6255
6417
  this.registerInternalSession(sessionId, 'manager');
6256
6418
  try {
6257
6419
  await this.sdkSession.registerExternal(sessionId, projectDir, {
@@ -6283,7 +6445,7 @@ ${observations.map((o, i) => `[${i + 1}] ${o}`).join('\n')}`;
6283
6445
  }
6284
6446
  rotateDeps() {
6285
6447
  return {
6286
- getManagerSession: (pd) => this.getGatewayDb().kvGet('manager-session', pd),
6448
+ getManagerSession: (pd) => this.readManagerSessionEntry(pd),
6287
6449
  lock: (pd, fn) => this.runManagerExclusive(pd, fn),
6288
6450
  ensure: (pd) => this.ensureManagerSession(pd, this.mafwDirFor(pd)),
6289
6451
  downgrade: async (sid) => {
@@ -6330,7 +6492,7 @@ ${observations.map((o, i) => `[${i + 1}] ${o}`).join('\n')}`;
6330
6492
  return undefined;
6331
6493
  if (!this.milestonePush) {
6332
6494
  this.milestonePush = new milestone_push_1.MilestonePushNotifier({
6333
- getManagerSession: (pd) => this.getGatewayDb().kvGet('manager-session', pd),
6495
+ getManagerSession: (pd) => this.readManagerSessionEntry(pd),
6334
6496
  wasNotified: (key) => !!this.getGatewayDb().kvGet('milestone-notified', key),
6335
6497
  markNotified: (key) => this.getGatewayDb().kvSet('milestone-notified', key, { at: new Date().toISOString() }),
6336
6498
  readGoalState: (goalId, pd) => {
@@ -6359,7 +6521,7 @@ ${observations.map((o, i) => `[${i + 1}] ${o}`).join('\n')}`;
6359
6521
  const sessionId = session.id;
6360
6522
  if (!sessionId)
6361
6523
  throw new Error('Failed to create manager session: no id returned');
6362
- 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() }));
6363
6525
  this.registerInternalSession(sessionId, 'manager');
6364
6526
  await this.sdkSession.registerExternal(sessionId, projectDir, {
6365
6527
  mafw: { role: 'manager', pinned: true, exemptFromTrim: true, exemptFromEvict: true, exemptFromArchive: true },