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.
- package/LICENSE +21 -0
- package/README.md +45 -2
- package/builtin/plugins/README.md +22 -0
- package/builtin/themes/README.md +64 -0
- package/builtin/themes/sunset/theme.json +19 -0
- package/config.example.json +20 -0
- package/demo-project/README.md +5 -0
- package/demo-project/index.mjs +14 -0
- package/package.json +48 -4
- package/public/app.js +1028 -0
- package/public/assistants.html +67 -0
- package/public/bean-icon.png +0 -0
- package/public/i18n-messages.js +967 -0
- package/public/i18n-static.js +615 -0
- package/public/i18n.js +50 -0
- package/public/index.html +149 -0
- package/public/made-icon.png +0 -0
- package/public/plugins.html +64 -0
- package/public/plugins.js +335 -0
- package/public/provider-icon.js +77 -0
- package/public/styles.css +445 -0
- package/public/tasks.html +85 -0
- package/src/agent-icons.mjs +138 -0
- package/src/agents/codex.mjs +94 -0
- package/src/agents/cursor.mjs +31 -0
- package/src/agents/events.mjs +92 -0
- package/src/agents/index.mjs +7 -0
- package/src/agents/qoder.mjs +34 -0
- package/src/agents/shared.mjs +191 -0
- package/src/agents/workbuddy.mjs +121 -0
- package/src/board-link.mjs +266 -0
- package/src/config.mjs +196 -0
- package/src/doctor.mjs +146 -0
- package/src/i18n.mjs +257 -0
- package/src/installed.mjs +291 -0
- package/src/jobs.mjs +369 -0
- package/src/language-settings.mjs +38 -0
- package/src/model-settings.mjs +63 -0
- package/src/openai-key.mjs +47 -0
- package/src/oray.mjs +243 -0
- package/src/pairing.mjs +203 -0
- package/src/plugin-package.mjs +132 -0
- package/src/plugin-runtime.mjs +169 -0
- package/src/plugins/contracts.d.ts +184 -0
- package/src/plugins/enhance.mjs +156 -0
- package/src/plugins/registry.mjs +146 -0
- package/src/providers.mjs +75 -0
- package/src/server.mjs +950 -0
- package/src/sessions.mjs +134 -0
- package/src/speech/index.mjs +10 -0
- package/src/speech/openai.mjs +48 -0
- package/src/speech/whisper.mjs +59 -0
- package/src/speech-settings.mjs +209 -0
- package/src/transcribe.mjs +85 -0
- package/src/tunnel.mjs +531 -0
- package/src/usb-receiver.mjs +664 -0
- package/src/voice-settings.mjs +69 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { BUILTIN_ROOT, packageRoots, packagesHome } from './config.mjs';
|
|
6
|
+
import { loadConfiguredPlugins, validatePlugin } from './plugins/registry.mjs';
|
|
7
|
+
import { isPackageEntry, packageDirectories, readPackageSettings, writePackageSettings } from './plugin-package.mjs';
|
|
8
|
+
|
|
9
|
+
const PLUGIN_ID = /^[a-z0-9][a-z0-9_-]{0,39}$/;
|
|
10
|
+
const THEME_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
11
|
+
const MODULE_NAME = /^[A-Za-z0-9_-]+\.(?:mjs|js)$/;
|
|
12
|
+
const fail = (message) => {
|
|
13
|
+
const error = new Error(message);
|
|
14
|
+
error.preserveMessage = true;
|
|
15
|
+
return error;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** 用户插件目录:市场装到这里,工作台也只允许从这里移除。 */
|
|
19
|
+
function pluginsDir(config) {
|
|
20
|
+
return path.join(packagesHome(config), 'plugins');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 用户主题目录:同上。 */
|
|
24
|
+
function themesDir(config) {
|
|
25
|
+
return path.join(packagesHome(config), 'themes');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 插件查找顺序:内置在前、用户目录在后(同名时用户目录覆盖内置)。 */
|
|
29
|
+
function pluginsDirs(config) {
|
|
30
|
+
return packageRoots(config).map((root) => path.join(root, 'plugins'));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 主题查找顺序:同上。 */
|
|
34
|
+
function themesDirs(config) {
|
|
35
|
+
return packageRoots(config).map((root) => path.join(root, 'themes'));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function contained(root, target) {
|
|
39
|
+
if (!fs.existsSync(root) || !fs.existsSync(target)) return false;
|
|
40
|
+
const base = fs.realpathSync(root);
|
|
41
|
+
const file = fs.realpathSync(target);
|
|
42
|
+
const relative = path.relative(base, file);
|
|
43
|
+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function moduleFiles(config) {
|
|
47
|
+
// 同名条目以用户目录为准:内置在前,后写入的覆盖前面的。
|
|
48
|
+
const found = new Map();
|
|
49
|
+
for (const directory of pluginsDirs(config)) {
|
|
50
|
+
if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) continue;
|
|
51
|
+
for (const item of packageDirectories(directory)) {
|
|
52
|
+
if (!contained(directory, item.entry)) continue;
|
|
53
|
+
found.set(item.name, item.entry);
|
|
54
|
+
}
|
|
55
|
+
for (const name of fs.readdirSync(directory).sort()) {
|
|
56
|
+
if (!MODULE_NAME.test(name)) continue;
|
|
57
|
+
const filename = path.join(directory, name);
|
|
58
|
+
if (!fs.statSync(filename).isFile() || !contained(directory, filename)) continue;
|
|
59
|
+
found.set(name, filename);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return [...found.values()];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function displayName(filename) {
|
|
66
|
+
return isPackageEntry(filename) ? path.basename(path.dirname(filename)) : path.basename(filename);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function packageInfo(filename, pluginId) {
|
|
70
|
+
if (!isPackageEntry(filename)) return { settingsSpec: [], settings: {}, settingsError: '' };
|
|
71
|
+
const pack = readPackageSettings(path.dirname(filename));
|
|
72
|
+
const mismatch = pack.id && pluginId && pack.id !== pluginId;
|
|
73
|
+
const error = mismatch ? '插件配置规范与插件 ID 不一致' : pack.error;
|
|
74
|
+
return {
|
|
75
|
+
settingsSpec: error ? [] : pack.fields,
|
|
76
|
+
settings: error ? {} : pack.values,
|
|
77
|
+
settingsError: error,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function describeModule(filename, config) {
|
|
82
|
+
const file = displayName(filename);
|
|
83
|
+
try {
|
|
84
|
+
const exported = (await import(pathToFileURL(filename).href)).default;
|
|
85
|
+
const bundle = Array.isArray(exported) ? exported : [exported];
|
|
86
|
+
return bundle.map((plugin) => {
|
|
87
|
+
validatePlugin(plugin);
|
|
88
|
+
if (plugin.kind !== 'coding-agent' && plugin.kind !== 'board-plugin') {
|
|
89
|
+
throw new Error('外部插件仅支持助手或管控');
|
|
90
|
+
}
|
|
91
|
+
const pack = packageInfo(filename, plugin.id);
|
|
92
|
+
let available = false;
|
|
93
|
+
try {
|
|
94
|
+
const result = plugin.probe(config);
|
|
95
|
+
available = Boolean(result && typeof result === 'object' && !(result instanceof Promise) && result.available === true);
|
|
96
|
+
} catch { /* Availability is display-only. */ }
|
|
97
|
+
return {
|
|
98
|
+
id: plugin.id,
|
|
99
|
+
label: plugin.label,
|
|
100
|
+
kind: plugin.kind,
|
|
101
|
+
file,
|
|
102
|
+
available,
|
|
103
|
+
enabled: !(config.disabledPlugins || []).includes(plugin.id),
|
|
104
|
+
events: plugin.kind === 'board-plugin' ? [...plugin.events] : [],
|
|
105
|
+
commands: plugin.kind === 'board-plugin' ? [...plugin.commands] : [],
|
|
106
|
+
...pack,
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
} catch {
|
|
110
|
+
return [{ file, id: '', label: file, kind: '', available: false, enabled: false, events: [], commands: [], settingsSpec: [], settings: {}, settingsError: '', error: '插件无法加载' }];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function listInstalled(config) {
|
|
115
|
+
const plugins = [];
|
|
116
|
+
for (const filename of moduleFiles(config)) plugins.push(...await describeModule(filename, config));
|
|
117
|
+
return { plugins, themes: listThemes(config) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function listThemes(config) {
|
|
121
|
+
const found = new Map();
|
|
122
|
+
for (const directory of themesDirs(config)) {
|
|
123
|
+
if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) continue;
|
|
124
|
+
for (const name of fs.readdirSync(directory).sort()) {
|
|
125
|
+
if (!THEME_NAME.test(name)) continue;
|
|
126
|
+
const dir = path.join(directory, name);
|
|
127
|
+
const manifestPath = path.join(dir, 'theme.json');
|
|
128
|
+
if (!fs.statSync(dir).isDirectory() || !contained(directory, dir) || !fs.existsSync(manifestPath)) continue;
|
|
129
|
+
let title = name;
|
|
130
|
+
try {
|
|
131
|
+
const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
132
|
+
if (typeof raw.title === 'string' && raw.title.trim()) title = raw.title.trim();
|
|
133
|
+
} catch { /* A broken manifest still shows the folder name. */ }
|
|
134
|
+
const files = fs.readdirSync(dir).filter((item) => {
|
|
135
|
+
const file = path.join(dir, item);
|
|
136
|
+
return /^[A-Za-z0-9_.-]+$/.test(item) && fs.statSync(file).isFile();
|
|
137
|
+
});
|
|
138
|
+
found.set(name, {
|
|
139
|
+
name,
|
|
140
|
+
title,
|
|
141
|
+
sound: files.includes('chime.wav'),
|
|
142
|
+
icon: files.includes('icon.bin'),
|
|
143
|
+
background: files.includes('bg.bin'),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return [...found.values()];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function writeConfig(config, mutate) {
|
|
151
|
+
const file = config.configFile;
|
|
152
|
+
if (!file || path.basename(file) === 'config.example.json') throw fail('请先复制 config.example.json 为 config.local.json');
|
|
153
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
154
|
+
mutate(raw);
|
|
155
|
+
const temporary = path.join(path.dirname(file), `.config-${process.pid}-${randomUUID()}.json`);
|
|
156
|
+
fs.writeFileSync(temporary, `${JSON.stringify(raw, null, 2)}\n`, { mode: 0o600 });
|
|
157
|
+
fs.renameSync(temporary, file);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function reload(config) {
|
|
161
|
+
config.pluginsRuntime = await loadConfiguredPlugins(config);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function covers(targetReal, candidateReal) {
|
|
165
|
+
if (candidateReal === targetReal) return true;
|
|
166
|
+
const relative = path.relative(targetReal, candidateReal);
|
|
167
|
+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function pluginPathInConfig(config, filename) {
|
|
171
|
+
const target = fs.realpathSync(filename);
|
|
172
|
+
return (config.plugins || []).filter((entry) => {
|
|
173
|
+
if (typeof entry !== 'string' || !entry || /^[a-z]+:/i.test(entry)) return true;
|
|
174
|
+
const resolved = path.resolve(config.configDirectory, entry);
|
|
175
|
+
return !fs.existsSync(resolved) || !covers(target, fs.realpathSync(resolved));
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function removalTarget(config, filename) {
|
|
180
|
+
const root = pluginsDir(config);
|
|
181
|
+
const dir = path.dirname(filename);
|
|
182
|
+
const base = path.basename(filename);
|
|
183
|
+
if ((base === 'plugin.mjs' || base === 'plugin.js') && contained(root, dir) && path.dirname(fs.realpathSync(dir)) === fs.realpathSync(root)) return dir;
|
|
184
|
+
return filename;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function setPluginEnabled(config, id, enabled) {
|
|
188
|
+
if (!PLUGIN_ID.test(id || '')) throw fail('插件不存在');
|
|
189
|
+
const installed = (await listInstalled(config)).plugins;
|
|
190
|
+
if (!installed.some((plugin) => plugin.id === id)) throw fail('插件不存在');
|
|
191
|
+
const previous = [...(config.disabledPlugins || [])];
|
|
192
|
+
const next = new Set(previous);
|
|
193
|
+
if (enabled) next.delete(id);
|
|
194
|
+
else next.add(id);
|
|
195
|
+
config.disabledPlugins = [...next];
|
|
196
|
+
try {
|
|
197
|
+
await reload(config);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
config.disabledPlugins = previous;
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
writeConfig(config, (raw) => { raw.disabledPlugins = config.disabledPlugins; });
|
|
204
|
+
} catch (error) {
|
|
205
|
+
config.disabledPlugins = previous;
|
|
206
|
+
await reload(config);
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
return listInstalled(config);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function savePluginSettings(config, id, values) {
|
|
213
|
+
if (!PLUGIN_ID.test(id || '') || !values || typeof values !== 'object' || Array.isArray(values)) throw fail('插件配置无效');
|
|
214
|
+
const filename = await entryForId(config, id);
|
|
215
|
+
if (!filename || !isPackageEntry(filename)) throw fail('插件没有可保存的配置');
|
|
216
|
+
const dir = path.dirname(filename);
|
|
217
|
+
const resolved = writePackageSettings(dir, values);
|
|
218
|
+
config.pluginSettings = { ...(config.pluginSettings || {}), [id]: resolved };
|
|
219
|
+
if (config.pluginPackages?.[id]) config.pluginPackages[id] = { ...config.pluginPackages[id], values: resolved, error: '' };
|
|
220
|
+
return listInstalled(config);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function entryForId(config, id) {
|
|
224
|
+
for (const candidate of moduleFiles(config)) {
|
|
225
|
+
const described = await describeModule(candidate, config);
|
|
226
|
+
if (described.some((plugin) => plugin.id === id)) return candidate;
|
|
227
|
+
}
|
|
228
|
+
return '';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function removePlugin(config, { id = '', file = '' } = {}) {
|
|
232
|
+
const filename = moduleFiles(config).find((candidate) => {
|
|
233
|
+
if (file) return displayName(candidate) === file;
|
|
234
|
+
return false;
|
|
235
|
+
});
|
|
236
|
+
let target = filename;
|
|
237
|
+
if (!target && id) {
|
|
238
|
+
for (const candidate of moduleFiles(config)) {
|
|
239
|
+
const described = await describeModule(candidate, config);
|
|
240
|
+
if (described.some((plugin) => plugin.id === id)) target = candidate;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (!target) throw fail('插件不存在');
|
|
244
|
+
if (!contained(pluginsDir(config), target)) throw fail('内置插件不能移除,只能移除用户目录里的插件');
|
|
245
|
+
target = removalTarget(config, target);
|
|
246
|
+
const described = await describeModule(target, config);
|
|
247
|
+
const ids = described.map((plugin) => plugin.id).filter((item) => PLUGIN_ID.test(item));
|
|
248
|
+
const previousDisabled = [...(config.disabledPlugins || [])];
|
|
249
|
+
const previousPlugins = [...(config.plugins || [])];
|
|
250
|
+
config.disabledPlugins = [...new Set([...previousDisabled, ...ids])];
|
|
251
|
+
config.plugins = pluginPathInConfig(config, target);
|
|
252
|
+
try {
|
|
253
|
+
await reload(config);
|
|
254
|
+
writeConfig(config, (raw) => {
|
|
255
|
+
raw.disabledPlugins = config.disabledPlugins;
|
|
256
|
+
if (Array.isArray(raw.plugins)) raw.plugins = config.plugins;
|
|
257
|
+
});
|
|
258
|
+
} catch (error) {
|
|
259
|
+
config.disabledPlugins = previousDisabled;
|
|
260
|
+
config.plugins = previousPlugins;
|
|
261
|
+
await reload(config).catch(() => {});
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
265
|
+
config.disabledPlugins = previousDisabled.filter((item) => !ids.includes(item));
|
|
266
|
+
try {
|
|
267
|
+
await reload(config);
|
|
268
|
+
writeConfig(config, (raw) => { raw.disabledPlugins = config.disabledPlugins; });
|
|
269
|
+
} catch {
|
|
270
|
+
// The file is already gone. Leaving it disabled still lets the bridge start.
|
|
271
|
+
}
|
|
272
|
+
return listInstalled(config);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function removeTheme(config, name) {
|
|
276
|
+
if (!THEME_NAME.test(name || '')) throw fail('主题不存在');
|
|
277
|
+
const directory = themesDir(config);
|
|
278
|
+
const target = path.join(directory, name);
|
|
279
|
+
if (!fs.existsSync(target) || !fs.statSync(target).isDirectory() || !contained(directory, target)) {
|
|
280
|
+
// 用户目录里没有,但内置目录里有:说清楚为什么删不掉。
|
|
281
|
+
if (fs.existsSync(path.join(BUILTIN_ROOT, 'themes', name, 'theme.json'))) throw fail('内置主题不能移除,只能移除用户目录里的主题');
|
|
282
|
+
throw fail('主题不存在');
|
|
283
|
+
}
|
|
284
|
+
if (!fs.existsSync(path.join(target, 'theme.json'))) throw fail('主题不存在');
|
|
285
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
286
|
+
return listThemes(config);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function themeExists(config, name) {
|
|
290
|
+
return listThemes(config).some((theme) => theme.name === name);
|
|
291
|
+
}
|
package/src/jobs.mjs
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { SessionStore, validAgentSessionId, VIBE_SESSION_ID } from './sessions.mjs';
|
|
5
|
+
import { builtinAgent } from './agents/index.mjs';
|
|
6
|
+
|
|
7
|
+
const ACTIVE = new Set(['waiting_confirmation', 'queued', 'running']);
|
|
8
|
+
|
|
9
|
+
function now() {
|
|
10
|
+
return new Date().toISOString();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class JobStore {
|
|
14
|
+
constructor({ file, projects, defaultProject, defaultProvider, workspaceRoot, maxParallel, run, providerAvailable, pluginsRuntime, hooks }) {
|
|
15
|
+
this.agent = pluginsRuntime ? (id) => pluginsRuntime.agent(id) : run.agent || builtinAgent;
|
|
16
|
+
this.hooks = hooks || null;
|
|
17
|
+
this.file = file;
|
|
18
|
+
this.projects = projects;
|
|
19
|
+
this.workspaceRoot = workspaceRoot || '';
|
|
20
|
+
this.defaultProject = defaultProject;
|
|
21
|
+
this.defaultProvider = defaultProvider;
|
|
22
|
+
this.run = run;
|
|
23
|
+
this.providerAvailable = providerAvailable;
|
|
24
|
+
// 同时执行的会话数。缺省 1 = 完全串行(保持既有语义,便于测试与降级)。
|
|
25
|
+
const lanes = Number(maxParallel);
|
|
26
|
+
this.maxParallel = Number.isFinite(lanes) ? Math.min(Math.max(Math.trunc(lanes), 1), 4) : 1;
|
|
27
|
+
// 正在占用车道的会话 ID。一个会话同时只会有一条指令在跑,所以按会话占位即可。
|
|
28
|
+
this.lanes = new Set();
|
|
29
|
+
this.sessions = new SessionStore({
|
|
30
|
+
file: path.join(path.dirname(file), 'sessions.json'), projects, defaultProject, defaultProvider, agent: this.agent,
|
|
31
|
+
});
|
|
32
|
+
this.jobs = [];
|
|
33
|
+
this.controllers = new Map();
|
|
34
|
+
this.progressSavedAt = new Map();
|
|
35
|
+
this.pumping = false;
|
|
36
|
+
this.handoffRefresh = null;
|
|
37
|
+
this.nextHandoffPollAt = 0;
|
|
38
|
+
this.handoffIndex = 0;
|
|
39
|
+
this.load();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
load() {
|
|
43
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 });
|
|
44
|
+
if (fs.existsSync(this.file)) {
|
|
45
|
+
const parsed = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
|
46
|
+
this.jobs = Array.isArray(parsed) ? parsed : [];
|
|
47
|
+
for (const job of this.jobs) {
|
|
48
|
+
if (!job.vibeSessionId || !this.sessions.get(job.vibeSessionId)) {
|
|
49
|
+
const session = this.sessions.create({
|
|
50
|
+
provider: job.provider, projectId: job.projectId, legacy: true,
|
|
51
|
+
cliSessionId: job.sessionId || '',
|
|
52
|
+
});
|
|
53
|
+
this.sessions.recordTurn(session.id, job.instruction || '历史任务');
|
|
54
|
+
job.vibeSessionId = session.id;
|
|
55
|
+
}
|
|
56
|
+
if (job.status === 'running' || job.status === 'queued') {
|
|
57
|
+
job.status = 'failed';
|
|
58
|
+
job.error = '桥接客户端重启,任务执行已中断。请检查代码目录后重新提交。';
|
|
59
|
+
job.updatedAt = now();
|
|
60
|
+
}
|
|
61
|
+
this.run.syncJobState?.(job);
|
|
62
|
+
if (job.status === 'handed_off' && job.handoff?.readState === 'unavailable') {
|
|
63
|
+
// A fresh process may have newly authorized credentials.
|
|
64
|
+
job.handoff.readState = 'retrying';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
this.save();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
save() {
|
|
72
|
+
for (const job of this.jobs) this.run.syncJobState?.(job);
|
|
73
|
+
const temp = `${this.file}.${process.pid}.tmp`;
|
|
74
|
+
fs.writeFileSync(temp, JSON.stringify(this.jobs, null, 2), { mode: 0o600 });
|
|
75
|
+
fs.renameSync(temp, this.file);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
list() {
|
|
79
|
+
// Both the desktop and the device read through this method. A throttled
|
|
80
|
+
// refresh makes handed-off replies available to both without a UI timer.
|
|
81
|
+
void this.refreshHandoffs();
|
|
82
|
+
return [...this.jobs].reverse();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
refreshHandoffs({ force = false } = {}) {
|
|
86
|
+
if (typeof this.run.readUpdates !== 'function') return Promise.resolve();
|
|
87
|
+
if (this.handoffRefresh) return this.handoffRefresh;
|
|
88
|
+
if (!force && Date.now() < this.nextHandoffPollAt) return Promise.resolve();
|
|
89
|
+
const jobs = this.jobs.filter((job) =>
|
|
90
|
+
job.status === 'handed_off' && job.handoff?.cursor &&
|
|
91
|
+
!['unavailable', 'ambiguous'].includes(job.handoff.readState) &&
|
|
92
|
+
(this.run.canReadUpdates?.(job) ?? true)
|
|
93
|
+
).reverse().slice(0, 8);
|
|
94
|
+
if (!jobs.length) return Promise.resolve();
|
|
95
|
+
const job = jobs[this.handoffIndex % jobs.length];
|
|
96
|
+
this.handoffIndex += 1;
|
|
97
|
+
this.nextHandoffPollAt = Date.now() + 8000;
|
|
98
|
+
this.handoffRefresh = this.pollHandoff(job).finally(() => {
|
|
99
|
+
this.handoffRefresh = null;
|
|
100
|
+
});
|
|
101
|
+
return this.handoffRefresh;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async pollHandoff(job) {
|
|
105
|
+
try {
|
|
106
|
+
const update = await this.run.readUpdates(job);
|
|
107
|
+
if (job.status !== 'handed_off' || !update) return;
|
|
108
|
+
let changed = false;
|
|
109
|
+
if (update.handoff && typeof update.handoff === 'object') {
|
|
110
|
+
job.handoff = { ...job.handoff, ...update.handoff };
|
|
111
|
+
changed = true;
|
|
112
|
+
}
|
|
113
|
+
if (typeof update.appendResult === 'string' && update.appendResult) {
|
|
114
|
+
job.result = `${job.result}\n\n${update.appendResult}`.slice(-12000);
|
|
115
|
+
changed = true;
|
|
116
|
+
}
|
|
117
|
+
if (changed) {
|
|
118
|
+
delete job.handoffReadError;
|
|
119
|
+
job.updatedAt = now();
|
|
120
|
+
this.save();
|
|
121
|
+
}
|
|
122
|
+
} catch (error) {
|
|
123
|
+
// Reading a reply never undoes a successful dispatch.
|
|
124
|
+
if (job.status === 'handed_off') {
|
|
125
|
+
job.handoff = { ...job.handoff, readState: 'retrying' };
|
|
126
|
+
job.handoffReadError = error instanceof Error ? error.message : String(error);
|
|
127
|
+
this.save();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
get(id) {
|
|
133
|
+
return id ? this.jobs.find((job) => job.id === id) : this.jobs.at(-1);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
listSessions(provider) {
|
|
137
|
+
return this.sessions.list(provider).map((session) => {
|
|
138
|
+
const latest = [...this.jobs].reverse().find((job) => job.vibeSessionId === session.id);
|
|
139
|
+
return { ...session, status: latest?.status || 'empty' };
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
createSession(payload) {
|
|
144
|
+
const session = this.sessions.create(payload);
|
|
145
|
+
return { ...session, status: 'empty' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
renameSession(id, title) {
|
|
149
|
+
this.sessions.rename(id, title);
|
|
150
|
+
return this.listSessions().find((session) => session.id === id);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
deleteSession(id) {
|
|
154
|
+
if (!VIBE_SESSION_ID.test(id || '') || !this.sessions.get(id)) throw new Error('任务不存在');
|
|
155
|
+
const turns = this.jobs.filter((job) => job.vibeSessionId === id);
|
|
156
|
+
if (turns.some((job) => job.status === 'running' && !this.agent(job.provider)?.capabilities.cancel)) {
|
|
157
|
+
throw new Error('这个编程工具不支持撤回正在执行的任务,请先在工具中结束执行');
|
|
158
|
+
}
|
|
159
|
+
for (const job of turns) {
|
|
160
|
+
if (ACTIVE.has(job.status)) this.cancel(job.id);
|
|
161
|
+
// A handoff poll or an agent that finishes after abort must not write a
|
|
162
|
+
// removed turn back into the bridge's task history.
|
|
163
|
+
job.status = 'deleted';
|
|
164
|
+
this.progressSavedAt.delete(job.id);
|
|
165
|
+
}
|
|
166
|
+
this.jobs = this.jobs.filter((job) => job.vibeSessionId !== id);
|
|
167
|
+
// Persist jobs first: a crash between files leaves an empty session,
|
|
168
|
+
// rather than an orphan turn that load() would recreate as a new session.
|
|
169
|
+
this.save();
|
|
170
|
+
this.sessions.delete(id);
|
|
171
|
+
return { id, deleted: true };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
submit({ instruction, sessionId, provider, projectId, source = 'voice' }) {
|
|
175
|
+
const text = String(instruction || '').trim();
|
|
176
|
+
if (!VIBE_SESSION_ID.test(sessionId || '')) throw new Error('请先新建任务');
|
|
177
|
+
const session = this.sessions.get(sessionId);
|
|
178
|
+
if (!session) throw new Error('任务不存在');
|
|
179
|
+
const selectedProvider = provider || session.provider;
|
|
180
|
+
const selectedProject = projectId || session.projectId;
|
|
181
|
+
if (selectedProvider !== session.provider || selectedProject !== session.projectId) {
|
|
182
|
+
throw new Error('任务与编程工具或项目不匹配');
|
|
183
|
+
}
|
|
184
|
+
if (text.length < 3 || text.length > 2000) {
|
|
185
|
+
throw new Error('任务内容需在 3 到 2000 字之间');
|
|
186
|
+
}
|
|
187
|
+
if (!this.agent(selectedProvider)) {
|
|
188
|
+
throw new Error('不支持的编程工具');
|
|
189
|
+
}
|
|
190
|
+
if (!this.projects.some((project) => project.id === selectedProject)) {
|
|
191
|
+
throw new Error('项目不在本机允许列表中');
|
|
192
|
+
}
|
|
193
|
+
if (this.jobs.some((item) => item.vibeSessionId === session.id && ACTIVE.has(item.status))) {
|
|
194
|
+
throw new Error('请先完成或取消当前任务的上一条指令');
|
|
195
|
+
}
|
|
196
|
+
if (session.contextMode === 'native' &&
|
|
197
|
+
this.jobs.some((item) => item.vibeSessionId === session.id && item.status === 'completed') &&
|
|
198
|
+
!session.cliSessionId) {
|
|
199
|
+
throw new Error('编程工具未返回可恢复的会话 ID,无法继续此任务;请新建任务');
|
|
200
|
+
}
|
|
201
|
+
const job = {
|
|
202
|
+
id: randomUUID().slice(0, 12),
|
|
203
|
+
vibeSessionId: session.id,
|
|
204
|
+
sessionId: session.cliSessionId || undefined,
|
|
205
|
+
instruction: text,
|
|
206
|
+
provider: selectedProvider,
|
|
207
|
+
projectId: selectedProject,
|
|
208
|
+
source,
|
|
209
|
+
status: 'waiting_confirmation',
|
|
210
|
+
createdAt: now(),
|
|
211
|
+
updatedAt: now(),
|
|
212
|
+
result: '',
|
|
213
|
+
progress: '',
|
|
214
|
+
progressAt: '',
|
|
215
|
+
error: '',
|
|
216
|
+
};
|
|
217
|
+
this.sessions.recordTurn(session.id, text);
|
|
218
|
+
this.jobs.push(job);
|
|
219
|
+
this.save();
|
|
220
|
+
this.hooks?.notify('created', job);
|
|
221
|
+
return job;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
confirm(id) {
|
|
225
|
+
const job = this.get(id);
|
|
226
|
+
if (!job) throw new Error('任务不存在');
|
|
227
|
+
if (job.status !== 'waiting_confirmation') throw new Error('当前任务不能确认');
|
|
228
|
+
const capability = this.providerAvailable(job.provider);
|
|
229
|
+
if (!capability?.available) {
|
|
230
|
+
const error = new Error(capability?.reason || '编程工具尚未就绪');
|
|
231
|
+
if (capability?.preserveMessage && capability.reason) error.preserveMessage = true;
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
job.error = '';
|
|
235
|
+
job.progress = '';
|
|
236
|
+
job.progressAt = '';
|
|
237
|
+
delete job.retryPending;
|
|
238
|
+
job.status = 'queued';
|
|
239
|
+
job.updatedAt = now();
|
|
240
|
+
this.save();
|
|
241
|
+
this.hooks?.notify('confirmed', job);
|
|
242
|
+
void this.pump();
|
|
243
|
+
return job;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
cancel(id) {
|
|
247
|
+
const job = this.get(id);
|
|
248
|
+
if (!job) throw new Error('任务不存在');
|
|
249
|
+
if (!ACTIVE.has(job.status)) throw new Error('当前任务不能取消');
|
|
250
|
+
if (job.status === 'running' && !this.agent(job.provider)?.capabilities.cancel) {
|
|
251
|
+
throw new Error('这个编程工具不支持撤回正在执行的任务,请在工具中处理');
|
|
252
|
+
}
|
|
253
|
+
job.status = 'cancelled';
|
|
254
|
+
delete job.retryPending;
|
|
255
|
+
job.updatedAt = now();
|
|
256
|
+
this.controllers.get(job.id)?.abort();
|
|
257
|
+
this.save();
|
|
258
|
+
this.hooks?.notify('cancelled', job);
|
|
259
|
+
return job;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async pump() {
|
|
263
|
+
if (this.pumping) return;
|
|
264
|
+
this.pumping = true;
|
|
265
|
+
try {
|
|
266
|
+
// 派活:每个会话占一条车道。会话之间可以并行,同一会话仍然串行——编程工具按
|
|
267
|
+
// 会话线程加写锁,两个进程写同一线程必然冲突,所以并行的粒度只能是"会话"。
|
|
268
|
+
for (const sessionId of this.jobs.map((item) => item.vibeSessionId)) {
|
|
269
|
+
if (this.lanes.size >= this.maxParallel) break;
|
|
270
|
+
if (this.lanes.has(sessionId)) continue;
|
|
271
|
+
if (!this.jobs.some((item) => item.status === 'queued' && item.vibeSessionId === sessionId)) continue;
|
|
272
|
+
this.lanes.add(sessionId);
|
|
273
|
+
void this.lane(sessionId).catch(() => {}).finally(() => {
|
|
274
|
+
this.lanes.delete(sessionId);
|
|
275
|
+
void this.pump();
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
} finally {
|
|
279
|
+
this.pumping = false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 单条车道:把这个会话里排队的指令依次跑完。 */
|
|
284
|
+
async lane(sessionId) {
|
|
285
|
+
try {
|
|
286
|
+
while (true) {
|
|
287
|
+
const job = this.jobs.find((item) => item.status === 'queued' && item.vibeSessionId === sessionId);
|
|
288
|
+
if (!job) break;
|
|
289
|
+
const controller = new AbortController();
|
|
290
|
+
this.controllers.set(job.id, controller);
|
|
291
|
+
job.status = 'running';
|
|
292
|
+
job.updatedAt = now();
|
|
293
|
+
this.save();
|
|
294
|
+
this.hooks?.notify('started', job);
|
|
295
|
+
try {
|
|
296
|
+
const configured = this.projects.find((item) => item.id === job.projectId);
|
|
297
|
+
const project = this.workspaceRoot
|
|
298
|
+
? { ...configured, path: path.join(this.workspaceRoot, job.vibeSessionId) }
|
|
299
|
+
: configured;
|
|
300
|
+
// 回合输入管道:插件可以改写本轮实际执行的指令;存储里的任务原文保持不变。
|
|
301
|
+
const instruction = this.hooks ? await this.hooks.turnIn(job) : job.instruction;
|
|
302
|
+
const effectiveJob = instruction === job.instruction ? job : { ...job, instruction };
|
|
303
|
+
const outcome = await this.run(effectiveJob, project, controller.signal, (text) => {
|
|
304
|
+
if (job.status === 'running' && this.agent(job.provider)?.capabilities.progress) {
|
|
305
|
+
const progress = String(text || '').replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, 80);
|
|
306
|
+
if (!progress || progress === job.progress) return;
|
|
307
|
+
job.progress = progress;
|
|
308
|
+
job.progressAt = now();
|
|
309
|
+
job.updatedAt = job.progressAt;
|
|
310
|
+
const current = Date.now();
|
|
311
|
+
if (current - (this.progressSavedAt.get(job.id) || 0) >= 750) {
|
|
312
|
+
this.progressSavedAt.set(job.id, current);
|
|
313
|
+
this.save();
|
|
314
|
+
this.hooks?.notify('progress', job);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}, (sessionId) => {
|
|
318
|
+
const plugin = this.agent(job.provider);
|
|
319
|
+
if (job.status === 'running' && plugin?.capabilities.session === 'native' &&
|
|
320
|
+
validAgentSessionId(plugin, sessionId) &&
|
|
321
|
+
(!job.sessionId || job.sessionId === sessionId)) {
|
|
322
|
+
job.sessionId = sessionId;
|
|
323
|
+
this.sessions.setCliSession(job.vibeSessionId, sessionId);
|
|
324
|
+
job.updatedAt = now();
|
|
325
|
+
this.save();
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
if (job.status === 'running') {
|
|
329
|
+
if (!outcome || !['completed', 'handed_off'].includes(outcome.status || 'completed')) {
|
|
330
|
+
throw new Error('编程工具插件返回了无效的任务状态');
|
|
331
|
+
}
|
|
332
|
+
job.status = outcome.status || 'completed';
|
|
333
|
+
let result = outcome.result || job.result;
|
|
334
|
+
// 回合输出管道:仅在任务完成时调用,插件返回新字符串即改写结果。
|
|
335
|
+
if (job.status === 'completed') result = this.hooks ? await this.hooks.turnOut(job, result) : result;
|
|
336
|
+
job.result = result;
|
|
337
|
+
if (outcome.handoff && typeof outcome.handoff === 'object') job.handoff = { ...outcome.handoff };
|
|
338
|
+
if (job.status === 'completed' && typeof outcome.gitSnapshot === 'string') {
|
|
339
|
+
this.sessions.setGitSnapshot(job.vibeSessionId, outcome.gitSnapshot);
|
|
340
|
+
}
|
|
341
|
+
job.updatedAt = now();
|
|
342
|
+
this.save();
|
|
343
|
+
this.hooks?.notify(job.status, job);
|
|
344
|
+
}
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (job.status === 'running') {
|
|
347
|
+
const retryableConflict = error?.retryable === true;
|
|
348
|
+
job.status = retryableConflict ? 'waiting_confirmation' : 'failed';
|
|
349
|
+
job.retryPending = retryableConflict;
|
|
350
|
+
job.error = error instanceof Error ? error.message : String(error);
|
|
351
|
+
if (retryableConflict) {
|
|
352
|
+
job.progress = '';
|
|
353
|
+
job.progressAt = '';
|
|
354
|
+
}
|
|
355
|
+
job.updatedAt = now();
|
|
356
|
+
this.save();
|
|
357
|
+
this.hooks?.notify('failed', job);
|
|
358
|
+
}
|
|
359
|
+
} finally {
|
|
360
|
+
this.controllers.delete(job.id);
|
|
361
|
+
this.progressSavedAt.delete(job.id);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
} finally {
|
|
365
|
+
this.lanes.delete(sessionId);
|
|
366
|
+
void this.pump();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|