made-bridge 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -2
  3. package/builtin/plugins/README.md +22 -0
  4. package/builtin/themes/README.md +64 -0
  5. package/builtin/themes/sunset/theme.json +19 -0
  6. package/config.example.json +20 -0
  7. package/demo-project/README.md +5 -0
  8. package/demo-project/index.mjs +14 -0
  9. package/package.json +48 -4
  10. package/public/app.js +1028 -0
  11. package/public/assistants.html +67 -0
  12. package/public/bean-icon.png +0 -0
  13. package/public/i18n-messages.js +967 -0
  14. package/public/i18n-static.js +615 -0
  15. package/public/i18n.js +50 -0
  16. package/public/index.html +149 -0
  17. package/public/made-icon.png +0 -0
  18. package/public/plugins.html +64 -0
  19. package/public/plugins.js +335 -0
  20. package/public/provider-icon.js +77 -0
  21. package/public/styles.css +445 -0
  22. package/public/tasks.html +85 -0
  23. package/src/agent-icons.mjs +138 -0
  24. package/src/agents/codex.mjs +94 -0
  25. package/src/agents/cursor.mjs +31 -0
  26. package/src/agents/events.mjs +92 -0
  27. package/src/agents/index.mjs +7 -0
  28. package/src/agents/qoder.mjs +34 -0
  29. package/src/agents/shared.mjs +191 -0
  30. package/src/agents/workbuddy.mjs +121 -0
  31. package/src/board-link.mjs +266 -0
  32. package/src/config.mjs +196 -0
  33. package/src/doctor.mjs +146 -0
  34. package/src/i18n.mjs +257 -0
  35. package/src/installed.mjs +291 -0
  36. package/src/jobs.mjs +369 -0
  37. package/src/language-settings.mjs +38 -0
  38. package/src/model-settings.mjs +63 -0
  39. package/src/openai-key.mjs +47 -0
  40. package/src/oray.mjs +243 -0
  41. package/src/pairing.mjs +203 -0
  42. package/src/plugin-package.mjs +132 -0
  43. package/src/plugin-runtime.mjs +169 -0
  44. package/src/plugins/contracts.d.ts +184 -0
  45. package/src/plugins/enhance.mjs +156 -0
  46. package/src/plugins/registry.mjs +146 -0
  47. package/src/providers.mjs +75 -0
  48. package/src/server.mjs +950 -0
  49. package/src/sessions.mjs +134 -0
  50. package/src/speech/index.mjs +10 -0
  51. package/src/speech/openai.mjs +48 -0
  52. package/src/speech/whisper.mjs +59 -0
  53. package/src/speech-settings.mjs +209 -0
  54. package/src/transcribe.mjs +85 -0
  55. package/src/tunnel.mjs +531 -0
  56. package/src/usb-receiver.mjs +664 -0
  57. package/src/voice-settings.mjs +69 -0
@@ -0,0 +1,132 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ const PACKAGE_DIR = /^[a-z0-9][a-z0-9_-]{0,39}$/;
6
+ const SETTING_KEY = /^[A-Za-z][A-Za-z0-9_]{0,31}$/;
7
+ const reserved = new Set(['__proto__', 'constructor', 'prototype']);
8
+ const MAX_FIELDS = 16;
9
+ const MAX_LABEL = 64;
10
+ const MAX_HELP = 200;
11
+ const MAX_VALUE = 500;
12
+ const MAX_PATTERN = 200;
13
+ const MAX_FILE = 16 * 1024;
14
+
15
+ const fail = (message) => {
16
+ const error = new Error(message);
17
+ error.preserveMessage = true;
18
+ return error;
19
+ };
20
+
21
+ const textOK = (value, max) => typeof value === 'string' && value.trim() && Buffer.byteLength(value) <= max && !/[\p{Cc}\p{Cf}]/u.test(value);
22
+
23
+ function readJson(file) {
24
+ const text = fs.readFileSync(file, 'utf8');
25
+ if (Buffer.byteLength(text) > MAX_FILE) throw fail('插件配置过大');
26
+ try {
27
+ return JSON.parse(text);
28
+ } catch {
29
+ throw fail('插件配置无效');
30
+ }
31
+ }
32
+
33
+ export function parseSettingsSpec(raw) {
34
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw fail('插件配置规范无效');
35
+ const allowed = new Set(['id', 'settings']);
36
+ if (Object.keys(raw).some((key) => !allowed.has(key))) throw fail('插件配置规范无效');
37
+ if (raw.id !== undefined && (typeof raw.id !== 'string' || !PACKAGE_DIR.test(raw.id))) throw fail('插件配置规范无效');
38
+ if (raw.settings === undefined) return { id: raw.id || '', fields: [] };
39
+ if (!Array.isArray(raw.settings) || raw.settings.length > MAX_FIELDS) throw fail('插件配置规范无效');
40
+ const keys = new Set();
41
+ const fields = raw.settings.map((field) => {
42
+ if (!field || typeof field !== 'object' || Array.isArray(field)) throw fail('插件配置规范无效');
43
+ const fieldKeys = new Set(['key', 'label', 'type', 'required', 'default', 'help', 'pattern']);
44
+ if (Object.keys(field).some((key) => !fieldKeys.has(key))) throw fail('插件配置规范无效');
45
+ if (!SETTING_KEY.test(field.key || '') || reserved.has(field.key) || keys.has(field.key)) throw fail('插件配置规范无效');
46
+ if (!textOK(field.label, MAX_LABEL) || field.type !== 'text') throw fail('插件配置规范无效');
47
+ if (field.required !== undefined && typeof field.required !== 'boolean') throw fail('插件配置规范无效');
48
+ if (field.default !== undefined && (typeof field.default !== 'string' || Buffer.byteLength(field.default) > MAX_VALUE)) throw fail('插件配置规范无效');
49
+ if (field.help !== undefined && !textOK(field.help, MAX_HELP)) throw fail('插件配置规范无效');
50
+ let pattern;
51
+ if (field.pattern !== undefined) {
52
+ if (typeof field.pattern !== 'string' || field.pattern.length > MAX_PATTERN) throw fail('插件配置规范无效');
53
+ try {
54
+ const compiled = new RegExp(field.pattern);
55
+ if (compiled.global || compiled.sticky) throw fail('插件配置规范无效');
56
+ } catch (error) {
57
+ if (error.preserveMessage) throw error;
58
+ throw fail('插件配置规范无效');
59
+ }
60
+ pattern = field.pattern;
61
+ }
62
+ keys.add(field.key);
63
+ return {
64
+ key: field.key,
65
+ label: field.label.trim(),
66
+ type: 'text',
67
+ required: field.required === true,
68
+ ...(field.default !== undefined ? { default: field.default } : {}),
69
+ ...(field.help ? { help: field.help.trim() } : {}),
70
+ ...(pattern ? { pattern } : {}),
71
+ };
72
+ });
73
+ return { id: raw.id || '', fields };
74
+ }
75
+
76
+ export function resolveSettings(fields, raw) {
77
+ if (raw === undefined || raw === null) raw = {};
78
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw fail('插件配置无效');
79
+ const known = new Set(fields.map((field) => field.key));
80
+ for (const key of Object.keys(raw)) {
81
+ if (!known.has(key) || typeof raw[key] !== 'string' || Buffer.byteLength(raw[key]) > MAX_VALUE) throw fail('插件配置无效');
82
+ }
83
+ const values = {};
84
+ for (const field of fields) {
85
+ const value = Object.hasOwn(raw, field.key) ? raw[field.key] : (field.default ?? '');
86
+ if (field.required && !value.trim()) throw fail('插件配置无效');
87
+ if (field.pattern && !new RegExp(field.pattern).test(value)) throw fail('插件配置无效');
88
+ values[field.key] = value;
89
+ }
90
+ return values;
91
+ }
92
+
93
+ export function readPackageSettings(dir) {
94
+ const specFile = path.join(dir, 'plugin.json');
95
+ if (!fs.existsSync(specFile)) return { id: '', fields: [], values: {}, error: '' };
96
+ try {
97
+ const spec = parseSettingsSpec(readJson(specFile));
98
+ const settingsFile = path.join(dir, 'settings.json');
99
+ const raw = fs.existsSync(settingsFile) ? readJson(settingsFile) : {};
100
+ return { id: spec.id, fields: spec.fields, values: resolveSettings(spec.fields, raw), error: '' };
101
+ } catch (error) {
102
+ return { id: '', fields: [], values: {}, error: error.preserveMessage ? error.message : '插件配置无效' };
103
+ }
104
+ }
105
+
106
+ export function isPackageEntry(filename) {
107
+ const base = path.basename(filename);
108
+ return (base === 'plugin.mjs' || base === 'plugin.js') && fs.existsSync(path.join(path.dirname(filename), 'plugin.json'));
109
+ }
110
+
111
+ export function packageDirectories(pluginsDir) {
112
+ if (!fs.existsSync(pluginsDir) || !fs.statSync(pluginsDir).isDirectory()) return [];
113
+ return fs.readdirSync(pluginsDir).sort().flatMap((name) => {
114
+ if (!PACKAGE_DIR.test(name)) return [];
115
+ const dir = path.join(pluginsDir, name);
116
+ if (!fs.statSync(dir).isDirectory()) return [];
117
+ const entry = ['plugin.mjs', 'plugin.js'].map((file) => path.join(dir, file)).find((file) => fs.existsSync(file) && fs.statSync(file).isFile());
118
+ return entry ? [{ name, dir, entry }] : [];
119
+ });
120
+ }
121
+
122
+ export function writePackageSettings(dir, values) {
123
+ const current = readPackageSettings(dir);
124
+ if (current.error) throw fail(current.error);
125
+ if (!current.fields.length) throw fail('插件没有可保存的配置');
126
+ const resolved = resolveSettings(current.fields, values);
127
+ const file = path.join(dir, 'settings.json');
128
+ const temporary = path.join(dir, `.settings-${process.pid}-${randomUUID()}.json`);
129
+ fs.writeFileSync(temporary, `${JSON.stringify(resolved, null, 2)}\n`, { mode: 0o600 });
130
+ fs.renameSync(temporary, file);
131
+ return resolved;
132
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * 插件运行时助手:跨平台的命令查找与子进程调用。
3
+ *
4
+ * 为什么单独放一个文件:插件常要包一层第三方 CLI 或 npm 包,而平台差异全在细节里——
5
+ * - Windows 上 npm 实际是 npm.cmd,Node 20 起不允许直接 spawn .cmd/.bat(报 EINVAL),
6
+ * 必须经 cmd.exe;而 Node 在 shell 模式下只是把命令和参数用空格拼起来,含空格的
7
+ * 路径(C:\Users\John Doe\…)会被 cmd 拆成两个参数,引号得自己加。
8
+ * - PATH 探测在 Windows 上要按 PATHEXT 逐个补扩展名,且 X_OK 在 Windows 上没有意义。
9
+ * - 「命令不存在」和「命令存在但执行失败」是两件事,报错必须分开,否则排查会被带偏。
10
+ * 这些收在一处,插件作者不必各自踩一遍,也不用引入 cross-spawn 之类的依赖。
11
+ *
12
+ * 插件通过 `config.runtime` 拿到这些函数,例如:
13
+ * const { runNpm } = config.runtime;
14
+ * const globalRoot = runNpm(['root', '-g']).trim();
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { spawnSync } from 'node:child_process';
19
+
20
+ export const platform = process.platform;
21
+ export const isWindows = platform === 'win32';
22
+
23
+ /** 命令是否已自带扩展名,Windows 上带了就不用再补 PATHEXT。 */
24
+ const HAS_EXTENSION = /\.(?:exe|com|bat|cmd|ps1)$/i;
25
+
26
+ /** Windows 下的可执行扩展名,按 PATHEXT 取,缺省给一套常见值。 */
27
+ function windowsExtensions(env) {
28
+ const raw = env.PATHEXT || '.COM;.EXE;.BAT;.CMD';
29
+ return raw.split(';').map((s) => s.trim()).filter(Boolean);
30
+ }
31
+
32
+ /**
33
+ * 单个文件能否当命令执行。Windows 上没有可执行位,能读到即可;
34
+ * 类 Unix 按 X_OK 判,避免把普通数据文件当成命令。
35
+ */
36
+ function isExecutable(filename) {
37
+ try {
38
+ if (!fs.statSync(filename).isFile()) return false;
39
+ fs.accessSync(filename, isWindows ? fs.constants.F_OK : fs.constants.X_OK);
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * 在 PATH 里查一个命令,返回绝对路径;找不到返回空串。
48
+ * 传了路径分隔符的命令按字面路径处理,不再搜 PATH。
49
+ */
50
+ export function findCommand(command, { env = process.env } = {}) {
51
+ const name = String(command || '').trim();
52
+ if (!name) return '';
53
+ if (name.includes('/') || name.includes('\\')) {
54
+ return isExecutable(name) ? path.resolve(name) : '';
55
+ }
56
+ const suffixes = isWindows && !HAS_EXTENSION.test(name) ? ['', ...windowsExtensions(env)] : [''];
57
+ for (const directory of (env.PATH || '').split(path.delimiter)) {
58
+ for (const suffix of suffixes) {
59
+ const candidate = path.join(directory || '.', name + suffix);
60
+ if (isExecutable(candidate)) return candidate;
61
+ }
62
+ }
63
+ return '';
64
+ }
65
+
66
+ /** 这条命令在本机是否存在(跨平台,等价于「装了没有」)。 */
67
+ export function hasCommand(command, options) {
68
+ return Boolean(findCommand(command, options));
69
+ }
70
+
71
+ /**
72
+ * 按 cmd.exe 的规则给一个参数加引号。Node 在 shell 模式下不做这层处理,
73
+ * 含空格或 cmd 元字符的参数必须自己包好,否则会被拆开或被当成控制字符。
74
+ */
75
+ function quoteWindowsArgument(value) {
76
+ const text = String(value ?? '');
77
+ if (text === '') return '""';
78
+ if (/[\s"^&|<>()]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
79
+ return text;
80
+ }
81
+
82
+ /** 把 argv 拼成一条 cmd.exe 命令行。启动 .cmd/.bat 时用它,异步 spawn 也用同一套。 */
83
+ export function windowsCommandLine(argv) {
84
+ return argv.map(quoteWindowsArgument).join(' ');
85
+ }
86
+
87
+ /**
88
+ * 同步执行一条命令。返回 spawnSync 的结果,并补两样东西:
89
+ * resolved —— 实际用的可执行文件路径;notFound —— 是不是「本机没这个命令」。
90
+ * stdio 传 'inherit' 时不做编码转换,交给子进程直连终端。
91
+ */
92
+ export function runCommand(command, args = [], {
93
+ env = process.env, cwd, timeout, input, encoding = 'utf8', stdio,
94
+ } = {}) {
95
+ const resolved = findCommand(command, { env });
96
+ if (!resolved) {
97
+ const error = Object.assign(new Error(`本机未找到 ${command} 命令`), { code: 'ENOENT' });
98
+ return { status: null, stdout: '', stderr: '', error, resolved: '', notFound: true };
99
+ }
100
+ const options = { env, cwd, timeout };
101
+ if (stdio) options.stdio = stdio;
102
+ else {
103
+ options.encoding = encoding;
104
+ if (input !== undefined) options.input = input;
105
+ }
106
+ // .cmd/.bat 只能经 cmd.exe 启动;命令与参数在这里拼成一条命令行。
107
+ const result = isWindows && /\.(?:cmd|bat)$/i.test(resolved)
108
+ ? spawnSync(windowsCommandLine([resolved, ...args]), { ...options, shell: true })
109
+ : spawnSync(resolved, args, options);
110
+ return { ...result, resolved, notFound: false };
111
+ }
112
+
113
+ /**
114
+ * 定位 npm。Windows 上 npm 是 npm.cmd,直接 spawn 会 EINVAL;能找到它旁边的
115
+ * npm-cli.js 就改用 node 跑那个脚本,彻底绕开 cmd 的引号规则(更稳)。
116
+ */
117
+ function locateNpm(env) {
118
+ const command = findCommand('npm', { env });
119
+ if (!command) return null;
120
+ if (isWindows) {
121
+ const script = path.join(path.dirname(command), 'node_modules', 'npm', 'bin', 'npm-cli.js');
122
+ if (fs.existsSync(script)) return { command, script };
123
+ }
124
+ return { command, script: '' };
125
+ }
126
+
127
+ /** npm 执行失败时的错误:带上最后几行输出,「装了但跑不动」和「没装」在 message 里分得清。 */
128
+ function npmFailure(args, detail) {
129
+ const tail = String(detail || '').trim().slice(-300);
130
+ return Object.assign(
131
+ new Error(`npm ${args.join(' ')} 执行失败${tail ? `:${tail}` : ''}`),
132
+ { code: 'ENPMFAIL' },
133
+ );
134
+ }
135
+
136
+ /**
137
+ * 同步执行 npm,成功返回 stdout(原样,调用方自己 trim)。失败抛错:
138
+ * 本机没装 npm 是 ENOENT(notFound=true),装了但执行失败是 ENPMFAIL。
139
+ */
140
+ export function runNpm(args = [], { env = process.env, cwd, timeout, input } = {}) {
141
+ const npm = locateNpm(env);
142
+ if (!npm) {
143
+ throw Object.assign(new Error('本机未找到 npm 命令,请先安装 Node.js(含 npm)'), {
144
+ code: 'ENOENT', notFound: true,
145
+ });
146
+ }
147
+ const options = { env, cwd, timeout, encoding: 'utf8' };
148
+ if (input !== undefined) options.input = input;
149
+ const result = npm.script
150
+ ? spawnSync(process.execPath, [npm.script, ...args], options)
151
+ : spawnSync(npm.command, args, options);
152
+ if (result.error) {
153
+ if (result.error.code === 'ENOENT') {
154
+ throw Object.assign(new Error('本机未找到 npm 命令,请先安装 Node.js(含 npm)'), {
155
+ code: 'ENOENT', notFound: true,
156
+ });
157
+ }
158
+ throw result.error;
159
+ }
160
+ if (result.status !== 0) throw npmFailure(args, `${result.stderr || ''}${result.stdout || ''}`);
161
+ return result.stdout || '';
162
+ }
163
+
164
+ /** 交给插件的那一份:冻结,避免插件改到别的插件或桥接器的行为。 */
165
+ export const pluginRuntime = Object.freeze({
166
+ platform, isWindows, findCommand, hasCommand, runCommand, runNpm,
167
+ });
168
+
169
+ export default pluginRuntime;
@@ -0,0 +1,184 @@
1
+ /** Vibe Bridge plugin API v1. Local plugins execute in the desktop process. */
2
+ /** Pixel image supplied by the bridge, not a firmware animal-template name. */
3
+ export interface AgentIcon {
4
+ format: 'indexed4';
5
+ width: number; // integer 1..48
6
+ height: number; // integer 1..48
7
+ palette: string[]; // 1..16 colors, RRGGBBAA without #
8
+ data: string; // canonical base64: 4-bit linear indices, high nibble first
9
+ background: string; // #RRGGBB
10
+ accent: string; // #RRGGBB
11
+ }
12
+ /** Legacy preset strings are expanded on the bridge before transmission. */
13
+ export type Icon = AgentIcon | 'fox' | 'rabbit' | 'owl' | 'panda';
14
+ export type Settings = Record<string, string>;
15
+ export interface Capabilities {
16
+ session: 'native' | 'external_unscoped';
17
+ model: boolean;
18
+ cancel: boolean;
19
+ progress: boolean;
20
+ }
21
+ export interface Job {
22
+ id: string;
23
+ provider: string;
24
+ instruction: string;
25
+ vibeSessionId: string;
26
+ sessionId?: string;
27
+ [key: string]: unknown;
28
+ }
29
+ export interface Project { id: string; label: string; path: string }
30
+ /**
31
+ * Cross-platform helpers the bridge hands to every plugin. Use these instead of
32
+ * hand-rolling PATH lookups or spawning npm: on Windows npm is npm.cmd, which
33
+ * Node 20+ refuses to spawn without a shell, and PATH lookups must walk PATHEXT.
34
+ */
35
+ export interface PluginRuntime {
36
+ /** process.platform of the bridge, e.g. 'darwin' | 'linux' | 'win32'. */
37
+ platform: string;
38
+ isWindows: boolean;
39
+ /** Absolute path of a command on PATH, or '' when it is not installed. */
40
+ findCommand: (command: string, options?: { env?: NodeJS.ProcessEnv }) => string;
41
+ /** Whether a command is installed. */
42
+ hasCommand: (command: string, options?: { env?: NodeJS.ProcessEnv }) => boolean;
43
+ /** Sync spawn that returns {status, stdout, stderr, resolved, notFound}. */
44
+ runCommand: (command: string, args?: string[], options?: Record<string, unknown>) => {
45
+ status: number | null; stdout: string; stderr: string; resolved: string; notFound: boolean;
46
+ };
47
+ /** Sync npm run; returns stdout. Throws ENOENT when npm is missing. */
48
+ runNpm: (args?: string[], options?: Record<string, unknown>) => string;
49
+ }
50
+ export interface PluginConfig {
51
+ locale?: 'zh-CN' | 'en';
52
+ stateDir: string;
53
+ models?: Record<string, string>;
54
+ /** Values for one plugin. A packaged plugin declares the keys in plugin.json; the bridge fills this from that package's settings.json. */
55
+ pluginSettings?: Record<string, Record<string, string>>;
56
+ /** Cross-platform helpers; prefer these over hand-rolled spawn/npm calls. */
57
+ runtime?: PluginRuntime;
58
+ [key: string]: unknown;
59
+ }
60
+ export interface RunContext {
61
+ job: Job;
62
+ project: Project;
63
+ signal: AbortSignal;
64
+ config: PluginConfig;
65
+ model?: string;
66
+ onProgress: (text: string) => void;
67
+ onSession: (sessionId: string) => void;
68
+ }
69
+ export interface RunResult {
70
+ status: 'completed' | 'handed_off';
71
+ result: string;
72
+ gitSnapshot?: string;
73
+ handoff?: { cursor: string; readState: string; replyCount?: number; [key: string]: unknown };
74
+ }
75
+ export interface CodingAgentPlugin {
76
+ apiVersion: 1;
77
+ kind: 'coding-agent';
78
+ id: string;
79
+ label: string;
80
+ icon?: Icon;
81
+ capabilities: Capabilities;
82
+ model?: { default: string; required: boolean };
83
+ sessionIdPattern?: RegExp;
84
+ probe: (config: PluginConfig) => { available: boolean; reason?: string; mode?: string };
85
+ run: (context: RunContext) => Promise<RunResult>;
86
+ readUpdates?: (context: { job: Job; config: PluginConfig }) => Promise<{
87
+ handoff: { cursor: string; readState: string; replyCount?: number };
88
+ appendResult: string;
89
+ }>;
90
+ }
91
+ export interface BoardEvent {
92
+ name: string;
93
+ fields: Record<string, string>;
94
+ }
95
+ export interface BoardCommand {
96
+ name: string;
97
+ fields?: Record<string, string>;
98
+ }
99
+ export interface QueuedBoardCommand extends BoardCommand {
100
+ id: string;
101
+ fields: Record<string, string>;
102
+ }
103
+ /** Bridge handles for board plugins: what an enhancement can reach without a hook. */
104
+ export interface BridgeFacade {
105
+ /** Enabled coding agents with a probed availability snapshot. */
106
+ agents(): { id: string; label: string; available: boolean; reason: string; mode: string; capabilities: Capabilities }[];
107
+ agent(id: string): ReturnType<BridgeFacade['agents']>[number] | undefined;
108
+ /** Persisted jobs, newest first. */
109
+ jobs(): Record<string, unknown>[];
110
+ job(id: string): Record<string, unknown> | undefined;
111
+ sessions(agentId?: string): Record<string, unknown>[];
112
+ /** Create a task (waiting_confirmation) for an agent; rate-limited per bridge. */
113
+ createTask(options: { instruction: string; agentId?: string; projectId?: string; title?: string }): { job: Record<string, unknown>; session: Record<string, unknown> };
114
+ /** Append a turn to an existing session. */
115
+ submitTurn(options: { instruction: string; sessionId: string }): Record<string, unknown>;
116
+ confirmJob(id: string): Record<string, unknown>;
117
+ cancelJob(id: string): Record<string, unknown>;
118
+ themes(): { name: string; title: string; sound: boolean; icon: boolean; background: boolean }[];
119
+ devices(): string[];
120
+ /** Enqueue a board command directly; only the board-side catalog constrains the name. */
121
+ send(deviceId: string, command: BoardCommand, audio?: Uint8Array): QueuedBoardCommand;
122
+ pending(deviceId: string): QueuedBoardCommand[];
123
+ }
124
+ /** Turn input pipeline: return a new string to override what the agent executes. */
125
+ export interface TurnInputContext {
126
+ instruction: string;
127
+ job: Job;
128
+ agentId: string;
129
+ config: PluginConfig;
130
+ bridge: BridgeFacade;
131
+ }
132
+ /** Turn output pipeline: called only when a job completes; a returned string overrides the result. */
133
+ export interface TurnOutputContext {
134
+ result: string;
135
+ job: Job;
136
+ agentId: string;
137
+ status: 'completed';
138
+ config: PluginConfig;
139
+ bridge: BridgeFacade;
140
+ }
141
+ export type JobEventType = 'created' | 'confirmed' | 'started' | 'progress' | 'completed' | 'handed_off' | 'failed' | 'cancelled';
142
+ /** Job lifecycle notification; fire-and-forget, failures never affect the job. */
143
+ export interface JobEventContext {
144
+ type: JobEventType;
145
+ job: Job;
146
+ config: PluginConfig;
147
+ bridge: BridgeFacade;
148
+ }
149
+ /**
150
+ * Bridge plugin: subscribes to board events. Event delivery and command
151
+ * delivery are separate. Return commands or call send(); both enqueue for a
152
+ * later pull. send() stays valid after onEvent returns. Pass a PCM WAV
153
+ * buffer as the second argument only for audio.play; the board fetches those
154
+ * bytes with the command.
155
+ */
156
+ export interface BoardPlugin {
157
+ apiVersion: 1;
158
+ kind: 'board-plugin';
159
+ id: string;
160
+ label: string;
161
+ events: string[];
162
+ commands: string[];
163
+ probe: (config: PluginConfig) => { available: boolean; reason?: string };
164
+ onConnect?: (context: {
165
+ deviceId: string;
166
+ config: PluginConfig;
167
+ send: (command: BoardCommand, audio?: Uint8Array) => QueuedBoardCommand;
168
+ bridge: BridgeFacade;
169
+ }) => Promise<void> | void;
170
+ onEvent: (context: {
171
+ event: BoardEvent;
172
+ config: PluginConfig;
173
+ deviceId: string;
174
+ send: (command: BoardCommand, audio?: Uint8Array) => QueuedBoardCommand;
175
+ bridge: BridgeFacade;
176
+ }) => Promise<{ commands?: BoardCommand[] } | void> | { commands?: BoardCommand[] } | void;
177
+ /** Turn input pipeline: a returned string overrides the instruction actually executed (the stored original stays intact). */
178
+ onTurnInput?: (context: TurnInputContext) => string | void | Promise<string | void>;
179
+ /** Turn output pipeline: called only for completed jobs; a returned string overrides the result. */
180
+ onTurnOutput?: (context: TurnOutputContext) => string | void | Promise<string | void>;
181
+ /** Job lifecycle notification. Tasks created by plugins do not broadcast 'created'. */
182
+ onJob?: (context: JobEventContext) => void;
183
+ }
184
+ export type VibePlugin = CodingAgentPlugin | BoardPlugin;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * 增强运行时:桥接器向管控插件开放的接口面。
3
+ *
4
+ * 管控插件是"有自主行为能力的增强体":触发(事件、回合钩子、任务通知)只决定它
5
+ * 何时被唤醒;被唤醒后能做什么,取决于这里露出了多少句柄。接口有两条供给线:
6
+ * - 板侧线:事件与命令,清单由固件上报,桥接器只做清单校验;要新命令(如关机)得改固件。
7
+ * - 桥接器线:本模块露出的句柄(助手、任务、主题、板侧发送),重启桥接器即生效。
8
+ *
9
+ * 回合管道是顺序链:每个插件拿到上一环的产物,返回新字符串即 override,最后一个
10
+ * 说话的算数。单个插件超时或出错时沿用上一环的值,绝不阻断任务本身。
11
+ */
12
+
13
+ const HOOK_TIMEOUT_MS = 1500;
14
+ const CREATE_TASK_LIMIT = 10;
15
+ const CREATE_TASK_WINDOW_MS = 60_000;
16
+
17
+ export function createEnhancement(config) {
18
+ const wired = { jobs: null, board: null, themes: null };
19
+ const created = [];
20
+
21
+ const boardPlugins = () => {
22
+ try { return config.pluginsRuntime?.boardPlugins?.() ?? []; } catch { return []; }
23
+ };
24
+
25
+ const usable = (plugin) => {
26
+ try {
27
+ const state = plugin.probe(config);
28
+ return Boolean(state && typeof state === 'object' && !(state instanceof Promise) && state.available === true);
29
+ } catch { return false; }
30
+ };
31
+
32
+ async function settle(value, plugin) {
33
+ let timer;
34
+ try {
35
+ return await Promise.race([
36
+ Promise.resolve(value),
37
+ new Promise((_, reject) => {
38
+ timer = setTimeout(() => reject(new Error(`插件 ${plugin.id} 处理超时`)), HOOK_TIMEOUT_MS);
39
+ }),
40
+ ]);
41
+ } finally {
42
+ clearTimeout(timer);
43
+ }
44
+ }
45
+
46
+ async function pipeline(hookName, initial, contextFor) {
47
+ let value = initial;
48
+ for (const plugin of boardPlugins()) {
49
+ const hook = plugin[hookName];
50
+ if (typeof hook !== 'function' || !usable(plugin)) continue;
51
+ try {
52
+ const next = await settle(hook(contextFor(value)), plugin);
53
+ if (typeof next === 'string') value = next;
54
+ } catch { /* 单个插件失败不阻断链,也不影响任务本身。 */ }
55
+ }
56
+ return value;
57
+ }
58
+
59
+ function agentView(plugin) {
60
+ let state = {};
61
+ try {
62
+ const probed = plugin.probe(config);
63
+ if (probed && typeof probed === 'object' && typeof probed.then !== 'function') state = probed;
64
+ } catch { state = {}; }
65
+ return {
66
+ id: plugin.id,
67
+ label: plugin.label,
68
+ capabilities: plugin.capabilities,
69
+ available: state.available === true,
70
+ reason: typeof state.reason === 'string' ? state.reason : '',
71
+ mode: typeof state.mode === 'string' ? state.mode : 'plugin',
72
+ };
73
+ }
74
+
75
+ /** 桥接器句柄:插件在事件、钩子与通知的上下文里拿到的就是这个对象。 */
76
+ function bridge() {
77
+ const agents = () => {
78
+ try { return (config.pluginsRuntime?.agents?.() ?? []).map(agentView); } catch { return []; }
79
+ };
80
+ return {
81
+ agents,
82
+ agent: (id) => agents().find((agent) => agent.id === id),
83
+ jobs: () => (wired.jobs ? wired.jobs.list().map((job) => ({ ...job })) : []),
84
+ job: (id) => {
85
+ if (!wired.jobs) return undefined;
86
+ const job = wired.jobs.get(id);
87
+ return job ? { ...job } : undefined;
88
+ },
89
+ sessions: (agentId) => (wired.jobs ? wired.jobs.listSessions(agentId).map((session) => ({ ...session })) : []),
90
+ createTask: ({ instruction, agentId, projectId, title } = {}) => {
91
+ if (!wired.jobs) throw new Error('桥接器尚未就绪');
92
+ createTaskGuard();
93
+ const provider = agentId || config.defaultProvider;
94
+ const project = projectId || wired.jobs.defaultProject;
95
+ const session = wired.jobs.createSession({ provider, projectId: project, title });
96
+ const job = wired.jobs.submit({ instruction, sessionId: session.id, provider, projectId: project, source: 'plugin' });
97
+ return { job: { ...job }, session: { ...session } };
98
+ },
99
+ submitTurn: ({ instruction, sessionId } = {}) => {
100
+ if (!wired.jobs || !sessionId) throw new Error('请指定要继续的任务');
101
+ return { ...wired.jobs.submit({ instruction, sessionId, source: 'plugin' }) };
102
+ },
103
+ confirmJob: (id) => ({ ...wired.jobs.confirm(id) }),
104
+ cancelJob: (id) => ({ ...wired.jobs.cancel(id) }),
105
+ themes: () => (wired.themes ? wired.themes() : []),
106
+ devices: () => (wired.board ? wired.board.deviceIds() : []),
107
+ send: (deviceId, command, audio) => {
108
+ if (!wired.board) throw new Error('桥接器尚未就绪');
109
+ return wired.board.send(deviceId, command, audio);
110
+ },
111
+ pending: (deviceId) => (wired.board ? wired.board.pending(deviceId) : []),
112
+ };
113
+ }
114
+
115
+ /** 回合输入管道:改写本轮实际执行的指令;存储里的任务原文保持不变。 */
116
+ async function turnIn(job) {
117
+ return pipeline('onTurnInput', job.instruction, (instruction) => ({
118
+ instruction, job, agentId: job.provider, config, bridge: bridge(),
119
+ }));
120
+ }
121
+
122
+ /** 回合输出管道:仅在任务完成时调用,返回新字符串即改写结果。 */
123
+ async function turnOut(job, result) {
124
+ return pipeline('onTurnOutput', result, (value) => ({
125
+ result: value, job, agentId: job.provider, status: 'completed', config, bridge: bridge(),
126
+ }));
127
+ }
128
+
129
+ /** 任务生命周期通知:created/confirmed/started/progress/completed/handed_off/failed/cancelled。 */
130
+ function notify(type, job) {
131
+ // 插件自己建的任务不再广播 created:否则"建任务→收到 created→再建任务"会自激。
132
+ if (type === 'created' && job.source === 'plugin') return;
133
+ for (const plugin of boardPlugins()) {
134
+ if (typeof plugin.onJob !== 'function' || !usable(plugin)) continue;
135
+ try {
136
+ const result = plugin.onJob({ type, job, config, bridge: bridge() });
137
+ if (result && typeof result.then === 'function') result.catch(() => {});
138
+ } catch { /* 通知失败只影响这个插件自己。 */ }
139
+ }
140
+ }
141
+
142
+ function createTaskGuard() {
143
+ const at = Date.now();
144
+ while (created.length && at - created[0] >= CREATE_TASK_WINDOW_MS) created.shift();
145
+ if (created.length >= CREATE_TASK_LIMIT) throw new Error('插件创建任务过于频繁,请稍后再试');
146
+ created.push(at);
147
+ }
148
+
149
+ function attach({ jobs, board, themes } = {}) {
150
+ if (jobs) wired.jobs = jobs;
151
+ if (board) wired.board = board;
152
+ if (themes) wired.themes = themes;
153
+ }
154
+
155
+ return { attach, turnIn, turnOut, notify, bridge };
156
+ }