@jack200714/mafw 4.5.2 → 4.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -99,6 +99,7 @@ const self_update_1 = require("./self-update");
99
99
  const step_inject_1 = require("./recall/step-inject");
100
100
  const inject_format_1 = require("./recall/inject-format");
101
101
  const normalize_1 = require("./runtime/normalize");
102
+ const event_broadcast_1 = require("./runtime/event-broadcast");
102
103
  const budget_guard_1 = require("./core/budget-guard");
103
104
  const goal_budget_1 = require("./core/goal-budget");
104
105
  const contract_1 = require("./runtime/contract");
@@ -107,6 +108,7 @@ const pi_runtime_1 = require("./runtime/plugins/pi-runtime");
107
108
  const permission_1 = require("./routes/permission");
108
109
  const runtime_switch_1 = require("./routes/runtime-switch");
109
110
  const plugins_1 = require("./routes/plugins");
111
+ const hub_1 = require("./plugins/hub");
110
112
  const restart_agent_1 = require("./routes/restart-agent");
111
113
  const session_mutations_1 = require("./routes/session-mutations");
112
114
  const serve_supervisor_1 = require("./runtime/serve-supervisor");
@@ -157,6 +159,10 @@ class MafwScheduler {
157
159
  serveOwned = false;
158
160
  serveExitStreak = 0;
159
161
  serveRecovering = false;
162
+ // External runtimes (pi) do not own serve: the watchdog can only warn and
163
+ // reconnect the event stream. Flag keeps that warning a one-shot ERROR per
164
+ // outage instead of an unbounded WARN loop.
165
+ serveExternalDownNotified = false;
160
166
  serveWatchdogTimer = null;
161
167
  serveRetryTimer = null;
162
168
  serveStableTimer = null;
@@ -206,8 +212,6 @@ class MafwScheduler {
206
212
  registeredProjects = new Map();
207
213
  registryPath;
208
214
  registryWriteQueue = Promise.resolve();
209
- configPath;
210
- configWriteQueue = Promise.resolve();
211
215
  running = true;
212
216
  // private dashboard?: DashboardServer;
213
217
  mcpEndpoint;
@@ -252,7 +256,6 @@ class MafwScheduler {
252
256
  this.serveUrl = config_1.config.server.serveUrl;
253
257
  this.apiPort = config_1.config.server.apiPort;
254
258
  this.pollInterval = config_1.config.timeouts.backupPollInterval;
255
- this.configPath = config_1.config.paths.globalConfig;
256
259
  this.registryPath = config_1.config.paths.registryFile;
257
260
  this.chatSessions = new chat_sessions_1.ChatSessionManager();
258
261
  this.serveSupervisor = (0, serve_supervisor_1.createServeSupervisor)({
@@ -423,8 +426,7 @@ class MafwScheduler {
423
426
  // 4. Dashboard is now served via the API server on the same port
424
427
  // this.dashboard = new DashboardServer(3001, this.projectDir, this);
425
428
  // this.dashboard.start();
426
- // 5. 恢复配置和注册表
427
- await this.recoverConfig();
429
+ // 5. 恢复注册表(权威源:gateway DB kv_store + legacy 文件兜底)
428
430
  await this.recoverRegistry();
429
431
  // 5.0 Data-directory migration: move memory store + pipeline files from
430
432
  // the previously-fixed gateway package .mafw (and any project-relative
@@ -773,7 +775,8 @@ class MafwScheduler {
773
775
  registeredAt: new Date().toISOString()
774
776
  });
775
777
  this.persistRegistry();
776
- this.persistConfig();
778
+ // 桌面 renderer 依此事件刷新 Rail 项目列表(否则只在 gateway ready 时拉一次)。
779
+ this.broadcast((0, event_broadcast_1.projectRegisteredEvent)(projectDir));
777
780
  logger_1.log.info(`[Scheduler] Project registered via filesystem: ${projectDir}`);
778
781
  }
779
782
  catch {
@@ -952,13 +955,15 @@ class MafwScheduler {
952
955
  catch (err) {
953
956
  logger_1.log.warn(`[Trajectory] idle aggregation failed (non-fatal): ${err.message}`);
954
957
  }
955
- this.broadcast({ type: 'opencode_event', data: { type: 'message.complete', sessionID, ...(memoryWorker ? { internal: true } : {}) } });
958
+ this.broadcast((0, event_broadcast_1.opencodeBroadcast)({ type: 'message.complete', sessionID }, memoryWorker));
956
959
  }
957
960
  else if (f.broadcast === 'error') {
958
- this.broadcast({ type: 'opencode_event', data: { type: 'message.error', sessionID, error: props?.error instanceof Error ? props.error.message : String(props?.error ?? 'Unknown error'), ...(memoryWorker ? { internal: true } : {}) } });
961
+ this.broadcast((0, event_broadcast_1.opencodeBroadcast)({ type: 'message.error', sessionID, error: props?.error instanceof Error ? props.error.message : String(props?.error ?? 'Unknown error') }, memoryWorker));
959
962
  }
960
963
  else {
961
- this.broadcast({ type: 'opencode_event', data: { type, properties: props, sessionID, ...(memoryWorker ? { internal: true } : {}) } });
964
+ // directory 透传:session.created/updated/deleted 的消费方(桌面 Rail)
965
+ // 据此定位所属项目做定向刷新(normalize 已从 GlobalEvent 信封提取)。
966
+ this.broadcast((0, event_broadcast_1.opencodeBroadcast)({ type, properties: props, sessionID, directory: f.directory }, memoryWorker));
962
967
  }
963
968
  }
964
969
  /** 能力门:runtime 未声明该能力时以 503 显式拒绝(fail-open 的声明式降级)。 */
@@ -2122,6 +2127,10 @@ class MafwScheduler {
2122
2127
  if (!this.running || this.serveRecovering)
2123
2128
  return;
2124
2129
  if (await this.isServeHealthy()) {
2130
+ if (failures > 0 || this.serveExternalDownNotified) {
2131
+ logger_1.log.info('[Scheduler] Serve back online');
2132
+ }
2133
+ this.serveExternalDownNotified = false;
2125
2134
  failures = 0;
2126
2135
  return;
2127
2136
  }
@@ -2130,7 +2139,10 @@ class MafwScheduler {
2130
2139
  if (failures >= this.serveWatchdogFailures) {
2131
2140
  failures = 0;
2132
2141
  if (this.opencodeClient?.external) {
2133
- logger_1.log.warn('[Scheduler] External serve unreachable; reconnecting event stream only (not killing external process)');
2142
+ if (!this.serveExternalDownNotified) {
2143
+ this.serveExternalDownNotified = true;
2144
+ logger_1.log.error('[Scheduler] External serve unreachable — the active runtime does not own the serve process, so it will NOT be respawned. Serve-dependent features (provider list, sessions, approvals) are degraded.');
2145
+ }
2134
2146
  try {
2135
2147
  await this.subscribeToEvents();
2136
2148
  }
@@ -3600,7 +3612,8 @@ class MafwScheduler {
3600
3612
  });
3601
3613
  // 持久化到磁盘(写队列防并发覆盖)
3602
3614
  await this.persistRegistry();
3603
- await this.persistConfig();
3615
+ // 桌面 renderer 依此事件刷新 Rail 项目列表(否则只在 gateway ready 时拉一次)。
3616
+ this.broadcast((0, event_broadcast_1.projectRegisteredEvent)(projectDir));
3604
3617
  if (this.opencodeClient) {
3605
3618
  try {
3606
3619
  await this.ensureManagerSession(projectDir, mafwDir);
@@ -4089,6 +4102,9 @@ class MafwScheduler {
4089
4102
  if (this.automationEngine)
4090
4103
  this.automationEngine.setRuntimeClient(rt);
4091
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 });
4092
4108
  if (prev && prev.dispose) {
4093
4109
  try {
4094
4110
  await prev.dispose();
@@ -4144,6 +4160,21 @@ class MafwScheduler {
4144
4160
  usage: config_1.config.resolvePath('usage-plugins'),
4145
4161
  ui: process.env.MAFW_UI_PLUGINS_DIR || path.join(os.homedir(), '.mafw', 'ui-plugins'),
4146
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
+ },
4147
4178
  getErrors: (type) => {
4148
4179
  const stateOf = (loader) => (loader && typeof loader.getState === 'function' ? loader.getState() : []);
4149
4180
  const source = type === 'runtime' ? this.runtimeLoader : type === 'media' ? this.mediaPluginLoader : type === 'usage' ? this.pluginLoader : null;
@@ -4166,6 +4197,16 @@ class MafwScheduler {
4166
4197
  },
4167
4198
  },
4168
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
+ }
4169
4210
  if (req.method === 'GET' && req.url?.match(/^\/api\/plugins(?:\?|$)/)) {
4170
4211
  await (0, plugins_1.handlePluginsList)(req, res, pluginHubDeps);
4171
4212
  return;
@@ -5433,33 +5474,6 @@ class MafwScheduler {
5433
5474
  logger_1.log.info(`[Scheduler] Recovered ${filtered.length} registered projects`);
5434
5475
  }
5435
5476
  }
5436
- async persistConfig() {
5437
- this.configWriteQueue = this.configWriteQueue.then(async () => {
5438
- const dir = path.dirname(this.configPath);
5439
- if (!fs.existsSync(dir))
5440
- fs.mkdirSync(dir, { recursive: true });
5441
- let config = {};
5442
- if (fs.existsSync(this.configPath)) {
5443
- config = JSON.parse(fs.readFileSync(this.configPath, 'utf-8'));
5444
- }
5445
- config.projects = Object.fromEntries(this.registeredProjects);
5446
- fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2));
5447
- });
5448
- await this.configWriteQueue;
5449
- }
5450
- async recoverConfig() {
5451
- if (fs.existsSync(this.configPath)) {
5452
- try {
5453
- const config = JSON.parse(fs.readFileSync(this.configPath, 'utf-8'));
5454
- const entries = Object.entries((config.projects || {}))
5455
- .filter(([dir]) => !this.isUserDataDir(dir));
5456
- this.registeredProjects = new Map(entries);
5457
- }
5458
- catch (err) {
5459
- logger_1.log.error(`[Scheduler] Failed to recover config: ${err.message}`);
5460
- }
5461
- }
5462
- }
5463
5477
  // ── 4. 轮询(降级兜底 + autoresume) ──
5464
5478
  startBackupPolling() {
5465
5479
  const interval = config_1.config.timeouts.backupPollInterval;
@@ -60,7 +60,6 @@ class MediaPluginLoader {
60
60
  if (!fs.existsSync(this.pluginsDir)) {
61
61
  fs.mkdirSync(this.pluginsDir, { recursive: true });
62
62
  fs.writeFileSync(path.join(this.pluginsDir, 'README.md'), README_CONTENT);
63
- fs.writeFileSync(path.join(this.pluginsDir, 'example.js.disabled'), EXAMPLE_CONTENT);
64
63
  logger_1.log.info(`[MediaPluginLoader] Created ${this.pluginsDir}`);
65
64
  }
66
65
  }
@@ -241,14 +240,3 @@ media:
241
240
  engine: qwen-vl # override for video
242
241
  \`\`\`
243
242
  `;
244
- const EXAMPLE_CONTENT = `// Rename to example.js to activate
245
- module.exports = {
246
- name: "example",
247
- modalities: ["image"],
248
- async createPrompt(ctx) {
249
- return async (parts, opts) => {
250
- return "Example analysis result";
251
- };
252
- },
253
- };
254
- `;
@@ -36,11 +36,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.HubError = void 0;
37
37
  exports.parseType = parseType;
38
38
  exports.listPlugins = listPlugins;
39
+ exports.cleanupExamples = cleanupExamples;
39
40
  exports.installPlugin = installPlugin;
40
41
  exports.setPluginEnabled = setPluginEnabled;
41
42
  exports.deletePlugin = deletePlugin;
42
43
  const fs = __importStar(require("fs"));
44
+ const os = __importStar(require("os"));
43
45
  const path = __importStar(require("path"));
46
+ const logger_1 = require("../core/utils/logger");
44
47
  class HubError extends Error {
45
48
  status;
46
49
  constructor(status, message) {
@@ -87,6 +90,19 @@ function statEntry(type, dir, file) {
87
90
  };
88
91
  }
89
92
  function listPlugins(deps) {
93
+ const userEntries = collectUserEntries(deps);
94
+ const builtins = (deps.builtinEntries?.() ?? []).map((e) => ({ ...e, builtin: true }));
95
+ const userKeys = new Set(userEntries.map((e) => `${e.type}/${e.name}`));
96
+ const configDisabled = deps.configDisabledUsage?.() ?? new Set();
97
+ for (const b of builtins) {
98
+ if (userKeys.has(`${b.type}/${b.name}`))
99
+ b.overridden = true;
100
+ if (b.type === 'usage' && configDisabled.has(b.name) && b.status === 'enabled')
101
+ b.status = 'config-disabled';
102
+ }
103
+ return [...builtins, ...userEntries];
104
+ }
105
+ function collectUserEntries(deps) {
90
106
  const entries = [];
91
107
  for (const type of PLUGIN_TYPES) {
92
108
  const dir = deps.dirs[type];
@@ -130,24 +146,52 @@ function listPlugins(deps) {
130
146
  }
131
147
  return entries;
132
148
  }
133
- function decodeContent(contentBase64, maxBytes) {
134
- if (typeof contentBase64 !== 'string' || contentBase64.length === 0)
135
- throw new HubError(400, 'empty content');
136
- const buf = Buffer.from(contentBase64, 'base64');
137
- if (buf.length === 0)
138
- throw new HubError(400, 'empty content');
139
- // round-trip check rejects non-base64 garbage
140
- if (buf.toString('base64') !== contentBase64)
141
- throw new HubError(400, 'content is not valid base64');
142
- if (buf.length > maxBytes)
143
- throw new HubError(413, `content exceeds ${maxBytes} bytes`);
144
- return buf;
149
+ function cleanupExamples(deps) {
150
+ const removed = [];
151
+ const failed = [];
152
+ for (const dir of Object.values(deps.dirs)) {
153
+ const target = path.join(dir, 'example.js.disabled');
154
+ try {
155
+ if (fs.existsSync(target)) {
156
+ fs.unlinkSync(target);
157
+ removed.push(target);
158
+ }
159
+ }
160
+ catch {
161
+ failed.push(target);
162
+ }
163
+ }
164
+ return { removed, failed };
145
165
  }
146
166
  async function installPlugin(deps, input) {
147
- const type = parseType(input.type);
148
167
  const filename = validateFilename(input.filename);
149
168
  if (!filename.endsWith('.js'))
150
169
  throw new HubError(400, `install filename must end with .js: ${filename}`);
170
+ const maxBytes = deps.maxBytes ?? DEFAULT_MAX_BYTES;
171
+ if (!Buffer.isBuffer(input.bytes) || input.bytes.length === 0)
172
+ throw new HubError(400, 'empty content');
173
+ if (input.bytes.length > maxBytes)
174
+ throw new HubError(413, `content exceeds ${maxBytes} bytes`);
175
+ const explicitType = input.type ? parseType(input.type) : undefined;
176
+ const { matches, mod } = inspectPlugin(filename, input.bytes);
177
+ validateName(mod, filename);
178
+ let type;
179
+ if (matches.length === 0)
180
+ throw new HubError(400, 'unrecognized plugin interface: export createRuntime / createPrompt / fetch / tools');
181
+ if (explicitType) {
182
+ if (!matches.includes(explicitType)) {
183
+ const listing = matches.map((m) => `${m} (${MATCH_IFACE[m]})`).join('/');
184
+ throw new HubError(400, `plugin interface mismatch: selected ${explicitType}, exports ${listing}`);
185
+ }
186
+ type = explicitType;
187
+ }
188
+ else {
189
+ if (matches.length > 1)
190
+ throw new HubError(400, `ambiguous plugin interface: ${matches.join('/')}`);
191
+ type = matches[0];
192
+ }
193
+ if (type === 'media')
194
+ await validateMediaActivation(mod);
151
195
  const dir = deps.dirs[type];
152
196
  fs.mkdirSync(dir, { recursive: true });
153
197
  const target = resolveInDir(dir, filename);
@@ -156,23 +200,113 @@ async function installPlugin(deps, input) {
156
200
  if (!input.overwrite && (fs.existsSync(dup) || fs.existsSync(dupDisabled))) {
157
201
  throw new HubError(409, `plugin already exists: ${filename}`);
158
202
  }
159
- const buf = decodeContent(input.contentBase64, deps.maxBytes ?? DEFAULT_MAX_BYTES);
160
203
  const tmp = `${target}.tmp`;
161
- fs.writeFileSync(tmp, buf);
204
+ fs.writeFileSync(tmp, input.bytes);
162
205
  fs.renameSync(tmp, target);
163
206
  await deps.reload?.(type);
164
207
  return statEntry(type, dir, filename);
165
208
  }
209
+ const MATCH_IFACE = {
210
+ runtime: 'createRuntime',
211
+ media: 'createPrompt',
212
+ usage: 'fetch',
213
+ ui: 'tools',
214
+ };
215
+ function computeMatches(mod) {
216
+ const matches = [];
217
+ if (typeof mod?.createRuntime === 'function')
218
+ matches.push('runtime');
219
+ if (typeof mod?.createPrompt === 'function' || typeof mod?.fixPayload === 'function'
220
+ || typeof mod?.engine === 'string' || Array.isArray(mod?.modalities))
221
+ matches.push('media');
222
+ if (typeof mod?.fetch === 'function'
223
+ && (mod.type === undefined || mod.type === 'api' || mod.type === 'token-plan' || mod.type === 'local'))
224
+ matches.push('usage');
225
+ if (mod?.tools && typeof mod.tools === 'object' && Object.keys(mod.tools).length > 0)
226
+ matches.push('ui');
227
+ return matches;
228
+ }
229
+ function inspectPlugin(filename, bytes) {
230
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mafw-hub-sniff-'));
231
+ const tmpFile = path.join(tmpDir, filename);
232
+ try {
233
+ fs.writeFileSync(tmpFile, bytes);
234
+ try {
235
+ delete require.cache[require.resolve(tmpFile)];
236
+ }
237
+ catch { /* first load */ }
238
+ let mod;
239
+ try {
240
+ mod = require(tmpFile);
241
+ }
242
+ catch (err) {
243
+ throw new HubError(400, `plugin failed to load: ${err.message}`);
244
+ }
245
+ return { matches: computeMatches(mod), mod };
246
+ }
247
+ finally {
248
+ try {
249
+ fs.rmSync(tmpDir, { recursive: true, force: true });
250
+ }
251
+ catch { /* best effort */ }
252
+ }
253
+ }
254
+ function validateName(mod, filename) {
255
+ const name = mod?.name;
256
+ if (typeof name !== 'string' || name.length === 0)
257
+ throw new HubError(400, 'missing plugin name');
258
+ const stem = baseName(filename);
259
+ if (name !== stem) {
260
+ throw new HubError(400, `plugin name mismatch: exports '${name}', filename '${filename}' (must match)`);
261
+ }
262
+ }
263
+ const VALID_MODALITIES = new Set(['image', 'video', 'audio']);
264
+ const STUB_MEDIA_CTX = {
265
+ apiKey: () => null,
266
+ fetch: (url, opts) => fetch(url, opts),
267
+ pluginConfig: () => null,
268
+ log: logger_1.log,
269
+ };
270
+ async function validateMediaActivation(mod) {
271
+ const modalities = mod?.modalities;
272
+ if (!Array.isArray(modalities) || modalities.length === 0
273
+ || !modalities.every((m) => VALID_MODALITIES.has(m))) {
274
+ throw new HubError(400, 'invalid modalities');
275
+ }
276
+ const hasCreatePrompt = typeof mod?.createPrompt === 'function';
277
+ const hasPiEngine = mod?.engine === 'pi';
278
+ if (!hasCreatePrompt && !hasPiEngine)
279
+ throw new HubError(400, 'missing createPrompt() or engine:"pi"');
280
+ if (hasCreatePrompt && hasPiEngine)
281
+ throw new HubError(400, 'createPrompt and engine:"pi" are mutually exclusive');
282
+ if (hasCreatePrompt) {
283
+ const fn = await mod.createPrompt(STUB_MEDIA_CTX);
284
+ if (typeof fn !== 'function')
285
+ throw new HubError(400, 'createPrompt did not return a function');
286
+ }
287
+ }
166
288
  async function setPluginEnabled(deps, input) {
167
289
  const type = parseType(input.type);
168
290
  const filename = validateFilename(input.filename);
169
291
  const dir = deps.dirs[type];
170
- const current = resolveInDir(dir, filename);
171
- if (!fs.existsSync(current))
292
+ // Accept either on-disk state: a stale client snapshot (double-toggled
293
+ // switch, unrefreshed list) sends the opposite-state filename, which must
294
+ // resolve idempotently instead of 404ing ("激活 not found").
295
+ const primary = resolveInDir(dir, filename);
296
+ const variant = /\.js\.disabled$/.test(filename)
297
+ ? filename.replace(/\.js\.disabled$/, '.js')
298
+ : filename.replace(/\.js$/, '.js.disabled');
299
+ const variantPath = resolveInDir(dir, variant);
300
+ const current = fs.existsSync(primary) ? primary : fs.existsSync(variantPath) ? variantPath : null;
301
+ if (!current)
172
302
  throw new HubError(404, `plugin file not found: ${filename}`);
173
- const nextName = input.enabled ? filename.replace(/\.js\.disabled$/, '.js') : filename.replace(/\.js$/, '.js.disabled');
303
+ const currentName = path.basename(current);
304
+ const nextName = input.enabled
305
+ ? currentName.replace(/\.js\.disabled$/, '.js')
306
+ : currentName.replace(/\.js$/, '.js.disabled');
174
307
  const next = resolveInDir(dir, nextName);
175
- fs.renameSync(current, next);
308
+ if (next !== current)
309
+ fs.renameSync(current, next);
176
310
  await deps.reload?.(type);
177
311
  return statEntry(type, dir, nextName);
178
312
  }
@@ -15,6 +15,14 @@ function readBody(req) {
15
15
  req.on('error', reject);
16
16
  });
17
17
  }
18
+ function readBodyBuffer(req) {
19
+ return new Promise((resolve, reject) => {
20
+ const chunks = [];
21
+ req.on('data', (c) => chunks.push(c));
22
+ req.on('end', () => resolve(Buffer.concat(chunks)));
23
+ req.on('error', reject);
24
+ });
25
+ }
18
26
  async function readJsonBody(req) {
19
27
  const raw = await readBody(req);
20
28
  try {
@@ -47,11 +55,13 @@ async function handlePluginsList(_req, res, deps) {
47
55
  }
48
56
  async function handlePluginsInstall(req, res, deps) {
49
57
  await guarded(res, async () => {
50
- const body = await readJsonBody(req);
51
- const entry = await (0, hub_1.installPlugin)(deps.hub, {
52
- type: body.type, filename: body.filename, contentBase64: body.contentBase64, overwrite: !!body.overwrite,
53
- });
54
- logger_1.log.info(`[PluginsHub] installed ${body.type}/${body.filename}`);
58
+ const url = new URL(req.url || '/', 'http://localhost');
59
+ const filename = url.searchParams.get('filename') || '';
60
+ const type = (url.searchParams.get('type') || undefined);
61
+ const overwrite = url.searchParams.get('overwrite') === '1' || url.searchParams.get('overwrite') === 'true';
62
+ const bytes = await readBodyBuffer(req);
63
+ const entry = await (0, hub_1.installPlugin)(deps.hub, { filename, type, bytes, overwrite });
64
+ logger_1.log.info(`[PluginsHub] installed ${entry.type}/${filename}`);
55
65
  return entry;
56
66
  });
57
67
  }
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ /**
3
+ * Mode A 全局广播信封构造器(MafwScheduler.broadcast 的载荷形状)。
4
+ *
5
+ * wire 契约(桌面 renderer / TUI 均按此解析):
6
+ * { type: 'opencode_event', data: { type, properties?, sessionID?, directory?, error?, internal? } }
7
+ *
8
+ * 规则:可选字段缺失时不得出现在 data 上(key 稳定性,消费方用
9
+ * `'x' in data` 判定时不受缺省值污染)。
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.opencodeBroadcast = opencodeBroadcast;
13
+ exports.projectRegisteredEvent = projectRegisteredEvent;
14
+ function opencodeBroadcast(data, internal) {
15
+ return {
16
+ type: 'opencode_event',
17
+ data: {
18
+ ...data,
19
+ ...(data.directory === undefined ? {} : { directory: data.directory }),
20
+ ...(internal ? { internal: true } : {}),
21
+ },
22
+ };
23
+ }
24
+ /**
25
+ * 项目注册广播(桌面 renderer 依此刷新 Rail 项目列表)。
26
+ *
27
+ * 顶层广播必须扁平(无 `data` 键):desktop 剥壳逻辑 `event = raw?.data || raw`
28
+ * 会把 `data` 当作 opencode_event 的内层载荷,`type` 随之丢失——与
29
+ * runtime_switched / user_question 同一约定。
30
+ */
31
+ function projectRegisteredEvent(projectDir) {
32
+ return { type: 'project_registered', projectDir };
33
+ }
@@ -69,7 +69,6 @@ class RuntimePluginLoader {
69
69
  if (!fs.existsSync(this.pluginsDir)) {
70
70
  fs.mkdirSync(this.pluginsDir, { recursive: true });
71
71
  fs.writeFileSync(path.join(this.pluginsDir, 'README.md'), README_CONTENT);
72
- fs.writeFileSync(path.join(this.pluginsDir, 'example.js.disabled'), EXAMPLE_CONTENT);
73
72
  logger_1.log.info(`[RuntimePluginLoader] Created ${this.pluginsDir}`);
74
73
  }
75
74
  }
@@ -90,6 +89,10 @@ class RuntimePluginLoader {
90
89
  this.factories.delete(prev.name);
91
90
  this.meta.delete(prev.name);
92
91
  }
92
+ if (prev?.alias) {
93
+ this.factories.delete(prev.alias);
94
+ this.meta.delete(prev.alias);
95
+ }
93
96
  this.state.delete(file);
94
97
  }
95
98
  }
@@ -123,9 +126,22 @@ class RuntimePluginLoader {
123
126
  const external = mod.external !== false;
124
127
  this.factories.set(name, mod.createRuntime);
125
128
  this.meta.set(name, { capabilities, external });
126
- this.state.set(file, { file, name, status: 'ok', capabilities });
127
129
  loadedNames.add(name);
128
- logger_1.log.info(`[RuntimePluginLoader] Loaded ${file} (${name})`);
130
+ // Filename-stem alias: the plugin hub addresses runtime plugins by
131
+ // filename stem (statEntry baseName) while switch/createRuntime
132
+ // validate by module.exports.name — register both so hub "激活" works
133
+ // regardless of the declared name. The real module name always wins
134
+ // (unconditional set above overwrites an earlier stem alias), and an
135
+ // alias never shadows a builtin.
136
+ const stem = file.replace(/\.js$/, '');
137
+ let alias;
138
+ if (stem !== name && !this.factories.has(stem) && !this.builtins.has(stem)) {
139
+ this.factories.set(stem, mod.createRuntime);
140
+ this.meta.set(stem, { capabilities, external });
141
+ alias = stem;
142
+ }
143
+ this.state.set(file, { file, name, status: 'ok', capabilities, alias });
144
+ logger_1.log.info(`[RuntimePluginLoader] Loaded ${file} (${name})${alias ? ` [alias: ${alias}]` : ''}`);
129
145
  }
130
146
  catch (err) {
131
147
  const prev = this.state.get(file);
@@ -144,6 +160,10 @@ class RuntimePluginLoader {
144
160
  return { createRuntime: builtin.factory, ...builtin };
145
161
  return undefined;
146
162
  }
163
+ /** 已注册内置件名(builtin 文件件不在其中,opencode 为恒等默认由 index.ts 注入 hub)。 */
164
+ getBuiltinNames() {
165
+ return [...this.builtins.keys()];
166
+ }
147
167
  getState() {
148
168
  for (const [name, b] of this.builtins) {
149
169
  this.state.set(`builtin:${name}`, { file: `builtin:${name}`, name, status: 'ok', capabilities: b.capabilities });
@@ -228,13 +248,3 @@ Capabilities declared here gate gateway features declaratively: missing
228
248
  capabilities disable the corresponding features (503 on gated endpoints,
229
249
  skipped event subscription) — they never crash.
230
250
  `;
231
- const EXAMPLE_CONTENT = `// Rename to example.js to activate
232
- module.exports = {
233
- name: "example",
234
- capabilities: { eventStream: false },
235
- async createRuntime(ctx) {
236
- ctx.log.info("[example-runtime] created");
237
- throw new Error("example plugin: implement createRuntime before activating");
238
- },
239
- };
240
- `;
@@ -59,9 +59,19 @@ function creditsInWindow(rows, prices) {
59
59
  const ROLLING_WINDOWS = [
60
60
  ['day', 'day', 24 * 3600e3],
61
61
  ['week', '7d', 7 * 24 * 3600e3],
62
- ['month', 'month', 30 * 24 * 3600e3],
63
62
  ];
64
63
 
64
+ // 月度刷新 = 自然月(每月 1 号 00:00 本地时区重置),非滚动 30 天。
65
+ function calendarMonthStart(now) {
66
+ const d = new Date(now);
67
+ return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
68
+ }
69
+
70
+ function calendarMonthEnd(now) {
71
+ const d = new Date(now);
72
+ return new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime();
73
+ }
74
+
65
75
  module.exports = {
66
76
  name: 'gateway',
67
77
  type: 'token-plan',
@@ -104,7 +114,7 @@ module.exports = {
104
114
  used,
105
115
  limit: lim.credit,
106
116
  unit: 'credit',
107
- pct: Math.round((used / lim.credit) * 100),
117
+ pct: Math.round((used / lim.credit) * 10000) / 100,
108
118
  remaining: Math.max(0, Math.round((lim.credit - used) * 100) / 100),
109
119
  detailLines,
110
120
  });
@@ -118,7 +128,19 @@ module.exports = {
118
128
  used,
119
129
  limit: lim[cfgKey],
120
130
  unit: 'credit',
121
- pct: Math.round((used / lim[cfgKey]) * 100),
131
+ pct: Math.round((used / lim[cfgKey]) * 10000) / 100,
132
+ });
133
+ }
134
+ if (lim.month > 0) {
135
+ const { credits } = creditsInWindow(stats({ provider: 'gateway', sinceMs: calendarMonthStart(Date.now()) }), prices);
136
+ const used = Math.round(credits * 100) / 100;
137
+ windows.push({
138
+ window: 'month',
139
+ used,
140
+ limit: lim.month,
141
+ unit: 'credit',
142
+ pct: Math.round((used / lim.month) * 10000) / 100,
143
+ resetAt: calendarMonthEnd(Date.now()),
122
144
  });
123
145
  }
124
146
  if (windows.length === 0) return null;
@@ -91,7 +91,6 @@ class PluginLoader {
91
91
  if (!fs.existsSync(this.pluginsDir)) {
92
92
  fs.mkdirSync(this.pluginsDir, { recursive: true });
93
93
  fs.writeFileSync(path.join(this.pluginsDir, 'README.md'), README_CONTENT);
94
- fs.writeFileSync(path.join(this.pluginsDir, 'example.js.disabled'), EXAMPLE_CONTENT);
95
94
  logger_1.log.info(`[PluginLoader] Created ${this.pluginsDir}`);
96
95
  }
97
96
  }
@@ -268,13 +267,3 @@ Return \`null\` to hide provider. Builtin plugins live in the package
268
267
  \`dist/usage/builtin-plugins/\`; drop a file with the same \`name\` here to override,
269
268
  or add the name to \`usage.disabledPlugins\` in config to disable.
270
269
  `;
271
- const EXAMPLE_CONTENT = `// Rename to example.js to activate
272
- module.exports = {
273
- name: "example",
274
- type: "api",
275
- plan: "Example Plan",
276
- async fetch(ctx) {
277
- return null; // Hide provider
278
- },
279
- };
280
- `;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mafw-gateway",
3
- "version": "5.0.0",
4
- "description": "MAFW Gateway v5.0 — SSE Event-Driven Scheduler",
3
+ "version": "4.8.0",
4
+ "description": "MAFW Gateway v5.0 —SSE Event-Driven Scheduler",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
7
7
  "build": "tsc && node -e \"const fs=require('fs'),p=require('path');const cp=(s,d)=>{if(fs.existsSync(s)){fs.cpSync(s,d,{recursive:true});console.log('[copy] '+d)}else{console.log('[skip] '+s)};};cp(p.join(__dirname,'src','dashboard','public'),p.join(__dirname,'dist','dashboard','public'));cp(p.join(__dirname,'src','tray','tray.ps1'),p.join(__dirname,'dist','tray','tray.ps1'));cp(p.join(__dirname,'src','usage','builtin-plugins'),p.join(__dirname,'dist','usage','builtin-plugins'));\"",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jack200714/mafw",
3
- "version": "4.5.2",
3
+ "version": "4.8.0",
4
4
  "description": "MAFW Loop Agent Plugin for OpenCode - Phase Relay + TMEM + Dynamic Compression",
5
5
  "repository": {
6
6
  "type": "git",
@@ -455,10 +455,15 @@ var require_client = __commonJS({
455
455
  plugins = {
456
456
  list: async () => this.request("/api/plugins"),
457
457
  install: async (input) => {
458
- const res = await this.fetchImpl(`${this.baseUrl}/api/plugins/install`, {
458
+ const params = new URLSearchParams({ filename: input.filename });
459
+ if (input.type)
460
+ params.set("type", input.type);
461
+ if (input.overwrite)
462
+ params.set("overwrite", "1");
463
+ const res = await this.fetchImpl(`${this.baseUrl}/api/plugins/install?${params.toString()}`, {
459
464
  method: "POST",
460
- headers: { "Content-Type": "application/json" },
461
- body: JSON.stringify({ ...input, overwrite: !!input.overwrite })
465
+ headers: { "Content-Type": "application/octet-stream" },
466
+ body: input.bytes
462
467
  });
463
468
  if (!res.ok) {
464
469
  const body = await res.json().catch(() => ({}));