@jack200714/mafw 4.8.0 → 4.10.2

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 (42) 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 +465 -440
  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 +53 -4
  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/registry.js +125 -0
  19. package/gateway/dist/routes/route-catalog.js +192 -0
  20. package/gateway/dist/routes/triage-dismiss.js +32 -0
  21. package/gateway/dist/routes/waitwhat-command.js +43 -0
  22. package/gateway/dist/routes/wave1-handlers.js +62 -0
  23. package/gateway/dist/routes/wave2-handlers.js +47 -0
  24. package/gateway/dist/runtime/contract.js +4 -1
  25. package/gateway/dist/runtime/event-broadcast.js +8 -0
  26. package/gateway/dist/runtime/loader.js +22 -1
  27. package/gateway/dist/runtime/normalize.js +13 -0
  28. package/gateway/dist/runtime/opencode-runtime.js +18 -2
  29. package/gateway/dist/runtime/pi/pi-approval-bridge.js +12 -2
  30. package/gateway/dist/runtime/pi/pi-approval-extension.js +11 -3
  31. package/gateway/dist/runtime/pi/pi-session.js +21 -3
  32. package/gateway/dist/runtime/plugins/pi-runtime.js +6 -3
  33. package/gateway/dist/runtime/serve-sidecar.js +4 -1
  34. package/gateway/dist/runtime/serve-supervisor.js +19 -20
  35. package/gateway/dist/runtime/validate.js +39 -0
  36. package/gateway/dist/skills/manager-identity.js +6 -1
  37. package/gateway/dist/usage/builtin-plugins/gateway.js +91 -36
  38. package/gateway/dist/usage/plugin-context.js +42 -2
  39. package/gateway/dist/usage/plugin-loader.js +32 -2
  40. package/gateway/package.json +2 -1
  41. package/package.json +3 -1
  42. package/packages/tui/dist/cli.js +114 -51
@@ -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");
@@ -94,7 +97,6 @@ const push_gateway_1 = require("./mobile/push-gateway");
94
97
  const device_store_1 = require("./mobile/device-store");
95
98
  const pairing_1 = require("./mobile/pairing");
96
99
  const tray_1 = require("./tray");
97
- const serve_sidecar_1 = require("./runtime/serve-sidecar");
98
100
  const self_update_1 = require("./self-update");
99
101
  const step_inject_1 = require("./recall/step-inject");
100
102
  const inject_format_1 = require("./recall/inject-format");
@@ -103,24 +105,19 @@ const event_broadcast_1 = require("./runtime/event-broadcast");
103
105
  const budget_guard_1 = require("./core/budget-guard");
104
106
  const goal_budget_1 = require("./core/goal-budget");
105
107
  const contract_1 = require("./runtime/contract");
108
+ const validate_1 = require("./runtime/validate");
106
109
  const loader_1 = require("./runtime/loader");
107
110
  const pi_runtime_1 = require("./runtime/plugins/pi-runtime");
108
111
  const permission_1 = require("./routes/permission");
109
- const runtime_switch_1 = require("./routes/runtime-switch");
110
- const plugins_1 = require("./routes/plugins");
111
- const hub_1 = require("./plugins/hub");
112
- const restart_agent_1 = require("./routes/restart-agent");
113
- const session_mutations_1 = require("./routes/session-mutations");
112
+ const package_host_1 = require("./plugins/package-host");
113
+ const package_context_1 = require("./plugins/package-context");
114
114
  const serve_supervisor_1 = require("./runtime/serve-supervisor");
115
115
  const serve_for_runtime_1 = require("./runtime/serve-for-runtime");
116
- const media_switch_1 = require("./routes/media-switch");
117
- const usage_plugins_1 = require("./routes/usage-plugins");
118
116
  const model_stats_1 = require("./usage/model-stats");
119
- const model_config_1 = require("./routes/model-config");
120
- const session_branch_1 = require("./routes/session-branch");
121
- const session_summarize_1 = require("./routes/session-summarize");
122
- const goal_sessions_1 = require("./routes/goal-sessions");
123
- const embedding_config_1 = require("./routes/embedding-config");
117
+ const registry_1 = require("./routes/registry");
118
+ const route_catalog_1 = require("./routes/route-catalog");
119
+ const wave1_handlers_1 = require("./routes/wave1-handlers");
120
+ const wave2_handlers_1 = require("./routes/wave2-handlers");
124
121
  const manager_rotate_1 = require("./routes/manager-rotate");
125
122
  function readBody(req) {
126
123
  return new Promise((resolve, reject) => {
@@ -146,6 +143,8 @@ async function isPortHealthy(port) {
146
143
  return false;
147
144
  }
148
145
  }
146
+ const manager_session_runtime_1 = require("./core/manager/manager-session-runtime");
147
+ const INTERNAL_SESSION_TTL_DAYS = 7;
149
148
  class MafwScheduler {
150
149
  serveInstance;
151
150
  serveUrl;
@@ -175,6 +174,8 @@ class MafwScheduler {
175
174
  lastActiveBySession = new Map();
176
175
  lastWriteBySession = new Map();
177
176
  tokenWriterSession = null;
177
+ /** 畸形事件 warn 限频(每 runtime 30s 一次) */
178
+ malformedEventWarnAt = new Map();
178
179
  stopTokenWatcher = null;
179
180
  // Path 1 step-injection state (mark-before-async + dedup + queue). All
180
181
  // per-session entries expire via TtlMap.
@@ -217,6 +218,12 @@ class MafwScheduler {
217
218
  mcpEndpoint;
218
219
  mcpStreamableEndpoint;
219
220
  opencodeClient = null;
221
+ /**
222
+ * P4 shadow registry + P5 Wave 1 dispatch:catalog 全量登记(130 条,贡献 spec),
223
+ * Wave 1 已 attach 的 22 条在请求链顶端优先 dispatch(见 startApiServer),
224
+ * 未 attach 的 shadow 条目自然落回 legacy 内联链。
225
+ */
226
+ routeRegistry = new registry_1.RouteRegistry().register(...(0, route_catalog_1.buildRouteCatalog)());
220
227
  runtimeCaps = (0, contract_1.minimalCapabilities)();
221
228
  runtimeName = 'opencode';
222
229
  runtimeLoader;
@@ -236,6 +243,8 @@ class MafwScheduler {
236
243
  mediaPluginLoader;
237
244
  mediaRuntimeExecutor;
238
245
  mediaRuntimeExecutorRt;
246
+ pluginHost;
247
+ usageStatsProvider;
239
248
  ttsService;
240
249
  kernels;
241
250
  automationEngine;
@@ -259,51 +268,34 @@ class MafwScheduler {
259
268
  this.registryPath = config_1.config.paths.registryFile;
260
269
  this.chatSessions = new chat_sessions_1.ChatSessionManager();
261
270
  this.serveSupervisor = (0, serve_supervisor_1.createServeSupervisor)({
262
- port: config_1.config.server.servePort,
263
- external: !!process.env.MAFW_SERVER_SERVE_URL,
264
- killPort: (port) => (0, serve_sidecar_1.killServePort)(port),
265
- // Spawn 原语来自活跃 runtime 的契约(agentProcess.spawnServe)——
266
- // gateway 核心不硬编码任何具体 agent 的 server 拉起细节。
271
+ // 全部经 runtime 契约且 late-bound(runtime 可热切换):
272
+ // managed = runtime 是否持有启停原语;health = 契约 healthCheck;
273
+ // baseUrl/killServe = runtime 自报地址与清场原语;spawn 不传 host/port
274
+ managed: () => !!this.opencodeClient?.agentProcess?.spawnServe,
275
+ health: async () => {
276
+ try {
277
+ return (await this.opencodeClient?.healthCheck?.()) ?? false;
278
+ }
279
+ catch {
280
+ return false;
281
+ }
282
+ },
283
+ baseUrl: () => this.opencodeClient?.getBaseUrl?.() ?? this.serveUrl,
284
+ killServe: () => this.opencodeClient?.agentProcess?.killServe?.(),
267
285
  spawn: async (opts) => {
268
286
  const spawnServe = this.opencodeClient?.agentProcess?.spawnServe;
269
287
  if (!spawnServe) {
270
288
  throw new Error('active runtime does not own a server process (agentProcess.spawnServe missing)');
271
289
  }
272
- const sidecar = await spawnServe({
273
- host: opts.host,
274
- port: opts.port,
275
- timeoutMs: opts.timeoutMs,
276
- });
290
+ const sidecar = await spawnServe({ timeoutMs: opts.timeoutMs });
277
291
  return { url: sidecar.url, close: () => sidecar.close() };
278
292
  },
279
- probe: async (url) => {
280
- try {
281
- const res = await fetch(`${url}/global/health`, { signal: AbortSignal.timeout(3000) });
282
- return res.ok;
283
- }
284
- catch {
285
- return false;
286
- }
287
- },
288
293
  });
289
294
  }
290
295
  get serveRunning() {
291
296
  return !!this.serveInstance;
292
297
  }
293
298
  /** Check actual serve health via TCP connection, not just object existence. */
294
- async checkServeActualHealth() {
295
- try {
296
- const url = `${this.serveUrl}/global/health`;
297
- const res = await fetch(url, {
298
- signal: AbortSignal.timeout(2000),
299
- headers: { 'User-Agent': 'MAFW-Gateway-HealthCheck' }
300
- });
301
- return res.ok;
302
- }
303
- catch {
304
- return false;
305
- }
306
- }
307
299
  async start() {
308
300
  logger_1.log.info('MAFW Scheduler v5.0 starting...');
309
301
  // Single-instance guard: if a healthy gateway already owns apiPort, this
@@ -360,6 +352,16 @@ class MafwScheduler {
360
352
  await this.runtimeLoader.init();
361
353
  // 内置插件注册:pi-coding-agent runtime(进程内 SDK 嵌入)
362
354
  this.runtimeLoader.registerBuiltin('pi', pi_runtime_1.createPiRuntime, pi_runtime_1.PI_CAPABILITIES, true);
355
+ // 2b. 统一插件包宿主(必须在 createRuntime 之前 init——包 runtime 贡献要先注册)
356
+ this.pluginHost = new package_host_1.PluginHost(process.env.MAFW_PLUGINS_DIR || config_1.config.resolvePath('plugins'), (name) => (0, package_context_1.createPluginPackageContext)(name, {
357
+ getCredentials: () => this.opencodeClient?.credentials ?? undefined,
358
+ usageStats: () => this.usageStatsProvider,
359
+ projectDir: this.projectDir,
360
+ gatewayPort: config_1.config.server.apiPort,
361
+ emit: (event) => this.broadcast(event),
362
+ }));
363
+ this.pluginHost.bindRuntime((entries) => this.runtimeLoader?.setPackageEntries(entries));
364
+ await this.pluginHost.init();
363
365
  // 3. 创建 SDK 客户端(auth),用于健康检查和后续通信
364
366
  const sdkConfig = {
365
367
  baseUrl: this.serveUrl,
@@ -370,6 +372,12 @@ class MafwScheduler {
370
372
  if (opencodePassword) {
371
373
  sdkConfig.headers = { Authorization: 'Basic ' + Buffer.from(`opencode:${opencodePassword}`).toString('base64') };
372
374
  }
375
+ // opencode 注册为正式内置插件(与用户插件同一接口;同名文件/包可覆盖)
376
+ const sdkSnapshot = { ...sdkConfig, headers: { ...sdkConfig.headers } };
377
+ this.runtimeLoader.registerBuiltin('opencode', async () => (await import('./runtime/opencode-runtime.js')).createOpencodeRuntime({
378
+ ...sdkSnapshot,
379
+ headers: { ...sdkSnapshot.headers },
380
+ }), (0, contract_1.fullCapabilities)(), !!process.env.MAFW_SERVER_SERVE_URL);
373
381
  const runtime = await this.createRuntime(sdkConfig);
374
382
  this.opencodeClient = runtime;
375
383
  this.runtimeCaps = runtime.capabilities;
@@ -404,7 +412,8 @@ class MafwScheduler {
404
412
  }
405
413
  else {
406
414
  logger_1.log.info('OpenCode Serve not reachable, checking for stale process...');
407
- (0, serve_sidecar_1.killServePort)(config_1.config.server.servePort);
415
+ // 清场原语归 runtime(serve 端口是实现细节;无原语的 runtime 无从清场)
416
+ this.opencodeClient?.agentProcess?.killServe?.();
408
417
  try {
409
418
  await this.startServe();
410
419
  serveReady = !!this.serveInstance;
@@ -600,7 +609,7 @@ class MafwScheduler {
600
609
  }
601
610
  else {
602
611
  try {
603
- const ms = this.getGatewayDb().kvGet('manager-session', this.projectDir);
612
+ const ms = this.readManagerSessionEntry(this.projectDir);
604
613
  if (ms?.sessionId)
605
614
  targets.add(ms.sessionId);
606
615
  }
@@ -644,8 +653,10 @@ class MafwScheduler {
644
653
  if (changed.includes('runtime')) {
645
654
  const newPlugin = config_1.config.runtime?.plugin;
646
655
  if (newPlugin !== prevPlugin && !this.switchingRuntime && !this.serveRecovering) {
647
- logger_1.log.info(`[Scheduler] Runtime plugin changed: '${prevPlugin ?? 'builtin'}' → '${newPlugin ?? 'builtin'}'; hot-switching...`);
656
+ this.switchingRuntime = true;
657
+ const prev = this.opencodeClient;
648
658
  try {
659
+ logger_1.log.info(`[Scheduler] Runtime plugin changed: '${prevPlugin ?? 'builtin'}' → '${newPlugin ?? 'builtin'}'; hot-switching...`);
649
660
  const sdkConfig = {
650
661
  baseUrl: this.serveUrl,
651
662
  directory: this.projectDir,
@@ -670,11 +681,28 @@ class MafwScheduler {
670
681
  if (this.automationEngine)
671
682
  this.automationEngine.setRuntimeClient(runtime);
672
683
  await this.resubscribeEvents(`config hot-reload runtime plugin changed to '${newPlugin ?? 'builtin'}'`);
684
+ // Same desktop hint as the route path: hand-edited config.yaml
685
+ // switches must also refresh the renderer (menus/tabs/manager kv).
686
+ this.broadcast({ type: 'runtime_switched', runtime: runtime.name, previous: prevPlugin ?? null });
687
+ // Dispose the previous runtime AFTER the new one is fully wired
688
+ // (mirror of the route path) — without this, switching away from
689
+ // pi leaks its AgentSessions/ApprovalBridges/event stream.
690
+ if (prev && prev.dispose) {
691
+ try {
692
+ await prev.dispose();
693
+ }
694
+ catch (err) {
695
+ logger_1.log.warn(`[Scheduler] dispose previous runtime failed: ${err.message}`);
696
+ }
697
+ }
673
698
  logger_1.log.info(`[Scheduler] Runtime hot-switched to '${runtime.name}'`);
674
699
  }
675
700
  catch (err) {
676
701
  logger_1.log.warn(`[Scheduler] Runtime hot-switch failed (non-fatal): ${err.message}`);
677
702
  }
703
+ finally {
704
+ this.switchingRuntime = false;
705
+ }
678
706
  }
679
707
  }
680
708
  }, 300);
@@ -849,6 +877,23 @@ class MafwScheduler {
849
877
  }
850
878
  handleOpencodeEvent(evt) {
851
879
  const f = (0, normalize_1.normalizeOpencodeEvent)(evt);
880
+ // 畸形事件诊断(限频):runtime 插件发来的事件 type/properties 全空时
881
+ // 静默穿过会污染 trajectory 与桌面 SSE——这里给可定位诊断。
882
+ if ((0, normalize_1.isMalformedEvent)(evt)) {
883
+ const now = Date.now();
884
+ const last = this.malformedEventWarnAt.get(this.runtimeName) ?? 0;
885
+ if (now - last > 30_000) {
886
+ this.malformedEventWarnAt.set(this.runtimeName, now);
887
+ let summary;
888
+ try {
889
+ summary = JSON.stringify(evt)?.slice(0, 200) ?? '(unserializable)';
890
+ }
891
+ catch {
892
+ summary = '(unserializable)';
893
+ }
894
+ logger_1.log.warn(`[SSE] malformed event from runtime '${this.runtimeName}' (no type/properties) — dropped consumers may misbehave; payload: ${summary}`);
895
+ }
896
+ }
852
897
  const { type, properties: props, sessionID } = f;
853
898
  // Only memory-system sessions (index-scan / extract / reflect workers) are
854
899
  // internal: their token-level deltas flooded the desktop renderer (per-delta
@@ -993,7 +1038,18 @@ class MafwScheduler {
993
1038
  if (plugin) {
994
1039
  try {
995
1040
  const creds = await this.runtimeCredentialsForPlugin();
996
- const rt = await plugin.createRuntime((0, loader_1.createRuntimePluginContext)(creds));
1041
+ const rt = await plugin.createRuntime((0, loader_1.createRuntimePluginContext)(creds, {
1042
+ projectDir: this.projectDir,
1043
+ gatewayPort: config_1.config.server.apiPort,
1044
+ }));
1045
+ // 声明 vs 实现一致性校验:错位插件在切换/启动期给出字段级诊断并回退,
1046
+ // 而不是运行时深处才炸。
1047
+ const issues = (0, validate_1.validateRuntimeShape)(rt, pluginName);
1048
+ if (issues.length > 0) {
1049
+ for (const issue of issues)
1050
+ logger_1.log.error(`[Runtime] shape violation: ${issue}`);
1051
+ throw new Error(`runtime '${rt.name}' failed shape validation (${issues.length} issue(s)) — see logs`);
1052
+ }
997
1053
  logger_1.log.info(`[Runtime] using plugin runtime '${rt.name}' (capabilities: ${JSON.stringify(rt.capabilities)})`);
998
1054
  return rt;
999
1055
  }
@@ -1005,6 +1061,11 @@ class MafwScheduler {
1005
1061
  logger_1.log.warn(`[Runtime] plugin '${pluginName}' not found — falling back to opencode`);
1006
1062
  }
1007
1063
  }
1064
+ const builtin = this.runtimeLoader?.get('opencode');
1065
+ if (builtin) {
1066
+ return builtin.createRuntime((0, loader_1.createRuntimePluginContext)(undefined));
1067
+ }
1068
+ // 最终安全网:loader 未注册时直连(不应发生)
1008
1069
  const { createOpencodeRuntime } = await import('./runtime/opencode-runtime.js');
1009
1070
  return createOpencodeRuntime({
1010
1071
  ...sdkConfig,
@@ -1198,6 +1259,120 @@ class MafwScheduler {
1198
1259
  const result = await rt.indexer.backfill(ids);
1199
1260
  return { ...result, vectors: rt.vectors.size(), indexEntries: entries.length };
1200
1261
  }
1262
+ /** P5 Wave 2:/api/runtime 路由 deps(自内联块上移,行为逐字节等价)。 */
1263
+ runtimeDeps() {
1264
+ return {
1265
+ loader: this.runtimeLoader,
1266
+ persist: (o) => config_1.config.persistOverrides(o),
1267
+ getCurrent: () => this.opencodeClient,
1268
+ runtimeName: () => this.runtimeName,
1269
+ runtimeCaps: () => this.runtimeCaps,
1270
+ envOverride: () => !!process.env.MAFW_RUNTIME_PLUGIN,
1271
+ createRuntime: async () => {
1272
+ const sdkConfig = {
1273
+ baseUrl: this.serveUrl,
1274
+ directory: this.projectDir,
1275
+ headers: {},
1276
+ };
1277
+ const opencodePassword = process.env.MAFW_OPENCODE_PASSWORD;
1278
+ if (opencodePassword) {
1279
+ sdkConfig.headers = { Authorization: 'Basic ' + Buffer.from(`opencode:${opencodePassword}`).toString('base64') };
1280
+ }
1281
+ return this.createRuntime(sdkConfig);
1282
+ },
1283
+ onSwitched: async (rt, prev) => {
1284
+ this.opencodeClient = rt;
1285
+ this.runtimeCaps = rt.capabilities;
1286
+ this.runtimeName = rt.name;
1287
+ // Switching onto a runtime that owns serve (builtin opencode) must
1288
+ // ensure the sidecar exists — the gateway may have started under an
1289
+ // external runtime (pi) that never spawned one. Must run AFTER the
1290
+ // assignment above: the supervisor's spawn closure reads
1291
+ // this.opencodeClient to find agentProcess.spawnServe.
1292
+ if (rt.agentProcess?.spawnServe) {
1293
+ await (0, serve_for_runtime_1.ensureServeForBuiltinRuntime)(this.serveSupervisor, { startWatchdog: () => this.startServeWatchdog() }, logger_1.log);
1294
+ }
1295
+ this.sdkSession.setClient(this.opencodeClient);
1296
+ if (this.trajectoryCollector)
1297
+ this.trajectoryCollector.setOpencodeClient(this.opencodeClient);
1298
+ if (this.automationEngine)
1299
+ this.automationEngine.setRuntimeClient(rt);
1300
+ await this.resubscribeEvents(`runtime switched to '${rt.name}'`);
1301
+ // Desktop hint: a runtime switch swaps the session storage backend
1302
+ // (opencode SQLite vs pi), so cached session lists are stale.
1303
+ this.broadcast({ type: 'runtime_switched', runtime: rt.name, previous: prev?.name ?? null });
1304
+ if (prev && prev.dispose) {
1305
+ try {
1306
+ await prev.dispose();
1307
+ }
1308
+ catch (err) {
1309
+ logger_1.log.warn(`[Runtime] dispose old runtime failed: ${err.message}`);
1310
+ }
1311
+ }
1312
+ },
1313
+ };
1314
+ }
1315
+ /** P5 Wave 2:/api/runtime/restart-agent deps。 */
1316
+ restartAgentDeps() {
1317
+ return {
1318
+ capabilities: () => this.runtimeCaps,
1319
+ isRecovering: () => this.serveRecovering,
1320
+ isSwitching: () => this.switchingRuntime,
1321
+ begin: () => { this.serveRecovering = true; },
1322
+ end: () => { this.serveRecovering = false; },
1323
+ restartAgent: () => this.restartAgentOrchestrated(),
1324
+ };
1325
+ }
1326
+ /** P5 Wave 2:/api/plugins* deps(Plugin Hub 四类型统一面)。 */
1327
+ pluginHubDeps() {
1328
+ return {
1329
+ hub: {
1330
+ dirs: {
1331
+ runtime: config_1.config.resolvePath('runtime-plugins'),
1332
+ media: config_1.config.resolvePath('media-plugins'),
1333
+ usage: config_1.config.resolvePath('usage-plugins'),
1334
+ ui: process.env.MAFW_UI_PLUGINS_DIR || path.join(os.homedir(), '.mafw', 'ui-plugins'),
1335
+ },
1336
+ builtinEntries: () => {
1337
+ const entries = [];
1338
+ const rt = (name) => ({ type: 'runtime', name, file: '(builtin)', status: 'enabled', size: 0, mtime: '' });
1339
+ for (const name of this.runtimeLoader?.getBuiltinNames?.() ?? [])
1340
+ entries.push(rt(name));
1341
+ for (const name of this.mediaPluginLoader?.getBuiltinEngineNames?.() ?? []) {
1342
+ entries.push({ type: 'media', name, file: '(builtin)', status: 'enabled', size: 0, mtime: '' });
1343
+ }
1344
+ const usageState = this.pluginLoader?.getState?.() ?? [];
1345
+ for (const s of usageState) {
1346
+ if (s.builtin && s.status === 'ok' && s.name) {
1347
+ entries.push({ type: 'usage', name: s.name, file: s.file, status: 'enabled', size: 0, mtime: '', pluginType: s.pluginType });
1348
+ }
1349
+ }
1350
+ return entries;
1351
+ },
1352
+ getErrors: (type) => {
1353
+ const stateOf = (loader) => (loader && typeof loader.getState === 'function' ? loader.getState() : []);
1354
+ const source = type === 'runtime' ? this.runtimeLoader : type === 'media' ? this.mediaPluginLoader : type === 'usage' ? this.pluginLoader : null;
1355
+ const out = {};
1356
+ for (const p of stateOf(source)) {
1357
+ if (p && p.error)
1358
+ out[p.name || p.file] = p.error;
1359
+ }
1360
+ return out;
1361
+ },
1362
+ configDisabledUsage: () => new Set(Array.isArray(config_1.config.usage?.disabledPlugins) ? config_1.config.usage.disabledPlugins : []),
1363
+ reload: async (type) => {
1364
+ if (type === 'runtime')
1365
+ await this.runtimeLoader?.scan();
1366
+ else if (type === 'media')
1367
+ await this.mediaPluginLoader?.reload();
1368
+ else if (type === 'usage')
1369
+ await this.pluginLoader?.reload();
1370
+ // ui: desktop main fs.watch picks it up automatically
1371
+ },
1372
+ getPackages: () => this.pluginHost?.getState() ?? [],
1373
+ },
1374
+ };
1375
+ }
1201
1376
  embeddingConfigDeps() {
1202
1377
  return {
1203
1378
  currentConfig: () => {
@@ -1711,6 +1886,7 @@ class MafwScheduler {
1711
1886
  }
1712
1887
  this.pluginLoader?.stop();
1713
1888
  this.mediaPluginLoader?.stop();
1889
+ this.pluginHost?.stop();
1714
1890
  if (this.opencodeClient && typeof this.opencodeClient.dispose === 'function') {
1715
1891
  void this.opencodeClient.dispose().catch((err) => {
1716
1892
  logger_1.log.warn(`[Scheduler] runtime dispose error: ${err?.message ?? String(err)}`);
@@ -1724,32 +1900,6 @@ class MafwScheduler {
1724
1900
  // }
1725
1901
  logger_1.log.info('[Scheduler] Stopping...');
1726
1902
  }
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
1903
  // 鈹€鈹€ Services & Event Bus 鈹€鈹€
1754
1904
  async initServices() {
1755
1905
  const projectDir = this.projectDir;
@@ -1774,6 +1924,9 @@ class MafwScheduler {
1774
1924
  getCredentials: () => this.opencodeClient?.credentials ?? undefined,
1775
1925
  });
1776
1926
  await this.mediaPluginLoader.init();
1927
+ this.pluginHost?.bindMedia((entries) => this.mediaPluginLoader?.setPackageEntries(entries));
1928
+ // 内置 pi 引擎登记(可被用户同名包/文件覆盖——resolveMediaPrompt 语义)
1929
+ this.mediaPluginLoader.registerBuiltinEngine('pi', ['image', 'video', 'audio']);
1777
1930
  this.mediaService = new media_service_1.MediaService({
1778
1931
  prompt: (0, pi_adapter_1.createPiPromptAdapter)({
1779
1932
  getApiKey: (provider) => {
@@ -1783,31 +1936,24 @@ class MafwScheduler {
1783
1936
  config: () => config_1.config.raw.media,
1784
1937
  resolvePrompt: (kind, cfg) => {
1785
1938
  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;
1939
+ return (0, resolve_prompt_1.resolveMediaPrompt)(engineName, kind, {
1940
+ engines: this.mediaPluginLoader?.getEngines() ?? new Map(),
1941
+ builtinPi: () => {
1942
+ // pi runtime 激活时:图片走 AgentRuntime 会话(MediaRuntimeExecutor),
1943
+ // video/audio executor 内部回退到 complete 路径
1944
+ if (this.opencodeClient?.name === 'pi') {
1945
+ // runtime 热切换后 opencodeClient 实例更换——旧 executor 持有 stale
1946
+ // runtime 引用,必须重建(dispose 尽力而为,不阻塞 prompt)。
1947
+ if (!this.mediaRuntimeExecutor || this.mediaRuntimeExecutorRt !== this.opencodeClient) {
1948
+ void this.mediaRuntimeExecutor?.dispose().catch(() => { });
1949
+ this.mediaRuntimeExecutor = (0, media_runtime_executor_1.createMediaRuntimeExecutor)(this.opencodeClient);
1950
+ this.mediaRuntimeExecutorRt = this.opencodeClient;
1951
+ }
1952
+ return this.mediaRuntimeExecutor.prompt;
1953
+ }
1954
+ return undefined;
1955
+ },
1956
+ });
1811
1957
  },
1812
1958
  });
1813
1959
  // Determine workspace name: if projectDir resolves to a 'gateway' subdirectory,
@@ -1916,6 +2062,9 @@ class MafwScheduler {
1916
2062
  const collector = new TrajectoryCollector(trajStore, this.getGatewayDb(), projectDir, () => config_1.config.trajectory.retentionDays);
1917
2063
  collector.setRoleFor((sid) => this.internalSessionRoles.get(sid) ?? null);
1918
2064
  this.trajectoryCollector = collector;
2065
+ const pruned = this.getGatewayDb().kvPruneOlderThan('internal-session', INTERNAL_SESSION_TTL_DAYS);
2066
+ if (pruned > 0)
2067
+ logger_1.log.info(`[Scheduler] pruned ${pruned} stale internal-session kv entries (> ${INTERNAL_SESSION_TTL_DAYS}d)`);
1919
2068
  const restored = this.getGatewayDb().kvAll('internal-session');
1920
2069
  for (const { key: sid, value } of restored) {
1921
2070
  if (value?.role && !this.internalSessionRoles.has(sid)) {
@@ -1932,10 +2081,12 @@ class MafwScheduler {
1932
2081
  const pluginsDir = path.join(os.homedir(), '.mafw', 'usage-plugins');
1933
2082
  const builtinPluginsDir = path.join(__dirname, 'usage', 'builtin-plugins');
1934
2083
  const disabledPlugins = Array.isArray(config_1.config.usage?.disabledPlugins) ? config_1.config.usage.disabledPlugins : [];
2084
+ const statsProvider = createUsageStatsProvider(trajStore);
2085
+ this.usageStatsProvider = statsProvider;
1935
2086
  const pluginLoader = new PluginLoader(pluginsDir, [], {
1936
2087
  builtinPluginsDir,
1937
2088
  disabledPlugins,
1938
- usageStats: createUsageStatsProvider(trajStore),
2089
+ usageStats: statsProvider,
1939
2090
  // inline provider key 兜底(auth.json 无条目的自建 provider,如 gateway)——
1940
2091
  // thunk 惰性求值,opencodeClient 此时尚未初始化也不影响。
1941
2092
  resolveInlineApiKey: async (providerID) => {
@@ -1951,6 +2102,7 @@ class MafwScheduler {
1951
2102
  });
1952
2103
  await pluginLoader.init();
1953
2104
  this.pluginLoader = pluginLoader;
2105
+ this.pluginHost?.bindUsage((entries) => pluginLoader.setPackageEntries(entries));
1954
2106
  const { UsagePoller } = require('./usage/usage-poller');
1955
2107
  this.usagePoller = new UsagePoller(trajStore, () => config_1.config.usage.limits, () => config_1.config.usage.budgets, pluginLoader);
1956
2108
  logger_1.log.info('[Trajectory] store initialized');
@@ -2027,12 +2179,10 @@ class MafwScheduler {
2027
2179
  return;
2028
2180
  }
2029
2181
  logger_1.log.info('Starting OpenCode Serve sidecar...');
2030
- const port = config_1.config.server.servePort;
2031
- const host = config_1.config.server.serveHost;
2032
2182
  try {
2183
+ // host/port 归 runtime 自定(serve 端口是实现细节);sidecar 实际 URL
2184
+ // 由 runtime 吸收(getBaseUrl 跟随),gateway 只记 bookkeeping。
2033
2185
  const sidecar = await spawnServe({
2034
- host,
2035
- port,
2036
2186
  onOutput: (chunk) => logger_1.log.debug(`[Serve] ${chunk.trimEnd()}`),
2037
2187
  onExit: (code) => this.handleServeExit(code),
2038
2188
  });
@@ -2055,6 +2205,18 @@ class MafwScheduler {
2055
2205
  handleServeExit(code) {
2056
2206
  if (!this.serveOwned || this.serveRecovering)
2057
2207
  return;
2208
+ // After a hot-switch to an in-process/external runtime, the (still owned)
2209
+ // opencode serve is no longer the active backend: its exit must NOT trigger
2210
+ // recovery — pi has no spawnServe, so the orchestrator would fall into the
2211
+ // supervisor's kill+spawn path and retry forever (killing whatever listens
2212
+ // on the serve port each cycle). A later switch back re-ensures via
2213
+ // ensureServeForBuiltinRuntime (probe → adopt or spawn).
2214
+ const rt = this.opencodeClient;
2215
+ if (rt?.external || !rt?.agentProcess?.spawnServe) {
2216
+ this.serveOwned = false;
2217
+ logger_1.log.info('[Scheduler] owned serve exited after runtime switch; recovery skipped (active runtime does not own serve)');
2218
+ return;
2219
+ }
2058
2220
  this.serveInstance = undefined;
2059
2221
  if (this.serveStableTimer) {
2060
2222
  clearTimeout(this.serveStableTimer);
@@ -2282,18 +2444,14 @@ class MafwScheduler {
2282
2444
  return projectID ? await this.sdkSession.listByProject(projectID) : await this.sdkSession.list();
2283
2445
  }
2284
2446
  async isServeHealthy() {
2285
- const url = `${this.serveUrl}/global/health`;
2286
- return new Promise((resolve) => {
2287
- const req = http.get(url, { timeout: 5000 }, (res) => {
2288
- resolve(res.statusCode === 200);
2289
- res.resume();
2290
- });
2291
- req.on('error', () => resolve(false));
2292
- req.on('timeout', () => {
2293
- req.destroy();
2294
- resolve(false);
2295
- });
2296
- });
2447
+ // 健康唯一真相源 = runtime 契约 healthCheck()(serve 型探测 serve,
2448
+ // 进程内 runtime 探测自身引擎);gateway 不自带 HTTP 探测。
2449
+ try {
2450
+ return (await this.opencodeClient?.healthCheck?.()) ?? false;
2451
+ }
2452
+ catch {
2453
+ return false;
2454
+ }
2297
2455
  }
2298
2456
  async waitForServeReady() {
2299
2457
  const maxRetries = config_1.config.timeouts.serveReadyMaxRetries;
@@ -2310,9 +2468,45 @@ class MafwScheduler {
2310
2468
  // 鈹€鈹€ 2. HTTP API 鈹€鈹€
2311
2469
  async startApiServer() {
2312
2470
  return new Promise((resolve) => {
2471
+ // P5 Wave 1: catalog shadow 登记 + 模块 handler 绑定(一次性,重复 attach 会抛错)。
2472
+ // adapter 显式桥接私有成员(结构化类型不认 private)。
2473
+ (0, wave1_handlers_1.attachWave1Handlers)(this.routeRegistry, {
2474
+ getGatewayDb: () => this.getGatewayDb(),
2475
+ opencodeClient: this.opencodeClient,
2476
+ automationEngine: this.automationEngine,
2477
+ ledger: this.ledger,
2478
+ rotateDeps: () => this.rotateDeps(),
2479
+ embeddingConfigDeps: () => this.embeddingConfigDeps(),
2480
+ modelConfigDeps: () => this.modelConfigDeps(),
2481
+ usagePluginsDeps: () => this.usagePluginsDeps(),
2482
+ pluginLoader: this.pluginLoader,
2483
+ mediaPluginLoader: this.mediaPluginLoader,
2484
+ broadcast: (e) => this.broadcast(e),
2485
+ runtimeCaps: this.runtimeCaps,
2486
+ });
2487
+ (0, wave2_handlers_1.attachWave2Handlers)(this.routeRegistry, {
2488
+ runtimeDeps: () => this.runtimeDeps(),
2489
+ restartAgentDeps: () => this.restartAgentDeps(),
2490
+ pluginHubDeps: () => this.pluginHubDeps(),
2491
+ runtimeSwitchBlocked: () => this.serveRecovering || this.switchingRuntime,
2492
+ beginRuntimeSwitch: () => { this.switchingRuntime = true; },
2493
+ endRuntimeSwitch: () => { this.switchingRuntime = false; },
2494
+ });
2313
2495
  const server = http.createServer(async (req, res) => {
2314
2496
  try {
2315
2497
  res.setHeader('Content-Type', 'application/json');
2498
+ // P5 Wave 1: registry dispatch —— attach 过 handler 的路由在 legacy 链之前
2499
+ // 统一接管(shadow 条目无 handler 自然落回内联链)。挂在鉴权之后、一切
2500
+ // /api matcher 之前:goals.sessions 须先于 dashboard 兜底、embedding-config
2501
+ // 须先于 /api/memory/* 委托(AGENTS.md §6.5 顺序教训由 dispatch 位置一次性满足)。
2502
+ {
2503
+ const matched = this.routeRegistry.match(req.method, req.url || '');
2504
+ if (matched?.def.handler) {
2505
+ const handled = await matched.def.handler(req, res, matched.params);
2506
+ if (handled !== false)
2507
+ return;
2508
+ }
2509
+ }
2316
2510
  // CORS headers for SSE
2317
2511
  res.setHeader("Access-Control-Allow-Origin", config_1.config.server.cors.origin);
2318
2512
  res.setHeader("Access-Control-Allow-Methods", config_1.config.server.cors.methods);
@@ -3141,6 +3335,32 @@ class MafwScheduler {
3141
3335
  res.end(JSON.stringify({ ok: true, text: answer }));
3142
3336
  return;
3143
3337
  }
3338
+ if (cmd === "waitwhat") {
3339
+ if (!sessionID) {
3340
+ res.writeHead(400);
3341
+ res.end(JSON.stringify({ ok: false, error: "sessionID required" }));
3342
+ return;
3343
+ }
3344
+ if (!this.opencodeClient) {
3345
+ res.writeHead(503);
3346
+ res.end(JSON.stringify({ ok: false, error: "LLM client not available" }));
3347
+ return;
3348
+ }
3349
+ const result = await (0, waitwhat_command_1.runWaitwhat)(String(sessionID), {
3350
+ listMessages: async (sid) => {
3351
+ const r = await this.opencodeClient.session.messages({ sessionID: sid, limit: 50 });
3352
+ return (r?.data || []);
3353
+ },
3354
+ promptAsync: async (sid, text) => {
3355
+ await this.opencodeClient.session.promptAsync({ sessionID: sid, parts: [{ type: "text", text }] });
3356
+ },
3357
+ });
3358
+ res.writeHead(result.ok ? 200 : 400, { 'Content-Type': 'application/json' });
3359
+ res.end(JSON.stringify(result.ok
3360
+ ? { ok: true, message: '重述请求已发送到当前会话' }
3361
+ : { ok: false, error: result.error }));
3362
+ return;
3363
+ }
3144
3364
  if (cmd === "status") {
3145
3365
  const statusPath = path.join(this.mafwDir, 'STATUS.md');
3146
3366
  const text = fs.existsSync(statusPath) ? fs.readFileSync(statusPath, 'utf-8') : 'No active Goals. Use /goal to create one.';
@@ -3254,16 +3474,8 @@ class MafwScheduler {
3254
3474
  return;
3255
3475
  }
3256
3476
  // GET/POST /api/memory/embedding-config — memory embedding engine
3257
- // settings (hot-swap: rebuild runtime + background backfill). MUST
3258
- // stay above the /api/memory/* dashboard delegation below.
3259
- if (req.url?.match(/^\/api\/memory\/embedding-config(?:\?|$)/) && req.method === 'GET') {
3260
- await (0, embedding_config_1.handleEmbeddingConfigGet)(req, res, this.embeddingConfigDeps());
3261
- return;
3262
- }
3263
- if (req.url?.match(/^\/api\/memory\/embedding-config(?:\?|$)/) && req.method === 'POST') {
3264
- await (0, embedding_config_1.handleEmbeddingConfigUpdate)(req, res, this.embeddingConfigDeps());
3265
- return;
3266
- }
3477
+ // settings(P5 Wave 1 起由 registry dispatch 接管,见 routes/wave1-handlers.ts;
3478
+ // 历史顺序约束"须高于 /api/memory/* dashboard 委托"由 dispatch 位置满足)
3267
3479
  // GET /api/memory/stats — consolidation health + vector coverage (P2)
3268
3480
  if (req.url?.match(/^\/api\/memory\/stats(?:\?|$)/) && req.method === 'GET') {
3269
3481
  try {
@@ -3510,16 +3722,8 @@ class MafwScheduler {
3510
3722
  res.end(JSON.stringify({ status: 'accepted' }));
3511
3723
  return;
3512
3724
  }
3513
- // GET /api/goals/:id/sessions — goal session 映射(编排可视化下钻)。
3514
- // 必须挂在 Dashboard API 的 /api/goals* 兜底之前(AGENTS.md §6.5 路由顺序教训)。
3515
- if (req.url?.match(/^\/api\/goals\/[^/]+\/sessions(?:\?|$)/) && req.method === 'GET') {
3516
- const handled = await (0, goal_sessions_1.handleGoalSessions)(req, res, req.url, {
3517
- listGoalSessions: (goalId) => this.getGatewayDb().listGoalSessions(goalId),
3518
- getSession: (sessionID) => this.opencodeClient?.session.get({ sessionID }).catch(() => null),
3519
- });
3520
- if (handled)
3521
- return;
3522
- }
3725
+ // GET /api/goals/:id/sessions — P5 Wave 1 起由 registry dispatch 接管
3726
+ // (历史约束"须挂 Dashboard /api/goals* 兜底之前"由 dispatch 位置满足)。
3523
3727
  // Dashboard API
3524
3728
  if (req.url?.startsWith("/api/goals") || req.url?.startsWith("/api/stats") || req.url?.startsWith("/api/memory")) {
3525
3729
  res.setHeader("Content-Type", "application/json");
@@ -3633,8 +3837,8 @@ class MafwScheduler {
3633
3837
  });
3634
3838
  return;
3635
3839
  }
3636
- // 控制指令
3637
- if (req.url === '/control' && req.method === 'POST') {
3840
+ // 控制指令(/control 为 MCP handler 兼容路径;/api/goals/control 为 SDK goals.control 契约路径,两者等价)
3841
+ if ((req.url === '/control' || req.url === '/api/goals/control') && req.method === 'POST') {
3638
3842
  let body = '';
3639
3843
  req.on('data', chunk => body += chunk);
3640
3844
  req.on('end', async () => {
@@ -3737,12 +3941,7 @@ class MafwScheduler {
3737
3941
  }
3738
3942
  return;
3739
3943
  }
3740
- // POST /api/manager/session/rotate — start a new manager topic
3741
- // (thin wiring → routes/manager-rotate.ts).
3742
- if (req.method === 'POST' && req.url?.match(/^\/api\/manager\/session\/rotate(?:\?|$)/)) {
3743
- await (0, manager_rotate_1.handleManagerRotate)(req, res, this.rotateDeps());
3744
- return;
3745
- }
3944
+ // POST /api/manager/session/rotate — P5 Wave 1 起由 registry dispatch 接管
3746
3945
  // GET /api/manager/session — return manager session info (per-project,
3747
3946
  // read from the gateway DB). ?projectDir= filters a single project.
3748
3947
  if (req.url && req.url.startsWith('/api/manager/session') && req.method === 'GET') {
@@ -3750,8 +3949,14 @@ class MafwScheduler {
3750
3949
  const filter = parsedUrl.searchParams.get('projectDir') || '';
3751
3950
  try {
3752
3951
  const all = this.getGatewayDb().kvAll('manager-session');
3952
+ // Per-runtime slots: surface only the ACTIVE runtime's manager
3953
+ // slot per project. Other runtimes' slots stay dormant (resumed
3954
+ // when switching back) — nothing is deleted here.
3955
+ const live = all
3956
+ .map((e) => ({ key: e.key, value: (0, manager_session_runtime_1.readManagerSlot)(e.value, this.runtimeName) }))
3957
+ .filter((e) => e.value !== null);
3753
3958
  if (filter) {
3754
- const found = all.find((e) => e.key === filter);
3959
+ const found = live.find((e) => e.key === filter);
3755
3960
  if (!found) {
3756
3961
  res.writeHead(404);
3757
3962
  res.end(JSON.stringify({ error: 'No manager session for project' }));
@@ -3765,13 +3970,13 @@ class MafwScheduler {
3765
3970
  }));
3766
3971
  return;
3767
3972
  }
3768
- if (all.length === 0) {
3973
+ if (live.length === 0) {
3769
3974
  res.writeHead(404);
3770
3975
  res.end(JSON.stringify({ error: 'No manager session' }));
3771
3976
  return;
3772
3977
  }
3773
3978
  res.writeHead(200);
3774
- res.end(JSON.stringify(all.map((e) => ({
3979
+ res.end(JSON.stringify(live.map((e) => ({
3775
3980
  projectDir: e.key,
3776
3981
  sessionId: e.value.sessionId,
3777
3982
  createdAt: e.value.createdAt || null,
@@ -3982,17 +4187,15 @@ class MafwScheduler {
3982
4187
  return;
3983
4188
  }
3984
4189
  // 鈹€鈹€ Question endpoints (AskCard 鈹€ proxy to native opencode Question API) 鈹€鈹€
3985
- // GET /api/questions 鈹€ list pending questions
4190
+ // GET /api/questions ── list pending questions(契约:session.question.list)
3986
4191
  if (req.url?.match(/^\/api\/questions(?:\?|$)/) && req.method === 'GET') {
3987
4192
  if (this.capGuardQuestion(res))
3988
4193
  return;
3989
4194
  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 }));
4195
+ const dir = new URL(req.url, this.serveUrl).searchParams.get('directory') || this.projectDir || undefined;
4196
+ const question = this.opencodeClient?.session?.question;
4197
+ const items = question ? await question.list(dir ? { directory: dir } : undefined) : [];
4198
+ res.end(JSON.stringify({ items: items ?? [] }));
3996
4199
  }
3997
4200
  catch (err) {
3998
4201
  logger_1.log.error('[Question] list error:', err.message);
@@ -4000,20 +4203,34 @@ class MafwScheduler {
4000
4203
  }
4001
4204
  return;
4002
4205
  }
4003
- // POST /api/questions/{id}/reply 鈹€ { answers: string[][] }
4206
+ // POST /api/questions/{id}/reply ── { answers: string[][] }(契约 + workspace 重试)
4004
4207
  const qReplyMatch = req.url?.match(/^\/api\/questions\/([^/]+)\/reply(?:\?|$)/);
4005
4208
  if (qReplyMatch && req.method === 'POST') {
4006
4209
  if (this.capGuardQuestion(res))
4007
4210
  return;
4211
+ const question = this.opencodeClient?.session?.question;
4212
+ if (!question) {
4213
+ res.writeHead(503);
4214
+ res.end(JSON.stringify({ status: 'error', error: 'question API not available on this runtime' }));
4215
+ return;
4216
+ }
4008
4217
  try {
4009
4218
  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;
4219
+ // workspace 路由:默认(SDK 自带 projectDir)→ 各注册项目逐试
4220
+ const dirs = [undefined, this.projectDir, ...this.registeredProjects.keys()];
4221
+ let lastErr = null;
4222
+ for (const dir of [...new Set(dirs)]) {
4223
+ try {
4224
+ await question.reply({ requestID: qReplyMatch[1], answers: body.answers, ...(dir ? { directory: dir } : {}) });
4225
+ res.end(JSON.stringify({ status: 'ok' }));
4226
+ return;
4227
+ }
4228
+ catch (err) {
4229
+ lastErr = err;
4230
+ }
4015
4231
  }
4016
- res.end(JSON.stringify({ status: 'ok' }));
4232
+ res.writeHead(400);
4233
+ res.end(JSON.stringify({ status: 'error', error: lastErr?.message ?? 'question reply failed on all workspaces' }));
4017
4234
  }
4018
4235
  catch (err) {
4019
4236
  logger_1.log.error('[Question] reply error:', err.message);
@@ -4022,19 +4239,32 @@ class MafwScheduler {
4022
4239
  }
4023
4240
  return;
4024
4241
  }
4025
- // POST /api/questions/{id}/reject
4242
+ // POST /api/questions/{id}/reject(契约 + workspace 重试)
4026
4243
  const qRejectMatch = req.url?.match(/^\/api\/questions\/([^/]+)\/reject(?:\?|$)/);
4027
4244
  if (qRejectMatch && req.method === 'POST') {
4028
4245
  if (this.capGuardQuestion(res))
4029
4246
  return;
4247
+ const question = this.opencodeClient?.session?.question;
4248
+ if (!question) {
4249
+ res.writeHead(503);
4250
+ res.end(JSON.stringify({ status: 'error', error: 'question API not available on this runtime' }));
4251
+ return;
4252
+ }
4030
4253
  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;
4254
+ const dirs = [undefined, this.projectDir, ...this.registeredProjects.keys()];
4255
+ let lastErr = null;
4256
+ for (const dir of [...new Set(dirs)]) {
4257
+ try {
4258
+ await question.reject({ requestID: qRejectMatch[1], ...(dir ? { directory: dir } : {}) });
4259
+ res.end(JSON.stringify({ status: 'ok' }));
4260
+ return;
4261
+ }
4262
+ catch (err) {
4263
+ lastErr = err;
4264
+ }
4036
4265
  }
4037
- res.end(JSON.stringify({ status: 'ok' }));
4266
+ res.writeHead(400);
4267
+ res.end(JSON.stringify({ status: 'error', error: lastErr?.message ?? 'question reject failed on all workspaces' }));
4038
4268
  }
4039
4269
  catch (err) {
4040
4270
  logger_1.log.error('[Question] reject error:', err.message);
@@ -4044,17 +4274,16 @@ class MafwScheduler {
4044
4274
  return;
4045
4275
  }
4046
4276
  // 鈹€鈹€ Permission endpoints (PermissionCard 鈹€ proxy to native opencode Permission API) 鈹€鈹€
4047
- // GET /api/permissions 鈹€ list pending permission requests
4277
+ // GET /api/permissions ── list pending permission requests(契约:session.permissionList)
4048
4278
  if (req.url?.match(/^\/api\/permissions(?:\?|$)/) && req.method === 'GET') {
4049
4279
  if (this.capGuard(res, 'nativeApprovals'))
4050
4280
  return;
4051
4281
  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 }));
4282
+ const dir = new URL(req.url, this.serveUrl).searchParams.get('directory') || this.projectDir || undefined;
4283
+ const items = this.opencodeClient?.session?.permissionList
4284
+ ? await this.opencodeClient.session.permissionList(dir ? { directory: dir } : undefined)
4285
+ : [];
4286
+ res.end(JSON.stringify({ items: items ?? [] }));
4058
4287
  }
4059
4288
  catch (err) {
4060
4289
  logger_1.log.error('[Permission] list error:', err.message);
@@ -4063,170 +4292,12 @@ class MafwScheduler {
4063
4292
  return;
4064
4293
  }
4065
4294
  // 鈹€鈹€ Provider & Agents (composer model pill / @agent mention) 鈹€鈹€
4066
- // ── /api/runtime routes (thin wiring routes/runtime-switch.ts) ──
4067
- if (req.url?.match(/^\/api\/runtime(?:\/|$)/)) {
4068
- const runtimeDeps = {
4069
- loader: this.runtimeLoader,
4070
- persist: (o) => config_1.config.persistOverrides(o),
4071
- getCurrent: () => this.opencodeClient,
4072
- runtimeName: () => this.runtimeName,
4073
- runtimeCaps: () => this.runtimeCaps,
4074
- envOverride: () => !!process.env.MAFW_RUNTIME_PLUGIN,
4075
- createRuntime: async () => {
4076
- const sdkConfig = {
4077
- baseUrl: this.serveUrl,
4078
- directory: this.projectDir,
4079
- headers: {},
4080
- };
4081
- const opencodePassword = process.env.MAFW_OPENCODE_PASSWORD;
4082
- if (opencodePassword) {
4083
- sdkConfig.headers = { Authorization: 'Basic ' + Buffer.from(`opencode:${opencodePassword}`).toString('base64') };
4084
- }
4085
- return this.createRuntime(sdkConfig);
4086
- },
4087
- onSwitched: async (rt, prev) => {
4088
- this.opencodeClient = rt;
4089
- this.runtimeCaps = rt.capabilities;
4090
- this.runtimeName = rt.name;
4091
- // Switching onto a runtime that owns serve (builtin opencode) must
4092
- // ensure the sidecar exists — the gateway may have started under an
4093
- // external runtime (pi) that never spawned one. Must run AFTER the
4094
- // assignment above: the supervisor's spawn closure reads
4095
- // this.opencodeClient to find agentProcess.spawnServe.
4096
- if (rt.agentProcess?.spawnServe) {
4097
- await (0, serve_for_runtime_1.ensureServeForBuiltinRuntime)(this.serveSupervisor, { startWatchdog: () => this.startServeWatchdog() }, logger_1.log);
4098
- }
4099
- this.sdkSession.setClient(this.opencodeClient);
4100
- if (this.trajectoryCollector)
4101
- this.trajectoryCollector.setOpencodeClient(this.opencodeClient);
4102
- if (this.automationEngine)
4103
- this.automationEngine.setRuntimeClient(rt);
4104
- await this.resubscribeEvents(`runtime switched to '${rt.name}'`);
4105
- // Desktop hint: a runtime switch swaps the session storage backend
4106
- // (opencode SQLite vs pi), so cached session lists are stale.
4107
- this.broadcast({ type: 'runtime_switched', runtime: rt.name, previous: prev?.name ?? null });
4108
- if (prev && prev.dispose) {
4109
- try {
4110
- await prev.dispose();
4111
- }
4112
- catch (err) {
4113
- logger_1.log.warn(`[Runtime] dispose old runtime failed: ${err.message}`);
4114
- }
4115
- }
4116
- },
4117
- };
4118
- if (req.method === 'GET' && req.url?.match(/^\/api\/runtime(?:\?|$)/)) {
4119
- await (0, runtime_switch_1.handleRuntimeGet)(req, res, runtimeDeps);
4120
- return;
4121
- }
4122
- if (req.method === 'POST' && req.url?.match(/^\/api\/runtime\/switch(?:\?|$)/)) {
4123
- if (this.serveRecovering || this.switchingRuntime) {
4124
- res.writeHead(409, { 'Content-Type': 'application/json' });
4125
- res.end(JSON.stringify({ error: 'Cannot switch runtime during agent restart' }));
4126
- return;
4127
- }
4128
- this.switchingRuntime = true;
4129
- try {
4130
- await (0, runtime_switch_1.handleRuntimeSwitch)(req, res, runtimeDeps);
4131
- }
4132
- finally {
4133
- this.switchingRuntime = false;
4134
- }
4135
- return;
4136
- }
4137
- if (req.method === 'POST' && req.url?.match(/^\/api\/runtime\/reload(?:\?|$)/)) {
4138
- await (0, runtime_switch_1.handleRuntimeReload)(req, res, runtimeDeps);
4139
- return;
4140
- }
4141
- if (req.method === 'POST' && req.url?.match(/^\/api\/runtime\/restart-agent(?:\?|$)/)) {
4142
- await (0, restart_agent_1.handleRestartAgent)(req, res, {
4143
- capabilities: () => this.runtimeCaps,
4144
- isRecovering: () => this.serveRecovering,
4145
- isSwitching: () => this.switchingRuntime,
4146
- begin: () => { this.serveRecovering = true; },
4147
- end: () => { this.serveRecovering = false; },
4148
- restartAgent: () => this.restartAgentOrchestrated(),
4149
- });
4150
- return;
4151
- }
4152
- return;
4153
- }
4154
- // ── Plugin Hub (all four plugin types) ──
4155
- const pluginHubDeps = {
4156
- hub: {
4157
- dirs: {
4158
- runtime: config_1.config.resolvePath('runtime-plugins'),
4159
- media: config_1.config.resolvePath('media-plugins'),
4160
- usage: config_1.config.resolvePath('usage-plugins'),
4161
- ui: process.env.MAFW_UI_PLUGINS_DIR || path.join(os.homedir(), '.mafw', 'ui-plugins'),
4162
- },
4163
- builtinEntries: () => {
4164
- const entries = [];
4165
- const rt = (name) => ({ type: 'runtime', name, file: '(builtin)', status: 'enabled', size: 0, mtime: '' });
4166
- entries.push(rt('opencode'));
4167
- for (const name of this.runtimeLoader?.getBuiltinNames?.() ?? [])
4168
- entries.push(rt(name));
4169
- entries.push({ type: 'media', name: 'pi', file: '(builtin)', status: 'enabled', size: 0, mtime: '' });
4170
- const usageState = this.pluginLoader?.getState?.() ?? [];
4171
- for (const s of usageState) {
4172
- if (s.builtin && s.status === 'ok' && s.name) {
4173
- entries.push({ type: 'usage', name: s.name, file: s.file, status: 'enabled', size: 0, mtime: '', pluginType: s.pluginType });
4174
- }
4175
- }
4176
- return entries;
4177
- },
4178
- getErrors: (type) => {
4179
- const stateOf = (loader) => (loader && typeof loader.getState === 'function' ? loader.getState() : []);
4180
- const source = type === 'runtime' ? this.runtimeLoader : type === 'media' ? this.mediaPluginLoader : type === 'usage' ? this.pluginLoader : null;
4181
- const out = {};
4182
- for (const p of stateOf(source)) {
4183
- if (p && p.error)
4184
- out[p.name || p.file] = p.error;
4185
- }
4186
- return out;
4187
- },
4188
- configDisabledUsage: () => new Set(Array.isArray(config_1.config.usage?.disabledPlugins) ? config_1.config.usage.disabledPlugins : []),
4189
- reload: async (type) => {
4190
- if (type === 'runtime')
4191
- await this.runtimeLoader?.scan();
4192
- else if (type === 'media')
4193
- await this.mediaPluginLoader?.reload();
4194
- else if (type === 'usage')
4195
- await this.pluginLoader?.reload();
4196
- // ui: desktop main fs.watch picks it up automatically
4197
- },
4198
- },
4199
- };
4200
- try {
4201
- const cleaned = (0, hub_1.cleanupExamples)(pluginHubDeps.hub);
4202
- if (cleaned.removed.length)
4203
- logger_1.log.info(`[PluginsHub] removed stale examples: ${cleaned.removed.length}`);
4204
- if (cleaned.failed.length)
4205
- logger_1.log.warn(`[PluginsHub] cleanupExamples failed: ${cleaned.failed.join(', ')}`);
4206
- }
4207
- catch (err) {
4208
- logger_1.log.warn(`[PluginsHub] cleanupExamples error: ${err.message}`);
4209
- }
4210
- if (req.method === 'GET' && req.url?.match(/^\/api\/plugins(?:\?|$)/)) {
4211
- await (0, plugins_1.handlePluginsList)(req, res, pluginHubDeps);
4212
- return;
4213
- }
4214
- if (req.method === 'POST' && req.url?.match(/^\/api\/plugins\/install(?:\?|$)/)) {
4215
- await (0, plugins_1.handlePluginsInstall)(req, res, pluginHubDeps);
4216
- return;
4217
- }
4218
- if (req.method === 'POST' && req.url?.match(/^\/api\/plugins\/enable(?:\?|$)/)) {
4219
- await (0, plugins_1.handlePluginsEnable)(req, res, pluginHubDeps);
4220
- return;
4221
- }
4222
- if (req.method === 'POST' && req.url?.match(/^\/api\/plugins\/disable(?:\?|$)/)) {
4223
- await (0, plugins_1.handlePluginsDisable)(req, res, pluginHubDeps);
4224
- return;
4225
- }
4226
- if (req.method === 'POST' && req.url?.match(/^\/api\/plugins\/delete(?:\?|$)/)) {
4227
- await (0, plugins_1.handlePluginsDelete)(req, res, pluginHubDeps);
4228
- return;
4229
- }
4295
+ // ── /api/runtime routes P5 Wave 2 起由 registry dispatch 接管
4296
+ // (runtimeDeps/restartAgentDeps 构造上移至私有方法;内联块对未知
4297
+ // /api/runtime/* 悬空 return 吞请求的行为随之终结——现落回 legacy 链)
4298
+ // ── Plugin Hub (all four plugin types) — P5 Wave 2 起由 registry dispatch
4299
+ // 接管(pluginHubDeps 构造上移至私有方法;cleanupExamples 改为命中
4300
+ // /api/plugins 路由时才执行——内联时代每个到达此处的请求都跑)。
4230
4301
  // GET /api/orchestration/outcomes — goal outcome query
4231
4302
  if (req.url?.match(/^\/api\/orchestration\/outcomes(?:\?|$)/) && req.method === 'GET') {
4232
4303
  const u = new URL(req.url, 'http://localhost');
@@ -4322,20 +4393,37 @@ class MafwScheduler {
4322
4393
  }
4323
4394
  return;
4324
4395
  }
4325
- // POST /api/permissions/{id}/reply 鈹€ { reply: 'once'|'always'|'reject', message?: string }
4396
+ // POST /api/permissions/{id}/reply ── { reply, message? }(契约:列表反查 sessionID → permissionReply)
4326
4397
  const pReplyMatch = req.url?.match(/^\/api\/permissions\/([^/]+)\/reply(?:\?|$)/);
4327
4398
  if (pReplyMatch && req.method === 'POST') {
4328
4399
  if (this.capGuard(res, 'nativeApprovals'))
4329
4400
  return;
4401
+ const runtime = this.opencodeClient;
4402
+ if (!runtime?.session?.permissionList || !runtime?.session?.permissionReply) {
4403
+ res.writeHead(503);
4404
+ res.end(JSON.stringify({ status: 'error', error: 'permission API not available on this runtime' }));
4405
+ return;
4406
+ }
4330
4407
  try {
4331
4408
  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 }));
4409
+ const reply = body.reply;
4410
+ if (reply !== 'once' && reply !== 'always' && reply !== 'reject') {
4411
+ res.writeHead(400);
4412
+ res.end(JSON.stringify({ status: 'error', error: "reply must be 'once'|'always'|'reject'" }));
4413
+ return;
4414
+ }
4415
+ // 反查 sessionID:pending 列表项携带(跨 runtime 形状一致)
4416
+ const pending = await runtime.session.permissionList();
4417
+ const found = (Array.isArray(pending) ? pending : []).find((p) => p?.id === pReplyMatch[1]);
4418
+ if (!found?.sessionID) {
4419
+ res.writeHead(404);
4420
+ res.end(JSON.stringify({ status: 'error', error: 'permission request not found' }));
4421
+ return;
4422
+ }
4423
+ const ok = await runtime.session.permissionReply(found.sessionID, pReplyMatch[1], reply, body.message);
4424
+ if (!ok) {
4425
+ res.writeHead(404);
4426
+ res.end(JSON.stringify({ status: 'error', error: 'permission request not found' }));
4339
4427
  return;
4340
4428
  }
4341
4429
  res.end(JSON.stringify({ status: 'ok' }));
@@ -4672,13 +4760,7 @@ class MafwScheduler {
4672
4760
  }
4673
4761
  return;
4674
4762
  }
4675
- // DELETE/PATCH /api/sessions/:id — true forwards (rename / delete)
4676
- if (await (0, session_mutations_1.handleSessionMutations)(req, res, {
4677
- getCapabilities: () => this.runtimeCaps,
4678
- getClient: () => (this.opencodeClient ?? null),
4679
- })) {
4680
- return;
4681
- }
4763
+ // DELETE/PATCH /api/sessions/:id — P5 Wave 1 起由 registry dispatch 接管
4682
4764
  // GET /api/sessions — list sessions (optional ?projectID=xxx)
4683
4765
  if (req.url?.match(/^\/api\/sessions(?:\?|$)/) && req.method === 'GET') {
4684
4766
  try {
@@ -4842,44 +4924,7 @@ class MafwScheduler {
4842
4924
  }
4843
4925
  return;
4844
4926
  }
4845
- // GET /api/usage/plugins — plugin state list (+templates for the wizard)
4846
- if (req.url?.match(/^\/api\/usage\/plugins(?:\?|$)/) && req.method === 'GET') {
4847
- await (0, usage_plugins_1.handleUsagePluginsList)(req, res, this.usagePluginsDeps());
4848
- return;
4849
- }
4850
- // POST /api/usage/plugins/create — template wizard or raw source
4851
- if (req.url?.match(/^\/api\/usage\/plugins\/create$/) && req.method === 'POST') {
4852
- await (0, usage_plugins_1.handleUsagePluginCreate)(req, res, this.usagePluginsDeps());
4853
- return;
4854
- }
4855
- // GET/PUT /api/usage/plugins/:name/source — user plugin code editor
4856
- const mSource = req.url?.match(/^\/api\/usage\/plugins\/([^/]+)\/source$/);
4857
- if (mSource && req.method === 'GET') {
4858
- await (0, usage_plugins_1.handleUsagePluginSourceGet)(req, res, this.usagePluginsDeps(), decodeURIComponent(mSource[1]));
4859
- return;
4860
- }
4861
- if (mSource && req.method === 'PUT') {
4862
- await (0, usage_plugins_1.handleUsagePluginSourcePut)(req, res, this.usagePluginsDeps(), decodeURIComponent(mSource[1]));
4863
- return;
4864
- }
4865
- // POST /api/usage/plugins/:name/test — one-shot adapter fetch
4866
- const mTest = req.url?.match(/^\/api\/usage\/plugins\/([^/]+)\/test$/);
4867
- if (mTest && req.method === 'POST') {
4868
- await (0, usage_plugins_1.handleUsagePluginTest)(req, res, this.usagePluginsDeps(), decodeURIComponent(mTest[1]));
4869
- return;
4870
- }
4871
- // DELETE /api/usage/plugins/:name — remove user plugin file
4872
- const mDel = req.url?.match(/^\/api\/usage\/plugins\/([^/]+)$/);
4873
- if (mDel && req.method === 'DELETE') {
4874
- await (0, usage_plugins_1.handleUsagePluginDelete)(req, res, this.usagePluginsDeps(), decodeURIComponent(mDel[1]));
4875
- return;
4876
- }
4877
- // POST /api/usage/plugins/reload — manual reload
4878
- if (req.url?.match(/^\/api\/usage\/plugins\/reload$/) && req.method === 'POST') {
4879
- await this.pluginLoader?.reload();
4880
- await (0, usage_plugins_1.handleUsagePluginsList)(req, res, this.usagePluginsDeps());
4881
- return;
4882
- }
4927
+ // /api/usage/plugins*P5 Wave 1 起由 registry dispatch 接管(7 条)
4883
4928
  // GET /api/media/plugins — media engine plugin state list
4884
4929
  if (req.url?.match(/^\/api\/media\/plugins(?:\?|$)/) && req.method === 'GET') {
4885
4930
  const plugins = (this.mediaPluginLoader?.getState() ?? []).map(s => ({
@@ -4907,43 +4952,10 @@ class MafwScheduler {
4907
4952
  res.end(JSON.stringify({ ok: true, plugins }));
4908
4953
  return;
4909
4954
  }
4910
- // POST /api/media/switch — switch media engine per modality
4911
- if (req.url?.match(/^\/api\/media\/switch(?:\?|$)/) && req.method === 'POST') {
4912
- await (0, media_switch_1.handleMediaSwitch)(req, res, {
4913
- persist: (o) => config_1.config.persistOverrides(o),
4914
- reloadPlugins: async () => { await this.mediaPluginLoader?.reload(); },
4915
- availableEngines: () => (this.mediaPluginLoader?.getState().map(s => s.name).filter((n) => !!n) ?? []),
4916
- currentMedia: () => ({
4917
- engine: config_1.config.raw.media.engine,
4918
- image: config_1.config.raw.media.image,
4919
- video: config_1.config.raw.media.video,
4920
- audio: config_1.config.raw.media.audio,
4921
- }),
4922
- });
4923
- return;
4924
- }
4925
- // GET/POST /api/model-config — recall worker model + media models (hot-apply)
4926
- if (req.url?.match(/^\/api\/model-config(?:\?|$)/) && req.method === 'GET') {
4927
- await (0, model_config_1.handleModelConfigGet)(req, res, this.modelConfigDeps());
4928
- return;
4929
- }
4930
- if (req.url?.match(/^\/api\/model-config(?:\?|$)/) && req.method === 'POST') {
4931
- await (0, model_config_1.handleModelConfigUpdate)(req, res, this.modelConfigDeps());
4932
- return;
4933
- }
4934
- // POST /api/sessions/:id/fork|revert|unrevert — session branch primitives
4935
- // (capability-gated: sessionBranchApi; unrevert is opencode-only → 404 on pi)
4936
- if (req.url?.match(/^\/api\/sessions\/[^/]+\/(fork|revert|unrevert)(?:\?|$)/) && req.method === 'POST') {
4937
- const handled = await (0, session_branch_1.handleSessionBranch)(req, res, req.url, { getRuntime: () => this.opencodeClient ?? null });
4938
- if (handled)
4939
- return;
4940
- }
4941
- // POST /api/session/:id/summarize — 手动压缩会话(TUI /compact;runtime 薄代理)
4942
- if (req.url?.match(/^\/api\/session\/[^/]+\/summarize(?:\?|$)/) && req.method === 'POST') {
4943
- const handled = await (0, session_summarize_1.handleSessionSummarize)(req, res, req.url, { getRuntime: () => this.opencodeClient ?? null });
4944
- if (handled)
4945
- return;
4946
- }
4955
+ // POST /api/media/switch — P5 Wave 1 起由 registry dispatch 接管
4956
+ // GET/POST /api/model-config P5 Wave 1 起由 registry dispatch 接管
4957
+ // POST /api/sessions/:id/fork|revert|unrevert + /api/session/:id/summarize
4958
+ // P5 Wave 1 起由 registry dispatch 接管
4947
4959
  // GET /api/usage?sessionID=xxx&projectID=xxx — consolidated usage (summary + providers)
4948
4960
  if (req.url?.match(/^\/api\/usage(?:\?|$)/) && req.method === 'GET') {
4949
4961
  try {
@@ -5183,7 +5195,10 @@ class MafwScheduler {
5183
5195
  session_id: sessionID,
5184
5196
  turn_id: turnId,
5185
5197
  source: source,
5186
- content: content.slice(0, 100_000),
5198
+ // Redact secrets once at capture so every downstream consumer
5199
+ // (turnCompress transcripts, worker prompts, archive) only
5200
+ // ever sees redacted content.
5201
+ content: (0, redact_1.redactSecrets)(content).slice(0, 100_000),
5187
5202
  failure,
5188
5203
  });
5189
5204
  // Async scan prefetch: precompute semantic recall for this turn
@@ -5203,6 +5218,7 @@ class MafwScheduler {
5203
5218
  });
5204
5219
  return;
5205
5220
  }
5221
+ // POST /api/events — P5 Wave 1 起由 registry dispatch 接管
5206
5222
  // SSE 事件(→ Dashboard / Chat)
5207
5223
  if (req.url && req.url.startsWith('/api/events') && req.method === 'GET') {
5208
5224
  const parsedUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
@@ -5231,8 +5247,9 @@ class MafwScheduler {
5231
5247
  res.end(JSON.stringify({ status: 'ok' }));
5232
5248
  return;
5233
5249
  }
5234
- // Reverse proxy to opencode server for non-MAFW routes
5235
- const serveUrl = config_1.config.server.serveUrl;
5250
+ // Reverse proxy to the agent backend for non-MAFW routes
5251
+ // target from the runtime contract (getBaseUrl), config 仅 bootstrap 兜底
5252
+ const serveUrl = this.opencodeClient?.getBaseUrl?.() ?? config_1.config.server.serveUrl;
5236
5253
  try {
5237
5254
  const proxyUrl = new URL(req.url || '/', serveUrl);
5238
5255
  const proxyReq = http.request(proxyUrl, {
@@ -5289,7 +5306,7 @@ class MafwScheduler {
5289
5306
  server.listen(this.apiPort, () => {
5290
5307
  logger_1.log.info(`[Scheduler] HTTP API on port ${this.apiPort}`);
5291
5308
  logger_1.log.info(`[Scheduler] - POST /register { projectDir, mafwDir }`);
5292
- logger_1.log.info(`[Scheduler] - POST /control { action, goalId, ... }`);
5309
+ logger_1.log.info(`[Scheduler] - POST /control | /api/goals/control { action, goalId, ... }`);
5293
5310
  logger_1.log.info(`[Scheduler] - GET /health`);
5294
5311
  logger_1.log.info(`[Scheduler] - GET /mcp (MCP legacy SSE / StreamableHTTP 405)`);
5295
5312
  logger_1.log.info(`[Scheduler] - POST /mcp (MCP StreamableHTTP stateless + legacy SSE messages)`);
@@ -6204,6 +6221,14 @@ ${observations.map((o, i) => `[${i + 1}] ${o}`).join('\n')}`;
6204
6221
  });
6205
6222
  return run;
6206
6223
  }
6224
+ // Slot-based read of a manager-session kv entry: each runtime owns a slot
6225
+ // in the value (byRuntime), so switching runtimes never drops a topic —
6226
+ // switching back resumes the previous manager. Legacy v1 entries are
6227
+ // inferred by session id prefix and only surface under their own runtime.
6228
+ // See manager-session-runtime.ts.
6229
+ readManagerSessionEntry(projectDir) {
6230
+ return (0, manager_session_runtime_1.readManagerSlot)(this.getGatewayDb().kvGet('manager-session', projectDir), this.runtimeName);
6231
+ }
6207
6232
  // Serialized rotate: joins any in-flight ensure/create for the same project
6208
6233
  // so rotate and lazy-init never double-create (spec §4).
6209
6234
  async runManagerExclusive(projectDir, fn) {
@@ -6220,7 +6245,7 @@ ${observations.map((o, i) => `[${i + 1}] ${o}`).join('\n')}`;
6220
6245
  // The inflight map is shared with ensureManagerSession, whose joiners
6221
6246
  // expect the promise to resolve to a session id string — map the rotate
6222
6247
  // result onto the resulting kv entry.
6223
- const mapped = run.then(() => this.getGatewayDb().kvGet('manager-session', projectDir)?.sessionId ?? '', () => '');
6248
+ const mapped = run.then(() => this.readManagerSessionEntry(projectDir)?.sessionId ?? '', () => '');
6224
6249
  this.managerSessionInflight.set(projectDir, mapped);
6225
6250
  try {
6226
6251
  return await run;
@@ -6235,7 +6260,7 @@ ${observations.map((o, i) => `[${i + 1}] ${o}`).join('\n')}`;
6235
6260
  throw new Error('opencodeClient not available');
6236
6261
  // Manager identity lives in the gateway DB (kv_store), so it survives
6237
6262
  // project-directory churn and never gets orphaned by directory moves.
6238
- const existing = this.getGatewayDb().kvGet('manager-session', projectDir);
6263
+ const existing = this.readManagerSessionEntry(projectDir);
6239
6264
  if (existing?.sessionId) {
6240
6265
  await this.sdkSession.registerExternal(existing.sessionId, projectDir, {
6241
6266
  mafw: { role: 'manager', pinned: true, exemptFromTrim: true, exemptFromEvict: true, exemptFromArchive: true },
@@ -6251,7 +6276,7 @@ ${observations.map((o, i) => `[${i + 1}] ${o}`).join('\n')}`;
6251
6276
  throw new Error('Failed to create manager session: no id returned');
6252
6277
  }
6253
6278
  const createdAt = new Date().toISOString();
6254
- this.getGatewayDb().kvSet('manager-session', projectDir, { sessionId, createdAt });
6279
+ this.getGatewayDb().kvSet('manager-session', projectDir, (0, manager_session_runtime_1.writeManagerSlot)(this.getGatewayDb().kvGet('manager-session', projectDir), this.runtimeName, { sessionId, createdAt }));
6255
6280
  this.registerInternalSession(sessionId, 'manager');
6256
6281
  try {
6257
6282
  await this.sdkSession.registerExternal(sessionId, projectDir, {
@@ -6283,7 +6308,7 @@ ${observations.map((o, i) => `[${i + 1}] ${o}`).join('\n')}`;
6283
6308
  }
6284
6309
  rotateDeps() {
6285
6310
  return {
6286
- getManagerSession: (pd) => this.getGatewayDb().kvGet('manager-session', pd),
6311
+ getManagerSession: (pd) => this.readManagerSessionEntry(pd),
6287
6312
  lock: (pd, fn) => this.runManagerExclusive(pd, fn),
6288
6313
  ensure: (pd) => this.ensureManagerSession(pd, this.mafwDirFor(pd)),
6289
6314
  downgrade: async (sid) => {
@@ -6330,7 +6355,7 @@ ${observations.map((o, i) => `[${i + 1}] ${o}`).join('\n')}`;
6330
6355
  return undefined;
6331
6356
  if (!this.milestonePush) {
6332
6357
  this.milestonePush = new milestone_push_1.MilestonePushNotifier({
6333
- getManagerSession: (pd) => this.getGatewayDb().kvGet('manager-session', pd),
6358
+ getManagerSession: (pd) => this.readManagerSessionEntry(pd),
6334
6359
  wasNotified: (key) => !!this.getGatewayDb().kvGet('milestone-notified', key),
6335
6360
  markNotified: (key) => this.getGatewayDb().kvSet('milestone-notified', key, { at: new Date().toISOString() }),
6336
6361
  readGoalState: (goalId, pd) => {
@@ -6359,7 +6384,7 @@ ${observations.map((o, i) => `[${i + 1}] ${o}`).join('\n')}`;
6359
6384
  const sessionId = session.id;
6360
6385
  if (!sessionId)
6361
6386
  throw new Error('Failed to create manager session: no id returned');
6362
- this.getGatewayDb().kvSet('manager-session', projectDir, { sessionId, createdAt: new Date().toISOString() });
6387
+ 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
6388
  this.registerInternalSession(sessionId, 'manager');
6364
6389
  await this.sdkSession.registerExternal(sessionId, projectDir, {
6365
6390
  mafw: { role: 'manager', pinned: true, exemptFromTrim: true, exemptFromEvict: true, exemptFromArchive: true },