@larktask/aamp-feishu-task-agent 0.1.0-dev.171

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.
@@ -0,0 +1,1899 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs, { promises as fsp } from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import crypto from 'node:crypto';
7
+ import { ReadStream, WriteStream } from 'node:tty';
8
+ import { emitKeypressEvents } from 'node:readline';
9
+ import { spawn } from 'node:child_process';
10
+ import { EventEmitter } from 'node:events';
11
+ import {
12
+ agentStartRetryError,
13
+ classifyNetworkError,
14
+ createSerializedLineWriter,
15
+ describeNetworkError,
16
+ isRetryableNetworkError,
17
+ launchDetachedDiagnostic,
18
+ networkEnvironmentSummary,
19
+ probeEndpoint,
20
+ safeDiagnosticUrl,
21
+ withNetworkRetry,
22
+ } from './runtime-network.mjs';
23
+
24
+ process.umask(0o077);
25
+
26
+ const COMMAND = process.argv[2] || 'help';
27
+ const HOME = os.homedir();
28
+ const STATE_HOME = process.env.AAMP_TASK_STATE_HOME || path.join(HOME, '.aamp', 'feishu-task-agent');
29
+ const CONFIG_FILE = process.env.AAMP_TASK_CONFIG_FILE || path.join(STATE_HOME, 'bindings-v1.json');
30
+ const RUNTIME_HOME = process.env.AAMP_TASK_RUNTIME_HOME || path.join(STATE_HOME, 'runtime-v1');
31
+ const CONFIG_LOCK = path.join(STATE_HOME, 'bindings-v1.lock');
32
+ const MUTATION_LOCK = path.join(STATE_HOME, 'bindings-v1-mutation.lock');
33
+ const LEASES_HOME = path.join(RUNTIME_HOME, 'leases');
34
+ const RUNTIME_SESSION_LOCK = path.join(LEASES_HOME, 'runtime-session.lock');
35
+ const RUN_LOG_DIR = process.env.AAMP_RUN_LOG_DIR || path.join(HOME, '.aamp', 'logs', 'runs', `${Date.now()}-${process.pid}`);
36
+ const RUN_ID = process.env.AAMP_RUN_ID || path.basename(RUN_LOG_DIR);
37
+ const RUN_STARTED_AT = nowIso();
38
+ const MANIFEST_FILE = path.join(RUN_LOG_DIR, 'manifest.json');
39
+ const ERRORS_LOG = process.env.ERRORS_LOG || path.join(RUN_LOG_DIR, 'errors.jsonl');
40
+ const BOOTSTRAP = process.env.AAMP_TASK_BOOTSTRAP_PATH || '';
41
+ const NPM_BIN = process.env.AAMP_TASK_NPM_BIN || 'npm';
42
+ const NPM_REGISTRY = process.env.AAMP_TASK_NPM_REGISTRY || 'https://registry.npmjs.org/';
43
+ const FEISHU_API_PROBE_URL = 'https://open.feishu.cn/';
44
+ const NPM_CACHE_DIR = process.env.AAMP_TASK_NPM_CACHE_DIR || path.join(os.tmpdir(), 'aamp-one-click-npm-cache');
45
+ const ACP_PACKAGE = process.env.AAMP_TASK_ACP_BRIDGE_PKG || '@zengxingyuan/aamp-acp-bridge@0.1.28-dev.20';
46
+ const FEISHU_PACKAGE = process.env.AAMP_TASK_FEISHU_BRIDGE_PKG || '@zengxingyuan/aamp-feishu-bridge@0.1.51';
47
+ const INSTALL_COMMAND = process.env.AAMP_TASK_INSTALL_COMMAND
48
+ || 'npx -y --package @larktask/aamp-feishu-task-agent@dev feishu-task-agent install';
49
+ const DEFAULT_AGENT = process.env.AAMP_TASK_DEFAULT_AGENT || '';
50
+ const DEFAULT_AAMP_HOST = process.env.AAMP_TASK_AAMP_HOST || 'https://meshmail.ai';
51
+ const DEBUG_MODE = process.env.AAMP_TASK_DEBUG_MODE === 'true';
52
+ const READY_TIMEOUT_MS = Number(process.env.AAMP_TASK_READY_TIMEOUT_MS || 90_000);
53
+ const NETWORK_MAX_ATTEMPTS = Math.max(1, Number(process.env.AAMP_TASK_NETWORK_MAX_ATTEMPTS || 3));
54
+ const NETWORK_RETRY_BASE_DELAY_MS = Math.max(0, Number(process.env.AAMP_TASK_NETWORK_RETRY_BASE_DELAY_MS || 500));
55
+ const NETWORK_PROBE_TIMEOUT_MS = Math.max(1_000, Number(process.env.AAMP_TASK_NETWORK_PROBE_TIMEOUT_MS || 10_000));
56
+ const CONFIG_SCHEMA = 'aamp.feishu-task-agent.bindings';
57
+ const CONFIG_VERSION = 1;
58
+ const AGENT_TYPES = ['codex', 'cursor'];
59
+ const PROFILE_DOMAINS = [
60
+ 'base', 'calendar', 'contact', 'docs', 'im', 'mail', 'mindnotes', 'minutes',
61
+ 'note', 'sheets', 'slides', 'task', 'vc', 'wiki',
62
+ ];
63
+
64
+ const secrets = new Set();
65
+ const managedProcesses = new Set();
66
+ const transientProcesses = new Set();
67
+ const heldLeases = new Set();
68
+ const bindingStatuses = new Map();
69
+ let stopRequested = false;
70
+ let stopSignal = '';
71
+ let cleanupPromise;
72
+ let terminal;
73
+
74
+ function nowIso() {
75
+ return new Date().toISOString();
76
+ }
77
+
78
+ function terminalStreams() {
79
+ if (terminal) return terminal;
80
+ let inputFd;
81
+ let outputFd;
82
+ try {
83
+ inputFd = fs.openSync('/dev/tty', 'r');
84
+ outputFd = fs.openSync('/dev/tty', 'w');
85
+ } catch {
86
+ throw new Error('交互操作需要终端');
87
+ }
88
+ terminal = {
89
+ input: new ReadStream(inputFd),
90
+ output: new WriteStream(outputFd),
91
+ };
92
+ return terminal;
93
+ }
94
+
95
+ function shortHash(value) {
96
+ return crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 12);
97
+ }
98
+
99
+ function randomId() {
100
+ const bytes = crypto.randomBytes(16);
101
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
102
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
103
+ const hex = bytes.toString('hex');
104
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
105
+ }
106
+
107
+ function safeId(value) {
108
+ return String(value).replace(/[^a-zA-Z0-9_-]+/g, '-').slice(0, 80) || 'item';
109
+ }
110
+
111
+ function addSecret(value) {
112
+ if (typeof value === 'string' && value.length >= 4) secrets.add(value);
113
+ }
114
+
115
+ function redact(value) {
116
+ let output = String(value ?? '');
117
+ for (const secret of secrets) output = output.split(secret).join('[REDACTED]');
118
+ output = output
119
+ .replace(/([?&]pair_code=)[^&\s"']+/gi, '$1[REDACTED]')
120
+ .replace(/("?(?:app_secret|appSecret|smtpPassword|mailboxToken|access_token|device_code|pairCode)"?\s*[:=]\s*"?)[^",\s}]+/gi, '$1[REDACTED]')
121
+ .replace(/(--app-secret\s+)[^\s]+/gi, '$1[REDACTED]');
122
+ return output;
123
+ }
124
+
125
+ async function ensurePrivateDir(dir) {
126
+ await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
127
+ await fsp.chmod(dir, 0o700).catch(() => {});
128
+ }
129
+
130
+ async function appendPrivate(file, content) {
131
+ await ensurePrivateDir(path.dirname(file));
132
+ await fsp.appendFile(file, redact(content), { encoding: 'utf8', mode: 0o600 });
133
+ await fsp.chmod(file, 0o600).catch(() => {});
134
+ }
135
+
136
+ async function appendDiagnostic(file, event) {
137
+ await appendPrivate(file, `${JSON.stringify({ timestamp: nowIso(), ...event })}\n`).catch(() => {});
138
+ }
139
+
140
+ function aampDiscoveryUrl(host) {
141
+ return safeDiagnosticUrl(new URL('/.well-known/aamp', host).toString());
142
+ }
143
+
144
+ function diagnosticEndpoints(host) {
145
+ return [
146
+ { target: 'aamp', url: aampDiscoveryUrl(host) },
147
+ { target: 'feishu-open-api', url: FEISHU_API_PROBE_URL },
148
+ { target: 'npm-registry', url: new URL('/', NPM_REGISTRY).toString() },
149
+ ];
150
+ }
151
+
152
+ async function probeFailureEndpoints(host, logFile, environment, stage) {
153
+ await Promise.allSettled(diagnosticEndpoints(host).map(async ({ target, url }) => {
154
+ try {
155
+ await probeEndpoint(url, {
156
+ maxAttempts: 1,
157
+ timeoutMs: NETWORK_PROBE_TIMEOUT_MS,
158
+ environment,
159
+ onAttempt: (event) => appendDiagnostic(logFile, { ...event, stage, target }),
160
+ });
161
+ } catch (error) {
162
+ await appendDiagnostic(logFile, {
163
+ type: 'network.probe.failed',
164
+ stage,
165
+ target,
166
+ url: safeDiagnosticUrl(url),
167
+ category: classifyNetworkError(error),
168
+ error: describeNetworkError(error),
169
+ });
170
+ }
171
+ }));
172
+ }
173
+
174
+ async function runNetworkStage(operation, {
175
+ stage,
176
+ label,
177
+ host,
178
+ logFile,
179
+ environment,
180
+ shouldRetry = isRetryableNetworkError,
181
+ }) {
182
+ const inherited = networkEnvironmentSummary(process.env);
183
+ const effective = networkEnvironmentSummary(environment);
184
+ await appendDiagnostic(logFile, {
185
+ type: 'network.context',
186
+ stage,
187
+ endpoints: diagnosticEndpoints(host).map(({ target, url }) => ({ target, url: safeDiagnosticUrl(url) })),
188
+ node: effective.node,
189
+ platform: effective.platform,
190
+ arch: effective.arch,
191
+ osRelease: effective.osRelease,
192
+ inheritedProxyEnvPresent: inherited.proxyEnvPresent,
193
+ effectiveProxyEnvPresent: effective.proxyEnvPresent,
194
+ proxyValuesLogged: false,
195
+ });
196
+ return withNetworkRetry(async ({ attempt, maxAttempts }) => {
197
+ const startedAt = Date.now();
198
+ await appendDiagnostic(logFile, {
199
+ type: 'bridge.stage',
200
+ stage,
201
+ status: 'starting',
202
+ attempt,
203
+ maxAttempts,
204
+ host: safeDiagnosticUrl(host),
205
+ });
206
+ try {
207
+ const result = await operation({ attempt, maxAttempts });
208
+ await appendDiagnostic(logFile, {
209
+ type: 'bridge.stage',
210
+ stage,
211
+ status: 'succeeded',
212
+ attempt,
213
+ maxAttempts,
214
+ durationMs: Date.now() - startedAt,
215
+ host: safeDiagnosticUrl(host),
216
+ });
217
+ return result;
218
+ } catch (error) {
219
+ await appendDiagnostic(logFile, {
220
+ type: 'bridge.stage',
221
+ stage,
222
+ status: 'failed',
223
+ attempt,
224
+ maxAttempts,
225
+ durationMs: Date.now() - startedAt,
226
+ host: safeDiagnosticUrl(host),
227
+ category: classifyNetworkError(error),
228
+ retryable: shouldRetry(error),
229
+ error: describeNetworkError(error),
230
+ });
231
+ throw error;
232
+ }
233
+ }, {
234
+ maxAttempts: NETWORK_MAX_ATTEMPTS,
235
+ baseDelayMs: NETWORK_RETRY_BASE_DELAY_MS,
236
+ shouldRetry,
237
+ onRetry: async (event) => {
238
+ await appendDiagnostic(logFile, { type: 'network.retry', stage, label, host: safeDiagnosticUrl(host), ...event });
239
+ console.log(`[aamp-one-click] ${label}遇到网络波动,正在重试(${event.attempt + 1}/${event.maxAttempts})...`);
240
+ launchDetachedDiagnostic(() => probeFailureEndpoints(host, logFile, environment, `${stage}-retry-probe`));
241
+ },
242
+ });
243
+ }
244
+
245
+ async function writeJsonAtomic(file, value) {
246
+ const parent = path.dirname(file);
247
+ await ensurePrivateDir(parent);
248
+ const temp = path.join(parent, `.${path.basename(file)}.${process.pid}.${randomId()}.tmp`);
249
+ let handle;
250
+ let renamed = false;
251
+ try {
252
+ handle = await fsp.open(temp, 'wx', 0o600);
253
+ await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
254
+ await handle.sync();
255
+ await handle.close();
256
+ handle = undefined;
257
+ await fsp.rename(temp, file);
258
+ renamed = true;
259
+ await fsp.chmod(file, 0o600);
260
+ const parentHandle = await fsp.open(parent, 'r').catch(() => undefined);
261
+ if (parentHandle) {
262
+ await parentHandle.sync().catch(() => {});
263
+ await parentHandle.close();
264
+ }
265
+ } finally {
266
+ if (handle) await handle.close().catch(() => {});
267
+ if (!renamed) await fsp.unlink(temp).catch(() => {});
268
+ }
269
+ }
270
+
271
+ async function readJson(file) {
272
+ return JSON.parse(await fsp.readFile(file, 'utf8'));
273
+ }
274
+
275
+ function pidAlive(pid) {
276
+ if (!Number.isInteger(pid) || pid <= 0) return false;
277
+ try {
278
+ process.kill(pid, 0);
279
+ return true;
280
+ } catch (error) {
281
+ return error?.code === 'EPERM';
282
+ }
283
+ }
284
+
285
+ async function acquireDirectoryLock(lockDir, label, timeoutMs = 10_000) {
286
+ const started = Date.now();
287
+ await ensurePrivateDir(path.dirname(lockDir));
288
+ while (Date.now() - started < timeoutMs) {
289
+ try {
290
+ await fsp.mkdir(lockDir, { mode: 0o700 });
291
+ await writeJsonAtomic(path.join(lockDir, 'owner.json'), {
292
+ pid: process.pid,
293
+ run_id: RUN_ID,
294
+ label,
295
+ created_at: nowIso(),
296
+ });
297
+ return async () => {
298
+ await fsp.rm(lockDir, { recursive: true, force: true }).catch(() => {});
299
+ };
300
+ } catch (error) {
301
+ if (error?.code !== 'EEXIST') throw error;
302
+ let owner;
303
+ try {
304
+ owner = await readJson(path.join(lockDir, 'owner.json'));
305
+ } catch {
306
+ owner = undefined;
307
+ }
308
+ if (!owner) {
309
+ const stat = await fsp.stat(lockDir).catch(() => undefined);
310
+ if (stat && Date.now() - stat.mtimeMs < 5_000) {
311
+ await delay(150);
312
+ continue;
313
+ }
314
+ }
315
+ if (!owner || !pidAlive(Number(owner.pid))) {
316
+ await fsp.rm(lockDir, { recursive: true, force: true }).catch(() => {});
317
+ continue;
318
+ }
319
+ await delay(150);
320
+ }
321
+ }
322
+ throw new Error(`${label} 正在被另一个 feishu-task-agent 进程使用`);
323
+ }
324
+
325
+ async function withConfigLock(callback) {
326
+ const release = await acquireDirectoryLock(CONFIG_LOCK, '配置文件');
327
+ try {
328
+ return await callback();
329
+ } finally {
330
+ await release();
331
+ }
332
+ }
333
+
334
+ async function withMutationLock(label, callback) {
335
+ const release = await acquireDirectoryLock(MUTATION_LOCK, label, 1_500);
336
+ try {
337
+ return await callback();
338
+ } finally {
339
+ await release();
340
+ }
341
+ }
342
+
343
+ async function hasActiveAgentLease() {
344
+ let entries;
345
+ try {
346
+ entries = await fsp.readdir(LEASES_HOME, { withFileTypes: true });
347
+ } catch (error) {
348
+ if (error?.code === 'ENOENT') return false;
349
+ throw error;
350
+ }
351
+ for (const entry of entries) {
352
+ if (!entry.isDirectory() || !entry.name.startsWith('agent-') || !entry.name.endsWith('.lock')) continue;
353
+ try {
354
+ const owner = await readJson(path.join(LEASES_HOME, entry.name, 'owner.json'));
355
+ if (pidAlive(Number(owner.pid))) return true;
356
+ } catch {
357
+ // Missing or malformed stale leases are handled by normal lease acquisition.
358
+ }
359
+ }
360
+ return false;
361
+ }
362
+
363
+ async function acquireRuntimeSessionLease(action) {
364
+ if (await hasActiveAgentLease()) {
365
+ throw new Error(`检测到已有 feishu-task-agent 正在运行。请先回到之前启动的终端,按 Ctrl+C 关闭后再运行 feishu-task-agent ${action}`);
366
+ }
367
+ let release;
368
+ try {
369
+ release = await acquireDirectoryLock(RUNTIME_SESSION_LOCK, 'Bridge 启动流程', 1_500);
370
+ } catch (error) {
371
+ if (String(error?.message || error).includes('正在被另一个 feishu-task-agent 进程使用')) {
372
+ throw new Error(`检测到已有 feishu-task-agent 正在运行。请先回到之前启动的终端,按 Ctrl+C 关闭后再运行 feishu-task-agent ${action}`);
373
+ }
374
+ throw error;
375
+ }
376
+ const lease = { lockDir: RUNTIME_SESSION_LOCK, release };
377
+ heldLeases.add(lease);
378
+ return lease;
379
+ }
380
+
381
+ function emptyStore() {
382
+ return { schema: CONFIG_SCHEMA, version: CONFIG_VERSION, bindings: [] };
383
+ }
384
+
385
+ function assertString(value, field) {
386
+ if (typeof value !== 'string' || !value.trim()) throw new Error(`配置字段 ${field} 无效`);
387
+ }
388
+
389
+ function bindingState(binding) {
390
+ return binding?.state ?? 'ready';
391
+ }
392
+
393
+ function bindingNeedsInitialStart(binding) {
394
+ return bindingState(binding) === 'pending';
395
+ }
396
+
397
+ function isPathInside(parent, candidate) {
398
+ const relative = path.relative(path.resolve(parent), path.resolve(candidate));
399
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
400
+ }
401
+
402
+ function expectedFeishuConfigDir(bindingId) {
403
+ const bindingsHome = path.join(RUNTIME_HOME, 'bindings');
404
+ const resolved = path.join(bindingsHome, bindingId, 'feishu-bridge');
405
+ if (!isPathInside(bindingsHome, resolved)) throw new Error('binding_id 不能逃逸新流程 runtime-v1');
406
+ return resolved;
407
+ }
408
+
409
+ async function assertNoSymlinkPath(root, candidate) {
410
+ if (!isPathInside(root, candidate)) throw new Error('runtime 路径逃逸新流程目录');
411
+ const rootPath = path.resolve(root);
412
+ const segments = path.relative(rootPath, path.resolve(candidate)).split(path.sep).filter(Boolean);
413
+ let current = rootPath;
414
+ for (const segment of ['', ...segments]) {
415
+ if (segment) current = path.join(current, segment);
416
+ try {
417
+ const stat = await fsp.lstat(current);
418
+ if (stat.isSymbolicLink()) throw new Error(`拒绝使用包含符号链接的 runtime 路径:${current}`);
419
+ } catch (error) {
420
+ if (error?.code === 'ENOENT') break;
421
+ throw error;
422
+ }
423
+ }
424
+ }
425
+
426
+ function validateBinding(binding, index) {
427
+ if (!binding || typeof binding !== 'object') throw new Error(`bindings[${index}] 无效`);
428
+ assertString(binding.binding_id, `bindings[${index}].binding_id`);
429
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(binding.binding_id)) {
430
+ throw new Error(`bindings[${index}].binding_id 必须是 UUID`);
431
+ }
432
+ if (!AGENT_TYPES.includes(binding.agent_type)) throw new Error(`bindings[${index}].agent_type 仅支持 codex/cursor`);
433
+ assertString(binding.aamp_host, `bindings[${index}].aamp_host`);
434
+ assertString(binding.environment?.name, `bindings[${index}].environment.name`);
435
+ assertString(binding.bot?.app_id, `bindings[${index}].bot.app_id`);
436
+ assertString(binding.bot?.app_secret, `bindings[${index}].bot.app_secret`);
437
+ assertString(binding.bot?.lark_cli_profile, `bindings[${index}].bot.lark_cli_profile`);
438
+ assertString(binding.feishu_config_dir, `bindings[${index}].feishu_config_dir`);
439
+ const expectedConfigDir = expectedFeishuConfigDir(binding.binding_id);
440
+ if (path.resolve(binding.feishu_config_dir) !== path.resolve(expectedConfigDir)) {
441
+ throw new Error(`bindings[${index}].feishu_config_dir 不属于新流程 runtime-v1`);
442
+ }
443
+ const state = bindingState(binding);
444
+ if (!['pending', 'ready'].includes(state)) throw new Error(`bindings[${index}].state 无效`);
445
+ if (state === 'pending') {
446
+ if (binding.agent_target_email !== undefined || binding.runtime !== undefined) {
447
+ throw new Error(`bindings[${index}] 待启动配置不能包含运行时配对信息`);
448
+ }
449
+ } else {
450
+ assertString(binding.agent_target_email, `bindings[${index}].agent_target_email`);
451
+ assertString(binding.runtime?.im_config_dir, `bindings[${index}].runtime.im_config_dir`);
452
+ assertString(binding.runtime?.task_config_dir, `bindings[${index}].runtime.task_config_dir`);
453
+ if (!isPathInside(expectedConfigDir, binding.runtime.im_config_dir) || !isPathInside(expectedConfigDir, binding.runtime.task_config_dir)) {
454
+ throw new Error(`bindings[${index}].runtime 配置目录不属于新流程 runtime-v1`);
455
+ }
456
+ }
457
+ addSecret(binding.bot.app_secret);
458
+ return binding;
459
+ }
460
+
461
+ function validateStore(store) {
462
+ if (!store || store.schema !== CONFIG_SCHEMA || store.version !== CONFIG_VERSION || !Array.isArray(store.bindings)) {
463
+ throw new Error(`新流程配置格式无效:${CONFIG_FILE}`);
464
+ }
465
+ const ids = new Set();
466
+ const appIds = new Set();
467
+ store.bindings.forEach((binding, index) => {
468
+ validateBinding(binding, index);
469
+ if (ids.has(binding.binding_id)) throw new Error(`配置中存在重复 binding_id:${binding.binding_id}`);
470
+ if (appIds.has(binding.bot.app_id)) throw new Error(`配置中存在重复 Bot:${binding.bot.app_id}`);
471
+ ids.add(binding.binding_id);
472
+ appIds.add(binding.bot.app_id);
473
+ });
474
+ return store;
475
+ }
476
+
477
+ async function loadStore() {
478
+ try {
479
+ return validateStore(await readJson(CONFIG_FILE));
480
+ } catch (error) {
481
+ if (error?.code === 'ENOENT') return emptyStore();
482
+ throw error;
483
+ }
484
+ }
485
+
486
+ async function replaceBindings(bindings) {
487
+ await withConfigLock(async () => {
488
+ await writeJsonAtomic(CONFIG_FILE, { ...emptyStore(), bindings });
489
+ });
490
+ }
491
+
492
+ async function appendBinding(binding) {
493
+ await withConfigLock(async () => {
494
+ const store = await loadStore();
495
+ if (store.bindings.some((item) => item.bot.app_id === binding.bot.app_id)) {
496
+ throw new Error(`Bot ${binding.bot.app_id} 已绑定,不能重复选择`);
497
+ }
498
+ validateBinding(binding, store.bindings.length);
499
+ await writeJsonAtomic(CONFIG_FILE, { ...emptyStore(), bindings: [...store.bindings, binding] });
500
+ });
501
+ }
502
+
503
+ async function updateBinding(binding) {
504
+ await withConfigLock(async () => {
505
+ const store = await loadStore();
506
+ const index = store.bindings.findIndex((item) => item.binding_id === binding.binding_id);
507
+ if (index < 0) throw new Error(`绑定配置已被移除:${binding.binding_id}`);
508
+ if (store.bindings[index].bot.app_id !== binding.bot.app_id) {
509
+ throw new Error(`绑定配置的 Bot 已变化:${binding.binding_id}`);
510
+ }
511
+ validateBinding(binding, index);
512
+ const bindings = [...store.bindings];
513
+ bindings[index] = binding;
514
+ await writeJsonAtomic(CONFIG_FILE, { ...emptyStore(), bindings });
515
+ });
516
+ }
517
+
518
+ function bindingLabel(binding) {
519
+ const botName = binding.bot?.display_name || binding.bot?.app_id || 'unknown Bot';
520
+ return `${binding.agent_type} ↔ ${botName} (${binding.bot?.app_id || 'unknown'})`;
521
+ }
522
+
523
+ function printBindingStarted(binding) {
524
+ console.log(`[aamp-one-click] 启动成功:${bindingLabel(binding)}`);
525
+ }
526
+
527
+ async function recordError(component, message, binding) {
528
+ await appendPrivate(ERRORS_LOG, `${JSON.stringify({
529
+ timestamp: nowIso(),
530
+ level: 'error',
531
+ component,
532
+ binding_id: binding?.binding_id,
533
+ app_id: binding?.bot?.app_id,
534
+ message: redact(message),
535
+ })}\n`);
536
+ }
537
+
538
+ async function writeManifest() {
539
+ const statuses = [...bindingStatuses.entries()].map(([bindingId, status]) => ({ binding_id: bindingId, ...status }));
540
+ await writeJsonAtomic(MANIFEST_FILE, {
541
+ schema: 'aamp.local_logs.run.v2',
542
+ run_id: RUN_ID,
543
+ task_agent_version: process.env.AAMP_TASK_AGENT_VERSION || '',
544
+ command: COMMAND,
545
+ started_at: process.env.AAMP_TASK_RUN_STARTED_AT || RUN_STARTED_AT,
546
+ config_file: CONFIG_FILE,
547
+ runtime_home: RUNTIME_HOME,
548
+ bindings: statuses,
549
+ errors_log: ERRORS_LOG,
550
+ log_dir: RUN_LOG_DIR,
551
+ });
552
+ }
553
+
554
+ async function setBindingStatus(binding, phase, status, reason = '') {
555
+ bindingStatuses.set(binding.binding_id, {
556
+ agent_type: binding.agent_type,
557
+ app_id: binding.bot.app_id,
558
+ bot_name: binding.bot.display_name || binding.bot.app_id,
559
+ phase,
560
+ status,
561
+ ...(reason ? { reason: redact(reason) } : {}),
562
+ updated_at: nowIso(),
563
+ });
564
+ await writeManifest();
565
+ }
566
+
567
+ async function chooseOne(title, items, render, initialIndex = 0) {
568
+ if (!items.length) throw new Error(`${title}:没有可选项`);
569
+ const { input, output } = terminalStreams();
570
+ let cursor = Math.min(Math.max(initialIndex, 0), items.length - 1);
571
+ const lineCount = items.length + 2;
572
+ const wasRaw = Boolean(input.isRaw);
573
+ emitKeypressEvents(input);
574
+ input.setRawMode(true);
575
+ input.resume();
576
+ output.write('\x1b[?25l');
577
+
578
+ const draw = (redraw = false) => {
579
+ if (redraw) output.write(`\x1b[${lineCount}A`);
580
+ output.write(`\x1b[2K\r${title}\n`);
581
+ items.forEach((item, index) => {
582
+ const pointer = index === cursor ? '>' : ' ';
583
+ output.write(`\x1b[2K\r ${pointer} ${render(item)}\n`);
584
+ });
585
+ output.write('\x1b[2K\r使用 ↑/↓ 移动,回车确认。\n');
586
+ };
587
+
588
+ draw();
589
+ return new Promise((resolve, reject) => {
590
+ const finish = (error) => {
591
+ input.off('keypress', onKeypress);
592
+ input.setRawMode(wasRaw);
593
+ input.pause();
594
+ output.write('\x1b[?25h');
595
+ if (error) reject(error);
596
+ else resolve(items[cursor]);
597
+ };
598
+ const onKeypress = (_value, key = {}) => {
599
+ if (key.ctrl && key.name === 'c') {
600
+ stopRequested = true;
601
+ stopSignal = 'SIGINT';
602
+ finish(new Error('用户取消操作'));
603
+ void cleanupAll();
604
+ return;
605
+ }
606
+ if (key.name === 'up' || key.name === 'k') cursor = (cursor + items.length - 1) % items.length;
607
+ else if (key.name === 'down' || key.name === 'j') cursor = (cursor + 1) % items.length;
608
+ else if (key.name === 'return' || key.name === 'enter') {
609
+ finish();
610
+ return;
611
+ } else {
612
+ return;
613
+ }
614
+ draw(true);
615
+ };
616
+ input.on('keypress', onKeypress);
617
+ });
618
+ }
619
+
620
+ async function chooseMany(title, items, render) {
621
+ const { input, output } = terminalStreams();
622
+ const options = [{ all: true, label: '全部' }, ...items.map((item) => ({ item, label: render(item) }))];
623
+ let cursor = 0;
624
+ const checked = new Set();
625
+ const lineCount = options.length + 2;
626
+ const wasRaw = Boolean(input.isRaw);
627
+ emitKeypressEvents(input);
628
+ input.setRawMode(true);
629
+ input.resume();
630
+ output.write('\x1b[?25l');
631
+
632
+ const draw = (redraw = false) => {
633
+ if (redraw) output.write(`\x1b[${lineCount}A`);
634
+ output.write(`\x1b[2K\r${title}\n`);
635
+ options.forEach((option, index) => {
636
+ const pointer = index === cursor ? '>' : ' ';
637
+ const mark = checked.has(index) ? 'x' : ' ';
638
+ output.write(`\x1b[2K\r ${pointer} [${mark}] ${option.label}\n`);
639
+ });
640
+ output.write('\x1b[2K\r使用 ↑/↓ 移动,空格多选,回车确认;选择“全部”会忽略其他选项。\n');
641
+ };
642
+
643
+ draw();
644
+ return new Promise((resolve, reject) => {
645
+ const finish = (error) => {
646
+ input.off('keypress', onKeypress);
647
+ input.setRawMode(wasRaw);
648
+ input.pause();
649
+ output.write('\x1b[?25h');
650
+ if (error) reject(error);
651
+ else if (checked.has(0)) resolve(items);
652
+ else resolve([...checked].sort((left, right) => left - right).map((index) => options[index].item));
653
+ };
654
+ const onKeypress = (_value, key = {}) => {
655
+ if (key.ctrl && key.name === 'c') {
656
+ stopRequested = true;
657
+ stopSignal = 'SIGINT';
658
+ finish(new Error('用户取消操作'));
659
+ void cleanupAll();
660
+ return;
661
+ }
662
+ if (key.name === 'up' || key.name === 'k') cursor = (cursor + options.length - 1) % options.length;
663
+ else if (key.name === 'down' || key.name === 'j') cursor = (cursor + 1) % options.length;
664
+ else if (key.name === 'space') {
665
+ if (cursor === 0) {
666
+ checked.clear();
667
+ checked.add(0);
668
+ } else {
669
+ checked.delete(0);
670
+ if (checked.has(cursor)) checked.delete(cursor);
671
+ else checked.add(cursor);
672
+ }
673
+ } else if (key.name === 'return' || key.name === 'enter') {
674
+ if (checked.size) finish();
675
+ return;
676
+ } else {
677
+ return;
678
+ }
679
+ draw(true);
680
+ };
681
+ input.on('keypress', onKeypress);
682
+ });
683
+ }
684
+
685
+ async function confirm(message, defaultValue = false) {
686
+ const options = [
687
+ { label: '是', value: true },
688
+ { label: '否', value: false },
689
+ ];
690
+ const selected = await chooseOne(message, options, (item) => item.label, defaultValue ? 0 : 1);
691
+ return selected.value;
692
+ }
693
+
694
+ function helperArgs(action, bindingOrAgent) {
695
+ const agent = typeof bindingOrAgent === 'object' ? bindingOrAgent.agent_type : bindingOrAgent;
696
+ const host = typeof bindingOrAgent === 'object' ? bindingOrAgent.aamp_host : DEFAULT_AAMP_HOST;
697
+ const args = [BOOTSTRAP, action];
698
+ if (agent) args.push('--agent', agent);
699
+ args.push('--aamp-host', host || DEFAULT_AAMP_HOST);
700
+ if (DEBUG_MODE) args.push('--debug');
701
+ return args;
702
+ }
703
+
704
+ async function runBootstrapHelper(action, bindingOrAgent, extraEnv = {}) {
705
+ if (!BOOTSTRAP) throw new Error('Bootstrap path is unavailable');
706
+ throwIfStopping();
707
+ const { input } = terminalStreams();
708
+ const helperEnv = { ...extraEnv };
709
+ const inputPayload = helperEnv.AAMP_TASK_INTERNAL_BINDING_JSON || '';
710
+ delete helperEnv.AAMP_TASK_INTERNAL_BINDING_JSON;
711
+ const child = spawn('bash', helperArgs(action, bindingOrAgent), {
712
+ env: {
713
+ ...process.env,
714
+ ...helperEnv,
715
+ AAMP_TASK_INTERNAL: 'true',
716
+ AAMP_TASK_INTERNAL_RESULT_FD: '3',
717
+ AAMP_TASK_INTERNAL_INPUT_FD: '4',
718
+ },
719
+ stdio: [input, 'inherit', 'inherit', 'pipe', 'pipe'],
720
+ });
721
+ const processRecord = trackTransientProcess(child, `Bootstrap helper ${action}`, false);
722
+ let result = '';
723
+ child.stdio[3].setEncoding('utf8');
724
+ child.stdio[3].on('data', (chunk) => { result += chunk; });
725
+ child.stdio[4].end(inputPayload ? `${inputPayload}\n` : '');
726
+ if (stopRequested) await stopManagedProcess(processRecord);
727
+ const exit = await processRecord.exitPromise;
728
+ throwIfStopping();
729
+ if (exit.code !== 0) throw exit.error || new Error(`Bootstrap helper ${action} failed${exit.signal ? ` (${exit.signal})` : ''}`);
730
+ try {
731
+ return JSON.parse(result.trim() || '{}');
732
+ } catch {
733
+ throw new Error(`Bootstrap helper ${action} returned invalid result`);
734
+ }
735
+ }
736
+
737
+ function npmExecArgs(packageSpec, executable, args) {
738
+ return [
739
+ 'exec', '--yes', '--registry', NPM_REGISTRY, '--cache', NPM_CACHE_DIR,
740
+ '--package', packageSpec, '--', executable, ...args,
741
+ ];
742
+ }
743
+
744
+ async function runCapture(packageSpec, executable, args, options = {}) {
745
+ throwIfStopping();
746
+ const child = spawn(NPM_BIN, npmExecArgs(packageSpec, executable, args), {
747
+ env: options.env || process.env,
748
+ stdio: ['pipe', 'pipe', 'pipe'],
749
+ detached: process.platform !== 'win32',
750
+ });
751
+ const processRecord = trackTransientProcess(child, executable, process.platform !== 'win32');
752
+ let stdout = '';
753
+ let stderr = '';
754
+ child.stdout.setEncoding('utf8');
755
+ child.stderr.setEncoding('utf8');
756
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
757
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
758
+ if (options.input !== undefined) child.stdin.end(options.input);
759
+ else child.stdin.end();
760
+ if (stopRequested) await stopManagedProcess(processRecord);
761
+ const exit = await processRecord.exitPromise;
762
+ throwIfStopping();
763
+ if (options.logFile) {
764
+ await appendPrivate(options.logFile, `${stdout}${stderr ? `\n${stderr}` : ''}`);
765
+ }
766
+ if (exit.code !== 0) {
767
+ const detail = redact(stderr.trim() || stdout.trim() || exit.error?.message || `exit ${exit.code}`);
768
+ throw new Error(`${executable} failed: ${detail.split('\n').slice(-8).join('\n')}`);
769
+ }
770
+ return { stdout, stderr };
771
+ }
772
+
773
+ function trackTransientProcess(child, label, processGroup) {
774
+ const record = {
775
+ label,
776
+ child,
777
+ exited: false,
778
+ exit: undefined,
779
+ expectedStop: false,
780
+ processGroup,
781
+ };
782
+ let spawnError;
783
+ record.exitPromise = new Promise((resolve) => {
784
+ child.once('error', (error) => { spawnError = error; });
785
+ child.once('close', (code, signal) => {
786
+ record.exited = true;
787
+ record.exit = { code: code ?? 1, signal, ...(spawnError ? { error: spawnError } : {}) };
788
+ transientProcesses.delete(record);
789
+ resolve(record.exit);
790
+ });
791
+ });
792
+ transientProcesses.add(record);
793
+ return record;
794
+ }
795
+
796
+ function parseJsonDocument(value, label) {
797
+ const text = String(value || '').trim();
798
+ try {
799
+ return JSON.parse(text);
800
+ } catch {
801
+ const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).reverse();
802
+ for (const line of lines) {
803
+ try {
804
+ return JSON.parse(line);
805
+ } catch {
806
+ // Continue looking for a JSON line.
807
+ }
808
+ }
809
+ }
810
+ throw new Error(`${label} did not return valid JSON`);
811
+ }
812
+
813
+ function createLineReader(stream, onLine) {
814
+ let pending = '';
815
+ stream.setEncoding('utf8');
816
+ stream.on('data', (chunk) => {
817
+ pending += chunk;
818
+ const lines = pending.split(/\r?\n/);
819
+ pending = lines.pop() || '';
820
+ for (const line of lines) onLine(line);
821
+ });
822
+ stream.on('end', () => {
823
+ if (pending) onLine(pending);
824
+ });
825
+ }
826
+
827
+ async function startManagedProcess({ label, packageSpec, executable, args, env, logFile }) {
828
+ await ensurePrivateDir(path.dirname(logFile));
829
+ throwIfStopping();
830
+ await fsp.writeFile(logFile, '', { mode: 0o600, flag: 'a' });
831
+ throwIfStopping();
832
+ const child = spawn(NPM_BIN, npmExecArgs(packageSpec, executable, args), {
833
+ env: env || process.env,
834
+ stdio: ['ignore', 'pipe', 'pipe'],
835
+ detached: process.platform !== 'win32',
836
+ });
837
+ const processStartedAt = Date.now();
838
+ const logWriter = createSerializedLineWriter((content) => appendPrivate(logFile, content));
839
+ const record = {
840
+ label,
841
+ child,
842
+ logFile,
843
+ events: [],
844
+ emitter: new EventEmitter(),
845
+ exited: false,
846
+ exit: undefined,
847
+ expectedStop: false,
848
+ processGroup: process.platform !== 'win32',
849
+ outputTail: [],
850
+ logWriter,
851
+ logWriteError: undefined,
852
+ };
853
+ managedProcesses.add(record);
854
+ void logWriter.write(`${JSON.stringify({
855
+ timestamp: nowIso(),
856
+ type: 'bridge.process',
857
+ status: 'started',
858
+ label,
859
+ executable,
860
+ package: packageSpec,
861
+ pid: child.pid || null,
862
+ node: process.version,
863
+ })}\n`).catch((error) => {
864
+ record.logWriteError ??= error;
865
+ });
866
+ const handleLine = (streamName, line) => {
867
+ const safeLine = redact(line);
868
+ record.outputTail.push(`[${streamName}] ${safeLine}`);
869
+ if (record.outputTail.length > 30) record.outputTail.shift();
870
+ if (streamName === 'stdout' && line.trim()) {
871
+ try {
872
+ const event = JSON.parse(line.trim());
873
+ if (event && typeof event.type === 'string') {
874
+ record.events.push(event);
875
+ record.emitter.emit('event', event);
876
+ }
877
+ } catch {
878
+ // Feishu task mode can mix human-readable lines with JSON events.
879
+ }
880
+ }
881
+ void logWriter.write(`${line}\n`).catch((error) => {
882
+ record.logWriteError ??= error;
883
+ });
884
+ };
885
+ createLineReader(child.stdout, (line) => { handleLine('stdout', line); });
886
+ createLineReader(child.stderr, (line) => { handleLine('stderr', line); });
887
+ record.exitPromise = new Promise((resolve) => {
888
+ let settled = false;
889
+ const finish = async (exit) => {
890
+ if (settled) return;
891
+ settled = true;
892
+ await logWriter.write(`${JSON.stringify({
893
+ timestamp: nowIso(),
894
+ type: 'bridge.process',
895
+ status: 'exited',
896
+ label,
897
+ executable,
898
+ package: packageSpec,
899
+ pid: child.pid || null,
900
+ durationMs: Date.now() - processStartedAt,
901
+ code: exit.code,
902
+ signal: exit.signal || null,
903
+ expectedStop: record.expectedStop,
904
+ ...(exit.error ? { error: describeNetworkError(exit.error) } : {}),
905
+ })}\n`).catch((error) => {
906
+ record.logWriteError ??= error;
907
+ });
908
+ await logWriter.flush().catch((error) => {
909
+ record.logWriteError ??= error;
910
+ });
911
+ record.exited = true;
912
+ record.exit = {
913
+ ...exit,
914
+ ...(record.logWriteError ? { logError: record.logWriteError } : {}),
915
+ };
916
+ record.emitter.emit('exit', record.exit);
917
+ resolve(record.exit);
918
+ };
919
+ child.once('error', (error) => { void finish({ code: 1, error }); });
920
+ child.once('close', (code, signal) => { void finish({ code: code ?? 1, signal }); });
921
+ });
922
+ if (stopRequested) {
923
+ await stopManagedProcess(record);
924
+ throwIfStopping();
925
+ }
926
+ return record;
927
+ }
928
+
929
+ function signalProcess(record, signal) {
930
+ if (!record || record.exited || !record.child.pid) return;
931
+ try {
932
+ if (process.platform !== 'win32' && record.processGroup) process.kill(-record.child.pid, signal);
933
+ else record.child.kill(signal);
934
+ } catch (error) {
935
+ if (error?.code !== 'ESRCH') throw error;
936
+ }
937
+ }
938
+
939
+ async function stopManagedProcess(record) {
940
+ if (!record || record.exited) return;
941
+ record.expectedStop = true;
942
+ signalProcess(record, 'SIGTERM');
943
+ await Promise.race([record.exitPromise, delay(5_000)]);
944
+ if (!record.exited) {
945
+ signalProcess(record, 'SIGKILL');
946
+ await Promise.race([record.exitPromise, delay(2_000)]);
947
+ }
948
+ }
949
+
950
+ async function waitForEvent(record, predicate, timeoutMs = READY_TIMEOUT_MS) {
951
+ const existing = record.events.find(predicate);
952
+ if (existing) return existing;
953
+ if (record.exited) {
954
+ const tail = record.outputTail.slice(-10).join('\n');
955
+ throw new Error(`${record.label} exited before ready (${record.exit?.signal || record.exit?.code})${tail ? `:\n${tail}` : ''}\n日志:${record.logFile}`);
956
+ }
957
+ return new Promise((resolve, reject) => {
958
+ const timeout = setTimeout(() => {
959
+ cleanup();
960
+ const tail = record.outputTail.slice(-10).join('\n');
961
+ reject(new Error(`${record.label} readiness timed out${tail ? `:\n${tail}` : ''}\n日志:${record.logFile}`));
962
+ }, timeoutMs);
963
+ const onEvent = (event) => {
964
+ if (!predicate(event)) return;
965
+ cleanup();
966
+ resolve(event);
967
+ };
968
+ const onExit = (exit) => {
969
+ cleanup();
970
+ const tail = record.outputTail.slice(-10).join('\n');
971
+ reject(new Error(`${record.label} exited before ready (${exit.signal || exit.code})${tail ? `:\n${tail}` : ''}\n日志:${record.logFile}`));
972
+ };
973
+ const cleanup = () => {
974
+ clearTimeout(timeout);
975
+ record.emitter.off('event', onEvent);
976
+ record.emitter.off('exit', onExit);
977
+ };
978
+ record.emitter.on('event', onEvent);
979
+ record.emitter.on('exit', onExit);
980
+ });
981
+ }
982
+
983
+ function delay(ms) {
984
+ return new Promise((resolve) => setTimeout(resolve, ms));
985
+ }
986
+
987
+ function throwIfStopping() {
988
+ if (stopRequested) throw new Error(`已收到 ${stopSignal || '停止信号'},不再启动新的 Bridge`);
989
+ }
990
+
991
+ function assertOnlineBinding(binding) {
992
+ if (binding?.environment?.name === 'online') return;
993
+ const environment = binding?.environment?.name || 'unknown';
994
+ throw new Error(`配置环境 ${environment} 不受支持;Task Agent 仅支持 Online,请使用 remove 删除后重新绑定`);
995
+ }
996
+
997
+ function onlineEnvironment(binding) {
998
+ if (binding) assertOnlineBinding(binding);
999
+ const env = { ...process.env };
1000
+ const proxyKeys = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'];
1001
+ proxyKeys.forEach((key) => delete env[key]);
1002
+ env.LARKSUITE_CLI_CONFIG_DIR = process.env.AAMP_LARK_CLI_CONFIG_DIR || path.join(HOME, '.lark-cli-aamp-one-click-v1');
1003
+ return env;
1004
+ }
1005
+
1006
+ function groupIdForHost(host) {
1007
+ return shortHash(host);
1008
+ }
1009
+
1010
+ function groupHomeForHost(host) {
1011
+ return path.join(RUNTIME_HOME, 'agent-bridges', groupIdForHost(host));
1012
+ }
1013
+
1014
+ async function acquireAgentLease(host, agentType) {
1015
+ throwIfStopping();
1016
+ const name = `agent-${shortHash(`${host}\u0000${agentType}`)}.lock`;
1017
+ const lockDir = path.join(LEASES_HOME, name);
1018
+ const release = await acquireDirectoryLock(lockDir, `${agentType} (${host})`, 1_500);
1019
+ const lease = { lockDir, release };
1020
+ heldLeases.add(lease);
1021
+ if (stopRequested) {
1022
+ await releaseLease(lease);
1023
+ throwIfStopping();
1024
+ }
1025
+ return lease;
1026
+ }
1027
+
1028
+ async function releaseLease(lease) {
1029
+ if (!lease || !heldLeases.has(lease)) return;
1030
+ heldLeases.delete(lease);
1031
+ await lease.release();
1032
+ }
1033
+
1034
+ async function setupAgentGroups(bindings) {
1035
+ const byHost = new Map();
1036
+ for (const binding of bindings) {
1037
+ if (!byHost.has(binding.aamp_host)) byHost.set(binding.aamp_host, new Map());
1038
+ if (!byHost.get(binding.aamp_host).has(binding.agent_type)) byHost.get(binding.aamp_host).set(binding.agent_type, binding);
1039
+ }
1040
+ const groups = new Map();
1041
+ for (const [host, agentBindings] of byHost) {
1042
+ throwIfStopping();
1043
+ const hostBinding = agentBindings.values().next().value;
1044
+ const bridgeEnv = onlineEnvironment(hostBinding);
1045
+ const home = groupHomeForHost(host);
1046
+ await assertNoSymlinkPath(RUNTIME_HOME, home);
1047
+ throwIfStopping();
1048
+ await ensurePrivateDir(home);
1049
+ throwIfStopping();
1050
+ const logFile = path.join(RUN_LOG_DIR, `acp-bridge-${groupIdForHost(host)}.jsonl`);
1051
+ const configFile = path.join(home, 'runs', safeId(RUN_ID), `${randomId()}.json`);
1052
+ await assertNoSymlinkPath(RUNTIME_HOME, configFile);
1053
+ const group = {
1054
+ host,
1055
+ home,
1056
+ configFile,
1057
+ logFile,
1058
+ identities: new Map(),
1059
+ availableAgents: new Set(),
1060
+ failures: new Map(),
1061
+ leases: new Map(),
1062
+ process: undefined,
1063
+ };
1064
+ groups.set(host, group);
1065
+ const agents = [];
1066
+ for (const [agentType, sampleBinding] of agentBindings) {
1067
+ throwIfStopping();
1068
+ try {
1069
+ const lease = await acquireAgentLease(host, agentType);
1070
+ throwIfStopping();
1071
+ group.leases.set(agentType, lease);
1072
+ console.log(`[aamp-one-click] 正在检查 ${agentType} 本地智能体...`);
1073
+ const prepared = await runBootstrapHelper('__prepare-agent', sampleBinding);
1074
+ throwIfStopping();
1075
+ if (path.resolve(prepared.lark_cli_config_dir || '') !== path.resolve(bridgeEnv.LARKSUITE_CLI_CONFIG_DIR)) {
1076
+ throw new Error(`Agent 使用的 lark-cli 配置目录与 Online 配置不一致:${prepared.lark_cli_config_dir || 'unknown'}`);
1077
+ }
1078
+ const agentHome = path.join(home, 'agents', agentType);
1079
+ await assertNoSymlinkPath(RUNTIME_HOME, agentHome);
1080
+ throwIfStopping();
1081
+ await ensurePrivateDir(agentHome);
1082
+ throwIfStopping();
1083
+ agents.push({
1084
+ name: agentType,
1085
+ acpCommand: prepared.acp_command,
1086
+ credentialsFile: path.join(agentHome, 'credentials.json'),
1087
+ pairingFile: path.join(agentHome, 'pairing.json'),
1088
+ senderPoliciesFile: path.join(agentHome, 'sender-policies.json'),
1089
+ createPairing: false,
1090
+ });
1091
+ } catch (error) {
1092
+ const reason = redact(error.message || error);
1093
+ group.failures.set(agentType, reason);
1094
+ await releaseLease(group.leases.get(agentType));
1095
+ group.leases.delete(agentType);
1096
+ if (stopRequested) throw error;
1097
+ }
1098
+ }
1099
+ if (!agents.length) continue;
1100
+ try {
1101
+ throwIfStopping();
1102
+ const initResult = await runNetworkStage(async () => {
1103
+ return runCapture(
1104
+ ACP_PACKAGE,
1105
+ 'aamp-acp-bridge',
1106
+ ['init', '--json', '--config', group.configFile, '--input', '-'],
1107
+ { input: JSON.stringify({ aampHost: host, agents }), env: bridgeEnv, logFile },
1108
+ );
1109
+ }, {
1110
+ stage: 'acp-init',
1111
+ label: 'Agent Bridge 初始化',
1112
+ host,
1113
+ logFile,
1114
+ environment: bridgeEnv,
1115
+ });
1116
+ throwIfStopping();
1117
+ const initialized = parseJsonDocument(initResult.stdout, 'ACP init');
1118
+ for (const agent of initialized.agents || []) group.identities.set(agent.name, agent.email);
1119
+ console.log(`[aamp-one-click] 正在启动本地 Agent Bridge (${agents.map((agent) => agent.name).join(', ')})...`);
1120
+ const started = await runNetworkStage(async ({ attempt, maxAttempts }) => {
1121
+ const process = await startManagedProcess({
1122
+ label: `ACP Bridge ${host}`,
1123
+ packageSpec: ACP_PACKAGE,
1124
+ executable: 'aamp-acp-bridge',
1125
+ args: ['start', '--config', group.configFile, '--json', ...(DEBUG_MODE ? ['--debug'] : [])],
1126
+ env: bridgeEnv,
1127
+ logFile,
1128
+ });
1129
+ group.process = process;
1130
+ try {
1131
+ const running = await waitForEvent(process, (event) => event.type === 'bridge.running');
1132
+ const retryError = agentStartRetryError(
1133
+ process.events,
1134
+ agents.map((agent) => agent.name),
1135
+ attempt,
1136
+ maxAttempts,
1137
+ );
1138
+ if (retryError) throw retryError;
1139
+ return { process, running };
1140
+ } catch (error) {
1141
+ await stopManagedProcess(process);
1142
+ managedProcesses.delete(process);
1143
+ if (group.process === process) group.process = undefined;
1144
+ throw error;
1145
+ }
1146
+ }, {
1147
+ stage: 'acp-start',
1148
+ label: 'Agent Bridge 启动',
1149
+ host,
1150
+ logFile,
1151
+ environment: bridgeEnv,
1152
+ });
1153
+ group.process = started.process;
1154
+ throwIfStopping();
1155
+ const running = started.running;
1156
+ throwIfStopping();
1157
+ for (const agent of running.agents || []) group.availableAgents.add(agent.name);
1158
+ for (const agent of agents) {
1159
+ if (!group.availableAgents.has(agent.name)) {
1160
+ const failed = group.process.events.find((event) => event.type === 'agent.failed' && event.agent === agent.name);
1161
+ group.failures.set(agent.name, failed?.message || `${agent.name} Agent Bridge 启动失败`);
1162
+ await releaseLease(group.leases.get(agent.name));
1163
+ group.leases.delete(agent.name);
1164
+ }
1165
+ }
1166
+ } catch (error) {
1167
+ for (const agent of agents) group.failures.set(agent.name, redact(error.message || error));
1168
+ if (group.process) await stopManagedProcess(group.process);
1169
+ for (const lease of group.leases.values()) await releaseLease(lease);
1170
+ group.leases.clear();
1171
+ if (stopRequested) throw error;
1172
+ }
1173
+ }
1174
+ return groups;
1175
+ }
1176
+
1177
+ function resolveGroup(groups, binding) {
1178
+ const group = groups.get(binding.aamp_host);
1179
+ if (!group) throw new Error(`Agent Bridge group is unavailable for ${binding.aamp_host}`);
1180
+ const agentFailure = group.failures.get(binding.agent_type);
1181
+ if ((!group.process || group.process.exited) && agentFailure) throw new Error(agentFailure);
1182
+ if (!group.process || group.process.exited) {
1183
+ const tail = group.process?.outputTail?.slice(-10).join('\n');
1184
+ throw new Error(`Agent Bridge 已退出${tail ? `:\n${tail}` : ''}`);
1185
+ }
1186
+ if (!group.availableAgents.has(binding.agent_type)) {
1187
+ throw new Error(group.failures.get(binding.agent_type) || `${binding.agent_type} Agent Bridge 未启动`);
1188
+ }
1189
+ const email = group.identities.get(binding.agent_type);
1190
+ if (!email) throw new Error(`${binding.agent_type} Agent mailbox is unavailable`);
1191
+ if (binding.agent_target_email && binding.agent_target_email !== email) {
1192
+ throw new Error(`Agent mailbox 已变化(配置=${binding.agent_target_email},当前=${email}),请使用 add 或 install 重新绑定`);
1193
+ }
1194
+ return { group, email };
1195
+ }
1196
+
1197
+ async function writeFeishuRuntimeProfile(binding) {
1198
+ const profileFile = path.join(binding.feishu_config_dir, 'task-runtime', 'task-profiles-v2.json');
1199
+ const instancesDir = path.join(binding.feishu_config_dir, 'task-runtime', 'instances');
1200
+ await assertNoSymlinkPath(RUNTIME_HOME, profileFile);
1201
+ await assertNoSymlinkPath(RUNTIME_HOME, instancesDir);
1202
+ await writeJsonAtomic(profileFile, {
1203
+ version: 1,
1204
+ profiles: [{
1205
+ app_id: binding.bot.app_id,
1206
+ app_secret: binding.bot.app_secret,
1207
+ profile: binding.bot.lark_cli_profile,
1208
+ display_name: binding.bot.display_name,
1209
+ auth_mode: 'lark-cli',
1210
+ capabilities: ['im', 'task'],
1211
+ domains: PROFILE_DOMAINS,
1212
+ updated_at: nowIso(),
1213
+ }],
1214
+ });
1215
+ }
1216
+
1217
+ async function ensureBindingProfile(binding) {
1218
+ return runBootstrapHelper('__ensure-profile', binding, {
1219
+ AAMP_TASK_INTERNAL_BINDING_JSON: JSON.stringify(binding),
1220
+ });
1221
+ }
1222
+
1223
+ function feishuArgs(binding, larkCliBin, target) {
1224
+ const targetArgs = target.pairingUrl
1225
+ ? ['--pairing-url', target.pairingUrl]
1226
+ : ['--target-agent', target.agentTargetEmail];
1227
+ return [
1228
+ 'start', '--enable-task',
1229
+ '--config-dir', binding.feishu_config_dir,
1230
+ '--aamp-host', binding.aamp_host,
1231
+ '--agent', binding.agent_type,
1232
+ ...targetArgs,
1233
+ '--app-id', binding.bot.app_id,
1234
+ '--bot-name', binding.bot.display_name || binding.bot.app_id,
1235
+ '--use-feishu-cli',
1236
+ '--feishu-cli-profile', binding.bot.lark_cli_profile,
1237
+ '--feishu-cli-bin', larkCliBin,
1238
+ '--json',
1239
+ ...(DEBUG_MODE ? ['--debug'] : []),
1240
+ ];
1241
+ }
1242
+
1243
+ async function prepareFeishuProcess(binding, phase) {
1244
+ throwIfStopping();
1245
+ await writeFeishuRuntimeProfile(binding);
1246
+ throwIfStopping();
1247
+ const profile = await ensureBindingProfile(binding);
1248
+ throwIfStopping();
1249
+ if (!profile.lark_cli_bin) throw new Error(`lark-cli profile ${binding.bot.lark_cli_profile} is unavailable`);
1250
+ return {
1251
+ binding,
1252
+ phase,
1253
+ larkCliBin: profile.lark_cli_bin,
1254
+ logFile: path.join(RUN_LOG_DIR, `feishu-bridge-${safeId(binding.binding_id)}-${phase}.jsonl`),
1255
+ };
1256
+ }
1257
+
1258
+ async function startPreparedFeishuProcess(prepared, target, environment = onlineEnvironment(prepared.binding)) {
1259
+ throwIfStopping();
1260
+ const { binding, phase, larkCliBin, logFile } = prepared;
1261
+ const phaseMessage = phase === 'install'
1262
+ ? '正在建立绑定并启动飞书任务 Bridge'
1263
+ : phase === 'add'
1264
+ ? '正在验证飞书任务绑定'
1265
+ : '正在启动飞书任务 Bridge';
1266
+ console.log(`[aamp-one-click] ${phaseMessage}:${bindingLabel(binding)}...`);
1267
+ return startManagedProcess({
1268
+ label: `Feishu Bridge ${bindingLabel(binding)}`,
1269
+ packageSpec: FEISHU_PACKAGE,
1270
+ executable: 'aamp-feishu-bridge',
1271
+ args: feishuArgs(binding, larkCliBin, target),
1272
+ env: environment,
1273
+ logFile,
1274
+ });
1275
+ }
1276
+
1277
+ async function startFeishuProcess(binding, phase, target) {
1278
+ const prepared = await prepareFeishuProcess(binding, phase);
1279
+ throwIfStopping();
1280
+ return startPreparedFeishuProcess(prepared, target);
1281
+ }
1282
+
1283
+ async function startPreparedFeishuUntilReady(prepared, target, { stage, pairingFile } = {}) {
1284
+ const { binding, logFile } = prepared;
1285
+ const environment = onlineEnvironment(binding);
1286
+ return runNetworkStage(async () => {
1287
+ const processRecord = await startPreparedFeishuProcess(prepared, target, environment);
1288
+ try {
1289
+ throwIfStopping();
1290
+ if (pairingFile) await waitForInitialBinding(processRecord, pairingFile);
1291
+ else await waitForEvent(processRecord, (event) => event.type === 'bridge.task_runtime.running');
1292
+ throwIfStopping();
1293
+ return processRecord;
1294
+ } catch (error) {
1295
+ await stopManagedProcess(processRecord);
1296
+ managedProcesses.delete(processRecord);
1297
+ const consumed = pairingFile
1298
+ && isRetryableNetworkError(error)
1299
+ && await pairingConsumed(pairingFile);
1300
+ await appendDiagnostic(logFile, {
1301
+ type: 'bridge.readiness.failed',
1302
+ stage,
1303
+ pairingConsumed: Boolean(consumed),
1304
+ category: classifyNetworkError(error),
1305
+ error: describeNetworkError(error),
1306
+ });
1307
+ if (consumed) {
1308
+ const pairingError = new Error('Feishu Bridge 启动失败且本次配对码已被消费,请重新执行绑定');
1309
+ pairingError.retryable = false;
1310
+ throw pairingError;
1311
+ }
1312
+ throw error;
1313
+ }
1314
+ }, {
1315
+ stage,
1316
+ label: 'Feishu Bridge 启动',
1317
+ host: binding.aamp_host,
1318
+ logFile,
1319
+ environment,
1320
+ shouldRetry: (error) => error?.retryable !== false && isRetryableNetworkError(error),
1321
+ });
1322
+ }
1323
+
1324
+ async function pairingConsumed(pairingFile) {
1325
+ try {
1326
+ const state = await readJson(pairingFile);
1327
+ return typeof state.consumedAt === 'string' && Boolean(state.consumedAt);
1328
+ } catch {
1329
+ return false;
1330
+ }
1331
+ }
1332
+
1333
+ async function waitForInitialBinding(record, pairingFile) {
1334
+ const deadline = Date.now() + READY_TIMEOUT_MS;
1335
+ while (Date.now() < deadline) {
1336
+ if (record.exited) {
1337
+ const tail = record.outputTail.slice(-10).join('\n');
1338
+ throw new Error(`${record.label} exited before binding completed${tail ? `:\n${tail}` : ''}\n日志:${record.logFile}`);
1339
+ }
1340
+ const running = record.events.some((event) => event.type === 'bridge.task_runtime.running');
1341
+ if (running && await pairingConsumed(pairingFile)) return;
1342
+ await delay(250);
1343
+ }
1344
+ const tail = record.outputTail.slice(-10).join('\n');
1345
+ throw new Error(`${record.label} binding timed out${tail ? `:\n${tail}` : ''}\n日志:${record.logFile}`);
1346
+ }
1347
+
1348
+ async function readInitialRuntimeMetadata(binding, record, expectedAgentEmail) {
1349
+ const starting = record.events.find((event) => event.type === 'bridge.task_runtime.starting' && event.appId === binding.bot.app_id);
1350
+ if (!starting?.imConfigDir || !starting?.taskConfigDir) throw new Error('Feishu Bridge 未返回实例配置目录');
1351
+ const imFile = path.join(starting.imConfigDir, 'config.json');
1352
+ const taskFile = path.join(starting.taskConfigDir, 'config.json');
1353
+ if (!isPathInside(binding.feishu_config_dir, starting.imConfigDir) || !isPathInside(binding.feishu_config_dir, starting.taskConfigDir)) {
1354
+ throw new Error('Feishu Bridge 返回了新流程 runtime-v1 之外的配置目录');
1355
+ }
1356
+ await assertNoSymlinkPath(RUNTIME_HOME, imFile);
1357
+ await assertNoSymlinkPath(RUNTIME_HOME, taskFile);
1358
+ const [imConfig, taskConfig] = await Promise.all([readJson(imFile), readJson(taskFile)]);
1359
+ if (imConfig.targetAgentEmail !== expectedAgentEmail || taskConfig.targetAgentEmail !== expectedAgentEmail) {
1360
+ throw new Error('Feishu Bridge 实例的 Agent mailbox 与本次配对不一致');
1361
+ }
1362
+ if (imConfig.feishu?.appId !== binding.bot.app_id || taskConfig.feishu?.appId !== binding.bot.app_id) {
1363
+ throw new Error('Feishu Bridge 实例的 Bot App ID 与本次配对不一致');
1364
+ }
1365
+ return {
1366
+ im_config_dir: starting.imConfigDir,
1367
+ task_config_dir: starting.taskConfigDir,
1368
+ feishu_bridge_email: imConfig.mailbox?.email || '',
1369
+ };
1370
+ }
1371
+
1372
+ async function validateSavedRuntime(binding) {
1373
+ if (!isPathInside(binding.feishu_config_dir, binding.runtime.im_config_dir)
1374
+ || !isPathInside(binding.feishu_config_dir, binding.runtime.task_config_dir)) {
1375
+ throw new Error('绑定运行配置不属于新流程 runtime-v1,请重新绑定');
1376
+ }
1377
+ const imFile = path.join(binding.runtime.im_config_dir, 'config.json');
1378
+ const taskFile = path.join(binding.runtime.task_config_dir, 'config.json');
1379
+ await assertNoSymlinkPath(RUNTIME_HOME, imFile);
1380
+ await assertNoSymlinkPath(RUNTIME_HOME, taskFile);
1381
+ let imConfig;
1382
+ let taskConfig;
1383
+ try {
1384
+ [imConfig, taskConfig] = await Promise.all([readJson(imFile), readJson(taskFile)]);
1385
+ } catch (error) {
1386
+ throw new Error(`绑定运行配置缺失,请使用 add 或 install 重新绑定:${redact(error.message || error)}`);
1387
+ }
1388
+ if (imConfig.targetAgentEmail !== binding.agent_target_email || taskConfig.targetAgentEmail !== binding.agent_target_email) {
1389
+ throw new Error('绑定运行配置与 Agent mailbox 不一致,请使用 add 或 install 重新绑定');
1390
+ }
1391
+ if (imConfig.feishu?.appId !== binding.bot.app_id || taskConfig.feishu?.appId !== binding.bot.app_id) {
1392
+ throw new Error('绑定运行配置与 Bot App ID 不一致,请重新绑定');
1393
+ }
1394
+ if (!imConfig.mailbox?.email || !taskConfig.mailbox?.email || imConfig.mailbox.email !== taskConfig.mailbox.email) {
1395
+ throw new Error('绑定运行配置中的 Feishu Bridge mailbox 无效,请重新绑定');
1396
+ }
1397
+ if (binding.runtime.feishu_bridge_email && binding.runtime.feishu_bridge_email !== imConfig.mailbox.email) {
1398
+ throw new Error('Feishu Bridge mailbox 已变化,请重新绑定');
1399
+ }
1400
+ }
1401
+
1402
+ async function bindOneDraft(draft, groups, mode) {
1403
+ await setBindingStatus(draft, 'bind', 'starting');
1404
+ throwIfStopping();
1405
+ const { group, email } = resolveGroup(groups, draft);
1406
+ const binding = { ...draft, state: 'ready', agent_target_email: email, updated_at: nowIso() };
1407
+ const preparedFeishu = await prepareFeishuProcess(binding, mode);
1408
+ throwIfStopping();
1409
+ const pairResult = await runCapture(
1410
+ ACP_PACKAGE,
1411
+ 'aamp-acp-bridge',
1412
+ ['pair', '--agent', draft.agent_type, '--config', group.configFile, '--json', '--no-start'],
1413
+ { logFile: group.logFile },
1414
+ );
1415
+ throwIfStopping();
1416
+ const pairing = parseJsonDocument(pairResult.stdout, 'ACP pairing');
1417
+ if (!pairing.connectUrl || !pairing.pairingFile || pairing.mailbox !== email) {
1418
+ throw new Error('ACP Bridge 返回的配对信息不完整或 mailbox 不一致');
1419
+ }
1420
+ let feishu;
1421
+ let keepRunning = false;
1422
+ try {
1423
+ feishu = await startPreparedFeishuUntilReady(
1424
+ preparedFeishu,
1425
+ { pairingUrl: pairing.connectUrl },
1426
+ { stage: mode === 'install' ? 'feishu-install-bind' : 'feishu-add-bind', pairingFile: pairing.pairingFile },
1427
+ );
1428
+ throwIfStopping();
1429
+ binding.runtime = await readInitialRuntimeMetadata(binding, feishu, email);
1430
+ throwIfStopping();
1431
+ await validateSavedRuntime(binding);
1432
+ throwIfStopping();
1433
+ const startsBridge = mode === 'install' || mode === 'start';
1434
+ await setBindingStatus(binding, startsBridge ? 'start' : 'bind', startsBridge ? 'running' : 'succeeded');
1435
+ throwIfStopping();
1436
+ keepRunning = startsBridge;
1437
+ return { binding, process: keepRunning ? feishu : undefined, group };
1438
+ } finally {
1439
+ if (!keepRunning) await stopManagedProcess(feishu);
1440
+ }
1441
+ }
1442
+
1443
+ async function startOneBinding(binding, groups) {
1444
+ await setBindingStatus(binding, 'start', 'starting');
1445
+ throwIfStopping();
1446
+ const { email } = resolveGroup(groups, binding);
1447
+ if (email !== binding.agent_target_email) throw new Error('当前 Agent mailbox 与绑定记录不一致,请重新绑定');
1448
+ await validateSavedRuntime(binding);
1449
+ throwIfStopping();
1450
+ const preparedFeishu = await prepareFeishuProcess(binding, 'start');
1451
+ const feishu = await startPreparedFeishuUntilReady(
1452
+ preparedFeishu,
1453
+ { agentTargetEmail: binding.agent_target_email },
1454
+ { stage: 'feishu-start' },
1455
+ );
1456
+ throwIfStopping();
1457
+ await setBindingStatus(binding, 'start', 'running');
1458
+ throwIfStopping();
1459
+ printBindingStarted(binding);
1460
+ return feishu;
1461
+ }
1462
+
1463
+ async function startSelectedBindings(bindings, existingGroups) {
1464
+ const running = [];
1465
+ const failed = [];
1466
+ const onlineBindings = [];
1467
+ for (const binding of bindings) {
1468
+ try {
1469
+ assertOnlineBinding(binding);
1470
+ onlineBindings.push(binding);
1471
+ } catch (error) {
1472
+ const reason = redact(error.message || error);
1473
+ failed.push({ binding, reason });
1474
+ await setBindingStatus(binding, 'start', 'failed', reason);
1475
+ await recordError('startup', reason, binding);
1476
+ console.error(`\n🔴 启动失败:${bindingLabel(binding)}\n 原因:${reason}`);
1477
+ console.error(' 已跳过该项,继续启动下一项。');
1478
+ }
1479
+ }
1480
+ const groups = existingGroups || await setupAgentGroups(onlineBindings);
1481
+ for (const binding of onlineBindings) {
1482
+ throwIfStopping();
1483
+ try {
1484
+ let activeBinding = binding;
1485
+ let group = groups.get(binding.aamp_host);
1486
+ let processRecord;
1487
+ if (bindingNeedsInitialStart(binding)) {
1488
+ const paired = await bindOneDraft(binding, groups, 'start');
1489
+ if (!paired.process) throw new Error('首次启动完成配对后未获得可监督的 Feishu Bridge 进程');
1490
+ try {
1491
+ await updateBinding(paired.binding);
1492
+ } catch (error) {
1493
+ await stopManagedProcess(paired.process);
1494
+ throw error;
1495
+ }
1496
+ activeBinding = paired.binding;
1497
+ group = paired.group;
1498
+ processRecord = paired.process;
1499
+ printBindingStarted(activeBinding);
1500
+ } else {
1501
+ processRecord = await startOneBinding(binding, groups);
1502
+ }
1503
+ throwIfStopping();
1504
+ running.push({ binding: activeBinding, process: processRecord, group });
1505
+ } catch (error) {
1506
+ if (stopRequested) throw error;
1507
+ const reason = redact(error.message || error);
1508
+ failed.push({ binding, reason });
1509
+ await setBindingStatus(binding, 'start', 'failed', reason);
1510
+ await recordError('startup', reason, binding);
1511
+ console.error(`\n🔴 启动失败:${bindingLabel(binding)}\n 原因:${reason}`);
1512
+ console.error(' 已跳过该项,继续启动下一项。');
1513
+ }
1514
+ }
1515
+ const reconciled = await reconcileRetainedBindings(running);
1516
+ running.splice(0, running.length, ...reconciled.alive);
1517
+ failed.push(...reconciled.failed);
1518
+ if (!running.length) {
1519
+ await shutdownGroups(groups);
1520
+ throw new Error('全部配置启动失败');
1521
+ }
1522
+ console.log(`\n已成功启动 ${running.length}/${bindings.length} 个配置。`);
1523
+ console.log('🟢 保持终端打开,你可以给 agent 派发飞书任务');
1524
+ if (failed.length) console.log(`另有 ${failed.length} 个配置启动失败,详情见上方信息和本地日志。`);
1525
+ await supervise(running, groups);
1526
+ }
1527
+
1528
+ async function markRuntimeFailed(binding, reason, component) {
1529
+ await setBindingStatus(binding, 'start', 'failed', reason);
1530
+ await recordError(component, reason, binding);
1531
+ }
1532
+
1533
+ async function reconcileRetainedBindings(running) {
1534
+ const alive = [];
1535
+ const failed = [];
1536
+ for (const item of running) {
1537
+ throwIfStopping();
1538
+ const feishuAlive = Boolean(item.process && !item.process.exited);
1539
+ const groupAlive = Boolean(item.group?.process && !item.group.process.exited);
1540
+ if (feishuAlive && groupAlive) {
1541
+ alive.push(item);
1542
+ continue;
1543
+ }
1544
+ const reason = !feishuAlive
1545
+ ? `${bindingLabel(item.binding)} 的 Feishu Bridge 在进入监督前已退出 (${item.process?.exit?.signal || item.process?.exit?.code || 'unknown'})`
1546
+ : `${bindingLabel(item.binding)} 的 Agent Bridge 在进入监督前已退出:${item.group?.host || item.binding.aamp_host}`;
1547
+ if (feishuAlive) await stopManagedProcess(item.process);
1548
+ await markRuntimeFailed(item.binding, reason, 'startup');
1549
+ failed.push({ binding: item.binding, reason });
1550
+ console.error(`\n🔴 启动失败:${bindingLabel(item.binding)}\n 原因:${reason}`);
1551
+ }
1552
+ return { alive, failed };
1553
+ }
1554
+
1555
+ async function supervise(running, groups) {
1556
+ const active = new Set(running);
1557
+ const reportedGroups = new Set();
1558
+ while (active.size && !stopRequested) {
1559
+ for (const item of [...active]) {
1560
+ if (!item.process.exited) continue;
1561
+ active.delete(item);
1562
+ if (!item.process.expectedStop) {
1563
+ const reason = `${bindingLabel(item.binding)} 的 Feishu Bridge 已退出 (${item.process.exit?.signal || item.process.exit?.code})`;
1564
+ await markRuntimeFailed(item.binding, reason, 'supervisor');
1565
+ console.error(`\n🔴 ${reason}`);
1566
+ }
1567
+ }
1568
+ for (const group of groups.values()) {
1569
+ if (!group.process?.exited || group.process.expectedStop || reportedGroups.has(group)) continue;
1570
+ reportedGroups.add(group);
1571
+ console.error(`\n🔴 Agent Bridge 已退出:${group.host}`);
1572
+ for (const item of [...active]) {
1573
+ if (item.group !== group) continue;
1574
+ const reason = `${bindingLabel(item.binding)} 的 Agent Bridge 已退出:${group.host}`;
1575
+ await markRuntimeFailed(item.binding, reason, 'supervisor');
1576
+ await stopManagedProcess(item.process);
1577
+ active.delete(item);
1578
+ }
1579
+ }
1580
+ if (active.size) await delay(400);
1581
+ }
1582
+ if (!stopRequested) process.exitCode = 1;
1583
+ await cleanupAll();
1584
+ }
1585
+
1586
+ async function shutdownGroups(groups) {
1587
+ for (const group of groups.values()) {
1588
+ if (group.process) await stopManagedProcess(group.process);
1589
+ for (const lease of group.leases.values()) await releaseLease(lease);
1590
+ group.leases.clear();
1591
+ }
1592
+ }
1593
+
1594
+ async function cleanupAll() {
1595
+ if (cleanupPromise) return cleanupPromise;
1596
+ cleanupPromise = (async () => {
1597
+ while (managedProcesses.size || transientProcesses.size || heldLeases.size) {
1598
+ const records = [...managedProcesses].reverse();
1599
+ for (const record of records) {
1600
+ managedProcesses.delete(record);
1601
+ await stopManagedProcess(record).catch(() => {});
1602
+ }
1603
+ for (const record of [...transientProcesses].reverse()) {
1604
+ transientProcesses.delete(record);
1605
+ await stopManagedProcess(record).catch(() => {});
1606
+ }
1607
+ for (const lease of [...heldLeases]) await releaseLease(lease).catch(() => {});
1608
+ }
1609
+ })();
1610
+ return cleanupPromise;
1611
+ }
1612
+
1613
+ function printLogHints(detailed = false) {
1614
+ console.log(` 日志:${RUN_LOG_DIR}`);
1615
+ if (!detailed) return;
1616
+ const logsBin = process.env.AAMP_LOGS_BIN || path.join(HOME, '.aamp', 'bin', 'aamp-logs');
1617
+ console.log(` 日志打包:${logsBin} collect --run-dir ${RUN_LOG_DIR}`);
1618
+ console.log(` 特定任务日志打包:${logsBin} collect --task-id xxx`);
1619
+ console.log(` 特定任务日志打包:${logsBin} collect --task-guid yyy`);
1620
+ }
1621
+
1622
+ function displayBindings(bindings) {
1623
+ if (!bindings.length) {
1624
+ console.log('当前电脑未绑定智能体-机器人');
1625
+ return;
1626
+ }
1627
+ const rows = bindings.map((binding, index) => ({
1628
+ index: String(index + 1),
1629
+ agent: binding.agent_type,
1630
+ bot: binding.bot.display_name || binding.bot.app_id,
1631
+ appId: binding.bot.app_id,
1632
+ environment: binding.environment.name,
1633
+ state: bindingNeedsInitialStart(binding) ? '待首次启动' : '已就绪',
1634
+ }));
1635
+ const widths = {
1636
+ index: Math.max(2, ...rows.map((row) => row.index.length)),
1637
+ agent: Math.max(5, ...rows.map((row) => row.agent.length)),
1638
+ bot: Math.max(3, ...rows.map((row) => row.bot.length)),
1639
+ appId: Math.max(6, ...rows.map((row) => row.appId.length)),
1640
+ };
1641
+ console.log(`${'#'.padEnd(widths.index)} ${'Agent'.padEnd(widths.agent)} ${'Bot'.padEnd(widths.bot)} ${'App ID'.padEnd(widths.appId)} 环境 状态`);
1642
+ rows.forEach((row) => console.log(`${row.index.padEnd(widths.index)} ${row.agent.padEnd(widths.agent)} ${row.bot.padEnd(widths.bot)} ${row.appId.padEnd(widths.appId)} ${row.environment.padEnd(6)} ${row.state}`));
1643
+ }
1644
+
1645
+ async function discoverAgents() {
1646
+ const result = await runBootstrapHelper('__discover-agents', '');
1647
+ const agents = (result.agents || []).filter((agent) => AGENT_TYPES.includes(agent));
1648
+ if (!agents.length) throw new Error('暂未检测到本地智能体。请先安装并登录 Codex 或 Cursor CLI 后重试。');
1649
+ return agents;
1650
+ }
1651
+
1652
+ async function createDraft(agents, unavailableAppIds) {
1653
+ const agent = DEFAULT_AGENT || await chooseOne('请选择要绑定的本地智能体:', agents, (item) => item);
1654
+ const registered = await runBootstrapHelper('__register-binding', agent);
1655
+ addSecret(registered.app_secret);
1656
+ if (!registered.app_id || !registered.app_secret || !registered.lark_cli_profile) {
1657
+ throw new Error('飞书应用授权结果不完整');
1658
+ }
1659
+ if (unavailableAppIds.has(registered.app_id)) {
1660
+ throw new Error(`Bot ${registered.app_id} 已经选择过,不能重复绑定`);
1661
+ }
1662
+ unavailableAppIds.add(registered.app_id);
1663
+ const bindingId = randomId();
1664
+ const timestamp = nowIso();
1665
+ return {
1666
+ binding_id: bindingId,
1667
+ agent_type: agent,
1668
+ bot: {
1669
+ app_id: registered.app_id,
1670
+ app_secret: registered.app_secret,
1671
+ display_name: registered.display_name || registered.app_id,
1672
+ lark_cli_profile: registered.lark_cli_profile,
1673
+ },
1674
+ environment: { name: 'online' },
1675
+ state: 'pending',
1676
+ aamp_host: DEFAULT_AAMP_HOST,
1677
+ feishu_config_dir: expectedFeishuConfigDir(bindingId),
1678
+ created_at: timestamp,
1679
+ updated_at: timestamp,
1680
+ };
1681
+ }
1682
+
1683
+ async function runBindingSession(mode) {
1684
+ const store = await loadStore();
1685
+ throwIfStopping();
1686
+ const unavailableAppIds = new Set(mode === 'add' ? store.bindings.map((binding) => binding.bot.app_id) : []);
1687
+ const agents = DEFAULT_AGENT ? [DEFAULT_AGENT] : await discoverAgents();
1688
+ throwIfStopping();
1689
+ const sessionDrafts = [];
1690
+ const succeeded = [];
1691
+ const failed = [];
1692
+ const selectionFailures = [];
1693
+ const running = [];
1694
+
1695
+ console.log('\n=== 选择绑定配置 ===');
1696
+ let keepGoing = true;
1697
+ while (keepGoing) {
1698
+ throwIfStopping();
1699
+ try {
1700
+ const draft = await createDraft(agents, unavailableAppIds);
1701
+ throwIfStopping();
1702
+ sessionDrafts.push(draft);
1703
+ console.log(`已选择:${bindingLabel(draft)}`);
1704
+ } catch (error) {
1705
+ if (stopRequested) throw error;
1706
+ const reason = redact(error.message || error);
1707
+ selectionFailures.push(reason);
1708
+ await recordError('selection', reason);
1709
+ console.error(`🔴 本次选择未完成:${reason}`);
1710
+ }
1711
+ throwIfStopping();
1712
+ keepGoing = await confirm('是否继续选择本地智能体和 Bot?', false);
1713
+ throwIfStopping();
1714
+ }
1715
+
1716
+ if (!sessionDrafts.length) {
1717
+ return { groups: new Map(), succeeded, failed, selectionFailures, running, selectedCount: 0 };
1718
+ }
1719
+
1720
+ if (mode === 'add') {
1721
+ console.log('\n=== 保存绑定配置 ===');
1722
+ for (const draft of sessionDrafts) {
1723
+ try {
1724
+ await appendBinding(draft);
1725
+ throwIfStopping();
1726
+ succeeded.push(draft);
1727
+ await setBindingStatus(draft, 'bind', 'saved');
1728
+ console.log(`🟢 已保存:${bindingLabel(draft)}`);
1729
+ } catch (error) {
1730
+ if (stopRequested) throw error;
1731
+ const reason = redact(error.message || error);
1732
+ failed.push({ binding: draft, reason });
1733
+ await setBindingStatus(draft, 'bind', 'failed', reason);
1734
+ await recordError('binding', reason, draft);
1735
+ console.error(`🔴 配置保存失败:${bindingLabel(draft)}\n 原因:${reason}`);
1736
+ console.error(' 已跳过该项,继续处理下一项。');
1737
+ }
1738
+ }
1739
+ return { groups: new Map(), succeeded, failed, selectionFailures, running, selectedCount: sessionDrafts.length };
1740
+ }
1741
+
1742
+ console.log('\n=== 建立绑定并启动 ===');
1743
+ throwIfStopping();
1744
+ const groups = await setupAgentGroups(sessionDrafts);
1745
+ throwIfStopping();
1746
+ for (const draft of sessionDrafts) {
1747
+ throwIfStopping();
1748
+ try {
1749
+ const paired = await bindOneDraft(draft, groups, mode);
1750
+ throwIfStopping();
1751
+ if (mode === 'install' && !paired.process) throw new Error('完成绑定后未获得可监督的 Feishu Bridge 进程');
1752
+ succeeded.push(paired.binding);
1753
+ if (mode === 'install') {
1754
+ running.push({ binding: paired.binding, process: paired.process, group: paired.group });
1755
+ }
1756
+ } catch (error) {
1757
+ if (stopRequested) throw error;
1758
+ const reason = redact(error.message || error);
1759
+ failed.push({ binding: draft, reason });
1760
+ await setBindingStatus(draft, 'bind', 'failed', reason);
1761
+ await recordError('binding', reason, draft);
1762
+ console.error(`🔴 绑定失败:${bindingLabel(draft)}\n 原因:${reason}`);
1763
+ console.error(' 已跳过该项,继续处理下一项。');
1764
+ }
1765
+ }
1766
+ return { groups, succeeded, failed, selectionFailures, running, selectedCount: sessionDrafts.length };
1767
+ }
1768
+
1769
+ async function runInstall() {
1770
+ const result = await withMutationLock('install 绑定流程', async () => {
1771
+ const bound = await runBindingSession('install');
1772
+ throwIfStopping();
1773
+ if (!bound.succeeded.length) {
1774
+ await shutdownGroups(bound.groups);
1775
+ throw new Error('没有配置完成绑定,现有新流程配置保持不变');
1776
+ }
1777
+ const reconciled = await reconcileRetainedBindings(bound.running);
1778
+ throwIfStopping();
1779
+ bound.running = reconciled.alive;
1780
+ bound.runtimeFailures = reconciled.failed;
1781
+ await replaceBindings(bound.succeeded);
1782
+ throwIfStopping();
1783
+ if (!bound.running.length) {
1784
+ await shutdownGroups(bound.groups);
1785
+ throw new Error(`全部已绑定配置启动失败;${bound.succeeded.length} 个真实配对配置已写入 ${CONFIG_FILE}`);
1786
+ }
1787
+ return bound;
1788
+ });
1789
+ console.log(`\n已成功建立绑定并启动 ${result.running.length}/${result.selectedCount} 个配置。`);
1790
+ console.log('🟢 保持终端打开,你可以给 agent 派发飞书任务');
1791
+ const failureCount = result.failed.length + result.selectionFailures.length + result.runtimeFailures.length;
1792
+ if (failureCount) console.log(`另有 ${failureCount} 次选择或绑定失败,详情见上方信息和本地日志。`);
1793
+ await supervise(result.running, result.groups);
1794
+ }
1795
+
1796
+ async function runAdd() {
1797
+ await withMutationLock('add 绑定流程', async () => {
1798
+ const bound = await runBindingSession('add');
1799
+ if (!bound.succeeded.length) throw new Error('没有配置完成绑定');
1800
+ return bound;
1801
+ });
1802
+ console.log('配置添加成功,运行feishu-task-agent start启动时生效');
1803
+ }
1804
+
1805
+ async function runList() {
1806
+ const store = await loadStore();
1807
+ displayBindings(store.bindings);
1808
+ }
1809
+
1810
+ async function runRemove() {
1811
+ const removed = await withMutationLock('remove 配置流程', async () => {
1812
+ const initial = await loadStore();
1813
+ if (!initial.bindings.length) return undefined;
1814
+ const selected = await chooseMany('请选择要移除的绑定配置:', initial.bindings, bindingLabel);
1815
+ const selectedIds = new Set(selected.map((binding) => binding.binding_id));
1816
+ return withConfigLock(async () => {
1817
+ const current = await loadStore();
1818
+ const next = current.bindings.filter((binding) => !selectedIds.has(binding.binding_id));
1819
+ const count = current.bindings.length - next.length;
1820
+ await writeJsonAtomic(CONFIG_FILE, { ...emptyStore(), bindings: next });
1821
+ return count;
1822
+ });
1823
+ });
1824
+ if (removed === undefined) {
1825
+ console.log('当前电脑未绑定智能体-机器人');
1826
+ return;
1827
+ }
1828
+ console.log(`已移除 ${removed} 个绑定配置;当前已经运行的 Bridge 不受影响。`);
1829
+ }
1830
+
1831
+ async function runStart() {
1832
+ const store = await loadStore();
1833
+ if (!store.bindings.length) {
1834
+ console.error('未找到已经绑定的智能体-Bot 配置,请先运行安装命令重新绑定:');
1835
+ console.error(` ${INSTALL_COMMAND}`);
1836
+ process.exitCode = 1;
1837
+ return;
1838
+ }
1839
+ const selected = await chooseMany('请选择要启动的绑定配置:', store.bindings, bindingLabel);
1840
+ await startSelectedBindings(selected);
1841
+ }
1842
+
1843
+ async function main() {
1844
+ await ensurePrivateDir(STATE_HOME);
1845
+ await assertNoSymlinkPath(RUNTIME_HOME, RUNTIME_HOME);
1846
+ await ensurePrivateDir(RUNTIME_HOME);
1847
+ await ensurePrivateDir(RUN_LOG_DIR);
1848
+ await fsp.writeFile(ERRORS_LOG, '', { mode: 0o600, flag: 'a' });
1849
+ await writeManifest();
1850
+ if (COMMAND === 'install' || COMMAND === 'start') await acquireRuntimeSessionLease(COMMAND);
1851
+ switch (COMMAND) {
1852
+ case 'install':
1853
+ await runInstall();
1854
+ break;
1855
+ case 'start':
1856
+ await runStart();
1857
+ break;
1858
+ case 'list':
1859
+ await runList();
1860
+ break;
1861
+ case 'add':
1862
+ await runAdd();
1863
+ break;
1864
+ case 'remove':
1865
+ await runRemove();
1866
+ break;
1867
+ default:
1868
+ throw new Error(`unknown controller command: ${COMMAND}`);
1869
+ }
1870
+ }
1871
+
1872
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
1873
+ process.on(signal, () => {
1874
+ if (stopRequested) return;
1875
+ stopRequested = true;
1876
+ stopSignal = signal;
1877
+ if (terminal?.input?.isRaw) terminal.input.setRawMode(false);
1878
+ terminal?.output?.write('\x1b[?25h');
1879
+ void cleanupAll().finally(() => {
1880
+ console.log(`\n已收到 ${signal},本次启动的 Bridge 已停止。`);
1881
+ process.exit(0);
1882
+ });
1883
+ });
1884
+ }
1885
+
1886
+ main()
1887
+ .catch(async (error) => {
1888
+ if (!stopRequested) {
1889
+ const reason = redact(error?.message || error);
1890
+ await recordError('controller', reason).catch(() => {});
1891
+ console.error(`\n🔴 运行失败:${reason}`);
1892
+ printLogHints(true);
1893
+ process.exitCode = 1;
1894
+ }
1895
+ })
1896
+ .finally(async () => {
1897
+ await cleanupAll();
1898
+ if (stopRequested && stopSignal) console.log(`\n已收到 ${stopSignal},本次启动的 Bridge 已停止。`);
1899
+ });