@bolloon/bolloon-agent 0.4.24 → 0.4.26

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 (49) hide show
  1. package/dist/agents/execution-supervisor.js +446 -0
  2. package/dist/agents/external-events.js +162 -0
  3. package/dist/agents/goal-criteria.js +124 -0
  4. package/dist/agents/goal-store.js +526 -0
  5. package/dist/agents/pi-harness.js +263 -0
  6. package/dist/agents/pi-sdk.js +607 -126
  7. package/dist/agents/run-store.js +772 -0
  8. package/dist/agents/runner-resolver.js +225 -0
  9. package/dist/agents/skill-readiness.js +133 -0
  10. package/dist/agents/skill-supervisor-link.js +70 -0
  11. package/dist/agents/skills-manager.js +717 -0
  12. package/dist/agents/supervisor-host.js +249 -0
  13. package/dist/cli/setup-wizard.js +96 -127
  14. package/dist/cron/tick-lock.js +1 -1
  15. package/dist/electron/first-run.js +33 -2
  16. package/dist/electron-build/electron/first-run.js +35 -2
  17. package/dist/electron-build/electron/first-run.js.map +1 -1
  18. package/dist/index.js +549 -26
  19. package/dist/ios/agent-delegate-server.js +58 -12
  20. package/dist/ios/icons/icon-1024x1024.png +0 -0
  21. package/dist/ios/icons/icon-1024x1024.webp +0 -0
  22. package/dist/ios/icons/icon-216x216.png +0 -0
  23. package/dist/ios/icons/icon-216x216.webp +0 -0
  24. package/dist/ios/index.html +21 -1
  25. package/dist/ios/manifest.json +1 -1
  26. package/dist/ios/mobile-agent.js +195 -1
  27. package/dist/ios/mobile-core.js +24876 -24723
  28. package/dist/ios/mobile.css +15 -0
  29. package/dist/ios/mobile.html +21 -1
  30. package/dist/ios/mobile.js +143 -0
  31. package/dist/ios/server.js +51 -4
  32. package/dist/llm/config-store.js +35 -4
  33. package/dist/network/agent-network.js +10 -0
  34. package/dist/network/goal-event-bridge.js +57 -0
  35. package/dist/setup/onboard.js +549 -0
  36. package/dist/setup/setup-store.js +592 -0
  37. package/dist/web/icons/icon-1024x1024.png +0 -0
  38. package/dist/web/icons/icon-1024x1024.webp +0 -0
  39. package/dist/web/icons/icon-216x216.png +0 -0
  40. package/dist/web/icons/icon-216x216.webp +0 -0
  41. package/dist/web/manifest.json +1 -1
  42. package/dist/web/mobile-agent.js +2 -2
  43. package/dist/web/mobile-core.js +24884 -24726
  44. package/dist/web/mobile-privacy.js +185 -0
  45. package/dist/web/mobile.css +15 -0
  46. package/dist/web/mobile.html +21 -1
  47. package/dist/web/mobile.js +179 -6
  48. package/dist/web/server.js +633 -0
  49. package/package.json +2 -2
@@ -0,0 +1,549 @@
1
+ /**
2
+ * onboard.ts — 可恢复的 Onboard 执行器 (Phase 2/4/6, 2026-09-16)
3
+ *
4
+ * 把"边问边写"的顺序脚本改成**阶段执行器**:
5
+ * load state → 显示已有输入 → 收集本次修改 → 本地校验 → 必要时真实验证
6
+ * → **原子提交该阶段结果** → 评估推进 → 下一阶段
7
+ *
8
+ * 失败一律: 保留已完成步骤 · 不清空旧配置 · 标记失败阶段与错误分类 ·
9
+ * 给"重试/修改/返回上一步/修复"入口 · **不显示配置完成** · 不允许 Agent 执行。
10
+ *
11
+ * 阶段 (与 setup-store 的状态机 1:1):
12
+ * env → identity → provider → credential → model → connectivity → runtime → commit
13
+ *
14
+ * 对照 Hermes (`/Users/apple/Downloads/hermes/hermes_cli/setup.py`):
15
+ * - **独立 section + 左箭头回退重放**: 回到上一步会重放此前选择 (这里用"已完成输入摘要 + 可改"实现同一效果);
16
+ * - `--reconfigure` 只补/改缺失项 (`_skip_configured_section`);
17
+ * - `setup_summary.py` 的逐能力 readiness 行 —— 这里落到 `describeSetup()` + readinessWhy。
18
+ */
19
+ import * as os from 'os';
20
+ import * as path from 'path';
21
+ import * as fs from 'fs/promises';
22
+ import { CANONICAL_CONFIG_FILE, LEGACY_CONFIG_FILE, describeSetup, evaluateSetup, readConfigFacts, readSetupState, recordSetupFailure, recordSetupStage, refreshSetupState, resolveBolloonHome, } from './setup-store.js';
23
+ // ── 错误分类 (timeout/auth/network/model/config/runtime) ────────────────────
24
+ export function classifyOnboardError(err) {
25
+ const m = String(err?.message || err || '');
26
+ if (/abort|timeout|timed out|ETIMEDOUT|超时/i.test(m))
27
+ return 'timeout';
28
+ if (/401|403|invalid.*key|unauthor|api key/i.test(m))
29
+ return 'auth';
30
+ if (/ENOTFOUND|ECONNREFUSED|ECONNRESET|fetch failed|network|EAI_AGAIN|socket/i.test(m))
31
+ return 'network';
32
+ if (/404|not found|baseUrl|endpoint/i.test(m))
33
+ return 'config';
34
+ if (/model/i.test(m))
35
+ return 'model';
36
+ if (/ENOSPC|EACCES|EPERM|EIO|write/i.test(m))
37
+ return 'io';
38
+ return 'unknown';
39
+ }
40
+ async function configStore() {
41
+ const mod = await import('../llm/config-store.js');
42
+ return mod.llmConfigStore;
43
+ }
44
+ const stepEnv = {
45
+ id: 'env', title: '环境检查', stage: 'uninitialized',
46
+ shouldRun: () => true,
47
+ async run(c) {
48
+ const major = Number(process.versions.node.split('.')[0]);
49
+ if (Number.isFinite(major) && major < 18)
50
+ return { ok: false, errorClass: 'runtime', message: `Node 版本过低 (${process.versions.node}), 需要 >=18` };
51
+ try {
52
+ await fs.mkdir(c.bolloonHome, { recursive: true });
53
+ const probe = path.join(c.bolloonHome, '.onboard-probe');
54
+ await fs.writeFile(probe, 'ok', 'utf8');
55
+ await fs.rm(probe, { force: true });
56
+ }
57
+ catch (err) {
58
+ return { ok: false, errorClass: 'io', message: `配置目录不可写 (${c.bolloonHome}): ${String(err?.message || err).slice(0, 120)}` };
59
+ }
60
+ return { ok: true, note: `Node ${process.versions.node} · 配置目录可写` };
61
+ },
62
+ };
63
+ const stepIdentity = {
64
+ id: 'identity', title: '用户身份', stage: 'identity_pending',
65
+ shouldRun: (c) => c.mode === 'reconfigure' ? c.targets.includes('identity') : !c.state.checks.identity,
66
+ async run(c) {
67
+ const { readUserIdentity, writeUserIdentity } = await import('../cli/setup-wizard.js');
68
+ const existing = await readUserIdentity(c.home);
69
+ if (existing?.did && c.mode !== 'reconfigure') {
70
+ c.io.print(`已有身份: ${existing.name || '(未命名)'} · DID ${String(existing.did).slice(0, 18)}… (复用, 不重新生成)`);
71
+ return { ok: true, patch: { inputs: { name: existing.name, identityDid: existing.did }, checks: { identity: true } }, note: '复用已有 DID' };
72
+ }
73
+ const name = await c.io.ask('你的称呼 (显示用, 40 字内)', { validate: (v) => (String(v || '').trim() ? { ok: true } : { ok: false, error: '不能为空' }) });
74
+ const res = await writeUserIdentity(name, c.home);
75
+ return {
76
+ ok: true,
77
+ patch: { inputs: { name: res.identity.name, identityDid: res.identity.did }, checks: { identity: true } },
78
+ note: `${res.created ? '生成新 DID' : '更新称呼 (DID 未变)'}: ${String(res.identity.did).slice(0, 18)}…`,
79
+ };
80
+ },
81
+ };
82
+ const stepProvider = {
83
+ id: 'provider', title: '模型供应商', stage: 'provider_pending',
84
+ shouldRun: (c) => c.mode === 'reconfigure' ? c.targets.includes('provider') : !c.state.checks.providerSelected,
85
+ async run(c) {
86
+ const mod = await import('../llm/config-store.js');
87
+ const info = mod.PROVIDER_INFO || {};
88
+ const store = mod.llmConfigStore;
89
+ await store.initialize?.();
90
+ const current = c.state.inputs.provider || (await store.getActiveProvider?.());
91
+ const choices = Object.entries(info).map(([value, v]) => ({
92
+ value,
93
+ label: `${v.name || value}${v.requiresApiKey === false ? ' (无需 key)' : ' (需要 key)'}`,
94
+ hint: v.description || (v.models?.[0] ? `默认模型 ${v.models[0]}` : undefined),
95
+ }));
96
+ c.io.print(`供应商选择${current ? ` (当前 ${current}, 直接回车保留)` : ''} — 显示是否需要 key / 默认模型 / 来源`);
97
+ let picked = await c.io.select('选择模型供应商', choices);
98
+ if (!picked && current)
99
+ picked = current;
100
+ if (!picked)
101
+ return { ok: false, errorClass: 'config', message: '没有选择供应商' };
102
+ if (Object.keys(info).length > 0 && !info[picked]) {
103
+ return { ok: false, errorClass: 'config', message: `未知供应商 "${picked}" (可选: ${Object.keys(info).slice(0, 8).join(', ')}…)` };
104
+ }
105
+ try {
106
+ await store.updateProvider(picked, { enabled: true });
107
+ // setup 模式直接激活; reconfigure 要等测试通过才切换 active (失败不破坏旧配置)
108
+ if (c.mode !== 'reconfigure')
109
+ await store.setActiveProvider(picked);
110
+ }
111
+ catch (err) {
112
+ return { ok: false, errorClass: 'io', message: `写配置失败: ${String(err?.message || err).slice(0, 120)}` };
113
+ }
114
+ return {
115
+ ok: true,
116
+ patch: { inputs: { provider: picked }, checks: { providerSelected: true } },
117
+ note: `${picked} 已 ${c.mode === 'reconfigure' ? '标记为候选 (测试通过后切换)' : '激活'}`,
118
+ };
119
+ },
120
+ };
121
+ const stepCredential = {
122
+ id: 'credential', title: 'API 凭证', stage: 'credential_pending',
123
+ shouldRun: (c) => {
124
+ if (c.mode === 'reconfigure')
125
+ return c.targets.includes('credential');
126
+ const p = c.state.inputs.provider || c.facts.activeProvider;
127
+ if (!p)
128
+ return false;
129
+ const cfg = c.facts.providers?.[p];
130
+ return !cfg?.apiKey && cfg?.requiresApiKey !== false;
131
+ },
132
+ async run(c) {
133
+ const store = await configStore();
134
+ const provider = c.state.inputs.provider || c.facts.activeProvider;
135
+ if (!provider)
136
+ return { ok: false, errorClass: 'config', message: '还没选供应商, 无法配置凭证' };
137
+ const existing = c.facts.providers?.[provider];
138
+ if (existing?.apiKey && c.mode !== 'reconfigure') {
139
+ c.io.print(`已存在 ${provider} 的 key (尾号 ****${String(existing.apiKey).slice(-4)}) — 复用, 不覆盖`);
140
+ return { ok: true, patch: { inputs: { hasApiKey: true }, checks: { credentialPresent: true, providerUsable: true } }, note: '复用已有 key' };
141
+ }
142
+ c.io.print(`API key 只在隐藏输入里收, **不写状态文件、不落日志**; 目录: ~/.bolloon/${CANONICAL_CONFIG_FILE}`);
143
+ const key = (await c.io.askHidden(`${provider} 的 API key`)).trim();
144
+ if (!key)
145
+ return { ok: false, errorClass: 'auth', message: '没有填 API key (Provider 已选择 ≠ 凭证已可用)' };
146
+ try {
147
+ await store.updateProvider(provider, { apiKey: key });
148
+ }
149
+ catch (err) {
150
+ return { ok: false, errorClass: 'io', message: `写配置失败: ${String(err?.message || err).slice(0, 120)}` };
151
+ }
152
+ return {
153
+ ok: true,
154
+ patch: { inputs: { hasApiKey: true }, checks: { credentialPresent: true, providerUsable: true } },
155
+ note: `key 已写入 (尾号 ****${key.slice(-4)})`,
156
+ };
157
+ },
158
+ };
159
+ const stepModel = {
160
+ id: 'model', title: '模型', stage: 'model_pending',
161
+ shouldRun: (c) => {
162
+ if (c.mode === 'reconfigure')
163
+ return c.targets.includes('model');
164
+ const p = c.state.inputs.provider || c.facts.activeProvider;
165
+ const cfg = p ? c.facts.providers?.[p] : undefined;
166
+ return !cfg?.model; // 没显式配过模型 → 让用户确认 (留空用默认)
167
+ },
168
+ async run(c) {
169
+ const store = await configStore();
170
+ const mod = await import('../llm/config-store.js');
171
+ const provider = c.state.inputs.provider || c.facts.activeProvider;
172
+ if (!provider)
173
+ return { ok: false, errorClass: 'config', message: '还没选供应商' };
174
+ const info = mod.PROVIDER_INFO?.[provider] || {};
175
+ const def = info.models?.[0];
176
+ c.io.print(`默认模型: ${def || '(该供应商未声明默认模型, 必须指定)'}${info.models?.length ? ` · 可选: ${info.models.slice(0, 5).join(', ')}` : ''}`);
177
+ const model = (await c.io.ask('模型名 (留空用默认)', { defaultValue: def || '' })).trim() || def || '';
178
+ if (!model)
179
+ return { ok: false, errorClass: 'model', message: '没有可用模型名 (供应商未声明默认, 也没有手动指定)' };
180
+ const custom = !(info.models || []).includes(model);
181
+ try {
182
+ await store.updateProvider(provider, { model });
183
+ }
184
+ catch (err) {
185
+ return { ok: false, errorClass: 'io', message: `写配置失败: ${String(err?.message || err).slice(0, 120)}` };
186
+ }
187
+ return {
188
+ ok: true,
189
+ patch: { inputs: { model }, checks: { modelPresent: true, modelVerified: !custom } },
190
+ note: `${model}${custom ? ' (自定义: 未经过供应商模型列表验证)' : ''}`,
191
+ };
192
+ },
193
+ };
194
+ const stepConnectivity = {
195
+ id: 'connectivity', title: '连通性实测', stage: 'connectivity_pending',
196
+ shouldRun: (c) => c.mode === 'test' || c.mode === 'repair' ? true : !c.state.checks.connectivityOk || c.mode === 'reconfigure',
197
+ async run(c) {
198
+ const store = await configStore();
199
+ const provider = c.state.inputs.provider || c.facts.activeProvider || (await store.getActiveProvider?.());
200
+ if (!provider)
201
+ return { ok: false, errorClass: 'config', message: '还没选供应商, 无法测试' };
202
+ c.io.print(`用最终保存的 provider/key/baseUrl/model 真实测试 ${provider} …`);
203
+ const res = await store.testProvider(provider);
204
+ if (res?.success) {
205
+ return { ok: true, patch: { checks: { connectivityOk: true, connectivityAt: new Date().toISOString(), connectivityErrorClass: undefined } }, note: `通过 (${res.latency ?? '?'}ms)` };
206
+ }
207
+ const raw = String(res?.error || '未知错误');
208
+ const cls = /401|403|key/i.test(raw) ? 'auth' : /429|限流/i.test(raw) ? 'network' : /404|端点|baseUrl/i.test(raw) ? 'config' : /timeout|超时/i.test(raw) ? 'timeout' : /fetch|network|ECONN/i.test(raw) ? 'network' : 'unknown';
209
+ // 记录失败 (不写成功)
210
+ await recordSetupStage('connectivity_pending', { checks: { connectivityOk: false, connectivityErrorClass: cls } }, c.bolloonHome);
211
+ return { ok: false, errorClass: cls, message: `连通性测试失败 [${cls}]: ${raw.slice(0, 200)}`, retryable: cls === 'timeout' || cls === 'network' || cls === 'unknown' };
212
+ },
213
+ };
214
+ const stepRuntime = {
215
+ id: 'runtime', title: '运行时初始化', stage: 'runtime_pending',
216
+ shouldRun: (c) => c.mode === 'test' || c.mode === 'repair' ? true : !c.state.checks.runtimeInitialized,
217
+ async run(c) {
218
+ // 真实执行: initMinimax + 建 session + 最小模型调用 (不是"检查 singleton 存在")
219
+ c.env.BOLLOON_SETUP_IN_PROGRESS = '1'; // 初始化期间允许自身调用模型 (不触发执行门禁)
220
+ const details = [];
221
+ try {
222
+ const mod = await import('../llm/pi-ai.js');
223
+ mod.initMinimax?.();
224
+ const model = mod.getMinimax?.() || mod.getModel?.();
225
+ if (!model)
226
+ return { ok: false, errorClass: 'runtime', message: 'initMinimax 之后仍拿不到模型对象' };
227
+ details.push('initMinimax ✓');
228
+ const chat = model.chat || model.generate || model.complete || model.call;
229
+ if (typeof chat !== 'function') {
230
+ return { ok: false, errorClass: 'runtime', message: '运行时对象没有可调用的 chat/generate 接口 —— 只有 singleton 不算 ready' };
231
+ }
232
+ const t0 = Date.now();
233
+ const out = await Promise.race([
234
+ chat.call(model, [{ role: 'user', content: 'ping' }], { maxTokens: 4 }),
235
+ new Promise((_, rej) => setTimeout(() => rej(new Error('timeout: 最小模型调用 20s 未返回')), 20_000)),
236
+ ]);
237
+ const text = typeof out === 'string' ? out : (out?.content || out?.text || '');
238
+ if (out == null)
239
+ return { ok: false, errorClass: 'runtime', message: '最小模型调用没有返回结果' };
240
+ details.push(`最小模型调用 ✓ (${Date.now() - t0}ms, ${String(text).slice(0, 20) || '空回复'})`);
241
+ // session 创建 (真实)
242
+ try {
243
+ const { createAgentSession } = await import('../agents/pi-sdk-session-factory.js');
244
+ const { readUserIdentity } = await import('../cli/setup-wizard.js');
245
+ const id = await readUserIdentity(c.home);
246
+ const sess = await createAgentSession({ cwd: process.cwd(), identityDoc: id ? { did: id.did, name: id.name } : undefined }, true);
247
+ if (sess)
248
+ details.push('session 创建 ✓');
249
+ else
250
+ return { ok: false, errorClass: 'runtime', message: 'session 创建返回空 (运行时不可用)' };
251
+ }
252
+ catch (err) {
253
+ return { ok: false, errorClass: 'runtime', message: `session 创建失败: ${String(err?.message || err).slice(0, 140)}` };
254
+ }
255
+ }
256
+ catch (err) {
257
+ return { ok: false, errorClass: classifyOnboardError(err), message: `运行时初始化失败: ${String(err?.message || err).slice(0, 160)}` };
258
+ }
259
+ finally {
260
+ delete c.env.BOLLOON_SETUP_IN_PROGRESS;
261
+ }
262
+ return { ok: true, patch: { checks: { runtimeInitialized: true } }, note: details.join(' · ') };
263
+ },
264
+ };
265
+ export const ONBOARD_STEPS = [stepEnv, stepIdentity, stepProvider, stepCredential, stepModel, stepConnectivity, stepRuntime];
266
+ /**
267
+ * 修复: ① 旧 `llm-config.json` → 迁移到 `bolloon-config.json` (迁移逻辑复用 config-store.initialize);
268
+ * ② 正式文件损坏 → **备份** 坏文件 (不静默丢弃) 后按默认重建, 并如实标注"配置被重置为默认"。
269
+ */
270
+ export async function repairConfig(bolloonHome = resolveBolloonHome(), env = process.env) {
271
+ const notes = [];
272
+ let migrated = false;
273
+ let backedUpCorrupt;
274
+ const canonical = path.join(bolloonHome, CANONICAL_CONFIG_FILE);
275
+ const legacy = path.join(bolloonHome, LEGACY_CONFIG_FILE);
276
+ // 正式文件是否存在且可解析
277
+ let canonicalOk = false;
278
+ let canonicalBad = false;
279
+ try {
280
+ JSON.parse(await fs.readFile(canonical, 'utf8'));
281
+ canonicalOk = true;
282
+ }
283
+ catch (err) {
284
+ if (err?.code === 'ENOENT')
285
+ canonicalOk = false;
286
+ else
287
+ canonicalBad = true;
288
+ }
289
+ if (canonicalBad) {
290
+ const bak = `${canonical}.corrupt-${Date.now()}`;
291
+ await fs.rename(canonical, bak);
292
+ backedUpCorrupt = bak;
293
+ notes.push(`正式配置损坏 → 已备份到 ${path.basename(bak)} 并按默认重建 (不假装原配置仍有效)`);
294
+ }
295
+ let legacyPresent = false;
296
+ try {
297
+ await fs.access(legacy);
298
+ legacyPresent = true;
299
+ }
300
+ catch {
301
+ legacyPresent = false;
302
+ }
303
+ if (!canonicalOk && legacyPresent) {
304
+ // 直接文件迁移 (不依赖 config-store 的单例缓存 —— 修复操作必须是确定性的, 旧文件保留作备份)
305
+ try {
306
+ const legacyText = await fs.readFile(legacy, 'utf8');
307
+ const parsed = JSON.parse(legacyText);
308
+ if (!parsed || typeof parsed !== 'object')
309
+ throw new Error('旧配置不是对象');
310
+ const tmp = `${canonical}.migrating`;
311
+ await fs.writeFile(tmp, JSON.stringify(parsed, null, 2), { encoding: 'utf8', mode: 0o600 });
312
+ await fs.rename(tmp, canonical);
313
+ migrated = true;
314
+ notes.push(`${LEGACY_CONFIG_FILE} → ${CANONICAL_CONFIG_FILE} 已迁移 (旧文件保留)`);
315
+ // 让 config-store 下次重新读盘 (它有自己的 initialized/config 缓存)
316
+ try {
317
+ const mod = await import('../llm/config-store.js');
318
+ if (mod.llmConfigStore) {
319
+ mod.llmConfigStore.initialized = false;
320
+ mod.llmConfigStore.config = null;
321
+ }
322
+ }
323
+ catch { /* 缓存失效失败不影响迁移结果 */ }
324
+ }
325
+ catch (err) {
326
+ notes.push(`迁移失败: ${String(err?.message || err).slice(0, 120)}`);
327
+ }
328
+ }
329
+ else if (!canonicalOk && !legacyPresent) {
330
+ notes.push('没有可迁移的旧配置 (将按新配置流程走)');
331
+ }
332
+ const ok = await (async () => { try {
333
+ JSON.parse(await fs.readFile(canonical, 'utf8'));
334
+ return true;
335
+ }
336
+ catch {
337
+ return !!backedUpCorrupt ? false : true;
338
+ } })();
339
+ return { migrated, backedUpCorrupt, notes, ok };
340
+ }
341
+ function stepIndexById(id) { return ONBOARD_STEPS.findIndex((s) => s.id === id); }
342
+ /** 从状态推导"该从哪一步开始" (resume 的语义: 从第一个未完成阶段继续) */
343
+ export function startStepFor(state) {
344
+ const c = state.checks;
345
+ if (!c.identity)
346
+ return 'identity';
347
+ if (!c.providerSelected)
348
+ return 'provider';
349
+ if (!c.providerUsable)
350
+ return 'credential';
351
+ if (!c.modelPresent)
352
+ return 'model';
353
+ if (!c.connectivityOk)
354
+ return 'connectivity';
355
+ if (!c.runtimeInitialized)
356
+ return 'runtime';
357
+ return 'runtime';
358
+ }
359
+ export async function runOnboard(opts) {
360
+ const mode = opts.mode || 'setup';
361
+ const env = opts.env || process.env;
362
+ const home = opts.home || env.HOME || os.homedir();
363
+ const bolloonHome = opts.bolloonHome || resolveBolloonHome(env, home);
364
+ const io = opts.io;
365
+ const steps = [];
366
+ const targets = opts.targets || ['provider', 'credential', 'model'];
367
+ const skip = new Set(opts.skipSteps || []);
368
+ // status: 只读
369
+ if (mode === 'status') {
370
+ const ev = await evaluateSetup({ bolloonHome });
371
+ io.print(describeSetup(ev));
372
+ return { ok: ev.gate === 'ready', mode, stage: ev.state.stage, gate: ev.gate, completed: ev.state.completed, steps, actions: ev.state.actions, state: ev.state, summary: describeSetup(ev) };
373
+ }
374
+ // repair 前处理: 迁移 / 备份坏文件
375
+ if (mode === 'repair') {
376
+ io.print('开始修复: 检查配置文件 (旧文件迁移 / 损坏备份)');
377
+ const rep = await repairConfig(bolloonHome, env);
378
+ for (const n of rep.notes)
379
+ io.print(` · ${n}`);
380
+ if (rep.backedUpCorrupt)
381
+ io.print(` · 已备份损坏文件: ${path.basename(rep.backedUpCorrupt)}`);
382
+ }
383
+ let state = (await readSetupState(bolloonHome)) || (await refreshSetupState({ bolloonHome })).state;
384
+ let facts;
385
+ try {
386
+ facts = await readConfigFacts(bolloonHome);
387
+ }
388
+ catch (err) {
389
+ // 正式配置损坏且不修 → 如实报错, 不进 ready
390
+ const st = await recordSetupFailure('provider', 'config', String(err?.message || err).slice(0, 200), bolloonHome);
391
+ return { ok: false, mode, stage: st.stage, gate: 'repair', completed: st.completed, steps, failedStage: 'provider', errorClass: 'config', message: '配置文件损坏 (可用 `--repair` 就地修复)', actions: st.actions, state: st, summary: describeSetup({ state: st, gate: 'repair', reasons: [], nextActions: st.actions }) };
392
+ }
393
+ const startAt = (mode === 'setup' || mode === 'resume') ? stepIndexById(startStepFor(state)) : 0;
394
+ io.print(`Onboard 模式: ${mode} · 从 ${ONBOARD_STEPS[Math.max(0, startAt)].title} 开始 · 已完成: ${state.completed.join(' → ') || '(无)'}`);
395
+ let failedStage;
396
+ let failedClass;
397
+ let failedMessage;
398
+ for (let i = 0; i < ONBOARD_STEPS.length; i++) {
399
+ const step = ONBOARD_STEPS[i];
400
+ if (i < startAt && mode !== 'repair' && mode !== 'reconfigure' && mode !== 'test')
401
+ continue;
402
+ if (skip.has(step.id)) {
403
+ steps.push({ id: step.id, title: step.title, status: 'skipped', note: '按参数跳过 (跳过 ≠ 通过, 门禁不会 ready)' });
404
+ continue;
405
+ }
406
+ let ctx = { home, bolloonHome, io, mode, state, facts, env, targets };
407
+ let should = step.shouldRun(ctx);
408
+ if (!should) {
409
+ steps.push({ id: step.id, title: step.title, status: 'skipped', note: '当前状态已满足' });
410
+ continue;
411
+ }
412
+ const t0 = Date.now();
413
+ let outcome;
414
+ try {
415
+ outcome = await step.run(ctx);
416
+ }
417
+ catch (err) {
418
+ outcome = { ok: false, errorClass: classifyOnboardError(err), message: String(err?.message || err).slice(0, 200) };
419
+ }
420
+ if (outcome.ok) {
421
+ // 原子提交该阶段 (只有成功才写)
422
+ await recordSetupStage(step.stage === 'uninitialized' ? 'uninitialized' : step.stage, outcome.patch || {}, bolloonHome);
423
+ steps.push({ id: step.id, title: step.title, status: 'done', note: outcome.note, ms: Date.now() - t0 });
424
+ io.print(` ✓ ${step.title}: ${outcome.note || '完成'}`);
425
+ // 重新评估 (每一步后刷新事实, 后面的步骤看到最新输入)
426
+ const ev = await refreshSetupState({ bolloonHome, light: true });
427
+ state = ev.state;
428
+ try {
429
+ facts = await readConfigFacts(bolloonHome);
430
+ }
431
+ catch { /* 保留旧 facts */ }
432
+ continue;
433
+ }
434
+ // 失败: 记录 + 停在这里 (保留已完成步骤, 不清配置, 不显示完成)
435
+ const st = await recordSetupFailure(step.id, outcome.errorClass, outcome.message, bolloonHome);
436
+ steps.push({ id: step.id, title: step.title, status: 'failed', note: outcome.message, errorClass: outcome.errorClass, ms: Date.now() - t0 });
437
+ failedStage = step.id;
438
+ failedClass = outcome.errorClass;
439
+ failedMessage = outcome.message;
440
+ io.print(` ✗ ${step.title}: ${outcome.message}`);
441
+ state = st;
442
+ if (opts.oneShot)
443
+ break;
444
+ // 交互模式: 给"重试 / 修改 / 返回上一步 / 停止" (恢复路径)
445
+ const action = await io.select('这一步失败了, 怎么办?', [
446
+ { value: 'retry', label: '重试这一步' },
447
+ { value: 'edit', label: '修改输入后重试' },
448
+ { value: 'back', label: '返回上一步' },
449
+ { value: 'repair', label: '进入修复 (迁移/备份坏文件)' },
450
+ { value: 'stop', label: '先停在这里 (下次 `bolloon setup` 从这里继续)' },
451
+ ]);
452
+ if (action === 'retry' || action === 'edit') {
453
+ i--;
454
+ failedStage = undefined;
455
+ continue;
456
+ }
457
+ if (action === 'back') {
458
+ i = Math.max(startAt - 1, 0) - 1;
459
+ failedStage = undefined;
460
+ continue;
461
+ }
462
+ if (action === 'repair') {
463
+ const r = await repairConfig(bolloonHome, env);
464
+ r.notes.forEach((n) => io.print(` · ${n}`));
465
+ i--;
466
+ failedStage = undefined;
467
+ continue;
468
+ }
469
+ break;
470
+ }
471
+ // 最终 commit: 只有门禁 ready 才算成功
472
+ const finalEv = await refreshSetupState({ bolloonHome });
473
+ const ok = finalEv.gate === 'ready';
474
+ const summary = describeSetup(finalEv);
475
+ io.print(ok ? '✅ 初始化完成 (config + 状态都已提交)' : '⛔ 初始化未完成 (不会显示"配置完成", Agent 也不会执行)');
476
+ io.print(summary);
477
+ return {
478
+ ok,
479
+ mode,
480
+ stage: finalEv.state.stage,
481
+ gate: finalEv.gate,
482
+ completed: finalEv.state.completed,
483
+ steps,
484
+ failedStage,
485
+ errorClass: failedClass,
486
+ message: failedMessage,
487
+ actions: finalEv.state.actions,
488
+ state: finalEv.state,
489
+ summary,
490
+ };
491
+ }
492
+ // ── 脚本化 IO (Web / 测试共用: 按顺序喂答案, 不阻塞) ────────────────────────
493
+ export class ScriptedIO {
494
+ log = [];
495
+ answers;
496
+ stepAnswers = {};
497
+ constructor(answers = []) {
498
+ this.answers = answers.map((a) => (typeof a === 'string' ? a : a.select));
499
+ }
500
+ print(msg) { this.log.push(msg); }
501
+ async ask(_q, opts) {
502
+ const a = this.answers.shift();
503
+ if (a === undefined)
504
+ return opts?.defaultValue ?? '';
505
+ return a;
506
+ }
507
+ async askHidden() { return this.answers.shift() ?? ''; }
508
+ async confirm(_q, d = true) { const a = this.answers.shift(); return a === undefined ? d : /^(y|yes|1|true|是)$/i.test(a); }
509
+ async select(q, choices) {
510
+ const a = this.answers.shift();
511
+ if (a !== undefined && choices.some((c) => c.value === a))
512
+ return a;
513
+ if (a !== undefined && /^\d+$/.test(a)) {
514
+ const idx = Number(a) - 1;
515
+ if (choices[idx])
516
+ return choices[idx].value;
517
+ } // 1-based, 与界面提示一致
518
+ if (a !== undefined && a !== '')
519
+ return a; // 未匹配 → 原样返回, 由阶段自己做校验 (不静默回退到第一个选项)
520
+ return choices[0]?.value || '';
521
+ }
522
+ }
523
+ export async function nextStepInfo(state, facts) {
524
+ const step = startStepFor(state);
525
+ const f = facts || (await readConfigFacts().catch(() => ({ source: 'missing', providers: {}, legacyPresent: false })));
526
+ switch (step) {
527
+ case 'identity':
528
+ return { step, title: '用户身份', needs: 'name', question: '你的称呼 (显示用, 已有 DID 会复用)' };
529
+ case 'provider': {
530
+ let choices = [];
531
+ try {
532
+ const mod = await import('../llm/config-store.js');
533
+ choices = Object.entries(mod.PROVIDER_INFO || {}).map(([value, v]) => ({
534
+ value, label: `${v.name || value}${v.requiresApiKey === false ? ' (无需 key)' : ' (需要 key)'}`, hint: v.models?.[0] ? `默认模型 ${v.models[0]}` : undefined,
535
+ }));
536
+ }
537
+ catch { /* 无 provider 列表也能继续 */ }
538
+ return { step, title: '模型供应商', needs: 'provider', question: '选择模型供应商', choices, defaultValue: state.inputs.provider };
539
+ }
540
+ case 'credential':
541
+ return { step, title: 'API 凭证', needs: 'credential', question: `${state.inputs.provider || f.activeProvider || 'provider'} 的 API key (不回显, 不写状态文件)` };
542
+ case 'model':
543
+ return { step, title: '模型', needs: 'model', question: '模型名 (留空用默认)', defaultValue: state.inputs.model };
544
+ case 'connectivity':
545
+ return { step, title: '连通性实测', needs: 'none', question: '用最终保存的配置做真实连通性测试' };
546
+ default:
547
+ return { step: 'runtime', title: '运行时初始化', needs: 'none', question: '真实 initMinimax + 建 session + 最小模型调用' };
548
+ }
549
+ }