Procházet zdrojové kódy

更新企微技能包个人版esm模块回调

gangvy před 1 měsícem
rodič
revize
ff01563340
22 změnil soubory, kde provedl 1042 přidání a 165 odebrání
  1. 3 0
      claude-code/claude-code-qiwe-assistant/.env.example
  2. 15 3
      claude-code/claude-code-qiwe-assistant/README.md
  3. 7 0
      claude-code/claude-code-qiwe-assistant/install.js
  4. 7 1
      claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-processor.js
  5. 95 3
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js
  6. 3 0
      claude-code/claude-code-qiwe-assistant/package.json
  7. 24 0
      claude-code/claude-code-qiwe-assistant/qiwei.runtime.config.example.mjs
  8. 6 0
      claude-code/claude-code-qiwe-assistant/runtime/callback-service/package.json
  9. 98 0
      claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/config-loader.mjs
  10. 177 0
      claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/enterprise-relay-client.mjs
  11. 165 0
      claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/index.mjs
  12. 103 0
      claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/personal-polling.mjs
  13. 1 0
      claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/personal-runtime.mjs
  14. 63 0
      claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/processor-bridge.mjs
  15. 100 0
      claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/runtime-state.mjs
  16. 108 0
      claude-code/claude-code-qiwe-assistant/scripts/callback-runtime-smoke-test.mjs
  17. 7 0
      claude-code/claude-code-qiwe-assistant/scripts/package-smoke-test.js
  18. 20 0
      claude-code/claude-code-qiwe-assistant/scripts/preview-dashboard.js
  19. 1 0
      claude-code/claude-code-qiwe-assistant/scripts/release-check.js
  20. 8 0
      claude-code/claude-code-qiwe-assistant/scripts/start-callback-runtime.mjs
  21. 22 153
      claude-code/claude-code-qiwe-assistant/scripts/start-relay-client.js
  22. 9 5
      claude-code/claude-code-qiwe-assistant/skills/qiwei-webhook-relay/SKILL.md

+ 3 - 0
claude-code/claude-code-qiwe-assistant/.env.example

@@ -75,3 +75,6 @@ CLAUDE_CODE_ALLOWED_TOOLS=Read,Glob,Grep
 # 可选:绑定项目主控 Claude Code Session。未配置时仍会生成项目级控制器标识,客户 Session 保持独立。
 QIWEI_AGENT_PROJECT_ID=
 QIWEI_AGENT_MAIN_SESSION_ID=
+
+# Optional ESM runtime config path. The installer creates qiwei.runtime.config.mjs in the customer project.
+QIWEI_RUNTIME_CONFIG=

+ 15 - 3
claude-code/claude-code-qiwe-assistant/README.md

@@ -2,8 +2,10 @@
 
 技能包支持两种产品运行模式:
 
-1. **个人版**:本地主动监听、单企微设备和本地数据;
-2. **企业版**:服务端统一回调、多企微设备消息归集、租户隔离和企业管理底座。
+1. **个人版**:ESM Runtime 在客户主机主动轮询,单企微设备和本地数据;
+2. **企业版**:ESM Runtime 从中央 Relay 拉取统一回调,多企微设备消息归集、租户隔离和企业管理底座。
+
+登录、联系人、群聊、发送消息和文件等业务接口,两种版本都通过 Fmode/Future Server 网关调用。版本差异只在消息接收、存储和设备管理方式。
 
 使用 `qiwei_product_mode_status` 查看模式,使用 `qiwei_product_mode_set` 切换。企业版属于独立增值服务,当前报价接口金额为 0 元占位,和企微账号席位费分开。
 
@@ -178,6 +180,16 @@ Claude Code / MCP
 
 ### Webhook 与 Relay
 
+运行时统一入口:
+
+```bash
+npm run runtime
+npm run runtime:status
+npm run runtime:stop
+```
+
+`npm run preview` 会在新启动 4320 工作台时自动嵌入 ESM Runtime,不增加用户操作步骤。个人版启动本地 Agent Poller;企业版启动 Relay Client。旧命令 `npm run relay` 保留兼容,内部同样进入企业版 ESM Runtime。
+
 | 工具 | 说明 |
 | --- | --- |
 | `qiwei_webhook_server_start` | 启动本地 webhook server |
@@ -185,7 +197,7 @@ Claude Code / MCP
 | `qiwei_webhook_status` | 查询 webhook 状态 |
 | `qiwei_webhook_discover` | 获取本地 webhook 回调地址 |
 | `qiwei_webhook_auto_setup` | 企业版注册设备并连接服务端统一回调 |
-| `qiwei_webhook_setup` | 个人版隔离部署的显式直连回调 |
+| `qiwei_webhook_setup` | 旧隔离部署的显式直连回调(不属于个人版标准流程) |
 | `qiwei_relay_config` | 读取 relay 配置 |
 | `qiwei_relay_save_config` | 保存 relay 公钥/租户配置 |
 | `qiwei_relay_register` | 注册 Relay 租户并保存凭证 |

+ 7 - 0
claude-code/claude-code-qiwe-assistant/install.js

@@ -40,6 +40,8 @@ function assertRequiredFiles() {
     '.claude-plugin/plugin.json',
     'skill-package-manifest.json',
     'mcp/src/server.js',
+    'runtime/callback-service/src/index.mjs',
+    'qiwei.runtime.config.example.mjs',
     'skills/qiwei-dashboard/SKILL.md',
     'skills/qiwei-goal-management/SKILL.md',
     'skills/qiwei-real-estate-auto-reply/SKILL.md',
@@ -120,6 +122,10 @@ function installWorkspace(targetInput, options = {}) {
   }
 
   const mcpPath = path.join(target, '.mcp.json');
+  const runtimeConfigPath = path.join(target, 'qiwei.runtime.config.mjs');
+  if (!fs.existsSync(runtimeConfigPath)) {
+    fs.copyFileSync(path.join(ROOT, 'qiwei.runtime.config.example.mjs'), runtimeConfigPath);
+  }
   const mcp = readJson(mcpPath, { mcpServers: {} });
   mcp.mcpServers ||= {};
   mcp.mcpServers['qiwei-assistant'] = {
@@ -130,6 +136,7 @@ function installWorkspace(targetInput, options = {}) {
       QIWEI_WORKSPACE_ROOT: target,
       QIWEI_OUTPUTS_DIR: path.join(target, 'outputs'),
       CLAUDE_CODE_WORKDIR: target,
+      QIWEI_RUNTIME_CONFIG: runtimeConfigPath,
     },
   };
   writeJson(mcpPath, mcp);

+ 7 - 1
claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-processor.js

@@ -628,7 +628,7 @@ async function processWebhookEvents(envelope) {
     }
 
     const eventFilePath = saveWebhookEventStructured(event, envelope.source || 'callback', envelope.__rawBody);
-    markEventProcessed(event.eventId);
+    let completed = false;
 
     try {
       const config = readWebhookConfig();
@@ -639,6 +639,7 @@ async function processWebhookEvents(envelope) {
         if (msgResult.success) result.processed++;
         else if (msgResult.ignored) result.ignored++;
         else result.errors++;
+        completed = Boolean(msgResult.success || msgResult.ignored);
         continue;
       }
 
@@ -647,6 +648,7 @@ async function processWebhookEvents(envelope) {
         if (groupResult.success) result.processed++;
         else if (groupResult.ignored) result.ignored++;
         else result.errors++;
+        completed = Boolean(groupResult.success || groupResult.ignored);
         continue;
       }
 
@@ -658,15 +660,19 @@ async function processWebhookEvents(envelope) {
         if (autoResult.success) result.processed++;
         else if (autoResult.ignored) result.ignored++;
         else result.errors++;
+        completed = Boolean(autoResult.success || autoResult.ignored);
         continue;
       }
 
       updateWebhookEventStatus(eventFilePath, 'IGNORED', `事件类型 ${event.parsedType} 不需要自动处理`);
       result.ignored++;
+      completed = true;
     } catch (err) {
       console.error('[Webhook] 事件处理异常:', err && err.message ? err.message : err);
       updateWebhookEventStatus(eventFilePath, 'ERROR', String(err && err.message ? err.message : err));
       result.errors++;
+    } finally {
+      if (completed) markEventProcessed(event.eventId);
     }
   }
 

+ 95 - 3
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js

@@ -1,7 +1,7 @@
 const fs = require('fs');
 const path = require('path');
 const crypto = require('crypto');
-const { PACKAGE_ROOT, WORKSPACE_ROOT, latestPath } = require('../core/output-paths');
+const { PACKAGE_ROOT, WORKSPACE_ROOT, latestPath, outputsRoot } = require('../core/output-paths');
 const { AgentWorkbenchDb } = require('../core/agent-workbench-db');
 const { AgentKnowledgeStore } = require('../core/agent-knowledge');
 const { QiweiAgentRuntime, extractExplicitCustomerIntelligence } = require('../core/agent-runtime');
@@ -42,6 +42,43 @@ function readEnvFile(filePath) {
   }
 }
 
+function readRuntimeState() {
+  try {
+    const filePath = path.join(outputsRoot(), 'runtime', 'qiwei-runtime.json');
+    return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
+  } catch {
+    return {};
+  }
+}
+
+function runtimeProcessAlive(pid) {
+  const numericPid = Number(pid);
+  if (!Number.isInteger(numericPid) || numericPid <= 0) return false;
+  try {
+    process.kill(numericPid, 0);
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+function listenerStateWithRuntime(product, localState) {
+  const runtime = readRuntimeState();
+  if (!runtimeProcessAlive(runtime.pid) || !['starting', 'running'].includes(runtime.status)) return localState;
+  const component = product.mode === 'enterprise'
+    ? runtime.components?.enterpriseRelay
+    : runtime.components?.personalPolling;
+  if (!component) return localState;
+  return {
+    ...localState,
+    running: Boolean(localState.running || ['starting', 'running'].includes(component.status)),
+    lastError: component.lastError || localState.lastError || '',
+    transport: runtime.transport,
+    runtimePid: runtime.pid,
+    runtimeStatus: component.status,
+  };
+}
+
 function readClaudeSettingsEnv() {
   const result = {};
   const home = process.env.USERPROFILE || process.env.HOME || '';
@@ -222,6 +259,7 @@ class QiweiAgentPoller {
     this.loopPromise = null;
     this.startedAt = 0;
     this.lastError = '';
+    this.wakeLoop = null;
   }
 
   status() {
@@ -251,10 +289,25 @@ class QiweiAgentPoller {
   stop() {
     if (!this.running) return this.status();
     this.running = false;
+    if (this.wakeLoop) this.wakeLoop();
     this.db.audit({ actor: 'runtime', action: 'poller_stopped', detail: this.status() });
     return this.status();
   }
 
+  waitInterval() {
+    return new Promise(resolve => {
+      const timer = setTimeout(() => {
+        this.wakeLoop = null;
+        resolve();
+      }, this.config.intervalMs);
+      this.wakeLoop = () => {
+        clearTimeout(timer);
+        this.wakeLoop = null;
+        resolve();
+      };
+    });
+  }
+
   async establishBaseline() {
     let cursor = 0;
     let reachedEnd = false;
@@ -293,7 +346,7 @@ class QiweiAgentPoller {
         this.lastError = error.message;
         this.db.audit({ actor: 'runtime', action: 'poller_error', detail: { message: error.message } });
       }
-      if (this.running) await delay(this.config.intervalMs);
+      if (this.running) await this.waitInterval();
     }
   }
 
@@ -824,12 +877,13 @@ async function getAgentStatus() {
   const account = await detectAccountStatus();
   const state = workbench.service.state(workbench.poller.status());
   const product = getProductMode();
+  const listener = listenerStateWithRuntime(product, state.poller);
   return {
     status: 'ok',
     data: {
       globalMode: state.global.paused ? 'paused' : state.global.defaultMode,
       global: state.global,
-      listener: state.poller,
+      listener,
       account,
       product,
       agent: state.agent,
@@ -1345,12 +1399,49 @@ async function startListenerForWorkbench(target, account) {
   };
 }
 
+async function ingestRuntimeMessage(message = {}, options = {}) {
+  await workbench.poller.process({
+    ...message,
+    _runtimeSource: String(options.source || 'runtime'),
+  });
+  return {
+    status: 'ok',
+    data: {
+      source: String(options.source || 'runtime'),
+      messageId: String(message.msgUniqueIdentifier || message.msgServerId || ''),
+    },
+  };
+}
+
 async function startListener() {
+  const product = getProductMode();
+  if (product.mode === 'enterprise') {
+    const runtime = listenerStateWithRuntime(product, workbench.poller.status());
+    if (!runtime.running) throw new Error('Enterprise Relay runtime is not running. Start the Qiwei runtime first.');
+    workbench.service.setGlobal({ paused: false, defaultMode: 'review' });
+    return {
+      status: 'ok',
+      assistantMessage: 'Enterprise Relay keeps collecting messages; AI review and reply processing is enabled.',
+      data: runtime,
+    };
+  }
   const account = await detectAccountStatus(true);
   return startListenerForWorkbench(workbench, account);
 }
 
 function stopListener() {
+  const product = getProductMode();
+  if (product.mode === 'enterprise') {
+    workbench.service.setGlobal({ paused: true, defaultMode: 'review' });
+    for (const conversation of workbench.db.listConversations()) {
+      workbench.service.setConversationMode(conversation.id, 'human');
+    }
+    return {
+      status: 'ok',
+      assistantMessage: 'Enterprise Relay continues collecting messages; AI replies are paused and conversations are in human mode.',
+      data: { running: false, relayRunning: true, transport: 'server_relay' },
+    };
+  }
   const status = workbench.poller.stop();
   workbench.service.setGlobal({ paused: false, defaultMode: 'review' });
   for (const conversation of workbench.db.listConversations()) {
@@ -1391,6 +1482,7 @@ module.exports = {
   syncCustomerTaskToOfficialTodo,
   updateCustomerAlert,
   getAudit,
+  ingestRuntimeMessage,
   startListener,
   stopListener,
   getAgentRuntimeConfig,

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 3 - 0
claude-code/claude-code-qiwe-assistant/package.json


+ 24 - 0
claude-code/claude-code-qiwe-assistant/qiwei.runtime.config.example.mjs

@@ -0,0 +1,24 @@
+export default {
+  // "auto" follows QIWEI_PRODUCT_MODE. Customer projects normally keep this unchanged.
+  edition: 'auto',
+  dashboard: {
+    enabled: true,
+    port: 4320,
+  },
+  personal: {
+    polling: {
+      enabled: true,
+      retryMs: 10000,
+      friendPollingEnabled: true,
+    },
+  },
+  enterprise: {
+    relay: {
+      enabled: true,
+      batchSize: 100,
+      waitMs: 30000,
+      retryMinMs: 1000,
+      retryMaxMs: 60000,
+    },
+  },
+};

+ 6 - 0
claude-code/claude-code-qiwe-assistant/runtime/callback-service/package.json

@@ -0,0 +1,6 @@
+{
+  "name": "@fmode/qiwei-callback-runtime",
+  "private": true,
+  "type": "module",
+  "main": "src/index.mjs"
+}

+ 98 - 0
claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/config-loader.mjs

@@ -0,0 +1,98 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { createRequire } from 'node:module';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+const require = createRequire(import.meta.url);
+const SOURCE_DIR = path.dirname(fileURLToPath(import.meta.url));
+export const PACKAGE_ROOT = path.resolve(SOURCE_DIR, '..', '..', '..');
+
+const { resolveWorkspaceRoot } = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'core', 'runtime-context.js'));
+const { getProductMode, normalizeProductMode } = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'core', 'product-mode.js'));
+
+const DEFAULT_CONFIG = Object.freeze({
+  edition: 'auto',
+  dashboard: Object.freeze({
+    enabled: true,
+    port: 4320,
+  }),
+  personal: Object.freeze({
+    polling: Object.freeze({
+      enabled: true,
+      retryMs: 10000,
+      friendPollingEnabled: true,
+    }),
+  }),
+  enterprise: Object.freeze({
+    relay: Object.freeze({
+      enabled: true,
+      batchSize: 100,
+      waitMs: 30000,
+      retryMinMs: 1000,
+      retryMaxMs: 60000,
+    }),
+  }),
+});
+
+function mergeConfig(base, override) {
+  if (!override || typeof override !== 'object' || Array.isArray(override)) return base;
+  const output = { ...base };
+  for (const [key, value] of Object.entries(override)) {
+    output[key] = value && typeof value === 'object' && !Array.isArray(value)
+      ? mergeConfig(base?.[key] || {}, value)
+      : value;
+  }
+  return output;
+}
+
+function normalizeConfig(config = {}) {
+  const merged = mergeConfig(structuredClone(DEFAULT_CONFIG), config);
+  merged.dashboard.port = Math.max(1, Math.min(65535, Number(merged.dashboard.port) || 4320));
+  merged.personal.polling.retryMs = Math.max(3000, Number(merged.personal.polling.retryMs) || 10000);
+  merged.enterprise.relay.batchSize = Math.max(1, Math.min(500, Number(merged.enterprise.relay.batchSize) || 100));
+  merged.enterprise.relay.waitMs = Math.max(1000, Math.min(60000, Number(merged.enterprise.relay.waitMs) || 30000));
+  merged.enterprise.relay.retryMinMs = Math.max(250, Number(merged.enterprise.relay.retryMinMs) || 1000);
+  merged.enterprise.relay.retryMaxMs = Math.max(merged.enterprise.relay.retryMinMs, Number(merged.enterprise.relay.retryMaxMs) || 60000);
+  return merged;
+}
+
+export function resolveRuntimeMode(config = {}, forceMode = '') {
+  const forced = normalizeProductMode(forceMode);
+  if (forced) return forced;
+  const configured = normalizeProductMode(config.edition);
+  if (configured) return configured;
+  return getProductMode().mode;
+}
+
+export async function loadRuntimeConfig(options = {}) {
+  const workspaceRoot = resolveWorkspaceRoot({
+    packageRoot: PACKAGE_ROOT,
+    workspaceRoot: options.workspaceRoot,
+  });
+  const explicitPath = String(options.configPath || process.env.QIWEI_RUNTIME_CONFIG || '').trim();
+  const projectPath = path.join(workspaceRoot, 'qiwei.runtime.config.mjs');
+  const examplePath = path.join(PACKAGE_ROOT, 'qiwei.runtime.config.example.mjs');
+  const configPath = explicitPath
+    ? path.resolve(workspaceRoot, explicitPath)
+    : fs.existsSync(projectPath)
+      ? projectPath
+      : examplePath;
+
+  let loaded = {};
+  if (fs.existsSync(configPath)) {
+    const version = fs.statSync(configPath).mtimeMs;
+    const module = await import(`${pathToFileURL(configPath).href}?v=${version}`);
+    loaded = module.default || {};
+  }
+
+  const config = normalizeConfig(loaded);
+  return {
+    config,
+    configPath,
+    workspaceRoot,
+    packageRoot: PACKAGE_ROOT,
+    mode: resolveRuntimeMode(config, options.forceMode),
+  };
+}
+
+export { DEFAULT_CONFIG, mergeConfig, normalizeConfig };

+ 177 - 0
claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/enterprise-relay-client.mjs

@@ -0,0 +1,177 @@
+import crypto from 'node:crypto';
+import path from 'node:path';
+import { createRequire } from 'node:module';
+import { PACKAGE_ROOT } from './config-loader.mjs';
+
+const require = createRequire(import.meta.url);
+const relayConfig = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'core', 'relay-config.js'));
+const { readQiweiGuid } = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'core', 'credentials.js'));
+export function decryptPayload(encryptedPayload, privateKeyPem) {
+  const key = privateKeyPem && typeof privateKeyPem === 'object' && privateKeyPem.type === 'private'
+    ? privateKeyPem
+    : crypto.createPrivateKey(privateKeyPem);
+  if (String(encryptedPayload).startsWith('v2:')) {
+    const envelope = JSON.parse(Buffer.from(String(encryptedPayload).slice(3), 'base64').toString('utf8'));
+    const aesKey = crypto.privateDecrypt(
+      { key, oaepHash: 'sha256' },
+      Buffer.from(envelope.key, 'base64'),
+    );
+    const decipher = crypto.createDecipheriv('aes-256-gcm', aesKey, Buffer.from(envelope.iv, 'base64'));
+    decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
+    return Buffer.concat([
+      decipher.update(Buffer.from(envelope.ciphertext, 'base64')),
+      decipher.final(),
+    ]).toString('utf8');
+  }
+  return crypto.privateDecrypt(
+    { key, oaepHash: 'sha256' },
+    Buffer.from(encryptedPayload, 'base64'),
+  ).toString('utf8');
+}
+
+function resolveRelayContext(explicitGuid = '') {
+  return {
+    baseUrl: relayConfig.getRelayBaseUrl(),
+    apiSecret: relayConfig.getTenantApiSecret(),
+    privateKey: relayConfig.getRelayPrivateKey(),
+    tenantId: relayConfig.getTenantId(),
+    guid: String(explicitGuid || relayConfig.getRelayDeviceGuid() || readQiweiGuid() || '').trim(),
+  };
+}
+
+async function ackEvents(context, eventIds, signal, fetchImpl = fetch) {
+  if (!eventIds.length) return 0;
+  const response = await fetchImpl(`${context.baseUrl}/api/relay/ack`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      Authorization: `Bearer ${context.apiSecret}`,
+    },
+    body: JSON.stringify({ guid: context.guid, eventIds }),
+    signal,
+  });
+  if (!response.ok) throw new Error(`Relay ACK failed: HTTP ${response.status}`);
+  const data = await response.json();
+  return Number(data.ackedCount || eventIds.length);
+}
+
+export class EnterpriseRelayRuntime {
+  constructor({ config, guid = '', onState = () => {}, processor = null, fetchImpl = fetch }) {
+    this.config = config;
+    this.guid = guid;
+    this.onState = onState;
+    this.processor = processor;
+    this.fetchImpl = fetchImpl;
+    this.running = false;
+    this.abortController = null;
+    this.loopPromise = null;
+    this.cancelWait = null;
+  }
+
+  wait(ms) {
+    return new Promise(resolve => {
+      const timer = setTimeout(() => {
+        this.cancelWait = null;
+        resolve();
+      }, ms);
+      this.cancelWait = () => {
+        clearTimeout(timer);
+        this.cancelWait = null;
+        resolve();
+      };
+    });
+  }
+
+  async pollOnce(context) {
+    const processRelayEnvelope = this.processor || (await import('./processor-bridge.mjs')).processRelayEnvelope;
+    this.abortController = new AbortController();
+    const response = await this.fetchImpl(`${context.baseUrl}/api/relay/poll`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Authorization: `Bearer ${context.apiSecret}`,
+      },
+      body: JSON.stringify({
+        guid: context.guid,
+        batchSize: this.config.batchSize,
+        waitMs: this.config.waitMs,
+      }),
+      signal: this.abortController.signal,
+    });
+    if (!response.ok) throw new Error(`Relay poll failed: HTTP ${response.status}`);
+    const data = await response.json();
+    const events = Array.isArray(data.events) ? data.events : [];
+    const processedIds = [];
+    const failures = [];
+
+    for (const event of events) {
+      try {
+        const decrypted = decryptPayload(event.encryptedPayload, context.privateKey);
+        const payload = JSON.parse(decrypted);
+        const envelope = {
+          code: 0,
+          msg: 'from-relay',
+          data: Array.isArray(payload) ? payload : [payload],
+          __rawBody: decrypted,
+        };
+        await processRelayEnvelope(envelope);
+        processedIds.push(event.eventId);
+      } catch (error) {
+        failures.push({ eventId: event.eventId, message: error.message });
+      }
+    }
+
+    const acked = await ackEvents(context, processedIds, this.abortController.signal, this.fetchImpl);
+    if (failures.length) throw new Error(`Relay retained ${failures.length} failed event(s): ${failures[0].message}`);
+    return { fetched: events.length, acked };
+  }
+
+  async loop() {
+    let backoff = this.config.retryMinMs;
+    while (this.running) {
+      const context = resolveRelayContext(this.guid);
+      const missing = ['apiSecret', 'privateKey', 'tenantId', 'guid'].filter(key => !context[key]);
+      if (missing.length) {
+        this.onState({ enterpriseRelay: { status: 'waiting', lastError: `Missing ${missing.join(', ')}` } });
+        await this.wait(Math.min(this.config.retryMaxMs, Math.max(5000, backoff)));
+        backoff = Math.min(backoff * 2, this.config.retryMaxMs);
+        continue;
+      }
+
+      try {
+        const result = await this.pollOnce(context);
+        this.onState({
+          enterpriseRelay: {
+            status: 'running',
+            guid: context.guid,
+            lastFetched: result.fetched,
+            lastAcked: result.acked,
+            lastPollAt: new Date().toISOString(),
+            lastError: '',
+          },
+        });
+        backoff = this.config.retryMinMs;
+      } catch (error) {
+        if (!this.running && error.name === 'AbortError') break;
+        this.onState({ enterpriseRelay: { status: 'error', lastError: error.message } });
+        await this.wait(backoff);
+        backoff = Math.min(backoff * 2, this.config.retryMaxMs);
+      }
+    }
+  }
+
+  start() {
+    if (this.running) return;
+    this.running = true;
+    this.onState({ enterpriseRelay: { status: 'starting', lastError: '' } });
+    this.loopPromise = this.loop();
+  }
+
+  async stop() {
+    this.running = false;
+    if (this.cancelWait) this.cancelWait();
+    this.abortController?.abort();
+    this.onState({ enterpriseRelay: { status: 'stopped' } });
+    await this.loopPromise;
+  }
+}

+ 165 - 0
claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/index.mjs

@@ -0,0 +1,165 @@
+import { loadRuntimeConfig } from './config-loader.mjs';
+import {
+  assertRuntimeAvailable,
+  clearRuntimeStopRequest,
+  isProcessAlive,
+  readRuntimeStopRequest,
+  readRuntimeState,
+  runtimeStatePath,
+  stopRuntimeProcess,
+  writeRuntimeState,
+} from './runtime-state.mjs';
+
+let activeRuntime = null;
+
+function mergeComponents(current = {}, patch = {}) {
+  const output = { ...current };
+  for (const [name, value] of Object.entries(patch)) {
+    output[name] = { ...(current[name] || {}), ...value, updatedAt: new Date().toISOString() };
+  }
+  return output;
+}
+
+export async function startRuntime(options = {}) {
+  if (activeRuntime) return activeRuntime;
+  const loaded = await loadRuntimeConfig(options);
+  const statePath = runtimeStatePath(options.statePath);
+  assertRuntimeAvailable(statePath);
+  clearRuntimeStopRequest(statePath);
+
+  let state = writeRuntimeState({
+    pid: process.pid,
+    status: 'starting',
+    mode: loaded.mode,
+    transport: loaded.mode === 'enterprise' ? 'server_relay' : 'local_polling',
+    workspaceRoot: loaded.workspaceRoot,
+    configPath: loaded.configPath,
+    startedAt: new Date().toISOString(),
+    components: {},
+  }, statePath);
+
+  const updateComponents = patch => {
+    state = writeRuntimeState({
+      ...state,
+      components: mergeComponents(state.components, patch),
+    }, statePath);
+  };
+
+  let dashboard = null;
+  if (options.dashboard !== false && loaded.config.dashboard.enabled) {
+    const { startDashboard } = await import('./processor-bridge.mjs');
+    dashboard = await startDashboard(loaded.config.dashboard.port);
+    updateComponents({ dashboard: { status: 'running', port: loaded.config.dashboard.port } });
+  }
+
+  let controller;
+  if (loaded.mode === 'enterprise') {
+    const { EnterpriseRelayRuntime } = await import('./enterprise-relay-client.mjs');
+    controller = new EnterpriseRelayRuntime({
+        config: loaded.config.enterprise.relay,
+        guid: options.guid || '',
+        onState: updateComponents,
+      });
+  } else {
+    const { PersonalRuntime } = await import('./personal-runtime.mjs');
+    controller = new PersonalRuntime({
+        config: loaded.config.personal.polling,
+        workspaceRoot: loaded.workspaceRoot,
+        onState: updateComponents,
+      });
+  }
+
+  if (options.dryRun !== true) controller.start();
+  state = writeRuntimeState({ ...state, status: options.dryRun ? 'ready' : 'running' }, statePath);
+
+  let stopping = false;
+  let stopWatcher = null;
+  const stop = async reason => {
+    if (stopping) return;
+    stopping = true;
+    if (stopWatcher) clearInterval(stopWatcher);
+    clearRuntimeStopRequest(statePath);
+    state = writeRuntimeState({ ...state, status: 'stopping', stopReason: reason || 'requested' }, statePath);
+    await controller.stop();
+    if (dashboard?.server) {
+      await new Promise(resolve => dashboard.server.close(resolve));
+    }
+    state = writeRuntimeState({
+      ...state,
+      status: 'stopped',
+      stoppedAt: new Date().toISOString(),
+    }, statePath);
+    activeRuntime = null;
+  };
+
+  stopWatcher = setInterval(() => {
+    const request = readRuntimeStopRequest(statePath);
+    if (!request.requestedAt) return;
+    if (request.targetPid && Number(request.targetPid) !== process.pid) return;
+    void stop('external-stop');
+  }, 500);
+
+  activeRuntime = {
+    mode: loaded.mode,
+    transport: state.transport,
+    config: loaded.config,
+    configPath: loaded.configPath,
+    workspaceRoot: loaded.workspaceRoot,
+    statePath,
+    controller,
+    dashboard,
+    stop,
+  };
+  return activeRuntime;
+}
+
+function parseArgs(argv) {
+  const command = argv.find(arg => !arg.startsWith('-')) || 'start';
+  const modeArg = argv.find(arg => arg.startsWith('--mode='));
+  const guidArg = argv.find(arg => arg.startsWith('--guid='));
+  return {
+    command,
+    forceMode: modeArg ? modeArg.slice('--mode='.length) : '',
+    guid: guidArg ? guidArg.slice('--guid='.length) : '',
+    dashboard: !argv.includes('--no-dashboard'),
+    dryRun: argv.includes('--dry-run'),
+  };
+}
+
+export async function runCli(argv = process.argv.slice(2)) {
+  const options = parseArgs(argv);
+  if (options.command === 'status') {
+    const state = readRuntimeState();
+    process.stdout.write(`${JSON.stringify({ ...state, alive: isProcessAlive(state.pid) }, null, 2)}\n`);
+    return;
+  }
+  if (options.command === 'stop') {
+    const result = stopRuntimeProcess();
+    let state = readRuntimeState();
+    for (let attempt = 0; result.requested && attempt < 50; attempt += 1) {
+      await new Promise(resolve => setTimeout(resolve, 100));
+      state = readRuntimeState();
+      if (state.status === 'stopped' || !isProcessAlive(state.pid)) break;
+    }
+    process.stdout.write(`${JSON.stringify({ ...result, status: state.status || 'unknown' }, null, 2)}\n`);
+    return;
+  }
+  if (options.command !== 'start') throw new Error('Use start, status, or stop.');
+
+  const runtime = await startRuntime(options);
+  process.stdout.write(`${JSON.stringify({
+    status: options.dryRun ? 'ready' : 'running',
+    pid: process.pid,
+    mode: runtime.mode,
+    transport: runtime.transport,
+    dashboard: runtime.dashboard?.url || null,
+    statePath: runtime.statePath,
+  }, null, 2)}\n`);
+
+  if (options.dryRun) await runtime.stop('dry-run');
+  const shutdown = signal => runtime.stop(signal).finally(() => process.exit(0));
+  process.once('SIGINT', () => shutdown('SIGINT'));
+  process.once('SIGTERM', () => shutdown('SIGTERM'));
+}
+
+export { readRuntimeState };

+ 103 - 0
claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/personal-polling.mjs

@@ -0,0 +1,103 @@
+import path from 'node:path';
+import { spawn } from 'node:child_process';
+import { PACKAGE_ROOT } from './config-loader.mjs';
+import {
+  getPersonalListenerStatus,
+  startPersonalListener,
+  stopPersonalListener,
+} from './processor-bridge.mjs';
+
+export class PersonalPollingRuntime {
+  constructor({ config, workspaceRoot, onState = () => {} }) {
+    this.config = config;
+    this.workspaceRoot = workspaceRoot;
+    this.onState = onState;
+    this.running = false;
+    this.listenerStarted = false;
+    this.friendWorker = null;
+    this.loopPromise = null;
+    this.cancelWait = null;
+  }
+
+  wait(ms) {
+    return new Promise(resolve => {
+      const timer = setTimeout(() => {
+        this.cancelWait = null;
+        resolve();
+      }, ms);
+      this.cancelWait = () => {
+        clearTimeout(timer);
+        this.cancelWait = null;
+        resolve();
+      };
+    });
+  }
+
+  startFriendWorker() {
+    if (!this.config.friendPollingEnabled || this.friendWorker) return;
+    const scriptPath = path.join(PACKAGE_ROOT, 'scripts', 'friend-polling-worker.js');
+    this.friendWorker = spawn(process.execPath, [scriptPath], {
+      cwd: this.workspaceRoot,
+      env: { ...process.env, QIWEI_WORKSPACE_ROOT: this.workspaceRoot },
+      stdio: 'inherit',
+      windowsHide: true,
+    });
+    this.onState({ friendPolling: { status: 'running', pid: this.friendWorker.pid } });
+    this.friendWorker.once('exit', (code, signal) => {
+      this.friendWorker = null;
+      this.onState({ friendPolling: { status: this.running ? 'error' : 'stopped', code, signal } });
+    });
+  }
+
+  async loop() {
+    while (this.running) {
+      try {
+        if (!this.listenerStarted) {
+          const result = await startPersonalListener();
+          this.listenerStarted = Boolean(result?.data?.running);
+          if (this.listenerStarted) this.startFriendWorker();
+        }
+        const result = await getPersonalListenerStatus();
+        const listener = result?.data?.listener || {};
+        this.listenerStarted = Boolean(listener.running);
+        this.onState({
+          personalPolling: {
+            status: listener.running ? 'running' : 'waiting',
+            syncKey: Number(listener.syncKey || 0),
+            startedAt: listener.startedAt || null,
+            lastError: listener.lastError || '',
+          },
+        });
+      } catch (error) {
+        this.listenerStarted = false;
+        this.onState({ personalPolling: { status: 'waiting', lastError: error.message } });
+      }
+      if (this.running) await this.wait(this.config.retryMs);
+    }
+  }
+
+  start() {
+    if (this.running) return;
+    this.running = true;
+    this.onState({ personalPolling: { status: 'starting', lastError: '' } });
+    this.loopPromise = this.loop();
+  }
+
+  async stop() {
+    this.running = false;
+    if (this.cancelWait) this.cancelWait();
+    if (this.listenerStarted) {
+      try { stopPersonalListener(); } catch {}
+    }
+    this.listenerStarted = false;
+    if (this.friendWorker) {
+      try { this.friendWorker.kill(); } catch {}
+      this.friendWorker = null;
+    }
+    this.onState({
+      personalPolling: { status: 'stopped' },
+      friendPolling: { status: 'stopped' },
+    });
+    await this.loopPromise;
+  }
+}

+ 1 - 0
claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/personal-runtime.mjs

@@ -0,0 +1 @@
+export { PersonalPollingRuntime as PersonalRuntime } from './personal-polling.mjs';

+ 63 - 0
claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/processor-bridge.mjs

@@ -0,0 +1,63 @@
+import path from 'node:path';
+import { createRequire } from 'node:module';
+import { PACKAGE_ROOT } from './config-loader.mjs';
+
+const require = createRequire(import.meta.url);
+const webhookServer = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'core', 'webhook-server.js'));
+const webhookTypes = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'core', 'webhook-types.js'));
+const agentService = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'dashboard', 'agent-service.js'));
+const dashboardServer = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'dashboard', 'server.js'));
+
+function isPrivateMessage(event) {
+  if (event.parsedType !== webhookTypes.ParsedWebhookEvent.NEW_MESSAGE) return false;
+  return !String(event.raw?.fromRoomId || '').trim();
+}
+
+export async function processRelayEnvelope(envelope) {
+  const events = webhookTypes.parseWebhookEnvelope(envelope);
+  const webhookResult = await webhookServer.processWebhookEvents({
+    ...envelope,
+    source: 'enterprise-relay',
+  });
+  const privateMessages = events.filter(isPrivateMessage);
+  let privateProcessed = 0;
+  const errors = [];
+
+  for (const event of privateMessages) {
+    try {
+      await agentService.ingestRuntimeMessage(event.raw, { source: 'enterprise-relay' });
+      privateProcessed += 1;
+    } catch (error) {
+      errors.push(error);
+    }
+  }
+
+  if (Number(webhookResult.errors || 0) > 0) {
+    errors.push(new Error(`Webhook processor reported ${webhookResult.errors} error(s).`));
+  }
+  if (errors.length) {
+    throw new Error(errors.map(error => error.message).join('; '));
+  }
+
+  return {
+    ...webhookResult,
+    privateProcessed,
+    total: events.length,
+  };
+}
+
+export function startPersonalListener() {
+  return agentService.startListener();
+}
+
+export function stopPersonalListener() {
+  return agentService.stopListener();
+}
+
+export function getPersonalListenerStatus() {
+  return agentService.getAgentStatus();
+}
+
+export function startDashboard(port) {
+  return dashboardServer.startServer(port);
+}

+ 100 - 0
claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/runtime-state.mjs

@@ -0,0 +1,100 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { createRequire } from 'node:module';
+import { PACKAGE_ROOT } from './config-loader.mjs';
+
+const require = createRequire(import.meta.url);
+const { outputsRoot } = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'core', 'output-paths.js'));
+
+const SECRET_KEY = /(token|secret|private.?key|authorization|credential|password)/i;
+
+function redactState(value) {
+  if (Array.isArray(value)) return value.map(redactState);
+  if (!value || typeof value !== 'object') return value;
+  const output = {};
+  for (const [key, item] of Object.entries(value)) {
+    if (SECRET_KEY.test(key)) continue;
+    output[key] = redactState(item);
+  }
+  return output;
+}
+
+export function runtimeStatePath(customPath = '') {
+  return customPath || path.join(outputsRoot(), 'runtime', 'qiwei-runtime.json');
+}
+
+export function runtimeStopRequestPath(customPath = '') {
+  return runtimeStatePath(customPath).replace(/\.json$/i, '.stop.json');
+}
+
+export function readRuntimeState(customPath = '') {
+  const filePath = runtimeStatePath(customPath);
+  try {
+    return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
+  } catch {
+    return {};
+  }
+}
+
+export function writeRuntimeState(state, customPath = '') {
+  const filePath = runtimeStatePath(customPath);
+  fs.mkdirSync(path.dirname(filePath), { recursive: true });
+  const safe = redactState({ ...state, updatedAt: new Date().toISOString() });
+  const temporary = `${filePath}.${process.pid}.tmp`;
+  fs.writeFileSync(temporary, `${JSON.stringify(safe, null, 2)}\n`, 'utf8');
+  fs.renameSync(temporary, filePath);
+  return safe;
+}
+
+export function isProcessAlive(pid) {
+  const numericPid = Number(pid);
+  if (!Number.isInteger(numericPid) || numericPid <= 0) return false;
+  try {
+    process.kill(numericPid, 0);
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+export function assertRuntimeAvailable(customPath = '') {
+  const current = readRuntimeState(customPath);
+  if (current.pid && current.pid !== process.pid && current.status !== 'stopped' && isProcessAlive(current.pid)) {
+    throw new Error(`Qiwei runtime is already running (pid=${current.pid}, mode=${current.mode || 'unknown'}).`);
+  }
+  return current;
+}
+
+export function readRuntimeStopRequest(customPath = '') {
+  try {
+    return JSON.parse(fs.readFileSync(runtimeStopRequestPath(customPath), 'utf8').replace(/^\uFEFF/, ''));
+  } catch {
+    return {};
+  }
+}
+
+export function clearRuntimeStopRequest(customPath = '') {
+  try { fs.unlinkSync(runtimeStopRequestPath(customPath)); } catch {}
+}
+
+export function requestRuntimeStop(customPath = '') {
+  const current = readRuntimeState(customPath);
+  const filePath = runtimeStopRequestPath(customPath);
+  fs.mkdirSync(path.dirname(filePath), { recursive: true });
+  fs.writeFileSync(filePath, `${JSON.stringify({
+    targetPid: Number(current.pid) || null,
+    requestedAt: new Date().toISOString(),
+  }, null, 2)}\n`, 'utf8');
+  return { requested: true, pid: Number(current.pid) || null };
+}
+
+export function stopRuntimeProcess(customPath = '') {
+  const current = readRuntimeState(customPath);
+  if (!isProcessAlive(current.pid)) {
+    writeRuntimeState({ ...current, status: 'stopped', stoppedAt: new Date().toISOString() }, customPath);
+    return { stopped: false, reason: 'not-running', pid: current.pid || null };
+  }
+  return requestRuntimeStop(customPath);
+}
+
+export { redactState };

+ 108 - 0
claude-code/claude-code-qiwe-assistant/scripts/callback-runtime-smoke-test.mjs

@@ -0,0 +1,108 @@
+import assert from 'node:assert/strict';
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { decryptPayload, EnterpriseRelayRuntime } from '../runtime/callback-service/src/enterprise-relay-client.mjs';
+import { loadRuntimeConfig, resolveRuntimeMode } from '../runtime/callback-service/src/config-loader.mjs';
+import {
+  clearRuntimeStopRequest,
+  readRuntimeState,
+  readRuntimeStopRequest,
+  requestRuntimeStop,
+  writeRuntimeState,
+} from '../runtime/callback-service/src/runtime-state.mjs';
+
+function encryptV2(payload, publicKey) {
+  const aesKey = crypto.randomBytes(32);
+  const iv = crypto.randomBytes(12);
+  const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv);
+  const ciphertext = Buffer.concat([cipher.update(payload, 'utf8'), cipher.final()]);
+  const envelope = {
+    key: crypto.publicEncrypt({ key: publicKey, oaepHash: 'sha256' }, aesKey).toString('base64'),
+    iv: iv.toString('base64'),
+    tag: cipher.getAuthTag().toString('base64'),
+    ciphertext: ciphertext.toString('base64'),
+  };
+  return `v2:${Buffer.from(JSON.stringify(envelope)).toString('base64')}`;
+}
+
+const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-runtime-smoke-'));
+try {
+  const configPath = path.join(tempRoot, 'runtime-config.mjs');
+  fs.writeFileSync(configPath, "export default { edition: 'personal', dashboard: { port: 4399 } };\n", 'utf8');
+  const loaded = await loadRuntimeConfig({ workspaceRoot: tempRoot, configPath });
+  assert.equal(loaded.mode, 'personal');
+  assert.equal(loaded.config.dashboard.port, 4399);
+  assert.equal(loaded.config.enterprise.relay.batchSize, 100);
+  assert.equal(resolveRuntimeMode({ edition: 'enterprise' }), 'enterprise');
+
+  const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+  const payload = JSON.stringify({ cmd: 15000, content: 'runtime-smoke' });
+  const encrypted = encryptV2(payload, publicKey);
+  assert.equal(decryptPayload(encrypted, privateKey), payload);
+
+  const relayConfig = { batchSize: 10, waitMs: 1000, retryMinMs: 250, retryMaxMs: 1000 };
+  const relayContext = {
+    baseUrl: 'http://relay.test',
+    apiSecret: 'test-secret',
+    privateKey,
+    tenantId: 'tenant-test',
+    guid: 'guid-test',
+  };
+  let ackBody = null;
+  let processedEnvelope = null;
+  const successFetch = async (url, options) => {
+    if (url.endsWith('/api/relay/poll')) {
+      return new Response(JSON.stringify({ events: [{ eventId: 'event-1', encryptedPayload: encrypted }] }), {
+        status: 200,
+        headers: { 'Content-Type': 'application/json' },
+      });
+    }
+    ackBody = JSON.parse(options.body);
+    return new Response(JSON.stringify({ ackedCount: 1 }), {
+      status: 200,
+      headers: { 'Content-Type': 'application/json' },
+    });
+  };
+  const relay = new EnterpriseRelayRuntime({
+    config: relayConfig,
+    fetchImpl: successFetch,
+    processor: async envelope => { processedEnvelope = envelope; },
+  });
+  const relayResult = await relay.pollOnce(relayContext);
+  assert.equal(relayResult.acked, 1);
+  assert.deepEqual(ackBody.eventIds, ['event-1']);
+  assert.equal(processedEnvelope.data[0].content, 'runtime-smoke');
+
+  let failureAckCalled = false;
+  const failureRelay = new EnterpriseRelayRuntime({
+    config: relayConfig,
+    fetchImpl: async url => {
+      if (url.endsWith('/api/relay/ack')) failureAckCalled = true;
+      return new Response(JSON.stringify({ events: [{ eventId: 'event-2', encryptedPayload: encrypted }] }), {
+        status: 200,
+        headers: { 'Content-Type': 'application/json' },
+      });
+    },
+    processor: async () => { throw new Error('processor-test-failure'); },
+  });
+  await assert.rejects(() => failureRelay.pollOnce(relayContext), /retained 1 failed event/);
+  assert.equal(failureAckCalled, false);
+
+  const statePath = path.join(tempRoot, 'runtime-state.json');
+  writeRuntimeState({ pid: 123, status: 'running', apiSecret: 'hidden', components: { relay: { token: 'hidden' } } }, statePath);
+  const state = readRuntimeState(statePath);
+  assert.equal(state.status, 'running');
+  assert.equal('apiSecret' in state, false);
+  assert.equal('token' in state.components.relay, false);
+  requestRuntimeStop(statePath);
+  assert.equal(readRuntimeStopRequest(statePath).targetPid, 123);
+  clearRuntimeStopRequest(statePath);
+  assert.deepEqual(readRuntimeStopRequest(statePath), {});
+
+  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 19 }, null, 2)}\n`);
+} finally {
+  fs.rmSync(tempRoot, { recursive: true, force: true });
+}

+ 7 - 0
claude-code/claude-code-qiwe-assistant/scripts/package-smoke-test.js

@@ -84,6 +84,13 @@ function main() {
     'mcp/src/core/startup-summary.js',
     'mcp/src/server.js',
     'mcp/src/tools/qiwei-agent-control-run.js',
+    'qiwei.runtime.config.example.mjs',
+    'runtime/callback-service/package.json',
+    'runtime/callback-service/src/index.mjs',
+    'runtime/callback-service/src/personal-polling.mjs',
+    'runtime/callback-service/src/enterprise-relay-client.mjs',
+    'scripts/start-callback-runtime.mjs',
+    'scripts/callback-runtime-smoke-test.mjs',
     'scripts/agent-console-smoke-test.js',
     'scripts/preview-dashboard.js',
     'scripts/startup-preview-smoke-test.js',

+ 20 - 0
claude-code/claude-code-qiwe-assistant/scripts/preview-dashboard.js

@@ -2,6 +2,7 @@
 
 const path = require('path');
 const { spawn } = require('child_process');
+const { pathToFileURL } = require('url');
 const { applyWorkspaceContext, workspaceIdentity } = require('../mcp/src/core/runtime-context');
 const { buildStartupSummary } = require('../mcp/src/core/startup-summary');
 
@@ -75,9 +76,17 @@ async function main() {
   }
 
   let server = null;
+  let runtime = null;
   if (!current.running) {
     const { startServer } = require('../mcp/src/dashboard/server');
     ({ server } = await startServer(options.port));
+    try {
+      const runtimeModuleUrl = pathToFileURL(path.join(__dirname, '..', 'runtime', 'callback-service', 'src', 'index.mjs')).href;
+      const runtimeModule = await import(runtimeModuleUrl);
+      runtime = await runtimeModule.startRuntime({ embedded: true, dashboard: false });
+    } catch (error) {
+      process.stderr.write(`Qiwei runtime startup is pending: ${error.message}\n`);
+    }
   }
 
   let status = {};
@@ -94,6 +103,17 @@ async function main() {
   printStartupSummary(baseUrl, summary, context.workspaceRoot, current.running);
   if (options.openBrowser) openUrl(`${baseUrl}/#agent`);
   if (current.running && !server) return;
+
+  if (runtime) {
+    let shuttingDown = false;
+    const shutdown = signal => {
+      if (shuttingDown) return;
+      shuttingDown = true;
+      runtime.stop(signal).finally(() => process.exit(0));
+    };
+    process.once('SIGINT', () => shutdown('SIGINT'));
+    process.once('SIGTERM', () => shutdown('SIGTERM'));
+  }
 }
 
 if (require.main === module) {

+ 1 - 0
claude-code/claude-code-qiwe-assistant/scripts/release-check.js

@@ -11,6 +11,7 @@ const outputsDir = path.join(tempRoot, 'outputs');
 const npmCli = process.env.npm_execpath || path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js');
 const checks = [
   'check',
+  'runtime:smoke',
   'group-ops:check',
   'group-ops:smoke',
   'group-ops:eval',

+ 8 - 0
claude-code/claude-code-qiwe-assistant/scripts/start-callback-runtime.mjs

@@ -0,0 +1,8 @@
+#!/usr/bin/env node
+
+import { runCli } from '../runtime/callback-service/src/index.mjs';
+
+runCli().catch(error => {
+  process.stderr.write(`Qiwei runtime failed: ${error.message}\n`);
+  process.exit(1);
+});

+ 22 - 153
claude-code/claude-code-qiwe-assistant/scripts/start-relay-client.js

@@ -1,162 +1,31 @@
 #!/usr/bin/env node
-/**
- * Relay 长轮询客户端
- *
- * 独立进程运行,从中央 Relay 拉取属于本租户的加密事件,
- * 用本地 RSA 私钥解密后构造 v1 envelope 并交给 processWebhookEvents 处理。
- *
- * 启动方式:
- *   node scripts/start-relay-client.js [device-guid]
- *   npm run relay
- */
 
-const crypto = require('crypto');
-const { processWebhookEvents } = require('../mcp/src/core/webhook-server');
-const { readQiweiGuid } = require('../mcp/src/core/credentials');
-const { getProductMode } = require('../mcp/src/core/product-mode');
-const {
-  getRelayBaseUrl,
-  getTenantApiSecret,
-  getRelayPrivateKey,
-  getTenantId,
-  getRelayDeviceGuid
-} = require('../mcp/src/core/relay-config');
+'use strict';
 
-const POLL_WAIT_MS = 30000;
-const INITIAL_BACKOFF_MS = 1000;
-const MAX_BACKOFF_MS = 60000;
-
-function decryptPayload(encryptedPayload, privateKeyPem) {
-  const key = crypto.createPrivateKey(privateKeyPem);
-  if (String(encryptedPayload).startsWith('v2:')) {
-    const envelope = JSON.parse(Buffer.from(String(encryptedPayload).slice(3), 'base64').toString('utf8'));
-    const aesKey = crypto.privateDecrypt(
-      { key, oaepHash: 'sha256' },
-      Buffer.from(envelope.key, 'base64')
-    );
-    const decipher = crypto.createDecipheriv(
-      'aes-256-gcm',
-      aesKey,
-      Buffer.from(envelope.iv, 'base64')
-    );
-    decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
-    return Buffer.concat([
-      decipher.update(Buffer.from(envelope.ciphertext, 'base64')),
-      decipher.final()
-    ]).toString('utf8');
-  }
-  const buffer = Buffer.from(encryptedPayload, 'base64');
-  const decrypted = crypto.privateDecrypt({ key, oaepHash: 'sha256' }, buffer);
-  return decrypted.toString('utf8');
-}
-
-async function ackEvents(baseUrl, apiSecret, guid, eventIds) {
-  if (!eventIds.length) return;
-  try {
-    const res = await fetch(`${baseUrl}/api/relay/ack`, {
-      method: 'POST',
-      headers: {
-        'Content-Type': 'application/json',
-        Authorization: `Bearer ${apiSecret}`
-      },
-      body: JSON.stringify({ guid, eventIds })
-    });
-
-    if (!res.ok) {
-      console.warn('[RelayClient] ACK 失败:', res.status, await res.text());
-      return;
-    }
-
-    const data = await res.json();
-    console.log(`[RelayClient] ACK ${data.ackedCount || eventIds.length} 条事件`);
-  } catch (err) {
-    console.warn('[RelayClient] ACK 请求异常:', err.message);
-  }
-}
-
-async function runPollOnce(baseUrl, apiSecret, guid, privateKey) {
-  const res = await fetch(`${baseUrl}/api/relay/poll`, {
-    method: 'POST',
-    headers: {
-      'Content-Type': 'application/json',
-      Authorization: `Bearer ${apiSecret}`
-    },
-    body: JSON.stringify({ guid, batchSize: 100, waitMs: POLL_WAIT_MS })
-  });
-
-  if (!res.ok) {
-    throw new Error(`poll failed: ${res.status} ${await res.text()}`);
-  }
-
-  const data = await res.json();
-  if (!data.events || !data.events.length) return { acked: 0 };
-
-  console.log(`[RelayClient] 取回 ${data.events.length} 条事件`);
-  const eventIds = [];
-
-  for (const event of data.events) {
-    try {
-      const decrypted = decryptPayload(event.encryptedPayload, privateKey);
-      const payload = JSON.parse(decrypted);
-      const envelope = { code: 0, msg: 'from-relay', data: Array.isArray(payload) ? payload : [payload], __rawBody: decrypted };
-      await processWebhookEvents(envelope);
-      eventIds.push(event.eventId);
-    } catch (err) {
-      console.error(`[RelayClient] 解密/处理失败 eventId=${event.eventId}:`, err.message);
-      // 解密失败也要 ACK,避免 Relay 重复投递
-      eventIds.push(event.eventId);
-    }
-  }
-
-  await ackEvents(baseUrl, apiSecret, guid, eventIds);
-  return { acked: eventIds.length };
-}
-
-function resolveGuid() {
-  // 命令行参数 > 环境变量 > relay-config.json > credentials
-  return process.argv[2] || process.env.RELAY_DEVICE_GUID || getRelayDeviceGuid() || readQiweiGuid() || '';
-}
+const path = require('path');
+const { pathToFileURL } = require('url');
 
 async function main() {
-  const product = getProductMode();
-  if (product.mode !== 'enterprise') {
-    console.error('[RelayClient] 当前为个人版,请使用工作台本地监听;企业 Relay Client 未启动');
-    process.exit(1);
-  }
-  const baseUrl = getRelayBaseUrl();
-  const apiSecret = getTenantApiSecret();
-  const privateKey = getRelayPrivateKey();
-  const tenantId = getTenantId();
-  const guid = resolveGuid();
-
-  if (!apiSecret || !privateKey || !tenantId) {
-    console.error('[RelayClient] 缺少配置:请检查 .env.local 中的 TENANT_API_SECRET、RELAY_PRIVATE_KEY、TENANT_ID');
-    process.exit(1);
-  }
-  if (!guid) {
-    console.error('[RelayClient] 缺少 deviceGuid:请通过命令行传入,或配置 RELAY_DEVICE_GUID / relay-config.json / 完成企微登录');
-    process.exit(1);
-  }
-
-  console.log(`[RelayClient] 启动 Relay 轮询: ${baseUrl}`);
-  console.log(`[RelayClient] tenantId=${tenantId}, guid=${guid}`);
-
-  let backoff = INITIAL_BACKOFF_MS;
-
-  while (true) {
-    try {
-      await runPollOnce(baseUrl, apiSecret, guid, privateKey);
-      backoff = INITIAL_BACKOFF_MS;
-    } catch (err) {
-      console.error('[RelayClient] 轮询异常:', err.message);
-      console.log(`[RelayClient] ${backoff}ms 后重试...`);
-      await new Promise((resolve) => setTimeout(resolve, backoff));
-      backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
-    }
-  }
+  const moduleUrl = pathToFileURL(path.join(__dirname, '..', 'runtime', 'callback-service', 'src', 'index.mjs')).href;
+  const { startRuntime } = await import(moduleUrl);
+  const runtime = await startRuntime({
+    forceMode: 'enterprise',
+    guid: process.argv[2] || '',
+    dashboard: false,
+  });
+  process.stdout.write(`[RelayClient] ESM runtime started. pid=${process.pid}, mode=${runtime.mode}\n`);
+
+  let shuttingDown = false;
+  const shutdown = signal => {
+    if (shuttingDown) return;
+    shuttingDown = true;
+    runtime.stop(signal).finally(() => process.exit(0));
+  };
+  process.once('SIGINT', () => shutdown('SIGINT'));
+  process.once('SIGTERM', () => shutdown('SIGTERM'));
 }
 
-main().catch((err) => {
-  console.error('[RelayClient] 致命错误:', err);
+main().catch(error => {
+  process.stderr.write(`[RelayClient] ${error.message}\n`);
   process.exit(1);
 });

+ 9 - 5
claude-code/claude-code-qiwe-assistant/skills/qiwei-webhook-relay/SKILL.md

@@ -1,13 +1,13 @@
 ---
 name: qiwei-webhook-relay
-description: 企微 Webhook 与 Relay:支持企业版中央 Relay 自动接入,以及隔离部署下的显式直连模式
+description: 企微消息运行时:个人版在客户主机主动轮询,企业版接入中央 Relay 统一回调
 ---
 
 # Webhook 与 Relay
 
 ## 使用边界
 
-先调用 `qiwei_product_mode_status` 判断产品模式。个人版默认使用本地主动监听;企业版才进入中央 Relay 注册和服务端回调流程。
+先调用 `qiwei_product_mode_status` 判断产品模式。个人版由技能包内置 ESM Runtime 在客户主机主动轮询;企业版由同一 Runtime 进入中央 Relay 注册和服务端回调流程。
 
 本 skill 负责:
 
@@ -16,11 +16,15 @@ description: 企微 Webhook 与 Relay:支持企业版中央 Relay 自动接入
 - 通过 Fmode 服务端专用接口配置企微全局回调;
 - 管理 Relay 配置。
 
-> 默认禁止本地进程直接覆盖企微回调。独立部署确需直连时,必须显式设置 `QIWEI_ALLOW_DIRECT_CALLBACK=true`
+标准产品流程只有 `local_polling` 和 `server_relay` 两种传输方式。旧的显式直连工具仅保留兼容,不作为个人版交付步骤
 
 ## 模式说明
 
-### 中央 Relay 模式(默认)
+### 个人版本地轮询
+
+运行 `npm run runtime` 后,ESM Runtime 复用现有 Agent Poller,通过 Fmode/Future Server 网关主动同步新消息,并将画像、待办、预警、草稿和会话数据保存在客户项目中。用户不需要配置公网地址。
+
+### 企业版中央 Relay
 
 企微平台把事件推送到 Fmode 中央 Relay 服务器(默认 `http://8.138.37.248:4000`),本地 Skill 通过长轮询主动取回属于自己的事件。适合 Skill 运行在本地电脑、内网或无固定公网 IP 的场景。
 
@@ -44,7 +48,7 @@ description: 企微 Webhook 与 Relay:支持企业版中央 Relay 自动接入
    - 由 Fmode 服务端使用服务端密钥,把 Token 级全局回调设为 `{RELAY_BASE_URL}/api/webhook/ingest`;
    - 客户端不会接触或提交全局回调签名密钥;
    - 返回配置结果,并提示启动 Relay 轮询客户端。
-4. 在服务器上执行 `npm run relay` 启动长轮询(或使用 systemd/pm2 持久化运行)
+4. 执行 `npm run runtime` 启动 ESM Runtime。兼容命令 `npm run relay` 也会进入相同企业版运行链路
 
 ### 手动注册 Relay 租户
 

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů