@larktask/aamp-feishu-task-agent 0.1.0-dev.173 → 0.1.1-dev.2

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.
@@ -8,8 +8,24 @@ import { ReadStream, WriteStream } from 'node:tty';
8
8
  import { emitKeypressEvents } from 'node:readline';
9
9
  import { spawn } from 'node:child_process';
10
10
  import { EventEmitter } from 'node:events';
11
+ import { fileURLToPath } from 'node:url';
12
+ import {
13
+ TASK_AGENT_TYPES,
14
+ resolveTaskAgentMetadata,
15
+ } from './agent-metadata.mjs';
16
+ import {
17
+ createKeyedSerialExecutor,
18
+ createSerializedRunner,
19
+ runLayeredStarts,
20
+ } from './runtime-concurrency.mjs';
21
+ import {
22
+ createPackageExecutableLauncher,
23
+ npmExecutableResolverArgs,
24
+ parseResolvedPackageExecutable,
25
+ } from './runtime-package-executable.mjs';
11
26
  import {
12
27
  agentStartRetryError,
28
+ agentStartFailureMessage,
13
29
  bridgeAuthenticationRetryError,
14
30
  classifyNetworkError,
15
31
  createSerializedLineWriter,
@@ -18,6 +34,7 @@ import {
18
34
  launchDetachedDiagnostic,
19
35
  networkEnvironmentSummary,
20
36
  probeEndpoint,
37
+ preserveAgentStartFailure,
21
38
  safeDiagnosticUrl,
22
39
  withNetworkRetry,
23
40
  } from './runtime-network.mjs';
@@ -43,8 +60,8 @@ const NPM_BIN = process.env.AAMP_TASK_NPM_BIN || 'npm';
43
60
  const NPM_REGISTRY = process.env.AAMP_TASK_NPM_REGISTRY || 'https://registry.npmjs.org/';
44
61
  const FEISHU_API_PROBE_URL = 'https://open.feishu.cn/';
45
62
  const NPM_CACHE_DIR = process.env.AAMP_TASK_NPM_CACHE_DIR || path.join(os.tmpdir(), 'aamp-one-click-npm-cache');
46
- const ACP_PACKAGE = process.env.AAMP_TASK_ACP_BRIDGE_PKG || '@zengxingyuan/aamp-acp-bridge@0.1.28-dev.20';
47
- const FEISHU_PACKAGE = process.env.AAMP_TASK_FEISHU_BRIDGE_PKG || '@zengxingyuan/aamp-feishu-bridge@0.1.51';
63
+ const ACP_PACKAGE = process.env.AAMP_TASK_ACP_BRIDGE_PKG || '@luckyterry/aamp-acp-bridge@0.1.29-dev.0';
64
+ const FEISHU_PACKAGE = process.env.AAMP_TASK_FEISHU_BRIDGE_PKG || '@iluolyx/aamp-feishu-bridge@0.1.52-dev.5';
48
65
  const INSTALL_COMMAND = process.env.AAMP_TASK_INSTALL_COMMAND
49
66
  || 'npx -y --package @larktask/aamp-feishu-task-agent@dev feishu-task-agent install';
50
67
  const DEFAULT_AGENT = process.env.AAMP_TASK_DEFAULT_AGENT || '';
@@ -54,24 +71,67 @@ const READY_TIMEOUT_MS = Number(process.env.AAMP_TASK_READY_TIMEOUT_MS || 90_000
54
71
  const NETWORK_MAX_ATTEMPTS = Math.max(1, Number(process.env.AAMP_TASK_NETWORK_MAX_ATTEMPTS || 3));
55
72
  const NETWORK_RETRY_BASE_DELAY_MS = Math.max(0, Number(process.env.AAMP_TASK_NETWORK_RETRY_BASE_DELAY_MS || 500));
56
73
  const NETWORK_PROBE_TIMEOUT_MS = Math.max(1_000, Number(process.env.AAMP_TASK_NETWORK_PROBE_TIMEOUT_MS || 10_000));
74
+ const FEISHU_START_CONCURRENCY = 4;
57
75
  const CONFIG_SCHEMA = 'aamp.feishu-task-agent.bindings';
58
76
  const CONFIG_VERSION = 1;
59
- const AGENT_TYPES = ['codex', 'cursor'];
60
- const PROFILE_DOMAINS = [
61
- 'base', 'calendar', 'contact', 'docs', 'im', 'mail', 'mindnotes', 'minutes',
62
- 'note', 'sheets', 'slides', 'task', 'vc', 'wiki',
63
- ];
77
+ const PROFILE_DOMAINS = ['task'];
64
78
 
65
79
  const secrets = new Set();
66
80
  const managedProcesses = new Set();
67
81
  const transientProcesses = new Set();
68
82
  const heldLeases = new Set();
69
83
  const bindingStatuses = new Map();
84
+ const runPairingSerially = createKeyedSerialExecutor();
85
+ const errorLogWriter = createSerializedLineWriter((content) => appendPrivate(ERRORS_LOG, content));
86
+ const manifestWriter = createSerializedRunner(async () => {
87
+ const statuses = [...bindingStatuses.entries()].map(([bindingId, status]) => ({
88
+ binding_id: bindingId,
89
+ ...status,
90
+ }));
91
+ await writeJsonAtomic(MANIFEST_FILE, {
92
+ schema: 'aamp.local_logs.run.v2',
93
+ run_id: RUN_ID,
94
+ task_agent_version: process.env.AAMP_TASK_AGENT_VERSION || '',
95
+ command: COMMAND,
96
+ started_at: process.env.AAMP_TASK_RUN_STARTED_AT || RUN_STARTED_AT,
97
+ config_file: CONFIG_FILE,
98
+ runtime_home: RUNTIME_HOME,
99
+ bindings: statuses,
100
+ errors_log: ERRORS_LOG,
101
+ log_dir: RUN_LOG_DIR,
102
+ });
103
+ });
70
104
  let stopRequested = false;
71
105
  let stopSignal = '';
72
- let cleanupPromise;
73
106
  let terminal;
74
107
 
108
+ function createResourceCleanup(drain) {
109
+ const runner = createSerializedRunner(drain);
110
+ return () => runner.run();
111
+ }
112
+
113
+ function createPromptInterrupter() {
114
+ let activeCancel;
115
+ return {
116
+ activate(cancel) {
117
+ if (activeCancel) throw new Error('已有交互提示正在等待输入');
118
+ activeCancel = cancel;
119
+ return () => {
120
+ if (activeCancel === cancel) activeCancel = undefined;
121
+ };
122
+ },
123
+ interrupt(error) {
124
+ const cancel = activeCancel;
125
+ if (!cancel) return false;
126
+ activeCancel = undefined;
127
+ cancel(error);
128
+ return true;
129
+ },
130
+ };
131
+ }
132
+
133
+ const promptInterrupter = createPromptInterrupter();
134
+
75
135
  function nowIso() {
76
136
  return new Date().toISOString();
77
137
  }
@@ -116,13 +176,258 @@ function addSecret(value) {
116
176
  function redact(value) {
117
177
  let output = String(value ?? '');
118
178
  for (const secret of secrets) output = output.split(secret).join('[REDACTED]');
179
+ output = output.replace(/\b(Bearer|Basic)\s+[^\s,}]+/gi, '$1 [REDACTED]');
119
180
  output = output
120
181
  .replace(/([?&]pair_code=)[^&\s"']+/gi, '$1[REDACTED]')
121
- .replace(/("?(?:app_secret|appSecret|smtpPassword|mailboxToken|access_token|device_code|pairCode)"?\s*[:=]\s*"?)[^",\s}]+/gi, '$1[REDACTED]')
182
+ .replace(/("?(?:app_secret|appSecret|smtpPassword|mailboxToken|access_token|accessToken|refresh_token|refreshToken|id_token|idToken|session_token|sessionToken|device_code|pairCode|api_key|apiKey|api-key|private_key|privateKey|private-key|auth_token|auth-token|password|authorization|cookie|credential|secret|token|session)"?\s*[:=]\s*"?)(?:(?:Bearer|Basic)\s+)?[^",\s}]+/gi, '$1[REDACTED]')
122
183
  .replace(/(--app-secret\s+)[^\s]+/gi, '$1[REDACTED]');
123
184
  return output;
124
185
  }
125
186
 
187
+ const REMOTE_EVENT_PATH_PREFIX = 'aamp-runtime:';
188
+ const REMOTE_AGENT_FAILED = 'REMOTE_AGENT_FAILED: Remote Agent execution failed.';
189
+ const REMOTE_AGENT_PREPARATION_FAILED = 'REMOTE_AGENT_PREPARATION_FAILED: Remote Agent preparation failed.';
190
+ const REMOTE_FAILURE_CODES = new Set([
191
+ 'AIME_ACCESS_DENIED',
192
+ 'AIME_EMPTY_RESPONSE',
193
+ 'AIME_MODEL_NOT_FOUND',
194
+ 'AIME_NETWORK_UNREACHABLE',
195
+ 'AIME_PROTOCOL_DRIFT',
196
+ 'AIME_SDK_INCOMPATIBLE',
197
+ 'AIME_SEND_FAILED',
198
+ 'AIME_SESSION_NOT_FOUND',
199
+ 'AIME_STREAM_INTERRUPTED',
200
+ 'AIME_UNSUPPORTED_CONTENT',
201
+ 'AUTH_CONFIGURATION_UNSUPPORTED',
202
+ 'AUTH_IDENTITY_CHANGED',
203
+ 'AUTH_IDENTITY_UNAVAILABLE',
204
+ 'AUTH_REQUIRED',
205
+ 'AUTH_SOURCE_UNSUPPORTED',
206
+ 'REMOTE_AGENT_FAILED',
207
+ 'REMOTE_ARTIFACT_UNSUPPORTED',
208
+ ]);
209
+
210
+ function isRemoteExecution(options) {
211
+ return options?.executionLocation === 'remote';
212
+ }
213
+
214
+ function encodeRemoteEventPath(value, options = {}) {
215
+ const eventPathRoot = options.eventPathRoot;
216
+ const eventPathHandles = options.eventPathHandles;
217
+ if (!eventPathRoot || !(eventPathHandles instanceof Map)
218
+ || typeof value !== 'string' || !path.isAbsolute(value)) return '';
219
+ const root = path.resolve(eventPathRoot);
220
+ const resolved = path.resolve(value);
221
+ if (!isPathInside(root, resolved)) return '';
222
+ let handle;
223
+ do {
224
+ handle = `${REMOTE_EVENT_PATH_PREFIX}${crypto.randomBytes(24).toString('base64url')}`;
225
+ } while (eventPathHandles.has(handle));
226
+ eventPathHandles.set(handle, { root, resolved });
227
+ return handle;
228
+ }
229
+
230
+ function safeRemoteFailure(value) {
231
+ const message = redact(value);
232
+ const candidate = /\b(?:AIME|AUTH|REMOTE)_[A-Z0-9_]+\b/.exec(message)?.[0];
233
+ const code = candidate && REMOTE_FAILURE_CODES.has(candidate) ? candidate : 'REMOTE_AGENT_FAILED';
234
+ if (message.trim()) return { code, message };
235
+ return { code, message: code === 'REMOTE_AGENT_FAILED' ? REMOTE_AGENT_FAILED : `${code}: Remote Agent execution failed.` };
236
+ }
237
+
238
+ function trustedAgentExecutionLocations(entries = []) {
239
+ const locations = new Map();
240
+ const ambiguous = new Set();
241
+ const values = entries instanceof Map ? entries.entries() : entries;
242
+ for (const entry of values || []) {
243
+ if (!Array.isArray(entry) || entry.length < 2) continue;
244
+ const [name, executionLocation] = entry;
245
+ if (typeof name !== 'string' || !['local', 'remote'].includes(executionLocation)) continue;
246
+ if (locations.has(name) || ambiguous.has(name)) {
247
+ locations.delete(name);
248
+ ambiguous.add(name);
249
+ continue;
250
+ }
251
+ locations.set(name, executionLocation);
252
+ }
253
+ return locations;
254
+ }
255
+
256
+ function trustedAgentIdentity(value, options, executionLocation) {
257
+ if (typeof value !== 'string') return '';
258
+ const actual = options.agentExecutionLocations?.get(value);
259
+ return actual && (!executionLocation || actual === executionLocation) ? value : '';
260
+ }
261
+
262
+ function allowedStructuralIdentity(value, allowedValues) {
263
+ return typeof value === 'string' && new Set(allowedValues || []).has(value) ? value : '';
264
+ }
265
+
266
+ function safeRemoteDuration(value) {
267
+ return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
268
+ }
269
+
270
+ function projectTrustedLocalAgentEvent(document, options = {}) {
271
+ if (!document || typeof document !== 'object' || Array.isArray(document)) return undefined;
272
+ const type = typeof document.type === 'string' ? document.type : '';
273
+ const agent = trustedAgentIdentity(document.agent, options, 'local');
274
+ if (!agent || !['agent.starting', 'agent.started', 'agent.identity', 'agent.failed'].includes(type)) {
275
+ return undefined;
276
+ }
277
+ const event = {
278
+ type,
279
+ ...(document.bridge === 'acp-bridge' ? { bridge: 'acp-bridge' } : {}),
280
+ agent,
281
+ };
282
+ if (type === 'agent.starting') return event;
283
+ const durationMs = safeRemoteDuration(document.durationMs);
284
+ if (type === 'agent.started') {
285
+ return {
286
+ ...event,
287
+ ...(typeof document.email === 'string' ? { email: redact(document.email) } : {}),
288
+ connected: document.connected === true,
289
+ pollingFallback: document.pollingFallback === true,
290
+ ...(durationMs === undefined ? {} : { durationMs }),
291
+ };
292
+ }
293
+ if (type === 'agent.identity') {
294
+ return {
295
+ ...event,
296
+ ...(typeof document.email === 'string' ? { email: redact(document.email) } : {}),
297
+ ...(typeof document.acpCommand === 'string' ? { acpCommand: redact(document.acpCommand) } : {}),
298
+ };
299
+ }
300
+ return {
301
+ ...event,
302
+ message: redact(document.message || `${agent} Agent Bridge 启动失败`),
303
+ ...(typeof document.code === 'string'
304
+ ? { code: redact(document.code) }
305
+ : typeof document.code === 'number' ? { code: document.code } : {}),
306
+ ...(durationMs === undefined ? {} : { durationMs }),
307
+ };
308
+ }
309
+
310
+ function projectRemoteOperationalEvent(document, options = {}) {
311
+ if (!document || typeof document !== 'object' || Array.isArray(document)) return undefined;
312
+ const type = typeof document.type === 'string' ? document.type : '';
313
+ if (type === 'bridge.process') {
314
+ if (!['started', 'exited'].includes(document.status)) return undefined;
315
+ const event = { type, status: document.status };
316
+ const durationMs = safeRemoteDuration(document.durationMs);
317
+ if (durationMs !== undefined) event.durationMs = durationMs;
318
+ if (document.status === 'exited') {
319
+ event.code = Number.isInteger(document.code) ? document.code : null;
320
+ event.signal = typeof document.signal === 'string' && /^SIG[A-Z0-9]+$/.test(document.signal)
321
+ ? document.signal
322
+ : null;
323
+ event.expectedStop = document.expectedStop === true;
324
+ if (document.error) event.error = REMOTE_AGENT_FAILED;
325
+ }
326
+ return event;
327
+ }
328
+ if (type === 'bridge.running') {
329
+ const agents = Array.isArray(document.agents)
330
+ ? document.agents.flatMap((agent) => {
331
+ const name = trustedAgentIdentity(agent?.name, options);
332
+ return name ? [{ name }] : [];
333
+ })
334
+ : [];
335
+ return { type, agentCount: agents.length, agents };
336
+ }
337
+ if (type === 'bridge.task_runtime.starting') {
338
+ const appId = allowedStructuralIdentity(document.appId, options.allowedAppIds);
339
+ const imConfigDir = encodeRemoteEventPath(document.imConfigDir, options);
340
+ const taskConfigDir = encodeRemoteEventPath(document.taskConfigDir, options);
341
+ if (!appId || !imConfigDir || !taskConfigDir) return undefined;
342
+ return { type, appId, imConfigDir, taskConfigDir };
343
+ }
344
+ if (type === 'bridge.task_runtime.running') {
345
+ return { type };
346
+ }
347
+ if (type === 'agent.starting') {
348
+ const agent = trustedAgentIdentity(document.agent, options, 'remote');
349
+ return agent ? { type, agent } : undefined;
350
+ }
351
+ if (type === 'agent.started') {
352
+ const agent = trustedAgentIdentity(document.agent, options, 'remote');
353
+ if (!agent) return undefined;
354
+ const durationMs = safeRemoteDuration(document.durationMs);
355
+ return {
356
+ type,
357
+ agent,
358
+ connected: document.connected === true,
359
+ pollingFallback: document.pollingFallback === true,
360
+ ...(durationMs === undefined ? {} : { durationMs }),
361
+ };
362
+ }
363
+ if (type === 'agent.identity') {
364
+ const agent = trustedAgentIdentity(document.agent, options, 'remote');
365
+ if (!agent) return undefined;
366
+ return {
367
+ type,
368
+ agent,
369
+ executionLocation: 'remote',
370
+ acpCommandConfigured: document.acpCommandConfigured === true,
371
+ };
372
+ }
373
+ if (type === 'agent.failed') {
374
+ const agent = trustedAgentIdentity(document.agent, options, 'remote');
375
+ if (!agent) return undefined;
376
+ const failure = safeRemoteFailure(document.message);
377
+ const durationMs = safeRemoteDuration(document.durationMs);
378
+ return {
379
+ type,
380
+ agent,
381
+ ...failure,
382
+ ...(durationMs === undefined ? {} : { durationMs }),
383
+ };
384
+ }
385
+ return undefined;
386
+ }
387
+
388
+ function safeOperationalLine(line, options = {}) {
389
+ if (!String(line || '').trim()) return '';
390
+ const text = String(line).trim();
391
+ if (!isRemoteExecution(options)) return redact(text);
392
+ try {
393
+ const document = JSON.parse(text);
394
+ const localEvent = projectTrustedLocalAgentEvent(document, options);
395
+ if (localEvent) return JSON.stringify(localEvent);
396
+ const projected = projectRemoteOperationalEvent(document, options);
397
+ return projected ? JSON.stringify(projected) : redact(text);
398
+ } catch {
399
+ return redact(text);
400
+ }
401
+ }
402
+
403
+ function safeOperationalOutput(value, options = {}) {
404
+ return String(value ?? '')
405
+ .split(/\r?\n/)
406
+ .map((line) => safeOperationalLine(line, options))
407
+ .join('\n');
408
+ }
409
+
410
+ function safeCapturedLog(stdout, stderr, options = {}) {
411
+ const chunks = [stdout, stderr]
412
+ .map((value) => safeOperationalOutput(value, options).replace(/\n+$/g, ''))
413
+ .filter(Boolean);
414
+ return chunks.length ? `${chunks.join('\n')}\n` : '';
415
+ }
416
+
417
+ function resolveRemoteEventPath(value, record, eventPathRoot) {
418
+ const isHandle = typeof value === 'string' && value.startsWith(REMOTE_EVENT_PATH_PREFIX);
419
+ if (!isHandle) {
420
+ if (record?.eventPathHandles instanceof Map) throw new Error('Feishu Bridge 返回了无效的安全路径');
421
+ return value;
422
+ }
423
+ const entry = record?.eventPathHandles?.get(value);
424
+ const root = path.resolve(eventPathRoot);
425
+ if (!entry || entry.root !== root || !isPathInside(root, entry.resolved)) {
426
+ throw new Error('Feishu Bridge 返回了无效的安全路径');
427
+ }
428
+ return entry.resolved;
429
+ }
430
+
126
431
  async function ensurePrivateDir(dir) {
127
432
  await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
128
433
  await fsp.chmod(dir, 0o700).catch(() => {});
@@ -380,6 +685,10 @@ async function acquireRuntimeSessionLease(action) {
380
685
  }
381
686
  const lease = { lockDir: RUNTIME_SESSION_LOCK, release };
382
687
  heldLeases.add(lease);
688
+ if (stopRequested) {
689
+ await releaseLease(lease);
690
+ throwIfStopping();
691
+ }
383
692
  return lease;
384
693
  }
385
694
 
@@ -428,18 +737,55 @@ async function assertNoSymlinkPath(root, candidate) {
428
737
  }
429
738
  }
430
739
 
740
+ async function resolveConfiguredPendingPairingFile(group, agentType) {
741
+ const configuredAgents = (group?.agents || []).filter((agent) => agent?.name === agentType);
742
+ if (configuredAgents.length !== 1 || typeof configuredAgents[0].pairingFile !== 'string'
743
+ || !configuredAgents[0].pairingFile.trim()) {
744
+ throw new Error('ACP Bridge 私有配对文件配置缺失或 Agent 不唯一');
745
+ }
746
+ if (typeof group?.home !== 'string' || !path.isAbsolute(group.home)
747
+ || !isPathInside(RUNTIME_HOME, group.home)) {
748
+ throw new Error('ACP Bridge runtime 不属于 Task Agent 私有目录');
749
+ }
750
+ if (!path.isAbsolute(configuredAgents[0].pairingFile)
751
+ || !isPathInside(group.home, configuredAgents[0].pairingFile)) {
752
+ throw new Error('ACP Bridge 私有配对文件不属于当前 Agent Bridge runtime');
753
+ }
754
+ const configuredPairingFile = path.resolve(configuredAgents[0].pairingFile);
755
+ await assertNoSymlinkPath(RUNTIME_HOME, configuredPairingFile);
756
+ return configuredPairingFile;
757
+ }
758
+
759
+ async function resolvePendingPairingFile(group, agentType, pairing) {
760
+ const configuredPairingFile = await resolveConfiguredPendingPairingFile(group, agentType);
761
+ const executionLocation = resolveTaskAgentMetadata(agentType).executionLocation;
762
+ if (executionLocation === 'remote' && pairing?.pairingFileConfigured !== true) {
763
+ throw new Error('ACP Bridge 未确认远程 Agent 的私有配对文件配置');
764
+ }
765
+ if (executionLocation !== 'remote' && (typeof pairing?.pairingFile !== 'string'
766
+ || path.resolve(pairing.pairingFile) !== configuredPairingFile)) {
767
+ throw new Error('ACP Bridge 返回的本地配对文件与私有配置不一致');
768
+ }
769
+ return configuredPairingFile;
770
+ }
771
+
431
772
  function validateBinding(binding, index) {
432
773
  if (!binding || typeof binding !== 'object') throw new Error(`bindings[${index}] 无效`);
433
774
  assertString(binding.binding_id, `bindings[${index}].binding_id`);
434
775
  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)) {
435
776
  throw new Error(`bindings[${index}].binding_id 必须是 UUID`);
436
777
  }
437
- if (!AGENT_TYPES.includes(binding.agent_type)) throw new Error(`bindings[${index}].agent_type 仅支持 codex/cursor`);
778
+ if (!TASK_AGENT_TYPES.includes(binding.agent_type)) {
779
+ throw new Error(`bindings[${index}].agent_type 仅支持 codex/cursor/coco/traex/traecli/workbuddy/workbuddy_ai/aime`);
780
+ }
438
781
  assertString(binding.aamp_host, `bindings[${index}].aamp_host`);
439
782
  assertString(binding.environment?.name, `bindings[${index}].environment.name`);
440
783
  assertString(binding.bot?.app_id, `bindings[${index}].bot.app_id`);
441
784
  assertString(binding.bot?.app_secret, `bindings[${index}].bot.app_secret`);
442
- assertString(binding.bot?.lark_cli_profile, `bindings[${index}].bot.lark_cli_profile`);
785
+ const metadata = resolveTaskAgentMetadata(binding.agent_type);
786
+ if (metadata.executionLocation === 'local') {
787
+ assertString(binding.bot?.lark_cli_profile, `bindings[${index}].bot.lark_cli_profile`);
788
+ }
443
789
  assertString(binding.feishu_config_dir, `bindings[${index}].feishu_config_dir`);
444
790
  const expectedConfigDir = expectedFeishuConfigDir(binding.binding_id);
445
791
  if (path.resolve(binding.feishu_config_dir) !== path.resolve(expectedConfigDir)) {
@@ -488,20 +834,57 @@ async function loadStore() {
488
834
  }
489
835
  }
490
836
 
491
- async function replaceBindings(bindings) {
492
- await withConfigLock(async () => {
493
- await writeJsonAtomic(CONFIG_FILE, { ...emptyStore(), bindings });
494
- });
837
+ function bindingExpectation(binding) {
838
+ return {
839
+ binding_id: binding.binding_id,
840
+ updated_at: binding.updated_at,
841
+ };
495
842
  }
496
843
 
497
- async function appendBinding(binding) {
498
- await withConfigLock(async () => {
844
+ function sameBindingRelationship(existing, candidate) {
845
+ return Boolean(existing && candidate
846
+ && existing.agent_type === candidate.agent_type
847
+ && existing.aamp_host === candidate.aamp_host
848
+ && existing.environment?.name === candidate.environment?.name
849
+ && existing.bot?.app_id === candidate.bot?.app_id);
850
+ }
851
+
852
+ async function upsertBindings(intents) {
853
+ return withConfigLock(async () => {
499
854
  const store = await loadStore();
500
- if (store.bindings.some((item) => item.bot.app_id === binding.bot.app_id)) {
501
- throw new Error(`Bot ${binding.bot.app_id} 已绑定,不能重复选择`);
855
+ const intentByAppId = new Map();
856
+ for (const intent of intents) {
857
+ const appId = intent.binding.bot.app_id;
858
+ if (intentByAppId.has(appId)) throw new Error(`Bot ${appId} 在本次操作中重复选择`);
859
+ intentByAppId.set(appId, intent);
860
+ }
861
+
862
+ let replacedCount = 0;
863
+ const consumed = new Set();
864
+ const bindings = store.bindings.map((current) => {
865
+ const appId = current.bot.app_id;
866
+ const intent = intentByAppId.get(appId);
867
+ if (!intent) return current;
868
+ const expected = intent.expected;
869
+ if (!expected
870
+ || current.binding_id !== expected.binding_id
871
+ || current.updated_at !== expected.updated_at) {
872
+ throw new Error(`Bot ${appId} 的绑定已发生变化,请重新执行`);
873
+ }
874
+ consumed.add(appId);
875
+ replacedCount += 1;
876
+ return intent.binding;
877
+ });
878
+
879
+ for (const [appId, intent] of intentByAppId) {
880
+ if (consumed.has(appId)) continue;
881
+ if (intent.expected) throw new Error(`Bot ${appId} 的绑定已发生变化,请重新执行`);
882
+ bindings.push(intent.binding);
502
883
  }
503
- validateBinding(binding, store.bindings.length);
504
- await writeJsonAtomic(CONFIG_FILE, { ...emptyStore(), bindings: [...store.bindings, binding] });
884
+
885
+ const next = validateStore({ ...emptyStore(), bindings });
886
+ await writeJsonAtomic(CONFIG_FILE, next);
887
+ return { bindings: intents.map(({ binding }) => binding), replacedCount };
505
888
  });
506
889
  }
507
890
 
@@ -520,50 +903,207 @@ async function updateBinding(binding) {
520
903
  });
521
904
  }
522
905
 
523
- function bindingLabel(binding) {
906
+ function agentSelectionDisplayName(agent) {
907
+ return agent;
908
+ }
909
+
910
+ function agentBindingDisplayName(agent) {
911
+ return agent;
912
+ }
913
+
914
+ function bindingLabel(binding, resolvedAgentType = binding.agent_type) {
524
915
  const botName = binding.bot?.display_name || binding.bot?.app_id || 'unknown Bot';
525
- return `${binding.agent_type} ↔ ${botName} (${binding.bot?.app_id || 'unknown'})`;
916
+ return `${agentBindingDisplayName(resolvedAgentType)} ↔ ${botName} (${binding.bot?.app_id || 'unknown'})`;
917
+ }
918
+
919
+ function pairingQueueKey(prepared) {
920
+ return `${prepared.group.host}\u0000${prepared.binding.agent_type}`;
526
921
  }
527
922
 
528
- function printBindingStarted(binding) {
529
- console.log(`[aamp-one-click] 启动成功:${bindingLabel(binding)}`);
923
+ function orderStartupItems(bindings, items) {
924
+ const order = new Map(bindings.map((binding, index) => [binding.binding_id, index]));
925
+ return [...items].sort((left, right) => (
926
+ (order.get(left.binding.binding_id) ?? Number.MAX_SAFE_INTEGER)
927
+ - (order.get(right.binding.binding_id) ?? Number.MAX_SAFE_INTEGER)
928
+ ));
929
+ }
930
+
931
+ function startupSummaryLines({ title, plannedCount, running = [], failed = [], cancelled = [] }) {
932
+ const lines = [`${title} ${running.length}/${plannedCount} 个配置。`];
933
+ if (running.length) {
934
+ lines.push('启动成功:');
935
+ for (const item of running) {
936
+ lines.push(`- ${bindingLabel(item.binding, item.runtimeAgentType)}`);
937
+ }
938
+ }
939
+ if (failed.length) {
940
+ lines.push('启动失败:');
941
+ for (const item of failed) {
942
+ lines.push(`- ${bindingLabel(item.binding, item.runtimeAgentType)}`);
943
+ lines.push(` 原因:${safeBindingFailureReason(item.binding, item.reason)}`);
944
+ }
945
+ }
946
+ if (cancelled.length) {
947
+ lines.push('已取消:');
948
+ for (const item of cancelled) {
949
+ lines.push(`- ${bindingLabel(item.binding, item.runtimeAgentType)}`);
950
+ lines.push(` 原因:${redact(item.reason)}`);
951
+ }
952
+ }
953
+ return lines;
954
+ }
955
+
956
+ function printStartupSummary(options) {
957
+ console.log(`\n${startupSummaryLines(options).join('\n')}`);
958
+ }
959
+
960
+ function installHasOnlyCancellations({ cancelled = [], failures = [], selectionFailures = [] }) {
961
+ return cancelled.length > 0 && failures.length === 0 && selectionFailures.length === 0;
962
+ }
963
+
964
+ function agentFailureMessage(agentType, message) {
965
+ const text = safeAgentFailureReason(agentType, message || 'Agent Bridge 启动失败');
966
+ if (agentType === 'traecli') {
967
+ return `${text}\n请执行 'traecli doctor --json' 检查 TraeCode CLI,修复后重试。`;
968
+ }
969
+ const productName = agentType === 'workbuddy'
970
+ ? 'WorkBuddy'
971
+ : agentType === 'workbuddy_ai'
972
+ ? 'WorkBuddy AI'
973
+ : '';
974
+ if (!productName) return text;
975
+ if (text.startsWith(`${productName} is not logged in.`)
976
+ || text.startsWith(`${productName} login expired.`)) return text;
977
+ return `${text}\n如果尚未登录,请打开 ${productName} 完成登录后重试。`;
978
+ }
979
+
980
+ function safeAgentFailureReason(agentType, message) {
981
+ try {
982
+ if (typeof resolveTaskAgentMetadata === 'function'
983
+ && resolveTaskAgentMetadata(agentType).executionLocation === 'remote') {
984
+ return safeRemoteFailure(message).message;
985
+ }
986
+ } catch {
987
+ // Unknown Agent validation remains authoritative at its existing call sites.
988
+ }
989
+ return String(message ?? '');
990
+ }
991
+
992
+ function safeBindingFailureReason(binding, message) {
993
+ try {
994
+ if (resolveTaskAgentMetadata(binding?.agent_type).executionLocation === 'remote') {
995
+ return safeRemoteFailure(message).message;
996
+ }
997
+ } catch {
998
+ // Binding validation remains authoritative at its existing call sites.
999
+ }
1000
+ return redact(message);
1001
+ }
1002
+
1003
+ function resolvePreparedAgentBindings(bindings, host, requestedAgentType, preparedAgentType) {
1004
+ const runtimeAgentType = preparedAgentType || requestedAgentType;
1005
+ if (!TASK_AGENT_TYPES.includes(runtimeAgentType)) {
1006
+ throw new Error(`unexpected prepared Agent type: ${runtimeAgentType}`);
1007
+ }
1008
+ if (runtimeAgentType === requestedAgentType) {
1009
+ return {
1010
+ requestedAgentType,
1011
+ runtimeAgentType,
1012
+ stableAgentType: requestedAgentType,
1013
+ stableAgentTypes: [requestedAgentType],
1014
+ bindingsToNormalize: [],
1015
+ };
1016
+ }
1017
+ if (requestedAgentType !== 'coco' || !['traex', 'traecli'].includes(runtimeAgentType)) {
1018
+ throw new Error(`unexpected prepared Agent type: ${requestedAgentType} -> ${runtimeAgentType}`);
1019
+ }
1020
+ const matching = bindings.filter((binding) => (
1021
+ binding.aamp_host === host && binding.agent_type === requestedAgentType
1022
+ ));
1023
+ const bindingsToNormalize = matching.filter((binding) => (
1024
+ binding.state === 'pending' && !binding.agent_target_email
1025
+ ));
1026
+ const keepHistoricalIdentity = matching.some((binding) => !bindingsToNormalize.includes(binding));
1027
+ const stableAgentTypes = [
1028
+ ...(keepHistoricalIdentity || !bindingsToNormalize.length ? [requestedAgentType] : []),
1029
+ ...(bindingsToNormalize.length ? [runtimeAgentType] : []),
1030
+ ];
1031
+ return {
1032
+ requestedAgentType,
1033
+ runtimeAgentType,
1034
+ stableAgentType: stableAgentTypes[0],
1035
+ stableAgentTypes,
1036
+ bindingsToNormalize,
1037
+ };
1038
+ }
1039
+
1040
+ function commitPreparedAgentBindings(plan) {
1041
+ for (const binding of plan.bindingsToNormalize) binding.agent_type = plan.runtimeAgentType;
1042
+ return plan.stableAgentType;
1043
+ }
1044
+
1045
+ async function prepareAndCommitAgentBindings(plan, prepare) {
1046
+ const results = [];
1047
+ for (const stableAgentType of plan.stableAgentTypes) {
1048
+ results.push(await prepare(stableAgentType));
1049
+ }
1050
+ commitPreparedAgentBindings(plan);
1051
+ return results;
1052
+ }
1053
+
1054
+ function recordPreparationFailure(group, requestedAgentType, runtimeAgentType, error) {
1055
+ const reason = agentFailureMessage(runtimeAgentType || requestedAgentType, error?.message || error);
1056
+ group.failures.set(requestedAgentType, reason);
1057
+ return reason;
1058
+ }
1059
+
1060
+ function recordStableAgentFailure(group, stableAgentType, message) {
1061
+ const runtimeAgentType = group.runtimeAgentTypes.get(stableAgentType) || stableAgentType;
1062
+ const reason = agentFailureMessage(runtimeAgentType, message);
1063
+ group.failures.set(stableAgentType, reason);
1064
+ return reason;
1065
+ }
1066
+
1067
+ function printBindingStarted(binding, runtimeAgentType = binding.agent_type) {
1068
+ console.log(`[aamp-one-click] 启动成功:${bindingLabel(binding, runtimeAgentType)}`);
1069
+ }
1070
+
1071
+ function bindingCancellationReason(groups, binding) {
1072
+ return groups.get(binding.aamp_host)?.cancellations?.get(binding.agent_type) || '';
1073
+ }
1074
+
1075
+ function printBindingCancelled(binding, reason) {
1076
+ console.log(`\n🟡 已取消:${bindingLabel(binding)}`);
1077
+ console.log(` 原因:${reason}`);
530
1078
  }
531
1079
 
532
1080
  async function recordError(component, message, binding) {
533
- await appendPrivate(ERRORS_LOG, `${JSON.stringify({
1081
+ const safeMessage = binding ? safeBindingFailureReason(binding, message) : redact(message);
1082
+ await errorLogWriter.write(`${JSON.stringify({
534
1083
  timestamp: nowIso(),
535
1084
  level: 'error',
536
1085
  component,
537
1086
  binding_id: binding?.binding_id,
538
1087
  app_id: binding?.bot?.app_id,
539
- message: redact(message),
1088
+ message: safeMessage,
540
1089
  })}\n`);
541
1090
  }
542
1091
 
543
1092
  async function writeManifest() {
544
- const statuses = [...bindingStatuses.entries()].map(([bindingId, status]) => ({ binding_id: bindingId, ...status }));
545
- await writeJsonAtomic(MANIFEST_FILE, {
546
- schema: 'aamp.local_logs.run.v2',
547
- run_id: RUN_ID,
548
- task_agent_version: process.env.AAMP_TASK_AGENT_VERSION || '',
549
- command: COMMAND,
550
- started_at: process.env.AAMP_TASK_RUN_STARTED_AT || RUN_STARTED_AT,
551
- config_file: CONFIG_FILE,
552
- runtime_home: RUNTIME_HOME,
553
- bindings: statuses,
554
- errors_log: ERRORS_LOG,
555
- log_dir: RUN_LOG_DIR,
556
- });
1093
+ await manifestWriter.run();
557
1094
  }
558
1095
 
559
1096
  async function setBindingStatus(binding, phase, status, reason = '') {
1097
+ const safeReason = status === 'failed'
1098
+ ? safeBindingFailureReason(binding, reason)
1099
+ : redact(reason);
560
1100
  bindingStatuses.set(binding.binding_id, {
561
1101
  agent_type: binding.agent_type,
562
1102
  app_id: binding.bot.app_id,
563
1103
  bot_name: binding.bot.display_name || binding.bot.app_id,
564
1104
  phase,
565
1105
  status,
566
- ...(reason ? { reason: redact(reason) } : {}),
1106
+ ...(reason ? { reason: safeReason } : {}),
567
1107
  updated_at: nowIso(),
568
1108
  });
569
1109
  await writeManifest();
@@ -592,7 +1132,12 @@ async function chooseOne(title, items, render, initialIndex = 0) {
592
1132
 
593
1133
  draw();
594
1134
  return new Promise((resolve, reject) => {
1135
+ let finished = false;
1136
+ let releasePrompt = () => {};
595
1137
  const finish = (error) => {
1138
+ if (finished) return;
1139
+ finished = true;
1140
+ releasePrompt();
596
1141
  input.off('keypress', onKeypress);
597
1142
  input.setRawMode(wasRaw);
598
1143
  input.pause();
@@ -618,6 +1163,7 @@ async function chooseOne(title, items, render, initialIndex = 0) {
618
1163
  }
619
1164
  draw(true);
620
1165
  };
1166
+ releasePrompt = promptInterrupter.activate(finish);
621
1167
  input.on('keypress', onKeypress);
622
1168
  });
623
1169
  }
@@ -642,12 +1188,17 @@ async function chooseMany(title, items, render) {
642
1188
  const mark = checked.has(index) ? 'x' : ' ';
643
1189
  output.write(`\x1b[2K\r ${pointer} [${mark}] ${option.label}\n`);
644
1190
  });
645
- output.write('\x1b[2K\r使用 ↑/↓ 移动,空格多选,回车确认;选择“全部”会忽略其他选项。\n');
1191
+ output.write('\x1b[2K\r使用 ↑/↓ 移动,按空格键选择(支持多选),按回车键确认;选择“全部”会取消其他选项的选中状态。\n');
646
1192
  };
647
1193
 
648
1194
  draw();
649
1195
  return new Promise((resolve, reject) => {
1196
+ let finished = false;
1197
+ let releasePrompt = () => {};
650
1198
  const finish = (error) => {
1199
+ if (finished) return;
1200
+ finished = true;
1201
+ releasePrompt();
651
1202
  input.off('keypress', onKeypress);
652
1203
  input.setRawMode(wasRaw);
653
1204
  input.pause();
@@ -683,6 +1234,7 @@ async function chooseMany(title, items, render) {
683
1234
  }
684
1235
  draw(true);
685
1236
  };
1237
+ releasePrompt = promptInterrupter.activate(finish);
686
1238
  input.on('keypress', onKeypress);
687
1239
  });
688
1240
  }
@@ -709,10 +1261,28 @@ function helperArgs(action, bindingOrAgent) {
709
1261
  async function runBootstrapHelper(action, bindingOrAgent, extraEnv = {}) {
710
1262
  if (!BOOTSTRAP) throw new Error('Bootstrap path is unavailable');
711
1263
  throwIfStopping();
712
- const { input } = terminalStreams();
1264
+ const helperAgent = typeof bindingOrAgent === 'object'
1265
+ ? bindingOrAgent?.agent_type
1266
+ : bindingOrAgent;
1267
+ let executionLocation = 'local';
1268
+ if (helperAgent) executionLocation = resolveTaskAgentMetadata(helperAgent).executionLocation;
1269
+ const input = executionLocation === 'remote' && !process.stdin.isTTY
1270
+ ? process.stdin
1271
+ : terminalStreams().input;
713
1272
  const helperEnv = { ...extraEnv };
714
1273
  const inputPayload = helperEnv.AAMP_TASK_INTERNAL_BINDING_JSON || '';
715
1274
  delete helperEnv.AAMP_TASK_INTERNAL_BINDING_JSON;
1275
+ const remoteHelper = executionLocation === 'remote';
1276
+ const helperProcessGroup = remoteHelper && process.platform !== 'win32';
1277
+ // node (v25) aborts at startup when spawned detached with a /dev/tty stdin.
1278
+ // Remote helpers never read stdin (interactive prompts use /dev/tty directly).
1279
+ const helperStdin = remoteHelper ? 'ignore' : input;
1280
+ const helperOutputOptions = {
1281
+ executionLocation,
1282
+ agentExecutionLocations: trustedAgentExecutionLocations(
1283
+ helperAgent ? [[helperAgent, executionLocation]] : [],
1284
+ ),
1285
+ };
716
1286
  const child = spawn('bash', helperArgs(action, bindingOrAgent), {
717
1287
  env: {
718
1288
  ...process.env,
@@ -720,21 +1290,52 @@ async function runBootstrapHelper(action, bindingOrAgent, extraEnv = {}) {
720
1290
  AAMP_TASK_INTERNAL: 'true',
721
1291
  AAMP_TASK_INTERNAL_RESULT_FD: '3',
722
1292
  AAMP_TASK_INTERNAL_INPUT_FD: '4',
1293
+ ...(remoteHelper ? {
1294
+ AAMP_TASK_INTERNAL_EXECUTION_LOCATION: 'remote',
1295
+ ONE_CLICK_LOG: '/dev/null',
1296
+ ERRORS_LOG: '/dev/null',
1297
+ } : {}),
723
1298
  },
724
- stdio: [input, 'inherit', 'inherit', 'pipe', 'pipe'],
1299
+ stdio: [helperStdin, remoteHelper ? 'pipe' : 'inherit', remoteHelper ? 'pipe' : 'inherit', 'pipe', 'pipe'],
1300
+ ...(helperProcessGroup ? { detached: true } : {}),
725
1301
  });
726
- const processRecord = trackTransientProcess(child, `Bootstrap helper ${action}`, false);
1302
+ const processRecord = trackTransientProcess(child, `Bootstrap helper ${action}`, helperProcessGroup);
727
1303
  let result = '';
1304
+ const relayWrites = [];
1305
+ const remoteDiagnostics = [];
1306
+ const relayRemoteLine = (streamName, line) => {
1307
+ if (!String(line || '').trim()) return;
1308
+ const safeLine = safeOperationalLine(line, helperOutputOptions);
1309
+ remoteDiagnostics.push(safeLine);
1310
+ const target = streamName === 'stderr' ? process.stderr : process.stdout;
1311
+ target.write(`${safeLine}\n`);
1312
+ if (process.env.ONE_CLICK_LOG && process.env.ONE_CLICK_LOG !== '/dev/null') {
1313
+ relayWrites.push(appendPrivate(process.env.ONE_CLICK_LOG, `${safeLine}\n`));
1314
+ }
1315
+ };
1316
+ if (remoteHelper) {
1317
+ createLineReader(child.stdout, (line) => relayRemoteLine('stdout', line));
1318
+ createLineReader(child.stderr, (line) => relayRemoteLine('stderr', line));
1319
+ }
728
1320
  child.stdio[3].setEncoding('utf8');
729
1321
  child.stdio[3].on('data', (chunk) => { result += chunk; });
730
1322
  child.stdio[4].end(inputPayload ? `${inputPayload}\n` : '');
731
1323
  if (stopRequested) await stopManagedProcess(processRecord);
732
1324
  const exit = await processRecord.exitPromise;
1325
+ await Promise.allSettled(relayWrites);
733
1326
  throwIfStopping();
734
- if (exit.code !== 0) throw exit.error || new Error(`Bootstrap helper ${action} failed${exit.signal ? ` (${exit.signal})` : ''}`);
1327
+ if (exit.code !== 0) {
1328
+ if (remoteHelper) {
1329
+ throw new Error(remoteDiagnostics.at(-1) || REMOTE_AGENT_PREPARATION_FAILED);
1330
+ }
1331
+ throw exit.error || new Error(`Bootstrap helper ${action} failed${exit.signal ? ` (${exit.signal})` : ''}`);
1332
+ }
735
1333
  try {
736
1334
  return JSON.parse(result.trim() || '{}');
737
1335
  } catch {
1336
+ if (remoteHelper) {
1337
+ throw new Error(remoteDiagnostics.at(-1) || REMOTE_AGENT_PREPARATION_FAILED);
1338
+ }
738
1339
  throw new Error(`Bootstrap helper ${action} returned invalid result`);
739
1340
  }
740
1341
  }
@@ -746,7 +1347,7 @@ function npmExecArgs(packageSpec, executable, args) {
746
1347
  ];
747
1348
  }
748
1349
 
749
- async function runCapture(packageSpec, executable, args, options = {}) {
1350
+ async function runNpmExecCapture(packageSpec, executable, args, options = {}) {
750
1351
  throwIfStopping();
751
1352
  const child = spawn(NPM_BIN, npmExecArgs(packageSpec, executable, args), {
752
1353
  env: options.env || process.env,
@@ -766,10 +1367,67 @@ async function runCapture(packageSpec, executable, args, options = {}) {
766
1367
  const exit = await processRecord.exitPromise;
767
1368
  throwIfStopping();
768
1369
  if (options.logFile) {
769
- await appendPrivate(options.logFile, `${stdout}${stderr ? `\n${stderr}` : ''}`);
1370
+ await appendPrivate(options.logFile, safeCapturedLog(stdout, stderr, options));
770
1371
  }
771
1372
  if (exit.code !== 0) {
772
- const detail = redact(stderr.trim() || stdout.trim() || exit.error?.message || `exit ${exit.code}`);
1373
+ const detail = safeOperationalOutput(
1374
+ stderr.trim() || stdout.trim() || exit.error?.message || `exit ${exit.code}`,
1375
+ options,
1376
+ );
1377
+ throw new Error(`${executable} failed: ${detail.split('\n').slice(-8).join('\n')}`);
1378
+ }
1379
+ return { stdout, stderr };
1380
+ }
1381
+
1382
+ const packageExecutableLauncher = createPackageExecutableLauncher({
1383
+ materialize: async (packageSpec, executable, options = {}) => {
1384
+ const result = await runNpmExecCapture(
1385
+ packageSpec,
1386
+ process.execPath,
1387
+ npmExecutableResolverArgs(executable),
1388
+ options,
1389
+ );
1390
+ return parseResolvedPackageExecutable(result.stdout, executable);
1391
+ },
1392
+ });
1393
+
1394
+ async function runCapture(packageSpec, executable, args, options = {}) {
1395
+ throwIfStopping();
1396
+ const preparedExecutable = await packageExecutableLauncher.resolve(
1397
+ packageSpec,
1398
+ executable,
1399
+ options,
1400
+ );
1401
+ throwIfStopping();
1402
+ const child = packageExecutableLauncher.launchPrepared({
1403
+ preparedExecutable,
1404
+ args,
1405
+ spawnOptions: {
1406
+ env: options.env || process.env,
1407
+ stdio: ['pipe', 'pipe', 'pipe'],
1408
+ detached: process.platform !== 'win32',
1409
+ },
1410
+ });
1411
+ const processRecord = trackTransientProcess(child, executable, process.platform !== 'win32');
1412
+ let stdout = '';
1413
+ let stderr = '';
1414
+ child.stdout.setEncoding('utf8');
1415
+ child.stderr.setEncoding('utf8');
1416
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
1417
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
1418
+ if (options.input !== undefined) child.stdin.end(options.input);
1419
+ else child.stdin.end();
1420
+ if (stopRequested) await stopManagedProcess(processRecord);
1421
+ const exit = await processRecord.exitPromise;
1422
+ throwIfStopping();
1423
+ if (options.logFile) {
1424
+ await appendPrivate(options.logFile, safeCapturedLog(stdout, stderr, options));
1425
+ }
1426
+ if (exit.code !== 0) {
1427
+ const detail = safeOperationalOutput(
1428
+ stderr.trim() || stdout.trim() || exit.error?.message || `exit ${exit.code}`,
1429
+ options,
1430
+ );
773
1431
  throw new Error(`${executable} failed: ${detail.split('\n').slice(-8).join('\n')}`);
774
1432
  }
775
1433
  return { stdout, stderr };
@@ -829,15 +1487,42 @@ function createLineReader(stream, onLine) {
829
1487
  });
830
1488
  }
831
1489
 
832
- async function startManagedProcess({ label, packageSpec, executable, args, env, logFile }) {
1490
+ async function startManagedProcess({
1491
+ label,
1492
+ packageSpec,
1493
+ executable,
1494
+ args,
1495
+ env,
1496
+ logFile,
1497
+ preparedExecutable,
1498
+ executionLocation = 'local',
1499
+ eventPathRoot,
1500
+ agentExecutionLocations = [],
1501
+ allowedAppIds = [],
1502
+ }) {
833
1503
  await ensurePrivateDir(path.dirname(logFile));
834
1504
  throwIfStopping();
835
1505
  await fsp.writeFile(logFile, '', { mode: 0o600, flag: 'a' });
836
1506
  throwIfStopping();
837
- const child = spawn(NPM_BIN, npmExecArgs(packageSpec, executable, args), {
838
- env: env || process.env,
839
- stdio: ['ignore', 'pipe', 'pipe'],
840
- detached: process.platform !== 'win32',
1507
+ const executableDescriptor = preparedExecutable || await packageExecutableLauncher.resolve(
1508
+ packageSpec,
1509
+ executable,
1510
+ {
1511
+ env: env || process.env,
1512
+ executionLocation,
1513
+ eventPathRoot,
1514
+ ...(executionLocation === 'remote' ? { logFile } : {}),
1515
+ },
1516
+ );
1517
+ throwIfStopping();
1518
+ const child = packageExecutableLauncher.launchPrepared({
1519
+ preparedExecutable: executableDescriptor,
1520
+ args,
1521
+ spawnOptions: {
1522
+ env: env || process.env,
1523
+ stdio: ['ignore', 'pipe', 'pipe'],
1524
+ detached: process.platform !== 'win32',
1525
+ },
841
1526
  });
842
1527
  const processStartedAt = Date.now();
843
1528
  const logWriter = createSerializedLineWriter((content) => appendPrivate(logFile, content));
@@ -852,11 +1537,19 @@ async function startManagedProcess({ label, packageSpec, executable, args, env,
852
1537
  expectedStop: false,
853
1538
  processGroup: process.platform !== 'win32',
854
1539
  outputTail: [],
1540
+ eventPathHandles: executionLocation === 'remote' && eventPathRoot ? new Map() : undefined,
855
1541
  logWriter,
856
1542
  logWriteError: undefined,
857
1543
  };
858
1544
  managedProcesses.add(record);
859
- void logWriter.write(`${JSON.stringify({
1545
+ const outputOptions = {
1546
+ executionLocation,
1547
+ eventPathRoot,
1548
+ eventPathHandles: record.eventPathHandles,
1549
+ agentExecutionLocations: trustedAgentExecutionLocations(agentExecutionLocations),
1550
+ allowedAppIds,
1551
+ };
1552
+ void logWriter.write(`${safeOperationalLine(JSON.stringify({
860
1553
  timestamp: nowIso(),
861
1554
  type: 'bridge.process',
862
1555
  status: 'started',
@@ -865,17 +1558,21 @@ async function startManagedProcess({ label, packageSpec, executable, args, env,
865
1558
  package: packageSpec,
866
1559
  pid: child.pid || null,
867
1560
  node: process.version,
868
- })}\n`).catch((error) => {
1561
+ }), outputOptions)}\n`).catch((error) => {
869
1562
  record.logWriteError ??= error;
870
1563
  });
871
1564
  const handleLine = (streamName, line) => {
872
- const safeLine = redact(line);
1565
+ const safeLine = safeOperationalLine(line, outputOptions);
873
1566
  record.outputTail.push(`[${streamName}] ${safeLine}`);
874
1567
  if (record.outputTail.length > 30) record.outputTail.shift();
875
1568
  record.emitter.emit('output', safeLine);
876
- if (streamName === 'stdout' && line.trim()) {
1569
+ if (streamName === 'stdout' && safeLine.trim()) {
877
1570
  try {
878
- const event = JSON.parse(line.trim());
1571
+ const rawEvent = JSON.parse(String(line).trim());
1572
+ const event = executionLocation === 'remote'
1573
+ ? (projectTrustedLocalAgentEvent(rawEvent, outputOptions)
1574
+ || projectRemoteOperationalEvent(rawEvent, outputOptions))
1575
+ : JSON.parse(safeLine.trim());
879
1576
  if (event && typeof event.type === 'string') {
880
1577
  record.events.push(event);
881
1578
  record.emitter.emit('event', event);
@@ -884,7 +1581,7 @@ async function startManagedProcess({ label, packageSpec, executable, args, env,
884
1581
  // Feishu task mode can mix human-readable lines with JSON events.
885
1582
  }
886
1583
  }
887
- void logWriter.write(`${line}\n`).catch((error) => {
1584
+ void logWriter.write(`${safeLine}\n`).catch((error) => {
888
1585
  record.logWriteError ??= error;
889
1586
  });
890
1587
  };
@@ -895,7 +1592,7 @@ async function startManagedProcess({ label, packageSpec, executable, args, env,
895
1592
  const finish = async (exit) => {
896
1593
  if (settled) return;
897
1594
  settled = true;
898
- await logWriter.write(`${JSON.stringify({
1595
+ await logWriter.write(`${safeOperationalLine(JSON.stringify({
899
1596
  timestamp: nowIso(),
900
1597
  type: 'bridge.process',
901
1598
  status: 'exited',
@@ -908,12 +1605,13 @@ async function startManagedProcess({ label, packageSpec, executable, args, env,
908
1605
  signal: exit.signal || null,
909
1606
  expectedStop: record.expectedStop,
910
1607
  ...(exit.error ? { error: describeNetworkError(exit.error) } : {}),
911
- })}\n`).catch((error) => {
1608
+ }), outputOptions)}\n`).catch((error) => {
912
1609
  record.logWriteError ??= error;
913
1610
  });
914
1611
  await logWriter.flush().catch((error) => {
915
1612
  record.logWriteError ??= error;
916
1613
  });
1614
+ record.eventPathHandles?.clear();
917
1615
  record.exited = true;
918
1616
  record.exit = {
919
1617
  ...exit,
@@ -1047,7 +1745,29 @@ async function releaseLease(lease) {
1047
1745
  await lease.release();
1048
1746
  }
1049
1747
 
1050
- async function setupAgentGroups(bindings) {
1748
+ function acpBridgeAgentPolicy(stableAgentType) {
1749
+ const metadata = resolveTaskAgentMetadata(stableAgentType);
1750
+ return {
1751
+ executionLocation: metadata.executionLocation,
1752
+ ...(metadata.attachmentPolicy ? { attachmentPolicy: metadata.attachmentPolicy } : {}),
1753
+ ...(metadata.taskDispatchConcurrency
1754
+ ? { taskDispatchConcurrency: metadata.taskDispatchConcurrency }
1755
+ : {}),
1756
+ };
1757
+ }
1758
+
1759
+ function agentExecutionLocations(agents) {
1760
+ return new Set((agents || []).map((agent) => (
1761
+ resolveTaskAgentMetadata(agent.name).executionLocation
1762
+ )));
1763
+ }
1764
+
1765
+ function groupProcessExecutionLocation(agents) {
1766
+ return agentExecutionLocations(agents).has('remote') ? 'remote' : 'local';
1767
+ }
1768
+
1769
+ async function initializeAgentGroups(bindings, operations = {}) {
1770
+ const prepareAgent = operations.runBootstrapHelper || runBootstrapHelper;
1051
1771
  const byHost = new Map();
1052
1772
  for (const binding of bindings) {
1053
1773
  if (!byHost.has(binding.aamp_host)) byHost.set(binding.aamp_host, new Map());
@@ -1074,45 +1794,88 @@ async function setupAgentGroups(bindings) {
1074
1794
  identities: new Map(),
1075
1795
  availableAgents: new Set(),
1076
1796
  failures: new Map(),
1797
+ cancellations: new Map(),
1798
+ runtimeAgentTypes: new Map(),
1077
1799
  leases: new Map(),
1800
+ agents: [],
1801
+ bridgeEnv,
1078
1802
  process: undefined,
1079
1803
  };
1080
1804
  groups.set(host, group);
1081
1805
  const agents = [];
1082
1806
  for (const [agentType, sampleBinding] of agentBindings) {
1083
1807
  throwIfStopping();
1808
+ let runtimeAgentType;
1809
+ let stableAgentTypes = [agentType];
1084
1810
  try {
1085
1811
  const lease = await acquireAgentLease(host, agentType);
1086
1812
  throwIfStopping();
1087
1813
  group.leases.set(agentType, lease);
1088
- console.log(`[aamp-one-click] 正在检查 ${agentType} 本地智能体...`);
1089
- const prepared = await runBootstrapHelper('__prepare-agent', sampleBinding);
1814
+ const metadata = resolveTaskAgentMetadata(agentType);
1815
+ console.log(`[aamp-one-click] 正在检查 ${agentSelectionDisplayName(agentType)} ${metadata.executionLocation === 'remote' ? '远程智能体' : '本地智能体'}...`);
1816
+ const prepared = await prepareAgent('__prepare-agent', sampleBinding);
1090
1817
  throwIfStopping();
1091
- if (path.resolve(prepared.lark_cli_config_dir || '') !== path.resolve(bridgeEnv.LARKSUITE_CLI_CONFIG_DIR)) {
1092
- throw new Error(`Agent 使用的 lark-cli 配置目录与 Online 配置不一致:${prepared.lark_cli_config_dir || 'unknown'}`);
1818
+ runtimeAgentType = prepared.agent_type || agentType;
1819
+ if (prepared.cancelled === true) {
1820
+ const reason = redact(prepared.reason || '用户取消了 Agent 准备流程');
1821
+ group.cancellations.set(agentType, reason);
1822
+ await releaseLease(group.leases.get(agentType));
1823
+ group.leases.delete(agentType);
1824
+ continue;
1093
1825
  }
1094
- const agentHome = path.join(home, 'agents', agentType);
1095
- await assertNoSymlinkPath(RUNTIME_HOME, agentHome);
1096
- throwIfStopping();
1097
- await ensurePrivateDir(agentHome);
1098
- throwIfStopping();
1099
- agents.push({
1100
- name: agentType,
1101
- acpCommand: prepared.acp_command,
1102
- credentialsFile: path.join(agentHome, 'credentials.json'),
1103
- pairingFile: path.join(agentHome, 'pairing.json'),
1104
- senderPoliciesFile: path.join(agentHome, 'sender-policies.json'),
1105
- createPairing: false,
1826
+ if (prepared.agent_type && prepared.agent_type !== agentType && agentBindings.has(prepared.agent_type)) {
1827
+ throw new Error(`Agent 类型归一化后发生重复:${agentType} -> ${prepared.agent_type}`);
1828
+ }
1829
+ const bindingPlan = resolvePreparedAgentBindings(
1830
+ bindings,
1831
+ host,
1832
+ agentType,
1833
+ runtimeAgentType,
1834
+ );
1835
+ stableAgentTypes = bindingPlan.stableAgentTypes;
1836
+ const preparedAgents = await prepareAndCommitAgentBindings(bindingPlan, async (stableAgentType) => {
1837
+ if (!group.leases.has(stableAgentType)) {
1838
+ group.leases.set(stableAgentType, await acquireAgentLease(host, stableAgentType));
1839
+ }
1840
+ const stableMetadata = resolveTaskAgentMetadata(stableAgentType);
1841
+ if (stableMetadata.executionLocation === 'local'
1842
+ && path.resolve(prepared.lark_cli_config_dir || '') !== path.resolve(bridgeEnv.LARKSUITE_CLI_CONFIG_DIR)) {
1843
+ throw new Error(`Agent 使用的 lark-cli 配置目录与 Online 配置不一致:${prepared.lark_cli_config_dir || 'unknown'}`);
1844
+ }
1845
+ const agentHome = path.join(home, 'agents', stableAgentType);
1846
+ await assertNoSymlinkPath(RUNTIME_HOME, agentHome);
1847
+ throwIfStopping();
1848
+ await ensurePrivateDir(agentHome);
1849
+ throwIfStopping();
1850
+ return {
1851
+ name: stableAgentType,
1852
+ acpCommand: prepared.acp_command,
1853
+ credentialsFile: path.join(agentHome, 'credentials.json'),
1854
+ pairingFile: path.join(agentHome, 'pairing.json'),
1855
+ senderPoliciesFile: path.join(agentHome, 'sender-policies.json'),
1856
+ createPairing: false,
1857
+ ...acpBridgeAgentPolicy(stableAgentType),
1858
+ };
1106
1859
  });
1860
+ if (!stableAgentTypes.includes(agentType)) {
1861
+ await releaseLease(group.leases.get(agentType));
1862
+ group.leases.delete(agentType);
1863
+ }
1864
+ agents.push(...preparedAgents);
1865
+ for (const stableAgentType of stableAgentTypes) {
1866
+ group.runtimeAgentTypes.set(stableAgentType, runtimeAgentType);
1867
+ }
1107
1868
  } catch (error) {
1108
- const reason = redact(error.message || error);
1109
- group.failures.set(agentType, reason);
1110
- await releaseLease(group.leases.get(agentType));
1111
- group.leases.delete(agentType);
1869
+ recordPreparationFailure(group, agentType, runtimeAgentType, redact(error.message || error));
1870
+ for (const stableAgentType of new Set([...stableAgentTypes, agentType])) {
1871
+ await releaseLease(group.leases.get(stableAgentType));
1872
+ group.leases.delete(stableAgentType);
1873
+ }
1112
1874
  if (stopRequested) throw error;
1113
1875
  }
1114
1876
  }
1115
1877
  if (!agents.length) continue;
1878
+ group.executionLocation = groupProcessExecutionLocation(agents);
1116
1879
  try {
1117
1880
  throwIfStopping();
1118
1881
  const initResult = await runNetworkStage(async () => {
@@ -1120,7 +1883,16 @@ async function setupAgentGroups(bindings) {
1120
1883
  ACP_PACKAGE,
1121
1884
  'aamp-acp-bridge',
1122
1885
  ['init', '--json', '--config', group.configFile, '--input', '-'],
1123
- { input: JSON.stringify({ aampHost: host, agents }), env: bridgeEnv, logFile },
1886
+ { input: JSON.stringify({ aampHost: host, agents }),
1887
+ env: bridgeEnv,
1888
+ logFile,
1889
+ executionLocation: group.executionLocation,
1890
+ eventPathRoot: group.home,
1891
+ agentExecutionLocations: trustedAgentExecutionLocations(agents.map((agent) => [
1892
+ agent.name,
1893
+ resolveTaskAgentMetadata(agent.name).executionLocation,
1894
+ ])),
1895
+ },
1124
1896
  );
1125
1897
  }, {
1126
1898
  stage: 'acp-init',
@@ -1132,15 +1904,58 @@ async function setupAgentGroups(bindings) {
1132
1904
  throwIfStopping();
1133
1905
  const initialized = parseJsonDocument(initResult.stdout, 'ACP init');
1134
1906
  for (const agent of initialized.agents || []) group.identities.set(agent.name, agent.email);
1135
- console.log(`[aamp-one-click] 正在启动本地 Agent Bridge (${agents.map((agent) => agent.name).join(', ')})...`);
1907
+ group.agents = agents;
1908
+ } catch (error) {
1909
+ for (const agent of agents) {
1910
+ const message = agentStartFailureMessage(
1911
+ error?.agentStartEvents || group.process?.events,
1912
+ agent.name,
1913
+ redact(error.message || error),
1914
+ );
1915
+ recordStableAgentFailure(group, agent.name, message);
1916
+ }
1917
+ if (group.process) await stopManagedProcess(group.process);
1918
+ for (const lease of group.leases.values()) await releaseLease(lease);
1919
+ group.leases.clear();
1920
+ if (stopRequested) throw error;
1921
+ }
1922
+ }
1923
+ return groups;
1924
+ }
1925
+
1926
+ async function startAgentGroups(groups) {
1927
+ for (const group of groups.values()) {
1928
+ const agents = group.agents || [];
1929
+ if (!agents.length) continue;
1930
+ const bridgeEnv = group.bridgeEnv;
1931
+ try {
1932
+ throwIfStopping();
1933
+ const runtimeAgentNames = agents
1934
+ .map((agent) => group.runtimeAgentTypes.get(agent.name) || agent.name)
1935
+ .map(agentSelectionDisplayName);
1936
+ const executionLocations = agentExecutionLocations(agents);
1937
+ if (executionLocations.size === 1 && executionLocations.has('remote')) {
1938
+ console.log(`[aamp-one-click] 正在启动远程 Agent Bridge (${runtimeAgentNames.join(', ')})...`);
1939
+ } else if (executionLocations.size === 1 && executionLocations.has('local')) {
1940
+ console.log(`[aamp-one-click] 正在启动本地 Agent Bridge (${runtimeAgentNames.join(', ')})...`);
1941
+ } else {
1942
+ console.log(`[aamp-one-click] 正在启动 Agent Bridge (${runtimeAgentNames.join(', ')})...`);
1943
+ }
1944
+ const executionLocation = executionLocations.has('remote') ? 'remote' : 'local';
1136
1945
  const started = await runNetworkStage(async ({ attempt, maxAttempts }) => {
1137
1946
  const process = await startManagedProcess({
1138
- label: `ACP Bridge ${host}`,
1947
+ label: `ACP Bridge ${group.host}`,
1139
1948
  packageSpec: ACP_PACKAGE,
1140
1949
  executable: 'aamp-acp-bridge',
1141
1950
  args: ['start', '--config', group.configFile, '--json', ...(DEBUG_MODE ? ['--debug'] : [])],
1142
1951
  env: bridgeEnv,
1143
- logFile,
1952
+ logFile: group.logFile,
1953
+ executionLocation,
1954
+ eventPathRoot: group.home,
1955
+ agentExecutionLocations: agents.map((agent) => [
1956
+ agent.name,
1957
+ resolveTaskAgentMetadata(agent.name).executionLocation,
1958
+ ]),
1144
1959
  });
1145
1960
  group.process = process;
1146
1961
  try {
@@ -1154,16 +1969,17 @@ async function setupAgentGroups(bindings) {
1154
1969
  if (retryError) throw retryError;
1155
1970
  return { process, running };
1156
1971
  } catch (error) {
1972
+ const failure = preserveAgentStartFailure(error, process.events);
1157
1973
  await stopManagedProcess(process);
1158
1974
  managedProcesses.delete(process);
1159
1975
  if (group.process === process) group.process = undefined;
1160
- throw error;
1976
+ throw failure;
1161
1977
  }
1162
1978
  }, {
1163
1979
  stage: 'acp-start',
1164
1980
  label: 'Agent Bridge 启动',
1165
- host,
1166
- logFile,
1981
+ host: group.host,
1982
+ logFile: group.logFile,
1167
1983
  environment: bridgeEnv,
1168
1984
  });
1169
1985
  group.process = started.process;
@@ -1174,13 +1990,24 @@ async function setupAgentGroups(bindings) {
1174
1990
  for (const agent of agents) {
1175
1991
  if (!group.availableAgents.has(agent.name)) {
1176
1992
  const failed = group.process.events.find((event) => event.type === 'agent.failed' && event.agent === agent.name);
1177
- group.failures.set(agent.name, failed?.message || `${agent.name} Agent Bridge 启动失败`);
1993
+ recordStableAgentFailure(
1994
+ group,
1995
+ agent.name,
1996
+ failed?.message || `${agent.name} Agent Bridge 启动失败`,
1997
+ );
1178
1998
  await releaseLease(group.leases.get(agent.name));
1179
1999
  group.leases.delete(agent.name);
1180
2000
  }
1181
2001
  }
1182
2002
  } catch (error) {
1183
- for (const agent of agents) group.failures.set(agent.name, redact(error.message || error));
2003
+ for (const agent of agents) {
2004
+ const message = agentStartFailureMessage(
2005
+ error?.agentStartEvents || group.process?.events,
2006
+ agent.name,
2007
+ redact(error.message || error),
2008
+ );
2009
+ recordStableAgentFailure(group, agent.name, message);
2010
+ }
1184
2011
  if (group.process) await stopManagedProcess(group.process);
1185
2012
  for (const lease of group.leases.values()) await releaseLease(lease);
1186
2013
  group.leases.clear();
@@ -1190,11 +2017,31 @@ async function setupAgentGroups(bindings) {
1190
2017
  return groups;
1191
2018
  }
1192
2019
 
1193
- function resolveGroup(groups, binding) {
2020
+ async function setupAgentGroups(bindings) {
2021
+ const groups = await initializeAgentGroups(bindings);
2022
+ await startAgentGroups(groups);
2023
+ return groups;
2024
+ }
2025
+
2026
+ function resolveInitializedGroup(groups, binding) {
1194
2027
  const group = groups.get(binding.aamp_host);
1195
2028
  if (!group) throw new Error(`Agent Bridge group is unavailable for ${binding.aamp_host}`);
2029
+ const agentCancellation = group.cancellations.get(binding.agent_type);
2030
+ if (agentCancellation) throw new Error(agentCancellation);
1196
2031
  const agentFailure = group.failures.get(binding.agent_type);
1197
- if ((!group.process || group.process.exited) && agentFailure) throw new Error(agentFailure);
2032
+ if (agentFailure) throw new Error(agentFailure);
2033
+ const email = group.identities.get(binding.agent_type);
2034
+ if (!email) throw new Error(`${binding.agent_type} Agent mailbox is unavailable`);
2035
+ if (binding.agent_target_email && binding.agent_target_email !== email) {
2036
+ throw new Error(`Agent mailbox 已变化(配置=${binding.agent_target_email},当前=${email}),请使用 add 或 install 重新绑定`);
2037
+ }
2038
+ const runtimeAgentType = group.runtimeAgentTypes.get(binding.agent_type) || binding.agent_type;
2039
+ return { group, email, runtimeAgentType };
2040
+ }
2041
+
2042
+ function resolveGroup(groups, binding) {
2043
+ const resolved = resolveInitializedGroup(groups, binding);
2044
+ const { group } = resolved;
1198
2045
  if (!group.process || group.process.exited) {
1199
2046
  const tail = group.process?.outputTail?.slice(-10).join('\n');
1200
2047
  throw new Error(`Agent Bridge 已退出${tail ? `:\n${tail}` : ''}`);
@@ -1202,12 +2049,7 @@ function resolveGroup(groups, binding) {
1202
2049
  if (!group.availableAgents.has(binding.agent_type)) {
1203
2050
  throw new Error(group.failures.get(binding.agent_type) || `${binding.agent_type} Agent Bridge 未启动`);
1204
2051
  }
1205
- const email = group.identities.get(binding.agent_type);
1206
- if (!email) throw new Error(`${binding.agent_type} Agent mailbox is unavailable`);
1207
- if (binding.agent_target_email && binding.agent_target_email !== email) {
1208
- throw new Error(`Agent mailbox 已变化(配置=${binding.agent_target_email},当前=${email}),请使用 add 或 install 重新绑定`);
1209
- }
1210
- return { group, email };
2052
+ return resolved;
1211
2053
  }
1212
2054
 
1213
2055
  async function writeFeishuRuntimeProfile(binding) {
@@ -1215,14 +2057,15 @@ async function writeFeishuRuntimeProfile(binding) {
1215
2057
  const instancesDir = path.join(binding.feishu_config_dir, 'task-runtime', 'instances');
1216
2058
  await assertNoSymlinkPath(RUNTIME_HOME, profileFile);
1217
2059
  await assertNoSymlinkPath(RUNTIME_HOME, instancesDir);
2060
+ const metadata = resolveTaskAgentMetadata(binding.agent_type);
1218
2061
  await writeJsonAtomic(profileFile, {
1219
2062
  version: 1,
1220
2063
  profiles: [{
1221
2064
  app_id: binding.bot.app_id,
1222
2065
  app_secret: binding.bot.app_secret,
1223
- profile: binding.bot.lark_cli_profile,
1224
2066
  display_name: binding.bot.display_name,
1225
- auth_mode: 'lark-cli',
2067
+ auth_mode: metadata.executionLocation === 'remote' ? 'app-secret' : 'lark-cli',
2068
+ ...(metadata.executionLocation === 'local' ? { profile: binding.bot.lark_cli_profile } : {}),
1226
2069
  capabilities: ['im', 'task'],
1227
2070
  domains: PROFILE_DOMAINS,
1228
2071
  updated_at: nowIso(),
@@ -1236,7 +2079,55 @@ async function ensureBindingProfile(binding) {
1236
2079
  });
1237
2080
  }
1238
2081
 
2082
+ async function probeBindingProfile(binding) {
2083
+ return runBootstrapHelper('__probe-profile', binding, {
2084
+ AAMP_TASK_INTERNAL_BINDING_JSON: JSON.stringify({
2085
+ agent_type: binding.agent_type,
2086
+ aamp_host: binding.aamp_host,
2087
+ bot: {
2088
+ app_id: binding.bot.app_id,
2089
+ lark_cli_profile: binding.bot.lark_cli_profile,
2090
+ },
2091
+ }),
2092
+ });
2093
+ }
2094
+
2095
+ const readyProfileProbeOperations = Object.freeze({
2096
+ probeBindingProfile,
2097
+ throwIfStopping,
2098
+ });
2099
+
2100
+ async function probeReadyBindingProfiles(
2101
+ bindings,
2102
+ operations = readyProfileProbeOperations,
2103
+ ) {
2104
+ const probes = new Map();
2105
+ for (const binding of bindings) {
2106
+ if (bindingNeedsInitialStart(binding)
2107
+ || resolveTaskAgentMetadata(binding.agent_type).executionLocation === 'remote') continue;
2108
+ operations.throwIfStopping();
2109
+ try {
2110
+ const profile = await operations.probeBindingProfile(binding);
2111
+ operations.throwIfStopping();
2112
+ if (profile?.ready === true && profile.lark_cli_bin) probes.set(binding, profile);
2113
+ } catch {
2114
+ operations.throwIfStopping();
2115
+ // The normal binding preparation remains authoritative for misses and failures.
2116
+ }
2117
+ }
2118
+ return probes;
2119
+ }
2120
+
2121
+ function prewarmFeishuExecutable(binding) {
2122
+ return packageExecutableLauncher.resolve(
2123
+ FEISHU_PACKAGE,
2124
+ 'aamp-feishu-bridge',
2125
+ { env: onlineEnvironment(binding) },
2126
+ );
2127
+ }
2128
+
1239
2129
  function feishuArgs(binding, larkCliBin, target) {
2130
+ const metadata = resolveTaskAgentMetadata(binding.agent_type);
1240
2131
  const targetArgs = target.pairingUrl
1241
2132
  ? ['--pairing-url', target.pairingUrl]
1242
2133
  : ['--target-agent', target.agentTargetEmail];
@@ -1245,48 +2136,104 @@ function feishuArgs(binding, larkCliBin, target) {
1245
2136
  '--config-dir', binding.feishu_config_dir,
1246
2137
  '--aamp-host', binding.aamp_host,
1247
2138
  '--agent', binding.agent_type,
2139
+ '--agent-execution-location', metadata.executionLocation,
1248
2140
  ...targetArgs,
1249
2141
  '--app-id', binding.bot.app_id,
1250
2142
  '--bot-name', binding.bot.display_name || binding.bot.app_id,
1251
- '--use-feishu-cli',
1252
- '--feishu-cli-profile', binding.bot.lark_cli_profile,
1253
- '--feishu-cli-bin', larkCliBin,
2143
+ ...(metadata.executionLocation === 'local' ? [
2144
+ '--use-feishu-cli',
2145
+ '--feishu-cli-profile', binding.bot.lark_cli_profile,
2146
+ '--feishu-cli-bin', larkCliBin,
2147
+ ] : []),
1254
2148
  '--json',
1255
2149
  ...(DEBUG_MODE ? ['--debug'] : []),
1256
2150
  ];
1257
2151
  }
1258
2152
 
1259
- async function prepareFeishuProcess(binding, phase) {
1260
- throwIfStopping();
1261
- await writeFeishuRuntimeProfile(binding);
1262
- throwIfStopping();
1263
- const profile = await ensureBindingProfile(binding);
1264
- throwIfStopping();
1265
- if (!profile.lark_cli_bin) throw new Error(`lark-cli profile ${binding.bot.lark_cli_profile} is unavailable`);
2153
+ const feishuPreparationOperations = Object.freeze({
2154
+ ensureBindingProfile,
2155
+ onlineEnvironment,
2156
+ throwIfStopping,
2157
+ writeFeishuRuntimeProfile,
2158
+ });
2159
+
2160
+ async function prepareFeishuProcess(
2161
+ binding,
2162
+ phase,
2163
+ runtimeAgentType = binding.agent_type,
2164
+ profileProbes,
2165
+ operations = feishuPreparationOperations,
2166
+ ) {
2167
+ operations.throwIfStopping();
2168
+ await operations.writeFeishuRuntimeProfile(binding);
2169
+ operations.throwIfStopping();
2170
+ const environment = operations.onlineEnvironment(binding);
2171
+ const metadata = resolveTaskAgentMetadata(binding.agent_type);
2172
+ const probedProfile = profileProbes?.get(binding);
2173
+ const profile = metadata.executionLocation === 'remote'
2174
+ ? {}
2175
+ : (probedProfile?.ready === true
2176
+ && probedProfile.lark_cli_bin
2177
+ && path.resolve(probedProfile.lark_cli_config_dir || '')
2178
+ === path.resolve(environment.LARKSUITE_CLI_CONFIG_DIR || '')
2179
+ ? probedProfile
2180
+ : await operations.ensureBindingProfile(binding));
2181
+ operations.throwIfStopping();
2182
+ if (metadata.executionLocation === 'local' && !profile.lark_cli_bin) throw new Error(`lark-cli profile ${binding.bot.lark_cli_profile} is unavailable`);
2183
+ const logFile = path.join(RUN_LOG_DIR, `feishu-bridge-${safeId(binding.binding_id)}-${phase}.jsonl`);
2184
+ const preparedExecutable = operations.resolveFeishuExecutable
2185
+ ? await operations.resolveFeishuExecutable(environment)
2186
+ : await packageExecutableLauncher.resolve(
2187
+ FEISHU_PACKAGE,
2188
+ 'aamp-feishu-bridge',
2189
+ {
2190
+ env: environment,
2191
+ executionLocation: metadata.executionLocation,
2192
+ ...(metadata.executionLocation === 'remote' ? { logFile } : {}),
2193
+ },
2194
+ );
2195
+ operations.throwIfStopping();
1266
2196
  return {
1267
2197
  binding,
1268
2198
  phase,
2199
+ runtimeAgentType,
1269
2200
  larkCliBin: profile.lark_cli_bin,
1270
- logFile: path.join(RUN_LOG_DIR, `feishu-bridge-${safeId(binding.binding_id)}-${phase}.jsonl`),
2201
+ logFile,
2202
+ preparedExecutable,
1271
2203
  };
1272
2204
  }
1273
2205
 
1274
2206
  async function startPreparedFeishuProcess(prepared, target, environment = onlineEnvironment(prepared.binding)) {
1275
2207
  throwIfStopping();
1276
- const { binding, phase, larkCliBin, logFile } = prepared;
2208
+ const {
2209
+ binding,
2210
+ phase,
2211
+ runtimeAgentType,
2212
+ larkCliBin,
2213
+ logFile,
2214
+ preparedExecutable,
2215
+ } = prepared;
1277
2216
  const phaseMessage = phase === 'install'
1278
2217
  ? '正在建立绑定并启动飞书任务 Bridge'
1279
2218
  : phase === 'add'
1280
2219
  ? '正在验证飞书任务绑定'
1281
2220
  : '正在启动飞书任务 Bridge';
1282
- console.log(`[aamp-one-click] ${phaseMessage}:${bindingLabel(binding)}...`);
2221
+ console.log(`[aamp-one-click] ${phaseMessage}:${bindingLabel(binding, runtimeAgentType)}...`);
1283
2222
  return startManagedProcess({
1284
- label: `Feishu Bridge ${bindingLabel(binding)}`,
2223
+ label: `Feishu Bridge ${bindingLabel(binding, runtimeAgentType)}`,
1285
2224
  packageSpec: FEISHU_PACKAGE,
1286
2225
  executable: 'aamp-feishu-bridge',
1287
2226
  args: feishuArgs(binding, larkCliBin, target),
1288
2227
  env: environment,
1289
2228
  logFile,
2229
+ preparedExecutable,
2230
+ executionLocation: resolveTaskAgentMetadata(binding.agent_type).executionLocation,
2231
+ eventPathRoot: binding.feishu_config_dir,
2232
+ agentExecutionLocations: [[
2233
+ binding.agent_type,
2234
+ resolveTaskAgentMetadata(binding.agent_type).executionLocation,
2235
+ ]],
2236
+ allowedAppIds: [binding.bot.app_id],
1290
2237
  });
1291
2238
  }
1292
2239
 
@@ -1366,9 +2313,11 @@ async function waitForInitialBinding(record, pairingFile) {
1366
2313
  async function readInitialRuntimeMetadata(binding, record, expectedAgentEmail) {
1367
2314
  const starting = record.events.find((event) => event.type === 'bridge.task_runtime.starting' && event.appId === binding.bot.app_id);
1368
2315
  if (!starting?.imConfigDir || !starting?.taskConfigDir) throw new Error('Feishu Bridge 未返回实例配置目录');
1369
- const imFile = path.join(starting.imConfigDir, 'config.json');
1370
- const taskFile = path.join(starting.taskConfigDir, 'config.json');
1371
- if (!isPathInside(binding.feishu_config_dir, starting.imConfigDir) || !isPathInside(binding.feishu_config_dir, starting.taskConfigDir)) {
2316
+ const imConfigDir = resolveRemoteEventPath(starting.imConfigDir, record, binding.feishu_config_dir);
2317
+ const taskConfigDir = resolveRemoteEventPath(starting.taskConfigDir, record, binding.feishu_config_dir);
2318
+ const imFile = path.join(imConfigDir, 'config.json');
2319
+ const taskFile = path.join(taskConfigDir, 'config.json');
2320
+ if (!isPathInside(binding.feishu_config_dir, imConfigDir) || !isPathInside(binding.feishu_config_dir, taskConfigDir)) {
1372
2321
  throw new Error('Feishu Bridge 返回了新流程 runtime-v1 之外的配置目录');
1373
2322
  }
1374
2323
  await assertNoSymlinkPath(RUNTIME_HOME, imFile);
@@ -1381,8 +2330,8 @@ async function readInitialRuntimeMetadata(binding, record, expectedAgentEmail) {
1381
2330
  throw new Error('Feishu Bridge 实例的 Bot App ID 与本次配对不一致');
1382
2331
  }
1383
2332
  return {
1384
- im_config_dir: starting.imConfigDir,
1385
- task_config_dir: starting.taskConfigDir,
2333
+ im_config_dir: imConfigDir,
2334
+ task_config_dir: taskConfigDir,
1386
2335
  feishu_bridge_email: imConfig.mailbox?.email || '',
1387
2336
  };
1388
2337
  }
@@ -1417,130 +2366,601 @@ async function validateSavedRuntime(binding) {
1417
2366
  }
1418
2367
  }
1419
2368
 
1420
- async function bindOneDraft(draft, groups, mode) {
1421
- await setBindingStatus(draft, 'bind', 'starting');
1422
- throwIfStopping();
1423
- const { group, email } = resolveGroup(groups, draft);
1424
- const binding = { ...draft, state: 'ready', agent_target_email: email, updated_at: nowIso() };
1425
- const preparedFeishu = await prepareFeishuProcess(binding, mode);
1426
- throwIfStopping();
1427
- const pairResult = await runCapture(
1428
- ACP_PACKAGE,
1429
- 'aamp-acp-bridge',
1430
- ['pair', '--agent', draft.agent_type, '--config', group.configFile, '--json', '--no-start'],
1431
- { logFile: group.logFile },
2369
+ const bindingStartOperations = Object.freeze({
2370
+ // User-visible success output is deferred until layered results are back in selection order.
2371
+ printBindingStarted: () => {},
2372
+ readInitialRuntimeMetadata,
2373
+ runCapture,
2374
+ parseJsonDocument,
2375
+ resolveConfiguredPendingPairingFile,
2376
+ resolvePendingPairingFile,
2377
+ setBindingStatus,
2378
+ startPreparedFeishuUntilReady,
2379
+ stopManagedProcess,
2380
+ throwIfStopping,
2381
+ updateBinding,
2382
+ validateSavedRuntime,
2383
+ });
2384
+
2385
+ const bindingPreparationOperations = Object.freeze({
2386
+ nowIso,
2387
+ prepareFeishuProcess,
2388
+ resolveGroup,
2389
+ resolveInitializedGroup,
2390
+ setBindingStatus,
2391
+ throwIfStopping,
2392
+ validateSavedRuntime,
2393
+ });
2394
+
2395
+ async function prepareBindingStart(
2396
+ binding,
2397
+ groups,
2398
+ mode,
2399
+ operations = bindingPreparationOperations,
2400
+ options = {},
2401
+ ) {
2402
+ operations.throwIfStopping();
2403
+ const pending = bindingNeedsInitialStart(binding);
2404
+ await operations.setBindingStatus(binding, pending ? 'bind' : 'start', 'starting');
2405
+ operations.throwIfStopping();
2406
+ const resolve = options.allowAgentStarting && !pending
2407
+ ? operations.resolveInitializedGroup
2408
+ : operations.resolveGroup;
2409
+ const { group, email, runtimeAgentType } = resolve(groups, binding);
2410
+ const activeBinding = pending
2411
+ ? { ...binding, state: 'ready', agent_target_email: email, updated_at: operations.nowIso() }
2412
+ : binding;
2413
+ if (!pending) {
2414
+ if (email !== binding.agent_target_email) {
2415
+ throw new Error('当前 Agent mailbox 与绑定记录不一致,请重新绑定');
2416
+ }
2417
+ await operations.validateSavedRuntime(binding);
2418
+ }
2419
+ operations.throwIfStopping();
2420
+ const preparedFeishu = await operations.prepareFeishuProcess(
2421
+ activeBinding,
2422
+ mode,
2423
+ runtimeAgentType,
2424
+ options.profileProbes,
1432
2425
  );
1433
- throwIfStopping();
1434
- const pairing = parseJsonDocument(pairResult.stdout, 'ACP pairing');
1435
- if (!pairing.connectUrl || !pairing.pairingFile || pairing.mailbox !== email) {
1436
- throw new Error('ACP Bridge 返回的配对信息不完整或 mailbox 不一致');
2426
+ return {
2427
+ originalBinding: binding,
2428
+ binding: activeBinding,
2429
+ group,
2430
+ mode,
2431
+ pending,
2432
+ email,
2433
+ runtimeAgentType,
2434
+ preparedFeishu,
2435
+ deferReadyCommit: Boolean(options.deferReadyCommit && !pending),
2436
+ };
2437
+ }
2438
+
2439
+ async function executePreparedReadyBindingStart(prepared, operations = bindingStartOperations) {
2440
+ const { binding, group, runtimeAgentType, preparedFeishu } = prepared;
2441
+ let feishu;
2442
+ try {
2443
+ feishu = await operations.startPreparedFeishuUntilReady(
2444
+ preparedFeishu,
2445
+ { agentTargetEmail: binding.agent_target_email },
2446
+ { stage: 'feishu-start' },
2447
+ );
2448
+ operations.throwIfStopping();
2449
+ if (!prepared.deferReadyCommit) {
2450
+ await operations.setBindingStatus(binding, 'start', 'running');
2451
+ operations.throwIfStopping();
2452
+ operations.printBindingStarted(binding, runtimeAgentType);
2453
+ }
2454
+ return { binding, process: feishu, group, runtimeAgentType };
2455
+ } catch (error) {
2456
+ if (feishu) await operations.stopManagedProcess(feishu);
2457
+ throw error;
1437
2458
  }
2459
+ }
2460
+
2461
+ async function executePreparedPendingBindingStart(prepared, operations = bindingStartOperations) {
2462
+ const {
2463
+ originalBinding,
2464
+ binding,
2465
+ group,
2466
+ mode,
2467
+ email,
2468
+ runtimeAgentType,
2469
+ preparedFeishu,
2470
+ } = prepared;
1438
2471
  let feishu;
1439
- let keepRunning = false;
1440
2472
  try {
1441
- feishu = await startPreparedFeishuUntilReady(
2473
+ const configuredPairingFile = await operations.resolveConfiguredPendingPairingFile(
2474
+ group,
2475
+ originalBinding.agent_type,
2476
+ );
2477
+ operations.throwIfStopping();
2478
+ const pairResult = await operations.runCapture(
2479
+ ACP_PACKAGE,
2480
+ 'aamp-acp-bridge',
2481
+ ['pair', '--agent', originalBinding.agent_type, '--config', group.configFile, '--json', '--no-start'],
2482
+ {
2483
+ logFile: group.logFile,
2484
+ executionLocation: resolveTaskAgentMetadata(originalBinding.agent_type).executionLocation,
2485
+ },
2486
+ );
2487
+ operations.throwIfStopping();
2488
+ const pairing = operations.parseJsonDocument(pairResult.stdout, 'ACP pairing');
2489
+ if (!pairing.connectUrl || pairing.mailbox !== email) {
2490
+ throw new Error('ACP Bridge 返回的配对信息不完整或 mailbox 不一致');
2491
+ }
2492
+ const pairingFile = await operations.resolvePendingPairingFile(
2493
+ group,
2494
+ originalBinding.agent_type,
2495
+ pairing,
2496
+ );
2497
+ if (pairingFile !== configuredPairingFile) {
2498
+ throw new Error('ACP Bridge 私有配对文件在配对过程中发生变化');
2499
+ }
2500
+ feishu = await operations.startPreparedFeishuUntilReady(
1442
2501
  preparedFeishu,
1443
2502
  { pairingUrl: pairing.connectUrl },
1444
- { stage: mode === 'install' ? 'feishu-install-bind' : 'feishu-add-bind', pairingFile: pairing.pairingFile },
2503
+ { stage: mode === 'install' ? 'feishu-install-bind' : 'feishu-add-bind', pairingFile },
1445
2504
  );
1446
- throwIfStopping();
1447
- binding.runtime = await readInitialRuntimeMetadata(binding, feishu, email);
1448
- throwIfStopping();
1449
- await validateSavedRuntime(binding);
1450
- throwIfStopping();
1451
- const startsBridge = mode === 'install' || mode === 'start';
1452
- await setBindingStatus(binding, startsBridge ? 'start' : 'bind', startsBridge ? 'running' : 'succeeded');
1453
- throwIfStopping();
1454
- keepRunning = startsBridge;
1455
- return { binding, process: keepRunning ? feishu : undefined, group };
1456
- } finally {
1457
- if (!keepRunning) await stopManagedProcess(feishu);
2505
+ operations.throwIfStopping();
2506
+ binding.runtime = await operations.readInitialRuntimeMetadata(binding, feishu, email);
2507
+ operations.throwIfStopping();
2508
+ await operations.validateSavedRuntime(binding);
2509
+ operations.throwIfStopping();
2510
+ await operations.updateBinding(binding);
2511
+ operations.throwIfStopping();
2512
+ await operations.setBindingStatus(binding, 'start', 'running');
2513
+ operations.throwIfStopping();
2514
+ return { binding, process: feishu, group, runtimeAgentType };
2515
+ } catch (error) {
2516
+ if (feishu) await operations.stopManagedProcess(feishu);
2517
+ throw error;
1458
2518
  }
1459
2519
  }
1460
2520
 
1461
- async function startOneBinding(binding, groups) {
1462
- await setBindingStatus(binding, 'start', 'starting');
1463
- throwIfStopping();
1464
- const { email } = resolveGroup(groups, binding);
1465
- if (email !== binding.agent_target_email) throw new Error('当前 Agent mailbox 与绑定记录不一致,请重新绑定');
1466
- await validateSavedRuntime(binding);
1467
- throwIfStopping();
1468
- const preparedFeishu = await prepareFeishuProcess(binding, 'start');
1469
- const feishu = await startPreparedFeishuUntilReady(
1470
- preparedFeishu,
1471
- { agentTargetEmail: binding.agent_target_email },
1472
- { stage: 'feishu-start' },
1473
- );
1474
- throwIfStopping();
1475
- await setBindingStatus(binding, 'start', 'running');
1476
- throwIfStopping();
1477
- printBindingStarted(binding);
1478
- return feishu;
2521
+ async function executePreparedBindingStart(prepared, operations = bindingStartOperations) {
2522
+ if (!prepared.pending) return executePreparedReadyBindingStart(prepared, operations);
2523
+ return runPairingSerially(pairingQueueKey(prepared), async () => {
2524
+ return executePreparedPendingBindingStart(prepared, operations);
2525
+ });
1479
2526
  }
1480
2527
 
1481
- async function startSelectedBindings(bindings, existingGroups) {
2528
+ async function runPreparedBindingStarts(preparedItems, start = executePreparedBindingStart) {
2529
+ const lanes = [];
2530
+ const pendingLanes = new Map();
2531
+ for (const item of preparedItems) {
2532
+ if (!item.prepared.pending) {
2533
+ lanes.push([item]);
2534
+ continue;
2535
+ }
2536
+ const key = pairingQueueKey(item.prepared);
2537
+ let lane = pendingLanes.get(key);
2538
+ if (!lane) {
2539
+ lane = [];
2540
+ pendingLanes.set(key, lane);
2541
+ lanes.push(lane);
2542
+ }
2543
+ lane.push(item);
2544
+ }
2545
+
2546
+ const laneOutcomes = await runLayeredStarts(lanes, {
2547
+ concurrency: FEISHU_START_CONCURRENCY,
2548
+ prepare: async (lane) => lane,
2549
+ start: async (lane) => {
2550
+ const outcomes = [];
2551
+ for (const item of lane) {
2552
+ try {
2553
+ outcomes.push({
2554
+ status: 'fulfilled',
2555
+ phase: 'start',
2556
+ value: await start(item.prepared),
2557
+ item: item.prepared.originalBinding || item.prepared.binding,
2558
+ index: item.index,
2559
+ });
2560
+ } catch (reason) {
2561
+ if (stopRequested) throw reason;
2562
+ outcomes.push({
2563
+ status: 'rejected',
2564
+ phase: 'start',
2565
+ reason,
2566
+ item: item.prepared.originalBinding || item.prepared.binding,
2567
+ index: item.index,
2568
+ });
2569
+ }
2570
+ }
2571
+ return outcomes;
2572
+ },
2573
+ });
2574
+ const rejectedLane = laneOutcomes.find((outcome) => outcome.status === 'rejected');
2575
+ if (rejectedLane && stopRequested) throw rejectedLane.reason;
2576
+ const byIndex = new Map(laneOutcomes.flatMap((outcome) => (
2577
+ outcome.status === 'fulfilled' ? outcome.value : []
2578
+ )).map((outcome) => [outcome.index, outcome]));
2579
+ return preparedItems.map(({ index }) => byIndex.get(index));
2580
+ }
2581
+
2582
+ function reportBindingFailure(binding, runtimeAgentType, reason, mode) {
2583
+ const safeReason = safeBindingFailureReason(binding, reason);
2584
+ console.error(`🔴 启动失败:${bindingLabel(binding, runtimeAgentType)}\n 原因:${safeReason}`);
2585
+ if (mode === 'install') {
2586
+ console.error(' 绑定配置已保存,可稍后运行 feishu-task-agent start 重试。');
2587
+ } else {
2588
+ console.error(' 已跳过该项,继续启动下一项。');
2589
+ }
2590
+ }
2591
+
2592
+ const bindingLauncherOperations = Object.freeze({
2593
+ bindingCancellationReason,
2594
+ executePreparedBindingStart,
2595
+ prepareBindingStart: (binding, groups, mode, options) => (
2596
+ prepareBindingStart(binding, groups, mode, bindingPreparationOperations, options)
2597
+ ),
2598
+ printBindingCancelled,
2599
+ printBindingStarted,
2600
+ recordError,
2601
+ reportBindingFailure,
2602
+ setBindingStatus,
2603
+ throwIfStopping,
2604
+ });
2605
+
2606
+ async function finalizeDeferredLaunchResults(launched, mode, operations = bindingLauncherOperations) {
2607
+ for (const item of launched.cancelled) {
2608
+ await operations.setBindingStatus(
2609
+ item.binding,
2610
+ mode === 'install' ? 'bind' : 'start',
2611
+ 'cancelled',
2612
+ item.reason,
2613
+ );
2614
+ operations.printBindingCancelled(item.binding, item.reason);
2615
+ }
2616
+ for (const item of launched.failed) {
2617
+ await operations.setBindingStatus(item.binding, 'start', 'failed', item.reason);
2618
+ await operations.recordError('startup', item.reason, item.binding);
2619
+ operations.reportBindingFailure(
2620
+ item.binding,
2621
+ item.runtimeAgentType,
2622
+ item.reason,
2623
+ mode,
2624
+ );
2625
+ }
2626
+ }
2627
+
2628
+ async function startBindingsWithGroups(
2629
+ bindings,
2630
+ groups,
2631
+ mode,
2632
+ operations = bindingLauncherOperations,
2633
+ options = {},
2634
+ ) {
2635
+ const cancelled = [];
2636
+ const candidates = [];
2637
+ for (const binding of bindings) {
2638
+ operations.throwIfStopping();
2639
+ const reason = operations.bindingCancellationReason(groups, binding);
2640
+ if (reason) {
2641
+ const runtimeAgentType = groups.get(binding.aamp_host)?.runtimeAgentTypes?.get(binding.agent_type)
2642
+ || binding.agent_type;
2643
+ cancelled.push({ binding, reason, runtimeAgentType });
2644
+ } else {
2645
+ candidates.push(binding);
2646
+ }
2647
+ }
2648
+
2649
+ const outcomes = new Array(candidates.length);
2650
+ const preparedItems = [];
2651
+ for (let index = 0; index < candidates.length; index += 1) {
2652
+ const binding = candidates[index];
2653
+ try {
2654
+ preparedItems.push({
2655
+ index,
2656
+ prepared: await operations.prepareBindingStart(binding, groups, mode, options),
2657
+ });
2658
+ } catch (reason) {
2659
+ if (stopRequested) throw reason;
2660
+ outcomes[index] = { status: 'rejected', phase: 'prepare', reason, item: binding, index };
2661
+ }
2662
+ }
2663
+ const started = await runPreparedBindingStarts(preparedItems, (prepared) => {
2664
+ operations.throwIfStopping();
2665
+ return operations.executePreparedBindingStart(prepared);
2666
+ });
2667
+ started.forEach((outcome, preparedIndex) => {
2668
+ outcomes[preparedItems[preparedIndex].index] = outcome;
2669
+ });
2670
+ operations.throwIfStopping();
2671
+
1482
2672
  const running = [];
1483
2673
  const failed = [];
2674
+ for (const outcome of outcomes) {
2675
+ if (outcome.status === 'fulfilled') {
2676
+ running.push(outcome.value);
2677
+ if (mode === 'start' && !options.deferReadyCommit) {
2678
+ operations.printBindingStarted(outcome.value.binding, outcome.value.runtimeAgentType);
2679
+ }
2680
+ continue;
2681
+ }
2682
+ const binding = outcome.item;
2683
+ const reason = safeBindingFailureReason(binding, outcome.reason?.message || outcome.reason);
2684
+ const runtimeAgentType = groups.get(binding.aamp_host)?.runtimeAgentTypes?.get(binding.agent_type)
2685
+ || binding.agent_type;
2686
+ failed.push({ binding, reason, runtimeAgentType });
2687
+ }
2688
+ const launched = {
2689
+ running: orderStartupItems(bindings, running),
2690
+ failed: orderStartupItems(bindings, failed),
2691
+ cancelled: orderStartupItems(bindings, cancelled),
2692
+ };
2693
+ if (!options.deferReadyCommit) {
2694
+ await finalizeDeferredLaunchResults(launched, mode, operations);
2695
+ }
2696
+ return launched;
2697
+ }
2698
+
2699
+ async function stopRunningBindings(running) {
2700
+ for (const item of [...running].reverse()) {
2701
+ if (item.process && !item.process.exited) await stopManagedProcess(item.process);
2702
+ }
2703
+ }
2704
+
2705
+ const overlappedReadyReconcileOperations = Object.freeze({
2706
+ printBindingCancelled,
2707
+ printBindingStarted,
2708
+ recordError,
2709
+ reportBindingFailure,
2710
+ setBindingStatus,
2711
+ stopManagedProcess,
2712
+ throwIfStopping,
2713
+ });
2714
+
2715
+ async function reconcileOverlappedReadyBindings(
2716
+ bindings,
2717
+ launched,
2718
+ groups,
2719
+ operations = overlappedReadyReconcileOperations,
2720
+ ) {
2721
+ const alive = [];
2722
+ const failed = [];
2723
+ const cancelled = [];
2724
+ const outcomes = orderStartupItems(bindings, [
2725
+ ...launched.running.map((item) => ({ ...item, launchStatus: 'running' })),
2726
+ ...launched.failed.map((item) => ({ ...item, launchStatus: 'failed' })),
2727
+ ...launched.cancelled.map((item) => ({ ...item, launchStatus: 'cancelled' })),
2728
+ ]);
2729
+ for (const item of outcomes) {
2730
+ operations.throwIfStopping();
2731
+ const { binding, process: feishu } = item;
2732
+ const group = item.group || groups.get(binding.aamp_host);
2733
+ const runtimeAgentType = item.runtimeAgentType
2734
+ || group?.runtimeAgentTypes?.get(binding.agent_type)
2735
+ || binding.agent_type;
2736
+ const cancellation = group?.cancellations?.get(binding.agent_type) || '';
2737
+ if (cancellation) {
2738
+ if (feishu && !feishu.exited) await operations.stopManagedProcess(feishu);
2739
+ await operations.setBindingStatus(binding, 'start', 'cancelled', cancellation);
2740
+ operations.printBindingCancelled(binding, cancellation);
2741
+ cancelled.push({ binding, reason: cancellation, runtimeAgentType });
2742
+ continue;
2743
+ }
2744
+
2745
+ let reason = group?.failures?.get(binding.agent_type) || '';
2746
+ if (!reason && (!group?.process || group.process.exited)) {
2747
+ reason = `${bindingLabel(binding, runtimeAgentType)} 的 Agent Bridge 在启动期间已退出:${group?.host || binding.aamp_host}`;
2748
+ }
2749
+ if (!reason && !group.availableAgents?.has(binding.agent_type)) {
2750
+ reason = `${binding.agent_type} Agent Bridge 未启动`;
2751
+ }
2752
+ if (!reason && item.launchStatus === 'failed') {
2753
+ reason = item.reason;
2754
+ }
2755
+ if (!reason && item.launchStatus === 'running' && (!feishu || feishu.exited)) {
2756
+ reason = `${bindingLabel(binding, runtimeAgentType)} 的 Feishu Bridge 在进入监督前已退出 (${feishu?.exit?.signal || feishu?.exit?.code || 'unknown'})`;
2757
+ }
2758
+ if (reason) {
2759
+ const redactedReason = safeBindingFailureReason(binding, reason);
2760
+ if (feishu && !feishu.exited) await operations.stopManagedProcess(feishu);
2761
+ await operations.setBindingStatus(binding, 'start', 'failed', redactedReason);
2762
+ await operations.recordError('startup', redactedReason, binding);
2763
+ operations.reportBindingFailure(binding, runtimeAgentType, redactedReason, 'start');
2764
+ failed.push({ binding, reason: redactedReason, runtimeAgentType });
2765
+ continue;
2766
+ }
2767
+ if (item.launchStatus === 'cancelled') {
2768
+ const launchCancellation = redact(item.reason);
2769
+ await operations.setBindingStatus(binding, 'start', 'cancelled', launchCancellation);
2770
+ operations.printBindingCancelled(binding, launchCancellation);
2771
+ cancelled.push({ binding, reason: launchCancellation, runtimeAgentType });
2772
+ continue;
2773
+ }
2774
+
2775
+ await operations.setBindingStatus(binding, 'start', 'running');
2776
+ operations.throwIfStopping();
2777
+ operations.printBindingStarted(binding, runtimeAgentType);
2778
+ alive.push(item);
2779
+ }
2780
+ return { alive, failed, cancelled };
2781
+ }
2782
+
2783
+ async function runOverlappedStartup(bindings, groups, operations, profileProbes) {
2784
+ const readyBindings = bindings.filter((binding) => !bindingNeedsInitialStart(binding));
2785
+ const pendingBindings = bindings.filter((binding) => bindingNeedsInitialStart(binding));
2786
+ const emptyLaunch = { running: [], failed: [], cancelled: [] };
2787
+ const agentBranch = Promise.resolve().then(() => operations.startAgentGroups(groups));
2788
+ const readyBranch = readyBindings.length
2789
+ ? Promise.resolve().then(() => operations.startBindingsWithGroups(
2790
+ readyBindings,
2791
+ groups,
2792
+ 'start',
2793
+ { allowAgentStarting: true, deferReadyCommit: true, profileProbes },
2794
+ ))
2795
+ : Promise.resolve(emptyLaunch);
2796
+
2797
+ const [agentOutcome, readyOutcome] = await Promise.allSettled([agentBranch, readyBranch]);
2798
+ const earlyRunning = readyOutcome.status === 'fulfilled' ? readyOutcome.value.running : [];
2799
+ if (agentOutcome.status === 'rejected' || readyOutcome.status === 'rejected') {
2800
+ await operations.stopRunningBindings(earlyRunning);
2801
+ operations.throwIfStopping();
2802
+ throw (agentOutcome.status === 'rejected' ? agentOutcome.reason : readyOutcome.reason);
2803
+ }
2804
+ operations.throwIfStopping();
2805
+
2806
+ const reconciledReady = await operations.reconcileOverlappedReadyBindings(
2807
+ readyBindings,
2808
+ readyOutcome.value,
2809
+ groups,
2810
+ );
2811
+ operations.throwIfStopping();
2812
+ const pendingLaunch = pendingBindings.length
2813
+ ? await operations.startBindingsWithGroups(pendingBindings, groups, 'start')
2814
+ : emptyLaunch;
2815
+ operations.throwIfStopping();
2816
+
2817
+ return {
2818
+ running: orderStartupItems(bindings, [
2819
+ ...reconciledReady.alive,
2820
+ ...pendingLaunch.running,
2821
+ ]),
2822
+ failed: orderStartupItems(bindings, [
2823
+ ...reconciledReady.failed,
2824
+ ...pendingLaunch.failed,
2825
+ ]),
2826
+ cancelled: orderStartupItems(bindings, [
2827
+ ...reconciledReady.cancelled,
2828
+ ...pendingLaunch.cancelled,
2829
+ ]),
2830
+ };
2831
+ }
2832
+
2833
+ function startupDisposition({ running = [], failed = [], cancelled = [] }, extraFailureCount = 0) {
2834
+ if (running.length) return 'supervise';
2835
+ if (cancelled.length && !failed.length && extraFailureCount === 0) return 'only-cancel';
2836
+ return 'all-failed';
2837
+ }
2838
+
2839
+ async function reconcileStartupResults(
2840
+ bindings,
2841
+ launched,
2842
+ validationFailures = [],
2843
+ reconcile = reconcileRetainedBindings,
2844
+ ) {
2845
+ const reconciled = await reconcile(launched.running);
2846
+ const result = {
2847
+ running: orderStartupItems(bindings, reconciled.alive),
2848
+ failed: orderStartupItems(bindings, [
2849
+ ...validationFailures,
2850
+ ...launched.failed,
2851
+ ...reconciled.failed,
2852
+ ]),
2853
+ cancelled: orderStartupItems(bindings, launched.cancelled),
2854
+ };
2855
+ return { ...result, disposition: startupDisposition(result) };
2856
+ }
2857
+
2858
+ async function recordOnlineValidationFailure(binding, error) {
2859
+ const reason = safeBindingFailureReason(binding, error.message || error);
2860
+ await setBindingStatus(binding, 'start', 'failed', reason);
2861
+ await recordError('startup', reason, binding);
2862
+ console.error(`\n🔴 启动失败:${bindingLabel(binding)}\n 原因:${reason}`);
2863
+ console.error(' 已跳过该项,继续启动下一项。');
2864
+ return { binding, reason };
2865
+ }
2866
+
2867
+ function startBindingsForOverlap(bindings, groups, mode, options) {
2868
+ return startBindingsWithGroups(bindings, groups, mode, bindingLauncherOperations, options);
2869
+ }
2870
+
2871
+ const startupOrchestrationOperations = Object.freeze({
2872
+ finalizeDeferredLaunchResults: (launched) => finalizeDeferredLaunchResults(launched, 'start'),
2873
+ initializeAgentGroups,
2874
+ prewarmFeishuExecutable,
2875
+ probeReadyBindingProfiles,
2876
+ reconcileRetainedBindings,
2877
+ reconcileOverlappedReadyBindings,
2878
+ recordValidationFailure: recordOnlineValidationFailure,
2879
+ startAgentGroups,
2880
+ startBindingsWithGroups: startBindingsForOverlap,
2881
+ stopRunningBindings,
2882
+ throwIfStopping,
2883
+ validateBinding: assertOnlineBinding,
2884
+ });
2885
+
2886
+ async function orchestrateStartupBindings(
2887
+ bindings,
2888
+ existingGroups,
2889
+ operations = startupOrchestrationOperations,
2890
+ ) {
2891
+ const validationFailures = [];
1484
2892
  const onlineBindings = [];
1485
2893
  for (const binding of bindings) {
1486
2894
  try {
1487
- assertOnlineBinding(binding);
2895
+ operations.validateBinding(binding);
1488
2896
  onlineBindings.push(binding);
1489
2897
  } catch (error) {
1490
- const reason = redact(error.message || error);
1491
- failed.push({ binding, reason });
1492
- await setBindingStatus(binding, 'start', 'failed', reason);
1493
- await recordError('startup', reason, binding);
1494
- console.error(`\n🔴 启动失败:${bindingLabel(binding)}\n 原因:${reason}`);
1495
- console.error(' 已跳过该项,继续启动下一项。');
2898
+ validationFailures.push(await operations.recordValidationFailure(binding, error));
1496
2899
  }
1497
2900
  }
1498
- const groups = existingGroups || await setupAgentGroups(onlineBindings);
1499
- for (const binding of onlineBindings) {
1500
- throwIfStopping();
2901
+ if (onlineBindings.length && operations.prewarmFeishuExecutable) {
1501
2902
  try {
1502
- let activeBinding = binding;
1503
- let group = groups.get(binding.aamp_host);
1504
- let processRecord;
1505
- if (bindingNeedsInitialStart(binding)) {
1506
- const paired = await bindOneDraft(binding, groups, 'start');
1507
- if (!paired.process) throw new Error('首次启动完成配对后未获得可监督的 Feishu Bridge 进程');
1508
- try {
1509
- await updateBinding(paired.binding);
1510
- } catch (error) {
1511
- await stopManagedProcess(paired.process);
1512
- throw error;
1513
- }
1514
- activeBinding = paired.binding;
1515
- group = paired.group;
1516
- processRecord = paired.process;
1517
- printBindingStarted(activeBinding);
1518
- } else {
1519
- processRecord = await startOneBinding(binding, groups);
1520
- }
1521
- throwIfStopping();
1522
- running.push({ binding: activeBinding, process: processRecord, group });
1523
- } catch (error) {
1524
- if (stopRequested) throw error;
1525
- const reason = redact(error.message || error);
1526
- failed.push({ binding, reason });
1527
- await setBindingStatus(binding, 'start', 'failed', reason);
1528
- await recordError('startup', reason, binding);
1529
- console.error(`\n🔴 启动失败:${bindingLabel(binding)}\n 原因:${reason}`);
1530
- console.error(' 已跳过该项,继续启动下一项。');
2903
+ void Promise.resolve(operations.prewarmFeishuExecutable(onlineBindings[0])).catch(() => {});
2904
+ } catch {
2905
+ // This is speculative only. Binding preparation performs the authoritative resolve.
1531
2906
  }
1532
2907
  }
1533
- const reconciled = await reconcileRetainedBindings(running);
1534
- running.splice(0, running.length, ...reconciled.alive);
1535
- failed.push(...reconciled.failed);
1536
- if (!running.length) {
1537
- await shutdownGroups(groups);
1538
- throw new Error('全部配置启动失败');
2908
+ let groups = existingGroups;
2909
+ let profileProbes;
2910
+ if (!groups) {
2911
+ const initialization = Promise.resolve().then(() => operations.initializeAgentGroups(onlineBindings));
2912
+ const probing = operations.probeReadyBindingProfiles
2913
+ ? Promise.resolve().then(() => operations.probeReadyBindingProfiles(onlineBindings))
2914
+ : Promise.resolve(new Map());
2915
+ const [initializationOutcome, probingOutcome] = await Promise.allSettled([
2916
+ initialization,
2917
+ probing,
2918
+ ]);
2919
+ operations.throwIfStopping();
2920
+ if (initializationOutcome.status === 'rejected') throw initializationOutcome.reason;
2921
+ groups = initializationOutcome.value;
2922
+ profileProbes = probingOutcome.status === 'fulfilled' ? probingOutcome.value : new Map();
2923
+ }
2924
+ const launched = existingGroups
2925
+ ? await operations.startBindingsWithGroups(onlineBindings, groups, 'start')
2926
+ : await runOverlappedStartup(onlineBindings, groups, operations, profileProbes);
2927
+ const result = await reconcileStartupResults(
2928
+ bindings,
2929
+ launched,
2930
+ validationFailures,
2931
+ operations.reconcileRetainedBindings,
2932
+ );
2933
+ return { groups, ...result };
2934
+ }
2935
+
2936
+ async function dispatchStartupResult(result, operations) {
2937
+ const disposition = startupDisposition(result, operations.extraFailureCount || 0);
2938
+ if (disposition === 'supervise') {
2939
+ return operations.supervise(result.running, result.groups);
1539
2940
  }
1540
- console.log(`\n已成功启动 ${running.length}/${bindings.length} 个配置。`);
1541
- console.log('🟢 保持终端打开,你可以给 agent 派发飞书任务');
1542
- if (failed.length) console.log(`另有 ${failed.length} 个配置启动失败,详情见上方信息和本地日志。`);
1543
- await supervise(running, groups);
2941
+ await operations.shutdown(result.groups);
2942
+ if (disposition === 'only-cancel') return operations.onlyCancelled();
2943
+ return operations.allFailed();
2944
+ }
2945
+
2946
+ async function startSelectedBindings(bindings, existingGroups) {
2947
+ const result = await orchestrateStartupBindings(bindings, existingGroups);
2948
+ printStartupSummary({
2949
+ title: '已成功启动',
2950
+ plannedCount: bindings.length,
2951
+ running: result.running,
2952
+ failed: result.failed,
2953
+ cancelled: result.cancelled,
2954
+ });
2955
+ await dispatchStartupResult(result, {
2956
+ supervise: async (running, groups) => {
2957
+ console.log('🟢 保持终端打开,你可以给 agent 派发飞书任务');
2958
+ await supervise(running, groups);
2959
+ },
2960
+ shutdown: shutdownGroups,
2961
+ onlyCancelled: async () => {},
2962
+ allFailed: async () => { throw new Error('全部配置启动失败'); },
2963
+ });
1544
2964
  }
1545
2965
 
1546
2966
  async function markRuntimeFailed(binding, reason, component) {
@@ -1560,12 +2980,12 @@ async function reconcileRetainedBindings(running) {
1560
2980
  continue;
1561
2981
  }
1562
2982
  const reason = !feishuAlive
1563
- ? `${bindingLabel(item.binding)} 的 Feishu Bridge 在进入监督前已退出 (${item.process?.exit?.signal || item.process?.exit?.code || 'unknown'})`
1564
- : `${bindingLabel(item.binding)} 的 Agent Bridge 在进入监督前已退出:${item.group?.host || item.binding.aamp_host}`;
2983
+ ? `${bindingLabel(item.binding, item.runtimeAgentType)} 的 Feishu Bridge 在进入监督前已退出 (${item.process?.exit?.signal || item.process?.exit?.code || 'unknown'})`
2984
+ : `${bindingLabel(item.binding, item.runtimeAgentType)} 的 Agent Bridge 在进入监督前已退出:${item.group?.host || item.binding.aamp_host}`;
1565
2985
  if (feishuAlive) await stopManagedProcess(item.process);
1566
2986
  await markRuntimeFailed(item.binding, reason, 'startup');
1567
- failed.push({ binding: item.binding, reason });
1568
- console.error(`\n🔴 启动失败:${bindingLabel(item.binding)}\n 原因:${reason}`);
2987
+ failed.push({ binding: item.binding, reason, runtimeAgentType: item.runtimeAgentType });
2988
+ console.error(`\n🔴 启动失败:${bindingLabel(item.binding, item.runtimeAgentType)}\n 原因:${reason}`);
1569
2989
  }
1570
2990
  return { alive, failed };
1571
2991
  }
@@ -1609,23 +3029,27 @@ async function shutdownGroups(groups) {
1609
3029
  }
1610
3030
  }
1611
3031
 
1612
- async function cleanupAll() {
1613
- if (cleanupPromise) return cleanupPromise;
1614
- cleanupPromise = (async () => {
1615
- while (managedProcesses.size || transientProcesses.size || heldLeases.size) {
1616
- const records = [...managedProcesses].reverse();
1617
- for (const record of records) {
1618
- managedProcesses.delete(record);
1619
- await stopManagedProcess(record).catch(() => {});
1620
- }
1621
- for (const record of [...transientProcesses].reverse()) {
1622
- transientProcesses.delete(record);
1623
- await stopManagedProcess(record).catch(() => {});
1624
- }
1625
- for (const lease of [...heldLeases]) await releaseLease(lease).catch(() => {});
3032
+ const cleanupRuntimeResources = createResourceCleanup(async () => {
3033
+ while (managedProcesses.size || transientProcesses.size || heldLeases.size) {
3034
+ const records = [...managedProcesses].reverse();
3035
+ for (const record of records) {
3036
+ managedProcesses.delete(record);
3037
+ await stopManagedProcess(record).catch(() => {});
1626
3038
  }
1627
- })();
1628
- return cleanupPromise;
3039
+ for (const record of [...transientProcesses].reverse()) {
3040
+ transientProcesses.delete(record);
3041
+ await stopManagedProcess(record).catch(() => {});
3042
+ }
3043
+ for (const lease of [...heldLeases]) await releaseLease(lease).catch(() => {});
3044
+ }
3045
+ });
3046
+
3047
+ async function cleanupAll() {
3048
+ await cleanupRuntimeResources();
3049
+ await Promise.allSettled([
3050
+ (async () => await manifestWriter.flush())(),
3051
+ (async () => await errorLogWriter.flush())(),
3052
+ ]);
1629
3053
  }
1630
3054
 
1631
3055
  function printLogHints(detailed = false) {
@@ -1644,7 +3068,7 @@ function displayBindings(bindings) {
1644
3068
  }
1645
3069
  const rows = bindings.map((binding, index) => ({
1646
3070
  index: String(index + 1),
1647
- agent: binding.agent_type,
3071
+ agent: agentBindingDisplayName(binding.agent_type),
1648
3072
  bot: binding.bot.display_name || binding.bot.app_id,
1649
3073
  appId: binding.bot.app_id,
1650
3074
  environment: binding.environment.name,
@@ -1662,22 +3086,26 @@ function displayBindings(bindings) {
1662
3086
 
1663
3087
  async function discoverAgents() {
1664
3088
  const result = await runBootstrapHelper('__discover-agents', '');
1665
- const agents = (result.agents || []).filter((agent) => AGENT_TYPES.includes(agent));
1666
- if (!agents.length) throw new Error('暂未检测到本地智能体。请先安装并登录 Codex 或 Cursor CLI 后重试。');
3089
+ const agents = (result.agents || []).filter((agent) => TASK_AGENT_TYPES.includes(agent));
3090
+ if (!agents.length) {
3091
+ throw new Error('暂未检测到智能体。请先安装 Codex、Cursor、Trae CLI、TraeCode CLI、WorkBuddy 或 WorkBuddy AI,或连接公司内网使用 AIME。');
3092
+ }
1667
3093
  return agents;
1668
3094
  }
1669
3095
 
1670
- async function createDraft(agents, unavailableAppIds) {
1671
- const agent = DEFAULT_AGENT || await chooseOne('请选择要绑定的本地智能体:', agents, (item) => item);
3096
+ async function createDraft(agents, selectedAppIds) {
3097
+ const agent = DEFAULT_AGENT || await chooseOne('请选择要绑定的智能体:', agents, agentSelectionDisplayName);
1672
3098
  const registered = await runBootstrapHelper('__register-binding', agent);
1673
3099
  addSecret(registered.app_secret);
1674
- if (!registered.app_id || !registered.app_secret || !registered.lark_cli_profile) {
3100
+ const metadata = resolveTaskAgentMetadata(agent);
3101
+ if (!registered.app_id || !registered.app_secret
3102
+ || (metadata.executionLocation === 'local' && !registered.lark_cli_profile)) {
1675
3103
  throw new Error('飞书应用授权结果不完整');
1676
3104
  }
1677
- if (unavailableAppIds.has(registered.app_id)) {
3105
+ if (selectedAppIds.has(registered.app_id)) {
1678
3106
  throw new Error(`Bot ${registered.app_id} 已经选择过,不能重复绑定`);
1679
3107
  }
1680
- unavailableAppIds.add(registered.app_id);
3108
+ selectedAppIds.add(registered.app_id);
1681
3109
  const bindingId = randomId();
1682
3110
  const timestamp = nowIso();
1683
3111
  return {
@@ -1687,7 +3115,7 @@ async function createDraft(agents, unavailableAppIds) {
1687
3115
  app_id: registered.app_id,
1688
3116
  app_secret: registered.app_secret,
1689
3117
  display_name: registered.display_name || registered.app_id,
1690
- lark_cli_profile: registered.lark_cli_profile,
3118
+ ...(metadata.executionLocation === 'local' ? { lark_cli_profile: registered.lark_cli_profile } : {}),
1691
3119
  },
1692
3120
  environment: { name: 'online' },
1693
3121
  state: 'pending',
@@ -1701,24 +3129,55 @@ async function createDraft(agents, unavailableAppIds) {
1701
3129
  async function runBindingSession(mode) {
1702
3130
  const store = await loadStore();
1703
3131
  throwIfStopping();
1704
- const unavailableAppIds = new Set(mode === 'add' ? store.bindings.map((binding) => binding.bot.app_id) : []);
3132
+ const existingByAppId = new Map(store.bindings.map((binding) => [binding.bot.app_id, binding]));
3133
+ const selectedAppIds = new Set();
1705
3134
  const agents = DEFAULT_AGENT ? [DEFAULT_AGENT] : await discoverAgents();
1706
3135
  throwIfStopping();
1707
- const sessionDrafts = [];
3136
+ const bindingIntents = [];
3137
+ const acceptedBindings = [];
3138
+ const selectedBindings = [];
1708
3139
  const succeeded = [];
1709
3140
  const failed = [];
3141
+ const cancelled = [];
1710
3142
  const selectionFailures = [];
1711
3143
  const running = [];
3144
+ let selectedCount = 0;
1712
3145
 
1713
3146
  console.log('\n=== 选择绑定配置 ===');
1714
3147
  let keepGoing = true;
1715
3148
  while (keepGoing) {
1716
3149
  throwIfStopping();
1717
3150
  try {
1718
- const draft = await createDraft(agents, unavailableAppIds);
3151
+ const draft = await createDraft(agents, selectedAppIds);
1719
3152
  throwIfStopping();
1720
- sessionDrafts.push(draft);
1721
- console.log(`已选择:${bindingLabel(draft)}`);
3153
+ selectedCount += 1;
3154
+ const existing = existingByAppId.get(draft.bot.app_id);
3155
+ let accepted = true;
3156
+ let acceptedBinding = draft;
3157
+ if (existing && sameBindingRelationship(existing, draft)) {
3158
+ acceptedBinding = existing;
3159
+ } else if (existing) {
3160
+ console.log(`Bot ${draft.bot.app_id} 已存在绑定:${bindingLabel(existing)}`);
3161
+ console.log(`拟替换为:${bindingLabel(draft)}`);
3162
+ if (!await confirm('是否替换绑定?', false)) {
3163
+ accepted = false;
3164
+ const reason = '用户取消替换已有绑定';
3165
+ cancelled.push({ binding: draft, reason });
3166
+ await setBindingStatus(draft, 'bind', 'cancelled', reason);
3167
+ printBindingCancelled(draft, reason);
3168
+ }
3169
+ }
3170
+ selectedBindings.push(accepted ? acceptedBinding : draft);
3171
+ if (accepted) {
3172
+ acceptedBindings.push(acceptedBinding);
3173
+ if (acceptedBinding === draft) {
3174
+ bindingIntents.push({
3175
+ binding: draft,
3176
+ expected: existing ? bindingExpectation(existing) : undefined,
3177
+ });
3178
+ }
3179
+ console.log(`已选择:${bindingLabel(acceptedBinding)}`);
3180
+ }
1722
3181
  } catch (error) {
1723
3182
  if (stopRequested) throw error;
1724
3183
  const reason = redact(error.message || error);
@@ -1727,97 +3186,147 @@ async function runBindingSession(mode) {
1727
3186
  console.error(`🔴 本次选择未完成:${reason}`);
1728
3187
  }
1729
3188
  throwIfStopping();
1730
- keepGoing = await confirm('是否继续选择本地智能体和 Bot?', false);
3189
+ keepGoing = await confirm('是否继续选择智能体和 Bot?', false);
1731
3190
  throwIfStopping();
1732
3191
  }
1733
3192
 
1734
- if (!sessionDrafts.length) {
1735
- return { groups: new Map(), succeeded, failed, selectionFailures, running, selectedCount: 0 };
3193
+ if (!acceptedBindings.length) {
3194
+ return {
3195
+ groups: new Map(),
3196
+ saved: [],
3197
+ acceptedBindings,
3198
+ selectedBindings,
3199
+ succeeded,
3200
+ failed,
3201
+ cancelled,
3202
+ selectionFailures,
3203
+ running,
3204
+ selectedCount,
3205
+ replacedCount: 0,
3206
+ };
1736
3207
  }
1737
3208
 
1738
- if (mode === 'add') {
3209
+ let persisted = { bindings: [], replacedCount: 0 };
3210
+ if (bindingIntents.length) {
1739
3211
  console.log('\n=== 保存绑定配置 ===');
1740
- for (const draft of sessionDrafts) {
1741
- try {
1742
- await appendBinding(draft);
1743
- throwIfStopping();
1744
- succeeded.push(draft);
1745
- await setBindingStatus(draft, 'bind', 'saved');
1746
- console.log(`🟢 已保存:${bindingLabel(draft)}`);
1747
- } catch (error) {
1748
- if (stopRequested) throw error;
1749
- const reason = redact(error.message || error);
1750
- failed.push({ binding: draft, reason });
1751
- await setBindingStatus(draft, 'bind', 'failed', reason);
1752
- await recordError('binding', reason, draft);
1753
- console.error(`🔴 配置保存失败:${bindingLabel(draft)}\n 原因:${reason}`);
1754
- console.error(' 已跳过该项,继续处理下一项。');
1755
- }
3212
+ persisted = await upsertBindings(bindingIntents);
3213
+ for (const binding of persisted.bindings) {
3214
+ await setBindingStatus(binding, 'bind', 'saved');
3215
+ console.log(`已保存:${bindingLabel(binding)}`);
1756
3216
  }
1757
- return { groups: new Map(), succeeded, failed, selectionFailures, running, selectedCount: sessionDrafts.length };
3217
+ }
3218
+ const saved = persisted.bindings;
3219
+
3220
+ if (mode === 'add') {
3221
+ succeeded.push(...acceptedBindings);
3222
+ return {
3223
+ groups: new Map(),
3224
+ saved,
3225
+ acceptedBindings,
3226
+ selectedBindings,
3227
+ succeeded,
3228
+ failed,
3229
+ cancelled,
3230
+ selectionFailures,
3231
+ running,
3232
+ selectedCount,
3233
+ replacedCount: persisted.replacedCount,
3234
+ };
1758
3235
  }
1759
3236
 
1760
3237
  console.log('\n=== 建立绑定并启动 ===');
1761
3238
  throwIfStopping();
1762
- const groups = await setupAgentGroups(sessionDrafts);
3239
+ const groups = await setupAgentGroups(acceptedBindings);
1763
3240
  throwIfStopping();
1764
- for (const draft of sessionDrafts) {
1765
- throwIfStopping();
1766
- try {
1767
- const paired = await bindOneDraft(draft, groups, mode);
1768
- throwIfStopping();
1769
- if (mode === 'install' && !paired.process) throw new Error('完成绑定后未获得可监督的 Feishu Bridge 进程');
1770
- succeeded.push(paired.binding);
1771
- if (mode === 'install') {
1772
- running.push({ binding: paired.binding, process: paired.process, group: paired.group });
1773
- }
1774
- } catch (error) {
1775
- if (stopRequested) throw error;
1776
- const reason = redact(error.message || error);
1777
- failed.push({ binding: draft, reason });
1778
- await setBindingStatus(draft, 'bind', 'failed', reason);
1779
- await recordError('binding', reason, draft);
1780
- console.error(`🔴 绑定失败:${bindingLabel(draft)}\n 原因:${reason}`);
1781
- console.error(' 已跳过该项,继续处理下一项。');
1782
- }
1783
- }
1784
- return { groups, succeeded, failed, selectionFailures, running, selectedCount: sessionDrafts.length };
3241
+ const launched = await startBindingsWithGroups(acceptedBindings, groups, mode);
3242
+ running.push(...launched.running);
3243
+ succeeded.push(...launched.running.map(({ binding }) => binding));
3244
+ failed.push(...launched.failed);
3245
+ cancelled.push(...launched.cancelled);
3246
+ return {
3247
+ groups,
3248
+ saved,
3249
+ acceptedBindings,
3250
+ selectedBindings,
3251
+ succeeded,
3252
+ failed,
3253
+ cancelled,
3254
+ selectionFailures,
3255
+ running,
3256
+ selectedCount,
3257
+ replacedCount: persisted.replacedCount,
3258
+ };
1785
3259
  }
1786
3260
 
1787
3261
  async function runInstall() {
1788
3262
  const result = await withMutationLock('install 绑定流程', async () => {
1789
3263
  const bound = await runBindingSession('install');
1790
3264
  throwIfStopping();
1791
- if (!bound.succeeded.length) {
1792
- await shutdownGroups(bound.groups);
1793
- throw new Error('没有配置完成绑定,现有新流程配置保持不变');
1794
- }
1795
- const reconciled = await reconcileRetainedBindings(bound.running);
1796
- throwIfStopping();
1797
- bound.running = reconciled.alive;
1798
- bound.runtimeFailures = reconciled.failed;
1799
- await replaceBindings(bound.succeeded);
3265
+ const composed = await reconcileStartupResults(bound.selectedBindings, {
3266
+ running: bound.running,
3267
+ failed: bound.failed,
3268
+ cancelled: bound.cancelled,
3269
+ });
1800
3270
  throwIfStopping();
1801
- if (!bound.running.length) {
1802
- await shutdownGroups(bound.groups);
1803
- throw new Error(`全部已绑定配置启动失败;${bound.succeeded.length} 个真实配对配置已写入 ${CONFIG_FILE}`);
1804
- }
3271
+ bound.running = composed.running;
3272
+ bound.failed = composed.failed;
3273
+ bound.cancelled = composed.cancelled;
3274
+ bound.disposition = composed.disposition;
1805
3275
  return bound;
1806
3276
  });
1807
- console.log(`\n已成功建立绑定并启动 ${result.running.length}/${result.selectedCount} 个配置。`);
1808
- console.log('🟢 保持终端打开,你可以给 agent 派发飞书任务');
1809
- const failureCount = result.failed.length + result.selectionFailures.length + result.runtimeFailures.length;
1810
- if (failureCount) console.log(`另有 ${failureCount} 次选择或绑定失败,详情见上方信息和本地日志。`);
1811
- await supervise(result.running, result.groups);
3277
+
3278
+ if (!result.acceptedBindings.length) {
3279
+ if (result.cancelled.length && !result.selectionFailures.length) {
3280
+ printStartupSummary({
3281
+ title: '已成功建立绑定并启动',
3282
+ plannedCount: result.selectedCount,
3283
+ running: [],
3284
+ failed: [],
3285
+ cancelled: result.cancelled,
3286
+ });
3287
+ return;
3288
+ }
3289
+ throw new Error('没有配置完成绑定,现有配置保持不变');
3290
+ }
3291
+ printStartupSummary({
3292
+ title: '已成功建立绑定并启动',
3293
+ plannedCount: result.selectedCount,
3294
+ running: result.running,
3295
+ failed: result.failed,
3296
+ cancelled: result.cancelled,
3297
+ });
3298
+ await dispatchStartupResult(result, {
3299
+ extraFailureCount: result.selectionFailures.length,
3300
+ supervise: async (running, groups) => {
3301
+ console.log('🟢 保持终端打开,你可以给 agent 派发飞书任务');
3302
+ if (result.selectionFailures.length) {
3303
+ console.log(`另有 ${result.selectionFailures.length} 次选择未完成,详情见上方信息和本地日志。`);
3304
+ }
3305
+ await supervise(running, groups);
3306
+ },
3307
+ shutdown: shutdownGroups,
3308
+ onlyCancelled: async () => {},
3309
+ allFailed: async () => {
3310
+ throw new Error(`全部配置启动失败;${result.acceptedBindings.length} 个绑定配置已保存,可稍后运行 feishu-task-agent start 重试`);
3311
+ },
3312
+ });
1812
3313
  }
1813
3314
 
1814
3315
  async function runAdd() {
1815
- await withMutationLock('add 绑定流程', async () => {
1816
- const bound = await runBindingSession('add');
1817
- if (!bound.succeeded.length) throw new Error('没有配置完成绑定');
1818
- return bound;
3316
+ const result = await withMutationLock('add 绑定流程', async () => {
3317
+ return runBindingSession('add');
1819
3318
  });
1820
- console.log('配置添加成功,运行feishu-task-agent start启动时生效');
3319
+ if (!result.acceptedBindings.length) {
3320
+ if (result.cancelled.length && !result.selectionFailures.length) {
3321
+ console.log(`已取消 ${result.cancelled.length} 个配置的绑定;现有配置保持不变。`);
3322
+ return;
3323
+ }
3324
+ throw new Error('没有配置完成绑定');
3325
+ }
3326
+ if (result.replacedCount > 0 && await hasActiveAgentLease()) {
3327
+ console.log('当前已运行的 Bridge 不受影响;替换将在下一次 feishu-task-agent start 时生效。');
3328
+ }
3329
+ console.log('配置添加成功,运行 feishu-task-agent start 启动时生效');
1821
3330
  }
1822
3331
 
1823
3332
  async function runList() {
@@ -1892,26 +3401,80 @@ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
1892
3401
  if (stopRequested) return;
1893
3402
  stopRequested = true;
1894
3403
  stopSignal = signal;
1895
- if (terminal?.input?.isRaw) terminal.input.setRawMode(false);
1896
- terminal?.output?.write('\x1b[?25h');
1897
- void cleanupAll().finally(() => {
1898
- console.log(`\n已收到 ${signal},本次启动的 Bridge 已停止。`);
1899
- process.exit(0);
1900
- });
3404
+ const interruptedPrompt = promptInterrupter.interrupt(new Error(`已收到 ${signal}`));
3405
+ if (!interruptedPrompt) {
3406
+ if (terminal?.input?.isRaw) terminal.input.setRawMode(false);
3407
+ terminal?.output?.write('\x1b[?25h');
3408
+ }
3409
+ void cleanupAll().catch(() => {});
1901
3410
  });
1902
3411
  }
1903
3412
 
1904
- main()
1905
- .catch(async (error) => {
1906
- if (!stopRequested) {
1907
- const reason = redact(error?.message || error);
1908
- await recordError('controller', reason).catch(() => {});
1909
- console.error(`\n🔴 运行失败:${reason}`);
1910
- printLogHints(true);
1911
- process.exitCode = 1;
1912
- }
1913
- })
1914
- .finally(async () => {
1915
- await cleanupAll();
1916
- if (stopRequested && stopSignal) console.log(`\n已收到 ${stopSignal},本次启动的 Bridge 已停止。`);
1917
- });
3413
+ let isMainModule = false;
3414
+ if (process.argv[1]) {
3415
+ try {
3416
+ isMainModule = fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
3417
+ } catch {
3418
+ isMainModule = path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
3419
+ }
3420
+ }
3421
+
3422
+ if (isMainModule) {
3423
+ main()
3424
+ .catch(async (error) => {
3425
+ if (!stopRequested) {
3426
+ const reason = redact(error?.message || error);
3427
+ await recordError('controller', reason).catch(() => {});
3428
+ console.error(`\n🔴 运行失败:${reason}`);
3429
+ printLogHints(true);
3430
+ process.exitCode = 1;
3431
+ }
3432
+ })
3433
+ .finally(async () => {
3434
+ await cleanupAll();
3435
+ if (stopRequested && stopSignal) console.log(`\n已收到 ${stopSignal},本次启动的 Bridge 已停止。`);
3436
+ });
3437
+ }
3438
+
3439
+ export {
3440
+ bindingExpectation,
3441
+ acpBridgeAgentPolicy,
3442
+ commitPreparedAgentBindings,
3443
+ createPromptInterrupter,
3444
+ createResourceCleanup,
3445
+ dispatchStartupResult,
3446
+ executePreparedBindingStart,
3447
+ executePreparedPendingBindingStart,
3448
+ executePreparedReadyBindingStart,
3449
+ installHasOnlyCancellations,
3450
+ initializeAgentGroups,
3451
+ orderStartupItems,
3452
+ prepareBindingStart,
3453
+ prepareFeishuProcess,
3454
+ readInitialRuntimeMetadata,
3455
+ resolveConfiguredPendingPairingFile,
3456
+ resolvePendingPairingFile,
3457
+ feishuArgs,
3458
+ prepareAndCommitAgentBindings,
3459
+ probeReadyBindingProfiles,
3460
+ reconcileStartupResults,
3461
+ reconcileOverlappedReadyBindings,
3462
+ recordPreparationFailure,
3463
+ recordError,
3464
+ recordStableAgentFailure,
3465
+ resolvePreparedAgentBindings,
3466
+ orchestrateStartupBindings,
3467
+ runOverlappedStartup,
3468
+ runPreparedBindingStarts,
3469
+ sameBindingRelationship,
3470
+ startBindingsWithGroups,
3471
+ startAgentGroups,
3472
+ startManagedProcess,
3473
+ runBootstrapHelper,
3474
+ setBindingStatus,
3475
+ startupSummaryLines,
3476
+ cleanupAll,
3477
+ upsertBindings,
3478
+ writeFeishuRuntimeProfile,
3479
+ writeManifest,
3480
+ };