@lightcone-ai/daemon 0.14.18 → 0.15.0

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.
@@ -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
+ );