made-bridge 0.2.3 → 0.3.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.
- package/package.json +1 -1
- package/public/app.js +85 -2
- package/public/i18n-static.js +23 -0
- package/public/index.html +16 -0
- package/src/agent-sprites.mjs +124 -9
- package/src/agents/events.mjs +4 -0
- package/src/agents/shared.mjs +8 -5
- package/src/agents/toolbox.mjs +15 -0
- package/src/board-link.mjs +13 -0
- package/src/config.mjs +8 -5
- package/src/firmware-store.mjs +166 -0
- package/src/installed.mjs +3 -2
- package/src/jobs.mjs +93 -7
- package/src/plugin-package.mjs +13 -3
- package/src/plugins/contracts.d.ts +44 -0
- package/src/plugins/enhance.mjs +5 -1
- package/src/plugins/hot-reload.mjs +189 -0
- package/src/plugins/registry.mjs +8 -4
- package/src/providers.mjs +27 -22
- package/src/server.mjs +106 -5
- package/src/sessions.mjs +1 -2
- package/src/usb-receiver.mjs +74 -26
- package/src/agents/codex.mjs +0 -94
- package/src/agents/cursor.mjs +0 -31
- package/src/agents/index.mjs +0 -7
- package/src/agents/qoder.mjs +0 -34
- package/src/agents/workbuddy.mjs +0 -121
package/src/jobs.mjs
CHANGED
|
@@ -2,7 +2,6 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { SessionStore, validAgentSessionId, VIBE_SESSION_ID } from './sessions.mjs';
|
|
5
|
-
import { builtinAgent } from './agents/index.mjs';
|
|
6
5
|
|
|
7
6
|
const ACTIVE = new Set(['waiting_confirmation', 'queued', 'running']);
|
|
8
7
|
|
|
@@ -10,9 +9,58 @@ function now() {
|
|
|
10
9
|
return new Date().toISOString();
|
|
11
10
|
}
|
|
12
11
|
|
|
12
|
+
const IMAGE_URL_RE = /\.(png|jpg|jpeg|gif|webp|bmp)(\?.*)?$/i;
|
|
13
|
+
const URL_RE = /https?:\/\/[^\s"`<>]+/gi;
|
|
14
|
+
const TRAILING_PUNCT_RE = /[.,;)\]]+$/;
|
|
15
|
+
|
|
16
|
+
function extractUrlAttachments(text) {
|
|
17
|
+
const attachments = [];
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
for (const match of String(text || '').matchAll(URL_RE)) {
|
|
20
|
+
let url = match[0];
|
|
21
|
+
while (TRAILING_PUNCT_RE.test(url)) url = url.replace(TRAILING_PUNCT_RE, '');
|
|
22
|
+
if (url.length < 8 || seen.has(url)) continue;
|
|
23
|
+
seen.add(url);
|
|
24
|
+
attachments.push({
|
|
25
|
+
type: IMAGE_URL_RE.test(url) ? 'image' : 'link',
|
|
26
|
+
url,
|
|
27
|
+
text: '',
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return attachments;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeAttachments(value) {
|
|
34
|
+
if (!Array.isArray(value)) return [];
|
|
35
|
+
return value.map((a) => ({
|
|
36
|
+
type: a.type === 'image' ? 'image' : 'link',
|
|
37
|
+
url: String(a.url || ''),
|
|
38
|
+
text: String(a.text || ''),
|
|
39
|
+
})).filter((a) => a.url);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function mergeAttachments(result, explicit) {
|
|
43
|
+
const fromText = extractUrlAttachments(result);
|
|
44
|
+
const fromExplicit = normalizeAttachments(explicit);
|
|
45
|
+
const seen = new Set(fromText.map((a) => a.url));
|
|
46
|
+
for (const a of fromExplicit) {
|
|
47
|
+
if (!seen.has(a.url)) {
|
|
48
|
+
fromText.push(a);
|
|
49
|
+
seen.add(a.url);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return fromText;
|
|
53
|
+
}
|
|
54
|
+
|
|
13
55
|
export class JobStore {
|
|
14
56
|
constructor({ file, projects, defaultProject, defaultProvider, workspaceRoot, maxParallel, maxAutoRetries, run, providerAvailable, pluginsRuntime, hooks }) {
|
|
15
|
-
|
|
57
|
+
// 允许传入 () => config.pluginsRuntime,热加载替换注册表后这里读到的是新插件。
|
|
58
|
+
this.agent = pluginsRuntime
|
|
59
|
+
? (id) => {
|
|
60
|
+
const runtime = typeof pluginsRuntime === 'function' ? pluginsRuntime() : pluginsRuntime;
|
|
61
|
+
return runtime?.agent?.(id);
|
|
62
|
+
}
|
|
63
|
+
: run.agent;
|
|
16
64
|
this.hooks = hooks || null;
|
|
17
65
|
this.file = file;
|
|
18
66
|
this.projects = projects;
|
|
@@ -137,14 +185,51 @@ export class JobStore {
|
|
|
137
185
|
}
|
|
138
186
|
|
|
139
187
|
listSessions(provider) {
|
|
188
|
+
// 单趟找每个会话的最新任务:以前每个会话都全量拷贝+反转 jobs 再线性找,
|
|
189
|
+
// 会话×任务的 O(n²) 在 /api/state 每次轮询都跑。
|
|
190
|
+
const latestBySession = new Map();
|
|
191
|
+
for (let i = this.jobs.length - 1; i >= 0; i -= 1) {
|
|
192
|
+
const job = this.jobs[i];
|
|
193
|
+
if (!latestBySession.has(job.vibeSessionId)) latestBySession.set(job.vibeSessionId, job);
|
|
194
|
+
}
|
|
140
195
|
return this.sessions.list(provider).map((session) => {
|
|
141
|
-
const latest =
|
|
196
|
+
const latest = latestBySession.get(session.id);
|
|
142
197
|
return { ...session, status: latest?.status || 'empty' };
|
|
143
198
|
});
|
|
144
199
|
}
|
|
145
200
|
|
|
201
|
+
/** 任务工作目录:workspaceRoot/<会话编号>。编号对不上就不给出路径。 */
|
|
202
|
+
workspacePath(sessionId) {
|
|
203
|
+
if (!this.workspaceRoot || !VIBE_SESSION_ID.test(sessionId || '')) return '';
|
|
204
|
+
const root = path.resolve(this.workspaceRoot);
|
|
205
|
+
const target = path.resolve(root, sessionId);
|
|
206
|
+
if (path.dirname(target) !== root) return '';
|
|
207
|
+
return target;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 桥接器只建空目录。助手开跑之前这个文件夹已经在。 */
|
|
211
|
+
ensureWorkspace(sessionId) {
|
|
212
|
+
const dir = this.workspacePath(sessionId);
|
|
213
|
+
if (!dir) return '';
|
|
214
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
215
|
+
return dir;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
removeWorkspace(sessionId) {
|
|
219
|
+
const dir = this.workspacePath(sessionId);
|
|
220
|
+
if (!dir) return;
|
|
221
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
projectFor(job) {
|
|
225
|
+
const configured = this.projects.find((item) => item.id === job.projectId);
|
|
226
|
+
const dir = this.workspacePath(job.vibeSessionId);
|
|
227
|
+
return dir ? { ...configured, path: dir } : configured;
|
|
228
|
+
}
|
|
229
|
+
|
|
146
230
|
createSession(payload) {
|
|
147
231
|
const session = this.sessions.create(payload);
|
|
232
|
+
this.ensureWorkspace(session.id);
|
|
148
233
|
return { ...session, status: 'empty' };
|
|
149
234
|
}
|
|
150
235
|
|
|
@@ -166,6 +251,7 @@ export class JobStore {
|
|
|
166
251
|
job.status = 'deleted';
|
|
167
252
|
this.progressSavedAt.delete(job.id);
|
|
168
253
|
}
|
|
254
|
+
this.removeWorkspace(id);
|
|
169
255
|
this.jobs = this.jobs.filter((job) => job.vibeSessionId !== id);
|
|
170
256
|
// Persist jobs first: a crash between files leaves an empty session,
|
|
171
257
|
// rather than an orphan turn that load() would recreate as a new session.
|
|
@@ -296,10 +382,8 @@ export class JobStore {
|
|
|
296
382
|
this.save();
|
|
297
383
|
this.hooks?.notify('started', job);
|
|
298
384
|
try {
|
|
299
|
-
|
|
300
|
-
const project = this.
|
|
301
|
-
? { ...configured, path: path.join(this.workspaceRoot, job.vibeSessionId) }
|
|
302
|
-
: configured;
|
|
385
|
+
this.ensureWorkspace(job.vibeSessionId);
|
|
386
|
+
const project = this.projectFor(job);
|
|
303
387
|
// 回合输入管道:插件可以改写本轮实际执行的指令;存储里的任务原文保持不变。
|
|
304
388
|
const instruction = this.hooks ? await this.hooks.turnIn(job) : job.instruction;
|
|
305
389
|
const effectiveJob = instruction === job.instruction ? job : { ...job, instruction };
|
|
@@ -337,6 +421,8 @@ export class JobStore {
|
|
|
337
421
|
// 回合输出管道:仅在任务完成时调用,插件返回新字符串即改写结果。
|
|
338
422
|
if (job.status === 'completed') result = this.hooks ? await this.hooks.turnOut(job, result) : result;
|
|
339
423
|
job.result = result;
|
|
424
|
+
// 富内容附件:插件可显式下发 attachments;同时自动从 result 文本解析 URL。
|
|
425
|
+
job.attachments = mergeAttachments(job.result, outcome.attachments);
|
|
340
426
|
if (outcome.handoff && typeof outcome.handoff === 'object') job.handoff = { ...outcome.handoff };
|
|
341
427
|
if (job.status === 'completed' && typeof outcome.gitSnapshot === 'string') {
|
|
342
428
|
this.sessions.setGitSnapshot(job.vibeSessionId, outcome.gitSnapshot);
|
package/src/plugin-package.mjs
CHANGED
|
@@ -76,11 +76,15 @@ export function parseSettingsSpec(raw) {
|
|
|
76
76
|
export function resolveSettings(fields, raw) {
|
|
77
77
|
if (raw === undefined || raw === null) raw = {};
|
|
78
78
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw fail('插件配置无效');
|
|
79
|
+
// 表单字段以插件自己的 plugin.json 为准。settings.json 里开发者另写的键
|
|
80
|
+
// 不是桥接器的预定义名单,只要键名和值合法就原样收下,不能因此整表失效。
|
|
79
81
|
const known = new Set(fields.map((field) => field.key));
|
|
82
|
+
const values = {};
|
|
80
83
|
for (const key of Object.keys(raw)) {
|
|
81
|
-
if (
|
|
84
|
+
if (reserved.has(key) || !SETTING_KEY.test(key)) throw fail('插件配置无效');
|
|
85
|
+
if (typeof raw[key] !== 'string' || Buffer.byteLength(raw[key]) > MAX_VALUE) throw fail('插件配置无效');
|
|
86
|
+
if (!known.has(key)) values[key] = raw[key];
|
|
82
87
|
}
|
|
83
|
-
const values = {};
|
|
84
88
|
for (const field of fields) {
|
|
85
89
|
const value = Object.hasOwn(raw, field.key) ? raw[field.key] : (field.default ?? '');
|
|
86
90
|
if (field.required && !value.trim()) throw fail('插件配置无效');
|
|
@@ -123,7 +127,13 @@ export function writePackageSettings(dir, values) {
|
|
|
123
127
|
const current = readPackageSettings(dir);
|
|
124
128
|
if (current.error) throw fail(current.error);
|
|
125
129
|
if (!current.fields.length) throw fail('插件没有可保存的配置');
|
|
126
|
-
|
|
130
|
+
// 工作台只提交 plugin.json 里的字段。写回时保留开发者另外放在 settings.json 的键。
|
|
131
|
+
const specKeys = new Set(current.fields.map((field) => field.key));
|
|
132
|
+
const kept = {};
|
|
133
|
+
for (const [key, value] of Object.entries(current.values)) {
|
|
134
|
+
if (!specKeys.has(key)) kept[key] = value;
|
|
135
|
+
}
|
|
136
|
+
const resolved = resolveSettings(current.fields, { ...kept, ...values });
|
|
127
137
|
const file = path.join(dir, 'settings.json');
|
|
128
138
|
const temporary = path.join(dir, `.settings-${process.pid}-${randomUUID()}.json`);
|
|
129
139
|
fs.writeFileSync(temporary, `${JSON.stringify(resolved, null, 2)}\n`, { mode: 0o600 });
|
|
@@ -27,10 +27,30 @@ export interface Job {
|
|
|
27
27
|
[key: string]: unknown;
|
|
28
28
|
}
|
|
29
29
|
export interface Project { id: string; label: string; path: string }
|
|
30
|
+
/** Streaming JSONL event parser shared by CLI coding agents. */
|
|
31
|
+
export interface CliEventStream {
|
|
32
|
+
write(chunk: string): void;
|
|
33
|
+
finish(): { result: string; hasAssistantText: boolean; error: string; rawTail: string };
|
|
34
|
+
}
|
|
35
|
+
/** Per-CLI adapter handed to runCli: command line plus event interpretation. */
|
|
36
|
+
export interface CliAdapter {
|
|
37
|
+
label: string;
|
|
38
|
+
createEvents: (onProgress?: (text: string) => void, onSession?: (sessionId: string) => void) => CliEventStream;
|
|
39
|
+
command: (options: { cwd: string; instruction: string; config: PluginConfig; model?: string; sessionId?: string }) => {
|
|
40
|
+
command: string; args: string[]; stdin?: string;
|
|
41
|
+
};
|
|
42
|
+
classifyExit?: (context: {
|
|
43
|
+
job: Job; project: Project; stderr: string;
|
|
44
|
+
output: { result: string; hasAssistantText: boolean; error: string; rawTail: string };
|
|
45
|
+
startingGitSnapshot: string;
|
|
46
|
+
}) => Error | void;
|
|
47
|
+
}
|
|
30
48
|
/**
|
|
31
49
|
* Cross-platform helpers the bridge hands to every plugin. Use these instead of
|
|
32
50
|
* hand-rolling PATH lookups or spawning npm: on Windows npm is npm.cmd, which
|
|
33
51
|
* Node 20+ refuses to spawn without a shell, and PATH lookups must walk PATHEXT.
|
|
52
|
+
* Also includes the CLI toolbox (probeCli/runCli/event parsing) used by CLI
|
|
53
|
+
* coding-agent plugins; guardrails and the stuck watchdog live in runCli.
|
|
34
54
|
*/
|
|
35
55
|
export interface PluginRuntime {
|
|
36
56
|
/** process.platform of the bridge, e.g. 'darwin' | 'linux' | 'win32'. */
|
|
@@ -46,6 +66,26 @@ export interface PluginRuntime {
|
|
|
46
66
|
};
|
|
47
67
|
/** Sync npm run; returns stdout. Throws ENOENT when npm is missing. */
|
|
48
68
|
runNpm: (args?: string[], options?: Record<string, unknown>) => string;
|
|
69
|
+
/** Sync CLI login probe; loginArgs like ['login', 'status']. */
|
|
70
|
+
probeCli: (command: string, loginArgs?: string[]) => { available: boolean; mode: string; reason: string };
|
|
71
|
+
/** Spawn a CLI adapter with guardrails, watchdog, and change-footer handling. */
|
|
72
|
+
runCli: (context: RunContext, adapter: CliAdapter) => Promise<RunResult>;
|
|
73
|
+
/** Git working-tree fingerprint for a project directory. */
|
|
74
|
+
gitState: (cwd: string) => { dirty: boolean; snapshot: string; changed: string };
|
|
75
|
+
/** JSONL event stream parser; interpret returns {sessionId?, activity?}. */
|
|
76
|
+
jsonLineEvents: (
|
|
77
|
+
interpret: (event: Record<string, unknown>, state: Record<string, unknown>) => { sessionId?: string; activity?: string } | void,
|
|
78
|
+
onProgress?: (text: string) => void,
|
|
79
|
+
onSession?: (sessionId: string) => void,
|
|
80
|
+
) => CliEventStream;
|
|
81
|
+
/** Update state from assistant/result events (Claude-style stream-json). */
|
|
82
|
+
assistantEvent: (event: Record<string, unknown>, state: Record<string, unknown>, options?: { append?: boolean }) => void;
|
|
83
|
+
/** Categorical activity text for Claude/Qoder-style stream events. */
|
|
84
|
+
streamActivity: (event: Record<string, unknown>) => string;
|
|
85
|
+
/** Categorical progress text for a tool name. */
|
|
86
|
+
toolActivity: (name: string, completed?: boolean) => string;
|
|
87
|
+
/** Concatenated text of string-or-blocks content. */
|
|
88
|
+
textBlocks: (content: unknown) => string;
|
|
49
89
|
}
|
|
50
90
|
export interface PluginConfig {
|
|
51
91
|
locale?: 'zh-CN' | 'en';
|
|
@@ -81,6 +121,8 @@ export interface CodingAgentPlugin {
|
|
|
81
121
|
capabilities: Capabilities;
|
|
82
122
|
model?: { default: string; required: boolean };
|
|
83
123
|
sessionIdPattern?: RegExp;
|
|
124
|
+
/** Declare that progress/error/reason strings are bridge-canonical messages; clients localize them at output boundaries. */
|
|
125
|
+
bridgeStrings?: boolean;
|
|
84
126
|
probe: (config: PluginConfig) => { available: boolean; reason?: string; mode?: string };
|
|
85
127
|
run: (context: RunContext) => Promise<RunResult>;
|
|
86
128
|
readUpdates?: (context: { job: Job; config: PluginConfig }) => Promise<{
|
|
@@ -117,6 +159,8 @@ export interface BridgeFacade {
|
|
|
117
159
|
cancelJob(id: string): Record<string, unknown>;
|
|
118
160
|
themes(): { name: string; title: string; sound: boolean; icon: boolean; background: boolean }[];
|
|
119
161
|
devices(): string[];
|
|
162
|
+
/** Action system: action names derived from the device command catalog (`action.<name>` entries, decided by the current assistant sprite). Advisory only — the board silently ignores unknown action commands. */
|
|
163
|
+
deviceActions(deviceId: string): string[];
|
|
120
164
|
/** Enqueue a board command directly; only the board-side catalog constrains the name. */
|
|
121
165
|
send(deviceId: string, command: BoardCommand, audio?: Uint8Array): QueuedBoardCommand;
|
|
122
166
|
pending(deviceId: string): QueuedBoardCommand[];
|
package/src/plugins/enhance.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 管控插件是"有自主行为能力的增强体":触发(事件、回合钩子、任务通知)只决定它
|
|
5
5
|
* 何时被唤醒;被唤醒后能做什么,取决于这里露出了多少句柄。接口有两条供给线:
|
|
6
6
|
* - 板侧线:事件与命令,清单由固件上报,桥接器只做清单校验;要新命令(如关机)得改固件。
|
|
7
|
-
* -
|
|
7
|
+
* - 桥接器线:本模块露出的句柄(助手、任务、主题、板侧发送)。保存插件或安装/停用后热加载即生效。
|
|
8
8
|
*
|
|
9
9
|
* 回合管道是顺序链:每个插件拿到上一环的产物,返回新字符串即 override,最后一个
|
|
10
10
|
* 说话的算数。单个插件超时或出错时沿用上一环的值,绝不阻断任务本身。
|
|
@@ -104,6 +104,10 @@ export function createEnhancement(config) {
|
|
|
104
104
|
cancelJob: (id) => ({ ...wired.jobs.cancel(id) }),
|
|
105
105
|
themes: () => (wired.themes ? wired.themes() : []),
|
|
106
106
|
devices: () => (wired.board ? wired.board.deviceIds() : []),
|
|
107
|
+
// 行动系统:可用动作从设备命令清单派生(action.<名> 条目,当前助手
|
|
108
|
+
// 素材决定);下发直接 send(deviceId, { name: 'action.yawn' })。
|
|
109
|
+
// 设备对不认识的动作会静默忽略,名单只是参考,不是下发闸门。
|
|
110
|
+
deviceActions: (deviceId) => (wired.board ? wired.board.actionsOf(deviceId) : []),
|
|
107
111
|
send: (deviceId, command, audio) => {
|
|
108
112
|
if (!wired.board) throw new Error('桥接器尚未就绪');
|
|
109
113
|
return wired.board.send(deviceId, command, audio);
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { registerHooks } from 'node:module';
|
|
5
|
+
import { packageRoots } from '../config.mjs';
|
|
6
|
+
import { loadConfiguredPlugins } from './registry.mjs';
|
|
7
|
+
|
|
8
|
+
// 钩子和热加载在同一线程,闭包里的代数对 resolve 可见。
|
|
9
|
+
// 只改写插件目录内的相对导入,给它们带上同一代数,子模块才不会停在旧缓存里。
|
|
10
|
+
let generation = 0;
|
|
11
|
+
let roots = [];
|
|
12
|
+
let generationClock = 0;
|
|
13
|
+
|
|
14
|
+
function localPath(urlString) {
|
|
15
|
+
try {
|
|
16
|
+
const url = new URL(urlString);
|
|
17
|
+
if (url.protocol !== 'file:') return '';
|
|
18
|
+
url.search = '';
|
|
19
|
+
url.hash = '';
|
|
20
|
+
return fileURLToPath(url);
|
|
21
|
+
} catch {
|
|
22
|
+
return '';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function underRoot(filePath) {
|
|
27
|
+
if (!filePath) return false;
|
|
28
|
+
return roots.some((root) => filePath.startsWith(root));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
registerHooks({
|
|
32
|
+
resolve(specifier, context, nextResolve) {
|
|
33
|
+
if (!generation || !context.parentURL) return nextResolve(specifier, context);
|
|
34
|
+
const parentPath = localPath(context.parentURL);
|
|
35
|
+
if (!underRoot(parentPath)) return nextResolve(specifier, context);
|
|
36
|
+
const resolved = nextResolve(specifier, context);
|
|
37
|
+
const target = localPath(resolved.url);
|
|
38
|
+
if (!target || target.includes(`${path.sep}node_modules${path.sep}`)) return resolved;
|
|
39
|
+
if (!underRoot(target)) return resolved;
|
|
40
|
+
const url = new URL(resolved.url);
|
|
41
|
+
url.searchParams.set('hot', String(generation));
|
|
42
|
+
return { ...resolved, url: url.href };
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function addDir(set, dir) {
|
|
47
|
+
try {
|
|
48
|
+
if (!dir || !fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return;
|
|
49
|
+
set.add(fs.realpathSync(dir) + path.sep);
|
|
50
|
+
} catch { /* 目录不可读就跳过,不影响已经加载的插件。 */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 监视范围:内置与用户 plugins/,以及配置里点名的本地模块所在目录。 */
|
|
54
|
+
export function pluginWatchRoots(config) {
|
|
55
|
+
const found = new Set();
|
|
56
|
+
for (const root of packageRoots(config)) addDir(found, path.join(root, 'plugins'));
|
|
57
|
+
for (const entry of config.plugins || []) {
|
|
58
|
+
if (typeof entry !== 'string' || !entry || /^[a-z]+:/i.test(entry)) continue;
|
|
59
|
+
const filename = path.resolve(config.configDirectory || process.cwd(), entry);
|
|
60
|
+
if (!fs.existsSync(filename)) continue;
|
|
61
|
+
addDir(found, path.dirname(filename));
|
|
62
|
+
}
|
|
63
|
+
return [...found];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function publish(config) {
|
|
67
|
+
generation = Number(config.pluginReloadGeneration) || 0;
|
|
68
|
+
roots = pluginWatchRoots(config);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 重新 import 已安装插件并换上注册表。失败时配置和注册表保持热加载前的那一份。
|
|
73
|
+
* 正在执行的 run() 仍是旧函数;下一轮、下一次目录刷新才用新代码。
|
|
74
|
+
*/
|
|
75
|
+
export async function reloadConfiguredPlugins(config) {
|
|
76
|
+
const snapshot = {
|
|
77
|
+
pluginsRuntime: config.pluginsRuntime,
|
|
78
|
+
pluginPackages: config.pluginPackages,
|
|
79
|
+
pluginSettings: config.pluginSettings,
|
|
80
|
+
};
|
|
81
|
+
config.pluginReloadGeneration = ++generationClock;
|
|
82
|
+
publish(config);
|
|
83
|
+
try {
|
|
84
|
+
config.pluginsRuntime = await loadConfiguredPlugins(config);
|
|
85
|
+
return config.pluginsRuntime;
|
|
86
|
+
} catch (error) {
|
|
87
|
+
config.pluginsRuntime = snapshot.pluginsRuntime;
|
|
88
|
+
config.pluginPackages = snapshot.pluginPackages;
|
|
89
|
+
config.pluginSettings = snapshot.pluginSettings;
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const RELOAD_DELAY_MS = 300;
|
|
95
|
+
|
|
96
|
+
function relevantChange(filename) {
|
|
97
|
+
if (!filename) return true;
|
|
98
|
+
const normalized = filename.split(path.sep).join('/');
|
|
99
|
+
if (normalized === 'node_modules' || normalized.startsWith('node_modules/') || normalized.includes('/node_modules/')) return false;
|
|
100
|
+
return /\.(mjs|js|cjs|json|png)$/i.test(normalized);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 监视插件文件。保存后约 300ms 热加载。
|
|
105
|
+
* onReload 收到 null 表示成功,否则是保留了上一份的错误。
|
|
106
|
+
*/
|
|
107
|
+
export function watchPlugins(config, { onReload } = {}) {
|
|
108
|
+
const watchers = [];
|
|
109
|
+
const watched = [];
|
|
110
|
+
let timer = null;
|
|
111
|
+
let closed = false;
|
|
112
|
+
let reloading = false;
|
|
113
|
+
let pending = false;
|
|
114
|
+
|
|
115
|
+
const arm = (target, recursive) => {
|
|
116
|
+
let real;
|
|
117
|
+
try { real = fs.realpathSync(target); } catch { return; }
|
|
118
|
+
if (watched.some((item) => item.path === real && item.recursive === recursive)) return;
|
|
119
|
+
watched.push({ path: real, recursive });
|
|
120
|
+
try {
|
|
121
|
+
watchers.push(fs.watch(target, { recursive }, (_event, filename) => {
|
|
122
|
+
if (!relevantChange(filename)) return;
|
|
123
|
+
schedule();
|
|
124
|
+
}));
|
|
125
|
+
} catch (error) {
|
|
126
|
+
console.error(`[plugins] 无法监视 ${target}:${error.message}`);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const covered = (dir) => {
|
|
131
|
+
let real;
|
|
132
|
+
try { real = fs.realpathSync(dir); } catch { return false; }
|
|
133
|
+
return watched.some((item) => item.recursive && (real === item.path || real.startsWith(item.path + path.sep)));
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
async function run() {
|
|
137
|
+
if (closed) return;
|
|
138
|
+
if (reloading) { pending = true; return; }
|
|
139
|
+
reloading = true;
|
|
140
|
+
try {
|
|
141
|
+
await reloadConfiguredPlugins(config);
|
|
142
|
+
const ids = config.pluginsRuntime?.agents?.().map((plugin) => plugin.id) ?? [];
|
|
143
|
+
console.error(`[plugins] 已热加载${ids.length ? `:${ids.join(', ')}` : ''}`);
|
|
144
|
+
onReload?.(null);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
console.error(`[plugins] 热加载失败,继续使用上一份:${error.message}`);
|
|
147
|
+
onReload?.(error);
|
|
148
|
+
} finally {
|
|
149
|
+
reloading = false;
|
|
150
|
+
if (pending && !closed) {
|
|
151
|
+
pending = false;
|
|
152
|
+
schedule();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function schedule() {
|
|
158
|
+
if (closed) return;
|
|
159
|
+
if (timer) clearTimeout(timer);
|
|
160
|
+
timer = setTimeout(() => { timer = null; void run(); }, RELOAD_DELAY_MS);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const root of packageRoots(config)) {
|
|
164
|
+
const dir = path.join(root, 'plugins');
|
|
165
|
+
if (fs.existsSync(dir)) arm(dir, true);
|
|
166
|
+
}
|
|
167
|
+
for (const entry of config.plugins || []) {
|
|
168
|
+
if (typeof entry !== 'string' || !entry || /^[a-z]+:/i.test(entry)) continue;
|
|
169
|
+
const filename = path.resolve(config.configDirectory || process.cwd(), entry);
|
|
170
|
+
if (!fs.existsSync(filename)) continue;
|
|
171
|
+
const dir = path.dirname(filename);
|
|
172
|
+
if (covered(dir)) continue;
|
|
173
|
+
arm(filename, false);
|
|
174
|
+
arm(dir, false);
|
|
175
|
+
}
|
|
176
|
+
if (watchers.length) console.error('[plugins] 正在监视插件变更');
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
close() {
|
|
180
|
+
closed = true;
|
|
181
|
+
if (timer) clearTimeout(timer);
|
|
182
|
+
timer = null;
|
|
183
|
+
for (const watcher of watchers) {
|
|
184
|
+
try { watcher.close(); } catch { /* 已经关掉。 */ }
|
|
185
|
+
}
|
|
186
|
+
watchers.length = 0;
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
package/src/plugins/registry.mjs
CHANGED
|
@@ -4,7 +4,6 @@ import fs from 'node:fs';
|
|
|
4
4
|
import { packageRoots } from '../config.mjs';
|
|
5
5
|
import { isPackageEntry, packageDirectories, readPackageSettings } from '../plugin-package.mjs';
|
|
6
6
|
import { MODEL_ID } from '../model-settings.mjs';
|
|
7
|
-
import { builtinAgents } from '../agents/index.mjs';
|
|
8
7
|
import { normalizeAgentIcon } from '../agent-icons.mjs';
|
|
9
8
|
import { readSprite } from '../agent-sprites.mjs';
|
|
10
9
|
import { builtinSpeechRecognizers } from '../speech/index.mjs';
|
|
@@ -67,7 +66,7 @@ export function validatePlugin(plugin) {
|
|
|
67
66
|
return plugin;
|
|
68
67
|
}
|
|
69
68
|
|
|
70
|
-
export function createPluginRegistry(plugins = [...
|
|
69
|
+
export function createPluginRegistry(plugins = [...builtinSpeechRecognizers]) {
|
|
71
70
|
const entries = new Map();
|
|
72
71
|
for (const raw of plugins) {
|
|
73
72
|
const plugin = validatePlugin(raw);
|
|
@@ -90,10 +89,15 @@ export async function loadConfiguredPlugins(config) {
|
|
|
90
89
|
for (const recognizer of builtinSpeechRecognizers) {
|
|
91
90
|
if (disabled.has(recognizer.id)) throw new Error(`语音识别 ${recognizer.id} 是内置接口,不能通过 disabledPlugins 禁用;请在语音设置中配置`);
|
|
92
91
|
}
|
|
93
|
-
|
|
92
|
+
// 内置编程助手已插件化为市场包(codex/cursor/qoder/workbuddy),桥接器
|
|
93
|
+
// 只保留内置语音识别;助手全部来自已安装的插件包。
|
|
94
|
+
const plugins = [...builtinSpeechRecognizers];
|
|
94
95
|
const loadFile = async (filename, label) => {
|
|
95
96
|
if (!/\.(mjs|js)$/.test(filename) || !fs.statSync(filename).isFile()) throw new Error(`插件模块无效:${label}`);
|
|
96
|
-
|
|
97
|
+
// 热加载时带上代数,避开 ESM 按 URL 缓存的旧模块。首次启动代数为 0,URL 不变。
|
|
98
|
+
const generation = Number(config.pluginReloadGeneration) || 0;
|
|
99
|
+
const href = pathToFileURL(filename).href + (generation ? `?hot=${generation}` : '');
|
|
100
|
+
const exported = (await import(href)).default;
|
|
97
101
|
const bundle = Array.isArray(exported) ? exported : [exported];
|
|
98
102
|
const pack = isPackageEntry(filename) ? readPackageSettings(path.dirname(filename)) : null;
|
|
99
103
|
const packageDir = isPackageEntry(filename) ? path.dirname(filename) : null;
|
package/src/providers.mjs
CHANGED
|
@@ -1,37 +1,48 @@
|
|
|
1
|
-
import { builtinAgents, builtinAgent } from './agents/index.mjs';
|
|
2
1
|
import { normalizeAgentIcon } from './agent-icons.mjs';
|
|
3
2
|
import { translateKnownMessage } from './i18n.mjs';
|
|
4
3
|
|
|
5
|
-
export { builtinAgents } from './agents/index.mjs';
|
|
6
|
-
|
|
7
4
|
export function agentFor(config, id) {
|
|
8
|
-
return config.pluginsRuntime ? config.pluginsRuntime.agent(id) :
|
|
5
|
+
return config.pluginsRuntime ? config.pluginsRuntime.agent(id) : undefined;
|
|
9
6
|
}
|
|
10
7
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
// 探针快照缓存:providerStates 每次都重跑全部插件 probe,而 CLI 探针内部是
|
|
9
|
+
// PATH 全量 statSync 扫描 + 60s TTL 的 spawnSync 登录检查。/api/state 是秒级
|
|
10
|
+
// 轮询端点,不缓存会把它放大成每秒上百次 stat、每分钟数次事件循环最长 3s 的
|
|
11
|
+
// 同步冻结。3s TTL 意味着设置变更最迟 3s 可见;调用方只读不写返回值。
|
|
12
|
+
// 按插件运行时实例隔离(WeakMap):不同注册表(测试/重载)互不串味。
|
|
13
|
+
const PROVIDER_STATES_TTL_MS = 3000;
|
|
14
|
+
const providerStatesCaches = new WeakMap();
|
|
15
15
|
|
|
16
16
|
export function providerStates(config, locale = config.locale) {
|
|
17
|
-
const
|
|
18
|
-
|
|
17
|
+
const runtime = config.pluginsRuntime;
|
|
18
|
+
if (!runtime) return [];
|
|
19
|
+
let byLocale = providerStatesCaches.get(runtime);
|
|
20
|
+
if (!byLocale) {
|
|
21
|
+
byLocale = new Map();
|
|
22
|
+
providerStatesCaches.set(runtime, byLocale);
|
|
23
|
+
}
|
|
24
|
+
const cached = byLocale.get(locale);
|
|
25
|
+
if (cached && cached.until > Date.now()) return cached.states;
|
|
26
|
+
const agents = runtime.agents();
|
|
27
|
+
const states = agents.map((plugin) => {
|
|
19
28
|
let state;
|
|
20
|
-
let bridgeReason = isBuiltinAgent(plugin);
|
|
21
29
|
try {
|
|
22
30
|
state = plugin.probe(config);
|
|
23
31
|
if (state?.then) {
|
|
24
32
|
// A mistaken async probe cannot hold up /api/state or produce an
|
|
25
33
|
// unhandled rejection. The plugin contract requires a sync result.
|
|
26
34
|
Promise.resolve(state).catch(() => {});
|
|
27
|
-
bridgeReason = true;
|
|
28
35
|
state = { available: false, reason: '插件状态检查必须同步返回' };
|
|
29
36
|
}
|
|
30
|
-
} catch {
|
|
37
|
+
} catch { state = { available: false, reason: `${plugin.label} 状态检查失败` }; }
|
|
31
38
|
return {
|
|
32
39
|
id: plugin.id, label: plugin.label, icon: normalizeAgentIcon(plugin.icon),
|
|
33
40
|
available: state?.available === true,
|
|
34
|
-
|
|
41
|
+
// 桥接器规范文案(bridgeStrings 插件)在输出边界翻译;第三方插件自有
|
|
42
|
+
// 提示原样返回,即使碰巧撞上桥接器文案也不改写。
|
|
43
|
+
reason: typeof state?.reason === 'string'
|
|
44
|
+
? (plugin.bridgeStrings === true ? translateKnownMessage(state.reason, locale) : state.reason).slice(0, 512)
|
|
45
|
+
: '',
|
|
35
46
|
mode: typeof state?.mode === 'string' ? state.mode.slice(0, 64) : 'plugin',
|
|
36
47
|
capabilities: {
|
|
37
48
|
session: plugin.capabilities.session, model: plugin.capabilities.model,
|
|
@@ -44,14 +55,8 @@ export function providerStates(config, locale = config.locale) {
|
|
|
44
55
|
canCancelRunning: plugin.capabilities.cancel,
|
|
45
56
|
};
|
|
46
57
|
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Compatibility for existing stream tests and embedders. Actual parsing belongs
|
|
50
|
-
// to each plugin and adding an agent never adds another branch here.
|
|
51
|
-
export function createCliEventStream(provider, onProgress, onSession) {
|
|
52
|
-
const plugin = builtinAgent(provider);
|
|
53
|
-
if (!plugin?.createEvents) throw new Error(`编程工具不支持 CLI 事件:${provider}`);
|
|
54
|
-
return plugin.createEvents(onProgress, onSession);
|
|
58
|
+
providerStatesCaches.get(runtime).set(locale, { states, until: Date.now() + PROVIDER_STATES_TTL_MS });
|
|
59
|
+
return states;
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
export function createRunner(config) {
|