@lightcone-ai/daemon 0.14.19 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightcone-ai/daemon",
3
- "version": "0.14.19",
3
+ "version": "0.15.1",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -0,0 +1,18 @@
1
+ function normalizeInteger(value, fallback = null) {
2
+ const parsed = Number.parseInt(String(value ?? ''), 10);
3
+ if (!Number.isFinite(parsed)) return fallback;
4
+ return parsed;
5
+ }
6
+
7
+ export function resolveDurationMs(phase, fallback = 0) {
8
+ const parsed = normalizeInteger(phase?.duration_ms, null);
9
+ if (parsed !== null && parsed >= 0) return parsed;
10
+
11
+ const dwellMs = normalizeInteger(phase?.dwell_ms, null);
12
+ if (dwellMs !== null && dwellMs >= 0) return dwellMs;
13
+
14
+ const secs = Number(phase?.duration_s);
15
+ if (Number.isFinite(secs) && secs >= 0) return Math.round(secs * 1000);
16
+
17
+ return fallback;
18
+ }
@@ -0,0 +1,43 @@
1
+ import { resolveDurationMs } from './phase-duration.js';
2
+ import { normalizePlanPhases } from './plan-executor.js';
3
+
4
+ export function estimatePlanDurationMs(plan = {}) {
5
+ let phases = [];
6
+ try {
7
+ phases = normalizePlanPhases(plan);
8
+ } catch {
9
+ phases = [];
10
+ }
11
+
12
+ return phases.reduce((total, phase) => {
13
+ const action = String(phase?.action ?? phase?.visual_action?.type ?? '').trim().toLowerCase();
14
+ const durationMs = resolveDurationMs(phase, Number.NaN);
15
+ const dwellMs = Number(phase?.dwell_ms);
16
+ const transitionMs = Number(phase?.transition_ms ?? phase?.visual_action?.transition_ms);
17
+ const effectiveHoldMs = Number.isFinite(dwellMs) && dwellMs > 0
18
+ ? dwellMs
19
+ : durationMs;
20
+
21
+ if (action === 'hold' && Number.isFinite(effectiveHoldMs) && effectiveHoldMs > 0) {
22
+ return total + effectiveHoldMs;
23
+ }
24
+ if (action === 'linear_scroll_during') {
25
+ if (Number.isFinite(effectiveHoldMs) && effectiveHoldMs > 0) return total + effectiveHoldMs;
26
+ return total + 1200;
27
+ }
28
+ if (action === 'scroll_to_dwell' || action === 'cursor_focus' || action === 'scroll_back') {
29
+ let next = total;
30
+ if (Number.isFinite(transitionMs) && transitionMs > 0) next += transitionMs;
31
+ if (Number.isFinite(effectiveHoldMs) && effectiveHoldMs > 0) next += effectiveHoldMs;
32
+ if (next === total) next += 1200;
33
+ return next;
34
+ }
35
+ if (Number.isFinite(transitionMs) && transitionMs > 0) {
36
+ return total + transitionMs;
37
+ }
38
+ if (Number.isFinite(durationMs) && durationMs > 0) {
39
+ return total + durationMs;
40
+ }
41
+ return total + 800;
42
+ }, 0);
43
+ }
@@ -18,10 +18,14 @@ import { startSession, stopSession, stopAllSessions } from './browser-login.js';
18
18
  import { markInvalidatedLeases } from './governance-state.js';
19
19
  import { resolveExitOfflineDetail, resolveLifecycleExitState } from './lifecycle-protocol.js';
20
20
  import { runPublishJob } from './publish-job-runner.js';
21
+ import { resolveOutboundRateLimitConfig } from './runtime-config.js';
22
+ import { probeChatBridgeCapability } from './capability-probe.js';
21
23
 
22
24
  const KIMI_SYSTEM_PROMPT_FILE = '.lightcone-kimi-system.md';
23
25
  const KIMI_AGENT_FILE = '.lightcone-kimi-agent.yaml';
24
26
  const KIMI_MCP_FILE = '.lightcone-kimi-mcp.json';
27
+ const CHAT_CAPABILITY_ID = 'chat';
28
+ const CAPABILITY_PROBE_TIMEOUT_MS = 8000;
25
29
  const LOCAL_FILE_DIR = path.dirname(fileURLToPath(import.meta.url));
26
30
  const LOCAL_MCP_ROOTS = Object.freeze([
27
31
  path.resolve(LOCAL_FILE_DIR, '../mcp-servers'),
@@ -129,6 +133,10 @@ function replacePlaceholders(text, replacements) {
129
133
  return next;
130
134
  }
131
135
 
136
+ function previewText(value, max = 500) {
137
+ return String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
138
+ }
139
+
132
140
  export class AgentManager {
133
141
  constructor({ serverUrl, machineApiKey }) {
134
142
  this.serverUrl = serverUrl;
@@ -138,6 +146,10 @@ export class AgentManager {
138
146
  // key → true (spawn in progress)
139
147
  this.starting = new Set();
140
148
  this.directiveRefreshTimers = new Map();
149
+ this.outboundRateLimit = resolveOutboundRateLimitConfig();
150
+ this.outboundHistory = new Map();
151
+ this.outboundCooldownUntil = new Map();
152
+ this.inboxPauseUntil = new Map();
141
153
  }
142
154
 
143
155
  _key(agentId, workspaceId) {
@@ -189,6 +201,111 @@ export class AgentManager {
189
201
  return dir;
190
202
  }
191
203
 
204
+ _nowMs() {
205
+ return Date.now();
206
+ }
207
+
208
+ _trimOutboundHistory(key, nowMs = this._nowMs()) {
209
+ const history = this.outboundHistory.get(key) ?? [];
210
+ const cutoff = nowMs - this.outboundRateLimit.windowMs;
211
+ const trimmed = history.filter(ts => Number.isFinite(ts) && ts > cutoff);
212
+ if (trimmed.length > 0) this.outboundHistory.set(key, trimmed);
213
+ else this.outboundHistory.delete(key);
214
+ return trimmed;
215
+ }
216
+
217
+ _getRemainingCooldownMs(key, nowMs = this._nowMs()) {
218
+ const cooldownUntil = Number(this.outboundCooldownUntil.get(key) ?? 0);
219
+ if (!Number.isFinite(cooldownUntil) || cooldownUntil <= nowMs) {
220
+ this.outboundCooldownUntil.delete(key);
221
+ this.inboxPauseUntil.delete(key);
222
+ return 0;
223
+ }
224
+ return cooldownUntil - nowMs;
225
+ }
226
+
227
+ _isInboundFlushPaused(key, nowMs = this._nowMs()) {
228
+ const pauseUntil = Number(this.inboxPauseUntil.get(key) ?? 0);
229
+ if (!Number.isFinite(pauseUntil) || pauseUntil <= nowMs) {
230
+ this.inboxPauseUntil.delete(key);
231
+ return false;
232
+ }
233
+ return true;
234
+ }
235
+
236
+ _emitOutboundRateLimitEvent(connection, {
237
+ agentId,
238
+ workspaceId,
239
+ historyCount,
240
+ nowMs,
241
+ cooldownUntilMs,
242
+ }) {
243
+ connection.send({
244
+ type: 'agent:governance_event',
245
+ agentId,
246
+ workspaceId,
247
+ eventType: 'agent_outbound_rate_limit_triggered',
248
+ reason: 'hard_cap',
249
+ payload: {
250
+ count: historyCount,
251
+ window_ms: this.outboundRateLimit.windowMs,
252
+ soft_cap: this.outboundRateLimit.softCap,
253
+ hard_cap: this.outboundRateLimit.hardCap,
254
+ cooldown_ms: this.outboundRateLimit.cooldownMs,
255
+ triggered_at: new Date(nowMs).toISOString(),
256
+ cooldown_until: new Date(cooldownUntilMs).toISOString(),
257
+ },
258
+ });
259
+ }
260
+
261
+ _recordSuccessfulOutboundMessage({
262
+ key,
263
+ agent,
264
+ agentId,
265
+ workspaceId,
266
+ connection,
267
+ nowMs = this._nowMs(),
268
+ }) {
269
+ const history = this._trimOutboundHistory(key, nowMs);
270
+ history.push(nowMs);
271
+ this.outboundHistory.set(key, history);
272
+
273
+ if (history.length > this.outboundRateLimit.softCap) {
274
+ console.warn(
275
+ `[WARN] [AgentManager] outbound rate high agent=${agent?.config?.displayName ?? agentId.slice(0, 8)} `
276
+ + `workspace=${workspaceId ?? 'none'} count_60s=${history.length}`
277
+ );
278
+ }
279
+ if (history.length <= this.outboundRateLimit.hardCap) {
280
+ return;
281
+ }
282
+
283
+ const cooldownUntilMs = nowMs + this.outboundRateLimit.cooldownMs;
284
+ this.outboundCooldownUntil.set(key, cooldownUntilMs);
285
+ this.inboxPauseUntil.set(key, cooldownUntilMs);
286
+
287
+ console.warn(
288
+ `[WARN] [AgentManager] outbound hard cap triggered agent=${agent?.config?.displayName ?? agentId.slice(0, 8)} `
289
+ + `workspace=${workspaceId ?? 'none'} count_60s=${history.length} cooldown_until=${new Date(cooldownUntilMs).toISOString()}`
290
+ );
291
+ this._emitOutboundRateLimitEvent(connection, {
292
+ agentId,
293
+ workspaceId,
294
+ historyCount: history.length,
295
+ nowMs,
296
+ cooldownUntilMs,
297
+ });
298
+
299
+ if (agent?.proc) {
300
+ agent.stopCause = 'outbound_rate_limited';
301
+ try {
302
+ agent.proc.kill();
303
+ } catch (err) {
304
+ console.warn(`[AgentManager] Failed to stop rate-limited agent ${agentId}: ${err.message}`);
305
+ }
306
+ }
307
+ }
308
+
192
309
  _materializeSkills(workspaceDir, skills) {
193
310
  const skillsDir = path.join(workspaceDir, '.skills');
194
311
  mkdirSync(skillsDir, { recursive: true });
@@ -206,8 +323,64 @@ export class AgentManager {
206
323
  }
207
324
  }
208
325
 
326
+ _normalizeDeliverySenderType(senderType) {
327
+ const normalized = String(senderType ?? '').trim().toLowerCase();
328
+ if (normalized === 'user') return 'user';
329
+ if (normalized === 'agent') return 'peer-agent';
330
+ return 'system';
331
+ }
332
+
333
+ _formatDeliveryHeaderValue(value) {
334
+ const normalized = String(value ?? '').trim();
335
+ if (!normalized) return 'unknown';
336
+ if (/^[A-Za-z0-9_:@#./-]+$/.test(normalized)) return normalized;
337
+ return JSON.stringify(normalized);
338
+ }
339
+
340
+ _formatDeliveryTarget(message) {
341
+ const workspaceType = String(message?.workspace_type ?? '').trim().toLowerCase();
342
+ const workspaceName = String(message?.workspace_name ?? '').trim();
343
+ const parentWorkspaceName = String(message?.parent_workspace_name ?? '').trim();
344
+ const parentWorkspaceType = String(message?.parent_workspace_type ?? '').trim().toLowerCase();
345
+
346
+ if (workspaceType === 'dm' || workspaceType === 'lightcone_dm') {
347
+ return `dm:@${workspaceName || 'unknown'}`;
348
+ }
349
+ if (workspaceType === 'thread') {
350
+ if (parentWorkspaceType === 'dm' || parentWorkspaceType === 'lightcone_dm') {
351
+ return `dm:@${parentWorkspaceName || workspaceName || 'unknown'}`;
352
+ }
353
+ return `#${parentWorkspaceName || workspaceName || 'unknown'}`;
354
+ }
355
+ return `#${workspaceName || 'all'}`;
356
+ }
357
+
209
358
  _formatDeliveryText(message) {
210
- return `New message in ${message.workspace_type === 'dm' ? 'dm from' : `#${message.workspace_name} from`} ${message.sender_name}: ${message.content}`;
359
+ const fromType = this._normalizeDeliverySenderType(message?.sender_type);
360
+ const senderName = String(message?.sender_name ?? '').trim();
361
+ const target = this._formatDeliveryTarget(message);
362
+ const messageIdShort = String(message?.message_id ?? '').trim().slice(0, 8);
363
+ const time = String(message?.timestamp ?? '').trim();
364
+ const content = String(message?.content ?? '').trim();
365
+
366
+ const fields = [`from=${fromType}`];
367
+ if (fromType === 'user') {
368
+ fields.push(`user=${this._formatDeliveryHeaderValue(senderName || 'unknown')}`);
369
+ } else if (fromType === 'peer-agent') {
370
+ fields.push(`agent=${this._formatDeliveryHeaderValue(senderName || 'unknown')}`);
371
+ } else {
372
+ const reason = String(message?.reason ?? message?.type ?? 'delivery').trim() || 'delivery';
373
+ fields.push(`reason=${this._formatDeliveryHeaderValue(reason)}`);
374
+ }
375
+ fields.push(`target=${this._formatDeliveryHeaderValue(target)}`);
376
+ if (messageIdShort) fields.push(`msg=${this._formatDeliveryHeaderValue(messageIdShort)}`);
377
+ if (time) fields.push(`time=${this._formatDeliveryHeaderValue(time)}`);
378
+
379
+ if (fromType === 'system') {
380
+ return `[${fields.join(' ')}] ${content}`;
381
+ }
382
+ const speaker = senderName ? `@${senderName}` : '@unknown';
383
+ return `[${fields.join(' ')}] ${speaker}: ${content}`;
211
384
  }
212
385
 
213
386
  _emitLifecycle(connection, {
@@ -235,6 +408,35 @@ export class AgentManager {
235
408
  });
236
409
  }
237
410
 
411
+ _emitCapabilityProbe(connection, {
412
+ agentId,
413
+ workspaceId,
414
+ capabilityId = CHAT_CAPABILITY_ID,
415
+ state = 'unknown',
416
+ reason = null,
417
+ runtime = null,
418
+ details = null,
419
+ latencyMs = null,
420
+ probedAt = new Date().toISOString(),
421
+ }) {
422
+ const normalizedCapabilityId = String(capabilityId ?? '').trim().toLowerCase() || CHAT_CAPABILITY_ID;
423
+ const normalizedState = String(state ?? '').trim().toLowerCase() || 'unknown';
424
+ const normalizedReason = String(reason ?? '').trim() || null;
425
+ const normalizedRuntime = String(runtime ?? '').trim().toLowerCase() || null;
426
+ connection.send({
427
+ type: 'agent:capability_probe',
428
+ agentId,
429
+ workspaceId,
430
+ capabilityId: normalizedCapabilityId,
431
+ state: normalizedState,
432
+ reason: normalizedReason,
433
+ runtime: normalizedRuntime,
434
+ details: details && typeof details === 'object' ? details : null,
435
+ latencyMs: Number.isFinite(Number(latencyMs)) ? Number(latencyMs) : null,
436
+ probedAt,
437
+ });
438
+ }
439
+
238
440
  _takePendingMessage(key) {
239
441
  if (!this._pendingMessages) return null;
240
442
  const pending = this._pendingMessages.get(key);
@@ -320,10 +522,14 @@ export class AgentManager {
320
522
  _buildCodexMcpArgs(mcpServers) {
321
523
  const args = [];
322
524
  for (const [serverKey, server] of Object.entries(mcpServers)) {
525
+ const normalizedKey = String(serverKey ?? '').trim();
526
+ if (!normalizedKey) continue;
527
+ const keyExpr = /^[A-Za-z0-9_-]+$/.test(normalizedKey)
528
+ ? normalizedKey
529
+ : JSON.stringify(normalizedKey);
323
530
  const serverArgs = Array.isArray(server.args) ? server.args.map((item) => String(item)) : [];
324
531
  const envVars = normalizeObject(server.env);
325
532
  const envPairs = Object.entries(envVars).map(([k, v]) => `${k}=${String(v ?? '')}`);
326
- const keyExpr = JSON.stringify(serverKey);
327
533
  const commandExpr = JSON.stringify(envPairs.length > 0 ? 'env' : server.command);
328
534
  const argsExpr = JSON.stringify(envPairs.length > 0 ? [...envPairs, server.command, ...serverArgs] : serverArgs);
329
535
  args.push(
@@ -373,7 +579,6 @@ export class AgentManager {
373
579
  const codexPrompt = startupMsg
374
580
  ? `${codexBasePrompt}\n\nNew message received:\n\n${this._formatDeliveryText(startupMsg.message)}\n\nRespond as appropriate. Complete all required work before stopping.`
375
581
  : codexBasePrompt;
376
-
377
582
  const authToken = config?.authToken || this.machineApiKey;
378
583
  const userId = config?.userId ?? 'default';
379
584
  const profileRoot = path.join(homedir(), '.lightcone', 'chrome-profiles');
@@ -390,6 +595,17 @@ export class AgentManager {
390
595
  };
391
596
  const mcpServers = this._resolveDirectiveMcpServers(directive, baseReplacements);
392
597
 
598
+ if (runtime === 'codex') {
599
+ const mcpKeys = Object.keys(mcpServers);
600
+ console.log(
601
+ `[AgentManager][codex][spawn] agent=${config?.displayName ?? agentId} workspace=${workspaceId ?? 'none'} `
602
+ + `startup_seq=${startupMsg?.seq ?? 'none'} prompt_len=${codexPrompt.length} `
603
+ + `session=${config?.sessionId ?? 'none'} `
604
+ + `mcp=${mcpKeys.join(',') || 'none'} `
605
+ + `startup_preview="${previewText(startupMsg?.message?.content ?? '')}"`
606
+ );
607
+ }
608
+
393
609
  let kimiFiles = null;
394
610
  if (runtime === 'kimi') {
395
611
  kimiFiles = this._createKimiConfigFiles(workspaceDir, {
@@ -596,6 +812,28 @@ export class AgentManager {
596
812
  console.log(`[AgentManager] Agent ${config?.displayName ?? agentId} in workspace ${workspaceName ?? workspaceId} already registered`);
597
813
  return;
598
814
  }
815
+ const remainingCooldownMs = this._getRemainingCooldownMs(key);
816
+ if (remainingCooldownMs > 0) {
817
+ const remainingSeconds = Math.ceil(remainingCooldownMs / 1000);
818
+ const reason = `outbound_rate_limited_cooldown:${remainingSeconds}s`;
819
+ console.warn(
820
+ `[WARN] [AgentManager] Rejecting start for rate-limited agent=${config?.displayName ?? agentId} `
821
+ + `workspace=${workspaceName ?? workspaceId ?? 'none'} cooldown_remaining_s=${remainingSeconds}`
822
+ );
823
+ this._emitLifecycle(connection, {
824
+ agentId,
825
+ workspaceId,
826
+ runtime: config?.runtime ?? 'claude',
827
+ reachability: 'reachable',
828
+ availability: 'paused',
829
+ runtimeState: 'stopped',
830
+ reason,
831
+ sessionId: config?.sessionId ?? null,
832
+ });
833
+ connection.send({ type: 'agent:status', agentId, workspaceId, status: 'inactive' });
834
+ connection.send({ type: 'agent:activity', agentId, workspaceId, activity: 'offline', detail: reason, entries: [] });
835
+ return;
836
+ }
599
837
  this.starting.add(key);
600
838
 
601
839
  const requestedRuntime = String(config.runtime ?? 'claude').trim().toLowerCase() || 'claude';
@@ -607,6 +845,15 @@ export class AgentManager {
607
845
  rollbackStartupMessage?.(reason ?? 'spawn_failed');
608
846
  this._clearDirectiveRefresh(key);
609
847
  this.starting.delete(key);
848
+ this._emitCapabilityProbe(connection, {
849
+ agentId,
850
+ workspaceId,
851
+ capabilityId: CHAT_CAPABILITY_ID,
852
+ state: 'unready',
853
+ reason: reason ?? 'spawn_failed',
854
+ runtime: runtimeHint,
855
+ details: { stage: 'fail_start' },
856
+ });
610
857
  this._emitLifecycle(connection, {
611
858
  agentId,
612
859
  workspaceId,
@@ -773,6 +1020,40 @@ export class AgentManager {
773
1020
  return;
774
1021
  }
775
1022
 
1023
+ this._emitCapabilityProbe(connection, {
1024
+ agentId,
1025
+ workspaceId,
1026
+ capabilityId: CHAT_CAPABILITY_ID,
1027
+ state: 'unknown',
1028
+ reason: 'probe_pending',
1029
+ runtime,
1030
+ details: { stage: 'spawn_preflight' },
1031
+ });
1032
+
1033
+ const capabilityProbe = await probeChatBridgeCapability({
1034
+ chatBridgePath,
1035
+ cwd: workspaceDir,
1036
+ timeoutMs: CAPABILITY_PROBE_TIMEOUT_MS,
1037
+ env: {
1038
+ ...spawnPlan.env,
1039
+ SERVER_URL: spawnPlan.env?.SERVER_URL ?? this.serverUrl,
1040
+ MACHINE_API_KEY: spawnPlan.env?.MACHINE_API_KEY ?? runtimeConfig.authToken ?? this.machineApiKey,
1041
+ AGENT_ID: spawnPlan.env?.AGENT_ID ?? agentId,
1042
+ WORKSPACE_ID: spawnPlan.env?.WORKSPACE_ID ?? (workspaceId ?? ''),
1043
+ WORKSPACE_DIR: spawnPlan.env?.WORKSPACE_DIR ?? workspaceDir,
1044
+ },
1045
+ });
1046
+ this._emitCapabilityProbe(connection, {
1047
+ agentId,
1048
+ workspaceId,
1049
+ capabilityId: CHAT_CAPABILITY_ID,
1050
+ state: capabilityProbe.state,
1051
+ reason: capabilityProbe.reason,
1052
+ runtime,
1053
+ details: capabilityProbe.details,
1054
+ latencyMs: capabilityProbe.latencyMs,
1055
+ });
1056
+
776
1057
  console.log(`[AgentManager] Spawning ${runtime} for ${config.displayName ?? agentId} workspace=${workspaceName ?? workspaceId ?? 'none'} directive=${directive.directive_id ?? 'n/a'}`);
777
1058
  const stdio = runtime === 'codex'
778
1059
  ? ['ignore', 'pipe', 'pipe']
@@ -791,6 +1072,15 @@ export class AgentManager {
791
1072
  this._clearDirectiveRefresh(key);
792
1073
  this.starting.delete(key);
793
1074
  this.agents.delete(key);
1075
+ this._emitCapabilityProbe(connection, {
1076
+ agentId,
1077
+ workspaceId,
1078
+ capabilityId: CHAT_CAPABILITY_ID,
1079
+ state: 'unready',
1080
+ reason: detail ?? 'spawn_failed',
1081
+ runtime,
1082
+ details: { stage: 'spawn_error' },
1083
+ });
794
1084
  this._emitLifecycle(connection, {
795
1085
  agentId,
796
1086
  workspaceId,
@@ -828,6 +1118,7 @@ export class AgentManager {
828
1118
  kimiIdle: false,
829
1119
  directive,
830
1120
  requiredCredentials,
1121
+ lastStartupMessage: startupMsg?.message ?? null,
831
1122
  stopCause: null,
832
1123
  });
833
1124
  this.starting.delete(key);
@@ -875,8 +1166,11 @@ export class AgentManager {
875
1166
  sessionId: config.sessionId ?? null,
876
1167
  proc,
877
1168
  runtime: 'codex',
1169
+ codexVisibleTextSeen: false,
1170
+ codexSendMessageUsed: false,
878
1171
  directive,
879
1172
  requiredCredentials,
1173
+ lastStartupMessage: startupMsg?.message ?? null,
880
1174
  stopCause: null,
881
1175
  });
882
1176
  this.starting.delete(key);
@@ -901,6 +1195,7 @@ export class AgentManager {
901
1195
  runtime: 'claude',
902
1196
  directive,
903
1197
  requiredCredentials,
1198
+ lastStartupMessage: startupMsg?.message ?? null,
904
1199
  stopCause: null,
905
1200
  });
906
1201
  this.starting.delete(key);
@@ -929,7 +1224,12 @@ export class AgentManager {
929
1224
  this._clearDirectiveRefresh(key);
930
1225
  this.agents.delete(key);
931
1226
 
932
- if (code === 0 && runtime === 'codex' && this._pendingMessages?.get(key)?.length) {
1227
+ if (code === 0
1228
+ && runtime === 'codex'
1229
+ && agent?.stopCause !== 'contract_violation'
1230
+ && agent?.stopCause !== 'outbound_rate_limited'
1231
+ && this._getRemainingCooldownMs(key) <= 0
1232
+ && this._pendingMessages?.get(key)?.length) {
933
1233
  const pendingCount = this._pendingMessages?.get(key)?.length ?? 0;
934
1234
  console.log(
935
1235
  `[AgentManager] Codex exited cleanly with pending=${pendingCount}; restarting next turn for ${config.displayName ?? agentId}`
@@ -1293,6 +1593,13 @@ export class AgentManager {
1293
1593
  _flushPending(key, connection) {
1294
1594
  if (!this._pendingMessages) return;
1295
1595
  if (this.agents.get(key)?.runtime === 'codex') return;
1596
+ if (this._isInboundFlushPaused(key)) {
1597
+ const pendingCount = this._pendingMessages.get(key)?.length ?? 0;
1598
+ if (pendingCount > 0) {
1599
+ console.warn(`[WARN] [AgentManager] Inbox flush paused key=${key} pending=${pendingCount}`);
1600
+ }
1601
+ return;
1602
+ }
1296
1603
  const pending = this._pendingMessages.get(key);
1297
1604
  if (!pending || pending.length === 0) return;
1298
1605
  this._pendingMessages.delete(key);
@@ -1358,7 +1665,21 @@ export class AgentManager {
1358
1665
  });
1359
1666
  connection.send({ type: 'agent:activity', agentId, workspaceId, activity: 'thinking', detail: '', entries: [] });
1360
1667
  break;
1668
+ case 'text':
1669
+ agent.codexVisibleTextSeen = true;
1670
+ console.log(`[AgentManager][${agent.config?.displayName ?? agentId.slice(0, 8)}][codex] <text> ${String(evt.text ?? '').slice(0, 500)}`);
1671
+ break;
1361
1672
  case 'tool_call':
1673
+ if (evt.name === 'send_message') {
1674
+ agent.codexSendMessageUsed = true;
1675
+ this._recordSuccessfulOutboundMessage({
1676
+ key,
1677
+ agent,
1678
+ agentId,
1679
+ workspaceId,
1680
+ connection,
1681
+ });
1682
+ }
1362
1683
  this._emitLifecycle(connection, {
1363
1684
  agentId,
1364
1685
  workspaceId,
@@ -1395,6 +1716,9 @@ export class AgentManager {
1395
1716
  _parseCodexLine(key, agentId, workspaceId, line, connection) {
1396
1717
  const agent = this.agents.get(key);
1397
1718
  if (!agent) return;
1719
+ console.log(
1720
+ `[AgentManager][${agent.config?.displayName ?? agentId.slice(0, 8)}][codex][raw] ${String(line).slice(0, 1200)}`
1721
+ );
1398
1722
  const events = parseCodexLine(line);
1399
1723
  for (const evt of events) {
1400
1724
  switch (evt.kind) {
@@ -1417,7 +1741,26 @@ export class AgentManager {
1417
1741
  });
1418
1742
  connection.send({ type: 'agent:activity', agentId, workspaceId, activity: 'thinking', detail: '', entries: [] });
1419
1743
  break;
1744
+ case 'text':
1745
+ agent.codexVisibleTextSeen = true;
1746
+ console.log(
1747
+ `[AgentManager][${agent.config?.displayName ?? agentId.slice(0, 8)}][codex][text] ${String(evt.text ?? '').slice(0, 1200)}`
1748
+ );
1749
+ break;
1420
1750
  case 'tool_call':
1751
+ if (evt.name === 'send_message') {
1752
+ agent.codexSendMessageUsed = true;
1753
+ this._recordSuccessfulOutboundMessage({
1754
+ key,
1755
+ agent,
1756
+ agentId,
1757
+ workspaceId,
1758
+ connection,
1759
+ });
1760
+ }
1761
+ console.log(
1762
+ `[AgentManager][${agent.config?.displayName ?? agentId.slice(0, 8)}][codex][tool] ${evt.name ?? 'unknown_tool'}`
1763
+ );
1421
1764
  this._emitLifecycle(connection, {
1422
1765
  agentId,
1423
1766
  workspaceId,
@@ -1431,6 +1774,15 @@ export class AgentManager {
1431
1774
  connection.send({ type: 'agent:activity', agentId, workspaceId, activity: 'working', detail: evt.name, entries: [] });
1432
1775
  break;
1433
1776
  case 'turn_end':
1777
+ const startupSenderType = String(agent.lastStartupMessage?.sender_type ?? '').trim().toLowerCase();
1778
+ if (startupSenderType === 'user' && agent.codexVisibleTextSeen && !agent.codexSendMessageUsed) {
1779
+ agent.stopCause = 'contract_violation';
1780
+ console.warn(
1781
+ `[AgentManager][${agent.config?.displayName ?? agentId.slice(0, 8)}][codex] contract_violation: visible text emitted without send_message workspace=${workspaceId ?? 'none'}`
1782
+ );
1783
+ }
1784
+ agent.codexVisibleTextSeen = false;
1785
+ agent.codexSendMessageUsed = false;
1434
1786
  this._emitLifecycle(connection, {
1435
1787
  agentId,
1436
1788
  workspaceId,
@@ -1444,6 +1796,9 @@ export class AgentManager {
1444
1796
  connection.send({ type: 'agent:activity', agentId, workspaceId, activity: 'online', detail: '', entries: [] });
1445
1797
  break;
1446
1798
  case 'error':
1799
+ console.error(
1800
+ `[AgentManager][${agent.config?.displayName ?? agentId.slice(0, 8)}][codex][error] ${evt.message}`
1801
+ );
1447
1802
  console.error(`[AgentManager][codex][${agentId}] Error: ${evt.message}`);
1448
1803
  break;
1449
1804
  }
@@ -1475,6 +1830,18 @@ export class AgentManager {
1475
1830
  console.log(`[AgentManager][${displayName}] <text> ${block.text?.slice(0, 500)}`);
1476
1831
  } else if (block.type === 'tool_use') {
1477
1832
  console.log(`[AgentManager][${displayName}] <tool_use> ${block.name} params=${JSON.stringify(block.input ?? {}).slice(0, 500)}`);
1833
+ if (block.name === 'send_message') {
1834
+ const agent = this.agents.get(key);
1835
+ if (agent) {
1836
+ this._recordSuccessfulOutboundMessage({
1837
+ key,
1838
+ agent,
1839
+ agentId,
1840
+ workspaceId,
1841
+ connection,
1842
+ });
1843
+ }
1844
+ }
1478
1845
  this._emitLifecycle(connection, {
1479
1846
  agentId,
1480
1847
  workspaceId,
@@ -0,0 +1,113 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
3
+
4
+ const REQUIRED_CHAT_TOOLS = Object.freeze(['check_messages', 'send_message']);
5
+ const DEFAULT_PROBE_TIMEOUT_MS = 8000;
6
+
7
+ function toErrorCode(error, fallback = 'probe_failed') {
8
+ const message = String(error?.message ?? error ?? '').trim();
9
+ if (!message) return fallback;
10
+ return message.toLowerCase().replace(/[^a-z0-9:_-]+/g, '_').slice(0, 120) || fallback;
11
+ }
12
+
13
+ function withTimeout(promise, timeoutMs, label) {
14
+ const effectiveTimeoutMs = Math.max(1000, Number(timeoutMs) || DEFAULT_PROBE_TIMEOUT_MS);
15
+ let timeout = null;
16
+ const timeoutPromise = new Promise((_, reject) => {
17
+ timeout = setTimeout(() => {
18
+ const error = new Error(label);
19
+ error.code = label;
20
+ reject(error);
21
+ }, effectiveTimeoutMs);
22
+ });
23
+ return Promise.race([
24
+ promise,
25
+ timeoutPromise,
26
+ ]).finally(() => {
27
+ if (timeout) clearTimeout(timeout);
28
+ });
29
+ }
30
+
31
+ function normalizeEnv(env = {}) {
32
+ const normalized = {};
33
+ for (const [key, value] of Object.entries(env)) {
34
+ if (value == null) continue;
35
+ normalized[key] = String(value);
36
+ }
37
+ return normalized;
38
+ }
39
+
40
+ function summarizeMissingTools(toolNames) {
41
+ return REQUIRED_CHAT_TOOLS.filter((toolName) => !toolNames.has(toolName));
42
+ }
43
+
44
+ export async function probeChatBridgeCapability({
45
+ chatBridgePath,
46
+ cwd,
47
+ env = {},
48
+ timeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
49
+ } = {}) {
50
+ const startedAt = Date.now();
51
+ const normalizedEnv = normalizeEnv(env);
52
+ const client = new Client({
53
+ name: 'lightcone-capability-probe',
54
+ version: '1.0.0',
55
+ });
56
+ const transport = new StdioClientTransport({
57
+ command: process.execPath,
58
+ args: [chatBridgePath],
59
+ cwd,
60
+ env: normalizedEnv,
61
+ stderr: 'pipe',
62
+ });
63
+
64
+ try {
65
+ await withTimeout(client.connect(transport), timeoutMs, 'probe_connect_timeout');
66
+ const listed = await withTimeout(client.listTools(), timeoutMs, 'probe_list_tools_timeout');
67
+ const tools = Array.isArray(listed?.tools) ? listed.tools : [];
68
+ const toolNames = new Set(
69
+ tools
70
+ .map((tool) => String(tool?.name ?? '').trim())
71
+ .filter(Boolean)
72
+ );
73
+ const missingTools = summarizeMissingTools(toolNames);
74
+ const latencyMs = Date.now() - startedAt;
75
+ if (missingTools.length > 0) {
76
+ return {
77
+ state: 'unready',
78
+ reason: `missing_tools:${missingTools.join(',')}`,
79
+ latencyMs,
80
+ details: {
81
+ toolCount: tools.length,
82
+ missingTools,
83
+ },
84
+ };
85
+ }
86
+ return {
87
+ state: 'ready',
88
+ reason: null,
89
+ latencyMs,
90
+ details: {
91
+ toolCount: tools.length,
92
+ missingTools: [],
93
+ },
94
+ };
95
+ } catch (error) {
96
+ return {
97
+ state: 'unready',
98
+ reason: toErrorCode(error, 'probe_failed'),
99
+ latencyMs: Date.now() - startedAt,
100
+ details: {
101
+ toolCount: null,
102
+ missingTools: [...REQUIRED_CHAT_TOOLS],
103
+ error: String(error?.message ?? error ?? 'unknown_error').slice(0, 500),
104
+ },
105
+ };
106
+ } finally {
107
+ try {
108
+ await withTimeout(client.close(), 1200, 'probe_close_timeout');
109
+ } catch {
110
+ // noop: closing a failed/half-open client may throw.
111
+ }
112
+ }
113
+ }
@@ -53,6 +53,12 @@ const GOVERNANCE_TIMEOUT_MS = Number(process.env.GOVERNANCE_TIMEOUT_MS ?? 5000);
53
53
  const LEASE_GRACE_MS = Number(process.env.GOVERNANCE_LEASE_GRACE_MS ?? 5000);
54
54
  const BUNDLE_EVENT_FLUSH_MS = Number(process.env.GOVERNANCE_BUNDLE_FLUSH_MS ?? 2000);
55
55
 
56
+ function redactTokenPrefix(token) {
57
+ const normalized = String(token ?? '').trim();
58
+ if (!normalized) return 'missing';
59
+ return normalized.slice(0, 18);
60
+ }
61
+
56
62
  // Current active workspaceId for memory isolation (defaults to spawn-time WORKSPACE_ID)
57
63
  let currentWorkspaceId = WORKSPACE_ID;
58
64
 
@@ -2266,4 +2272,12 @@ if (IS_HOST_AGENT) {
2266
2272
  // ── start ─────────────────────────────────────────────────────────────────────
2267
2273
  const transport = new StdioServerTransport();
2268
2274
  await server.connect(transport);
2269
- console.error(`[chat-bridge] MCP Server started (agentId=${AGENT_ID}, host=${IS_HOST_AGENT ? 'true' : 'false'})`);
2275
+ console.error(
2276
+ `[chat-bridge] MCP Server started `
2277
+ + `agentId=${AGENT_ID || 'missing'} `
2278
+ + `workspaceId=${currentWorkspaceId || 'none'} `
2279
+ + `host=${IS_HOST_AGENT ? 'true' : 'false'} `
2280
+ + `serverUrl=${SERVER_URL || 'missing'} `
2281
+ + `machineKey=${redactTokenPrefix(MACHINE_API_KEY)} `
2282
+ + `governanceBundle=${GOVERNANCE_SPAWN_BUNDLE_ID || 'none'}`
2283
+ );
@@ -40,34 +40,46 @@ You have MCP tools from the "chat" server. Use ONLY these for communication:
40
40
 
41
41
  CRITICAL RULES:
42
42
  - Always communicate through ${t("send_message")}. This is your only output method.
43
+ - Plain assistant text is not a user-visible reply. If you need the human or another agent to see something, you must call ${t("send_message")}.
43
44
  - Use only the provided MCP tools for messaging — they are already available and ready.
44
45
  - Always claim a task via ${t("claim_tasks")} before starting work on it. If the claim fails, move on to a different task.
45
46
 
46
47
  ## Startup sequence
47
48
 
48
- 1. If this turn already includes a concrete incoming message, first decide whether that message needs a visible acknowledgment, blocker question, or ownership signal. If it does, send it early with ${t("send_message")} before deep context gathering.
49
+ 1. If this turn already includes a concrete incoming message with \`from=user\`, first decide whether it needs a visible acknowledgment, blocker question, or ownership signal. If it does, send it early with ${t("send_message")} before deep context gathering.
49
50
  2. Read MEMORY.md (via ${t("read_memory")}) and then only the additional memory/notes files you need to handle the current turn well.
50
51
  3. If there is no concrete incoming message to handle, stop and wait. New messages will be delivered to you automatically via stdin.
51
- 4. When you receive a message, process it and reply with ${t("send_message")}.
52
+ 4. When you receive a message, first apply the audience-aware rules below before deciding whether to call ${t("send_message")}. Do not treat plain narrative output as a substitute for a visible reply.
52
53
  5. **Complete ALL your work before stopping.** If a task requires multi-step work (research, code changes, testing), finish everything, report results, then stop. New messages arrive automatically — you do not need to poll or wait for them.
53
54
 
54
55
  ## Messaging
55
56
 
56
- Messages you receive have a single RFC 5424-style structured data header followed by the sender and content:
57
+ Messages you receive have a single structured data header followed by the sender/content payload:
57
58
 
58
59
  \`\`\`
59
- [target=#general msg=a1b2c3d4 time=2026-03-15T01:00:00] @richard: hello everyone
60
- [target=#general msg=e5f6a7b8 time=2026-03-15T01:00:01 type=agent] @Alice: hi there
61
- [target=dm:@richard msg=c9d0e1f2 time=2026-03-15T01:00:02] @richard: hey, can you help?
62
- [target=#general:a1b2c3d4 msg=f3a4b5c6 time=2026-03-15T01:00:03] @richard: thread reply
63
- [target=dm:@richard:x9y8z7a0 msg=d7e8f9a0 time=2026-03-15T01:00:04] @richard: DM thread reply
60
+ [from=user user=richard target=#general msg=a1b2c3d4 time=2026-03-15T01:00:00] @richard: hello everyone
61
+ [from=peer-agent agent=Alice target=#general msg=e5f6a7b8 time=2026-03-15T01:00:01] @Alice: hi there
62
+ [from=user user=richard target=dm:@richard msg=c9d0e1f2 time=2026-03-15T01:00:02] @richard: hey, can you help?
63
+ [from=system reason=startup target=#general msg=f3a4b5c6 time=2026-03-15T01:00:03] Agent resumed
64
64
  \`\`\`
65
65
 
66
66
  Header fields:
67
+ - \`from=\` — one of \`user\` / \`peer-agent\` / \`system\`.
67
68
  - \`target=\` — where the message came from. Reuse as the \`target\` parameter when replying.
68
69
  - \`msg=\` — message short ID (first 8 chars of UUID). Use as thread suffix to start/reply in a thread.
69
70
  - \`time=\` — timestamp.
70
- - \`type=agent\` — present only if the sender is an agent.
71
+
72
+ ## Audience-aware messaging
73
+
74
+ Each incoming message has a \`from=\` tag and should be handled as follows:
75
+
76
+ - **from=user**: this is a real user request. You MUST acknowledge with ${t("send_message")} before deep context-gathering, even if the acknowledgment is short.
77
+ - **from=peer-agent**: default is **no reply**. Reply only when at least one condition holds:
78
+ 1. You were explicitly @mentioned.
79
+ 2. You have materially new information that changes execution decisions.
80
+ 3. The peer explicitly asked you a concrete question.
81
+ Pure receipts like "收到/已对齐/分工确认" are not requests and should not trigger a reply.
82
+ - **from=system**: internal lifecycle/governance/startup signal. Do not reply unless explicitly instructed.
71
83
 
72
84
  ### Sending messages
73
85
 
@@ -123,6 +135,12 @@ Only top-level workspace / DM messages can become tasks. Messages inside threads
123
135
  4. When done, set status to \`in_review\` so a human can validate
124
136
  5. After approval (e.g. "looks good", "merge it"), set status to \`done\`
125
137
 
138
+ **Primary-agent dispatch hard rule (fail-closed):**
139
+ - If your role is the workspace primary agent/owner and a user sends an execution request, you MUST call \`${t("create_tasks")}\` first and include an explicit \`scenario_type\` before routing work via \`${t("send_message")}\`.
140
+ - Execution requests include requests like content writing, short-video scripting, research, design/asset production, implementation, or any request that requires downstream execution instead of a simple answer.
141
+ - Use \`scenario_type\` values declared by your scenario manifest/dispatch protocol (for example: \`trend_scan\`, \`topic_research\`, \`research\`, \`graphic_writing\`, \`short_video_scripting\`, \`publish\`).
142
+ - Do not route execution work with only \`${t("send_message")}\`: skipping \`${t("create_tasks")}\` can cause downstream \`${t("claim_tasks")}\` failures and deadlock the workflow.
143
+
126
144
  **What \`${t("create_tasks")}\` really means:**
127
145
  - Tasks live in the same chat flow as messages. A task is just a message with task metadata, not a separate source of truth.
128
146
  - \`${t("create_tasks")}\` is a convenience helper for a specific sequence: create a brand-new message, then publish that new message as a task-message.
@@ -39,6 +39,14 @@ function quote(value) {
39
39
  return JSON.stringify(value);
40
40
  }
41
41
 
42
+ function formatCodexServerKey(serverKey) {
43
+ const normalizedKey = String(serverKey ?? '').trim();
44
+ if (!normalizedKey) return null;
45
+ return /^[A-Za-z0-9_-]+$/.test(normalizedKey)
46
+ ? normalizedKey
47
+ : quote(normalizedKey);
48
+ }
49
+
42
50
  function normalizeCodexModel(model) {
43
51
  if (!model) return 'gpt-5.2';
44
52
  const normalized = String(model).trim().toLowerCase();
@@ -71,7 +79,6 @@ export function adaptCodexSystemPrompt(sourcePrompt) {
71
79
  if (!basePrompt.trim()) return '';
72
80
 
73
81
  let prompt = basePrompt
74
- .replaceAll('mcp__chat__', '')
75
82
  .replace(
76
83
  '3. If there is no concrete incoming message to handle, stop and wait. New messages will be delivered to you automatically via stdin.',
77
84
  '3. If there is no concrete incoming message to handle, stop. The daemon will restart you when new messages arrive.'
@@ -117,22 +124,24 @@ function normalizeDirectiveMcpServers(value) {
117
124
  function buildDirectiveMcpFlags(mcpServers) {
118
125
  const args = [];
119
126
  for (const [serverKey, mc] of Object.entries(mcpServers)) {
127
+ const keyExpr = formatCodexServerKey(serverKey);
128
+ if (!keyExpr) continue;
120
129
  const envPairs = Object.entries(mc.env ?? {}).map(([k, v]) => `${k}=${v ?? ''}`);
121
130
  if (envPairs.length > 0) {
122
131
  args.push(
123
- '-c', `mcp_servers.${quote(serverKey)}.command=${quote('env')}`,
124
- '-c', `mcp_servers.${quote(serverKey)}.args=${quote([...envPairs, mc.command, ...(mc.args ?? [])])}`,
125
- '-c', `mcp_servers.${quote(serverKey)}.enabled=true`
132
+ '-c', `mcp_servers.${keyExpr}.command=${quote('env')}`,
133
+ '-c', `mcp_servers.${keyExpr}.args=${quote([...envPairs, mc.command, ...(mc.args ?? [])])}`,
134
+ '-c', `mcp_servers.${keyExpr}.enabled=true`
126
135
  );
127
136
  } else {
128
137
  args.push(
129
- '-c', `mcp_servers.${quote(serverKey)}.command=${quote(mc.command)}`,
130
- '-c', `mcp_servers.${quote(serverKey)}.args=${quote(mc.args ?? [])}`,
131
- '-c', `mcp_servers.${quote(serverKey)}.enabled=true`
138
+ '-c', `mcp_servers.${keyExpr}.command=${quote(mc.command)}`,
139
+ '-c', `mcp_servers.${keyExpr}.args=${quote(mc.args ?? [])}`,
140
+ '-c', `mcp_servers.${keyExpr}.enabled=true`
132
141
  );
133
142
  }
134
143
  if (mc.required) {
135
- args.push('-c', `mcp_servers.${quote(serverKey)}.required=true`);
144
+ args.push('-c', `mcp_servers.${keyExpr}.required=true`);
136
145
  }
137
146
  }
138
147
  return args;
@@ -206,19 +215,21 @@ export function buildCodexSpawn({
206
215
  });
207
216
 
208
217
  for (const [serverKey, mc] of Object.entries(skillMcpServers)) {
218
+ const keyExpr = formatCodexServerKey(serverKey);
219
+ if (!keyExpr) continue;
209
220
  const envPairs = Object.entries(mc.env ?? {}).map(([k, v]) => `${k}=${v ?? ''}`);
210
221
  if (envPairs.length > 0) {
211
222
  args.push(
212
- '-c', `mcp_servers.${quote(serverKey)}.command=${quote('env')}`,
213
- '-c', `mcp_servers.${quote(serverKey)}.args=${quote([...envPairs, mc.command, ...(mc.args ?? [])])}`,
214
- '-c', `mcp_servers.${quote(serverKey)}.enabled=true`
223
+ '-c', `mcp_servers.${keyExpr}.command=${quote('env')}`,
224
+ '-c', `mcp_servers.${keyExpr}.args=${quote([...envPairs, mc.command, ...(mc.args ?? [])])}`,
225
+ '-c', `mcp_servers.${keyExpr}.enabled=true`
215
226
  );
216
227
  continue;
217
228
  }
218
229
  args.push(
219
- '-c', `mcp_servers.${quote(serverKey)}.command=${quote(mc.command)}`,
220
- '-c', `mcp_servers.${quote(serverKey)}.args=${quote(mc.args ?? [])}`,
221
- '-c', `mcp_servers.${quote(serverKey)}.enabled=true`
230
+ '-c', `mcp_servers.${keyExpr}.command=${quote(mc.command)}`,
231
+ '-c', `mcp_servers.${keyExpr}.args=${quote(mc.args ?? [])}`,
232
+ '-c', `mcp_servers.${keyExpr}.enabled=true`
222
233
  );
223
234
  }
224
235
 
@@ -1,6 +1,8 @@
1
1
  export function resolveExitOfflineDetail({ code, signal, stopCause }) {
2
2
  if (stopCause === 'manual_stop') return '';
3
3
  if (stopCause === 'credential_revoked') return 'credential_revoked';
4
+ if (stopCause === 'contract_violation') return 'contract_violation:visible_text_without_send_message';
5
+ if (stopCause === 'outbound_rate_limited') return 'outbound_rate_limited';
4
6
  if (code === 0) return '';
5
7
  if (signal === 'SIGTERM') return '';
6
8
  if (signal === 'SIGKILL') return 'agent_timeout';
@@ -25,6 +27,22 @@ export function resolveLifecycleExitState({ runtime, code, signal, stopCause })
25
27
  reason: 'manual_stop',
26
28
  };
27
29
  }
30
+ if (stopCause === 'contract_violation') {
31
+ return {
32
+ reachability: 'reachable',
33
+ availability: 'available',
34
+ runtimeState: 'standby',
35
+ reason: 'contract_violation:visible_text_without_send_message',
36
+ };
37
+ }
38
+ if (stopCause === 'outbound_rate_limited') {
39
+ return {
40
+ reachability: 'reachable',
41
+ availability: 'paused',
42
+ runtimeState: 'stopped',
43
+ reason: 'outbound_rate_limited',
44
+ };
45
+ }
28
46
  if (normalizedRuntime === 'codex' && code === 0) {
29
47
  return {
30
48
  reachability: 'reachable',
package/src/mcp-config.js CHANGED
@@ -23,8 +23,14 @@ function resolveMcpPathToken(arg, mcpPaths = {}) {
23
23
  if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null;
24
24
 
25
25
  const legacyId = LEGACY_MCP_PATH_TOKENS[trimmed] ?? null;
26
+ const legacyUnderscoreMatch = trimmed.match(/^\{([a-z0-9_]+)_mcp_path\}$/i);
27
+ const legacyUnderscoreId = legacyUnderscoreMatch
28
+ ? legacyUnderscoreMatch[1].toLowerCase().replaceAll('_', '-')
29
+ : null;
26
30
  const dynamicMatch = trimmed.match(/^\{mcp_path:([a-z0-9][a-z0-9_-]*)\}$/i);
27
- const serverId = legacyId ?? (dynamicMatch ? dynamicMatch[1].toLowerCase() : null);
31
+ const serverId = legacyId
32
+ ?? legacyUnderscoreId
33
+ ?? (dynamicMatch ? dynamicMatch[1].toLowerCase() : null);
28
34
  if (!serverId) return null;
29
35
 
30
36
  const runtimeMcpPath = typeof mcpPaths?.[serverId] === 'string'
@@ -99,20 +105,25 @@ export function buildSkillMcpServers({
99
105
  const mc = skill.mcpConfig;
100
106
  if (mcpServers[mc.server]) continue;
101
107
 
102
- const resolvedArgs = (mc.args ?? []).map(arg => resolveSkillArg(arg, config));
103
- const resolvedEnv = {};
104
- for (const envKey of (mc.env ?? [])) {
105
- resolvedEnv[envKey] = agentEnv[envKey] ?? process.env[envKey] ?? '';
106
- }
108
+ try {
109
+ const resolvedArgs = (mc.args ?? []).map(arg => resolveSkillArg(arg, config));
110
+ const resolvedEnv = {};
111
+ for (const envKey of (mc.env ?? [])) {
112
+ resolvedEnv[envKey] = agentEnv[envKey] ?? process.env[envKey] ?? '';
113
+ }
107
114
 
108
- mcpServers[mc.server] = {
109
- command: mc.command,
110
- args: resolvedArgs,
111
- env: {
112
- ...resolvedEnv,
113
- ...baseEnvForServer(mc.server, { serverUrl, authToken, agentId, workspaceId, workspaceDir }),
114
- },
115
- };
115
+ mcpServers[mc.server] = {
116
+ command: mc.command,
117
+ args: resolvedArgs,
118
+ env: {
119
+ ...resolvedEnv,
120
+ ...baseEnvForServer(mc.server, { serverUrl, authToken, agentId, workspaceId, workspaceDir }),
121
+ },
122
+ };
123
+ } catch (error) {
124
+ const message = error instanceof Error ? error.message : String(error ?? 'unknown_error');
125
+ console.warn(`[mcp-config] Skipping MCP server '${mc.server}' due to invalid skill config: ${message}`);
126
+ }
116
127
  }
117
128
 
118
129
  return mcpServers;
@@ -1,5 +1,24 @@
1
1
  const DEFAULT_LIGHTCONE_PORT = 9779;
2
+ const DEFAULT_OUTBOUND_WINDOW_MS = 60_000;
3
+ const DEFAULT_OUTBOUND_SOFT_CAP = 5;
4
+ const DEFAULT_OUTBOUND_HARD_CAP = 10;
5
+ const DEFAULT_OUTBOUND_COOLDOWN_MS = 5 * 60_000;
6
+
7
+ function resolvePositiveInt(value, fallback) {
8
+ const parsed = Number.parseInt(String(value ?? ''), 10);
9
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
10
+ return parsed;
11
+ }
2
12
 
3
13
  export function resolveLightconeServerUrl(env = process.env) {
4
14
  return env.SERVER_URL || `http://localhost:${DEFAULT_LIGHTCONE_PORT}`;
5
15
  }
16
+
17
+ export function resolveOutboundRateLimitConfig(env = process.env) {
18
+ return {
19
+ windowMs: resolvePositiveInt(env.AGENT_OUTBOUND_RATE_WINDOW_MS, DEFAULT_OUTBOUND_WINDOW_MS),
20
+ softCap: resolvePositiveInt(env.AGENT_OUTBOUND_RATE_SOFT_CAP, DEFAULT_OUTBOUND_SOFT_CAP),
21
+ hardCap: resolvePositiveInt(env.AGENT_OUTBOUND_RATE_HARD_CAP, DEFAULT_OUTBOUND_HARD_CAP),
22
+ cooldownMs: resolvePositiveInt(env.AGENT_OUTBOUND_RATE_COOLDOWN_MS, DEFAULT_OUTBOUND_COOLDOWN_MS),
23
+ };
24
+ }