@lightcone-ai/daemon 0.15.2 → 0.15.3

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,7 +8,6 @@ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
8
8
  function randomInt(min, max) { return Math.floor(min + Math.random() * (max - min + 1)); }
9
9
 
10
10
  const PACE_SCALE = Math.max(0.2, Number(process.env.PUBLISHER_PACE_SCALE ?? '1') || 1);
11
- const DRY_RUN = process.env.DOUYIN_PUBLISH_DRY_RUN !== '0';
12
11
 
13
12
  async function humanPause(minMs, maxMs, label = '') {
14
13
  const ms = Math.round(randomInt(minMs, maxMs) * PACE_SCALE);
@@ -22,14 +21,14 @@ const REQUIREMENTS = {
22
21
  max_images: 35,
23
22
  image_formats: ['jpg', 'jpeg', 'png', 'webp'],
24
23
  required_fields: ['text', 'images'],
25
- notes: '图文模式最多 35 张图;正文最多 2200 字;默认停在最终发布前,需设置 DOUYIN_PUBLISH_DRY_RUN=0 才会点击发布',
24
+ notes: '图文模式最多 35 张图;正文最多 2200 字;自动化会填写后点击暂存,不会直接发布',
26
25
  },
27
26
  short_video: {
28
27
  max_text_length: 2200,
29
28
  video_max_duration: 900,
30
29
  video_formats: ['mp4', 'mov', 'webm'],
31
30
  required_fields: ['text', 'video'],
32
- notes: '视频最长 15 分钟;建议 9:16 竖版;默认停在最终发布前,需设置 DOUYIN_PUBLISH_DRY_RUN=0 才会点击发布',
31
+ notes: '视频最长 15 分钟;建议 9:16 竖版;自动化会填写后点击暂存,不会直接发布',
33
32
  },
34
33
  };
35
34
 
@@ -70,6 +69,14 @@ const CONTENT_SELECTOR = [
70
69
  '.ProseMirror[contenteditable="true"]',
71
70
  '[contenteditable="true"]',
72
71
  ].join(', ');
72
+ const CONTENT_EDITOR_SELECTOR = [
73
+ 'textarea[placeholder*="描述"]',
74
+ 'textarea[placeholder*="作品描述"]',
75
+ '[placeholder*="添加作品描述"]',
76
+ '[contenteditable="true"][data-placeholder*="描述"]',
77
+ '[contenteditable="true"][aria-label*="描述"]',
78
+ '.ProseMirror[contenteditable="true"]',
79
+ ].join(', ');
73
80
 
74
81
  const ERROR_SELECTORS = [
75
82
  '[class*="error"]',
@@ -125,18 +132,9 @@ export class DouyinAdapter {
125
132
 
126
133
  await humanPause(4500, 9000, 'before-publish-check');
127
134
  await this._assertNoBlockingErrors();
128
- await this._assertPublishButtonReady();
129
-
130
- if (DRY_RUN) {
131
- console.error('[DouyinAdapter] DOUYIN_PUBLISH_DRY_RUN enabled; skipping final publish click.');
132
- return { success: true, dry_run: true, post_url: null };
133
- }
134
-
135
- await humanPause(1200, 3000, 'before-publish-click');
136
- const clicked = await this._clickByText('发布');
137
- if (!clicked) throw new Error('PUBLISH_FAILED: 找不到发布按钮,页面结构可能已变化');
138
- const result = await this._waitForPublishResult(120_000);
139
- return { success: true, post_url: result.postUrl || result.url };
135
+ await this._assertDraftButtonReady();
136
+ const draftResult = await this._saveAsDraft();
137
+ return { success: true, draft_saved: true, draft_url: draftResult.url, post_url: null };
140
138
  }
141
139
 
142
140
  async publishShortVideo({ title, text, tags = [], video, cover }) {
@@ -163,18 +161,9 @@ export class DouyinAdapter {
163
161
 
164
162
  await humanPause(4500, 9000, 'before-publish-check');
165
163
  await this._assertNoBlockingErrors();
166
- await this._assertPublishButtonReady();
167
-
168
- if (DRY_RUN) {
169
- console.error('[DouyinAdapter] DOUYIN_PUBLISH_DRY_RUN enabled; skipping final publish click.');
170
- return { success: true, dry_run: true, post_url: null };
171
- }
172
-
173
- await humanPause(1200, 3000, 'before-publish-click');
174
- const clicked = await this._clickByText('发布');
175
- if (!clicked) throw new Error('PUBLISH_FAILED: 找不到发布按钮,页面结构可能已变化');
176
- const result = await this._waitForPublishResult(120_000);
177
- return { success: true, post_url: result.postUrl || result.url };
164
+ await this._assertDraftButtonReady();
165
+ const draftResult = await this._saveAsDraft();
166
+ return { success: true, draft_saved: true, draft_url: draftResult.url, post_url: null };
178
167
  }
179
168
 
180
169
  async _openPublishComposer(kind) {
@@ -325,7 +314,7 @@ export class DouyinAdapter {
325
314
  hasLoginHint: /登录|扫码|验证码|手机号/.test(text) && !/发布|作品管理|创作服务平台/.test(text),
326
315
  isCreatorLoggedIn: /创作服务平台|创作者中心|发布作品|作品管理|内容管理/.test(text),
327
316
  hasUploader: !!document.querySelector(${JSON.stringify(FILE_INPUT_SELECTOR)}),
328
- hasPublishEditor: !!document.querySelector(${JSON.stringify(`${TITLE_SELECTOR}, ${CONTENT_SELECTOR}`)}) && /发布|作品描述|标题/.test(text),
317
+ hasPublishEditor: !!document.querySelector(${JSON.stringify(`${TITLE_SELECTOR}, ${CONTENT_EDITOR_SELECTOR}`)}) && /发布|作品描述|标题|暂存/.test(text),
329
318
  postUrl: document.querySelector('a[href*="/video/"]')?.href || document.querySelector('a[href*="/note/"]')?.href || '',
330
319
  };
331
320
  })()
@@ -366,34 +355,82 @@ export class DouyinAdapter {
366
355
  if (blocking) throw new Error(`PUBLISH_BLOCKED: ${blocking}`);
367
356
  }
368
357
 
369
- async _assertPublishButtonReady() {
358
+ async _assertDraftButtonReady() {
370
359
  const state = await this._inspectPage();
371
- const button = (state.buttons ?? []).find(b => b.text === '发布' || b.text?.includes('发布'));
372
- if (!button) throw new Error('PUBLISH_FAILED: 找不到发布按钮,页面结构可能已变化');
360
+ const button = (state.buttons ?? []).find(b =>
361
+ b.text === '暂存'
362
+ || b.text?.includes('暂存')
363
+ || b.text?.includes('保存草稿')
364
+ );
365
+ if (!button) throw new Error('PUBLISH_FAILED: 找不到暂存按钮,页面结构可能已变化');
373
366
  if (button.disabled) {
374
- const errors = (state.errors ?? []).join(';') || '发布按钮不可点击,可能仍在上传、必填项缺失或账号状态受限';
367
+ const errors = (state.errors ?? []).join(';') || '暂存按钮不可点击,可能仍在上传、必填项缺失或账号状态受限';
375
368
  throw new Error(`PUBLISH_BLOCKED: ${errors}`);
376
369
  }
377
370
  }
378
371
 
372
+ async _saveAsDraft() {
373
+ await humanPause(1200, 3000, 'before-save-draft-click');
374
+ const labels = ['暂存', '暂存离开', '保存草稿'];
375
+ for (const label of labels) {
376
+ const clicked = await this._clickByText(label);
377
+ if (!clicked) continue;
378
+ return this._waitForDraftSaved(60_000);
379
+ }
380
+ throw new Error('PUBLISH_FAILED: 找不到暂存按钮,页面结构可能已变化');
381
+ }
382
+
379
383
  async _waitForUploadSettled(expectedCount, timeoutMs) {
380
384
  const deadline = Date.now() + timeoutMs;
385
+ const startedAt = Date.now();
386
+ let sawProgress = expectedCount === 0;
387
+ let stableReadyRounds = 0;
388
+ let pollCount = 0;
389
+ let lastHint = 'no_state';
390
+
381
391
  while (Date.now() < deadline) {
382
392
  await sleep(1000);
383
- await this._assertNoBlockingErrors();
393
+ pollCount += 1;
394
+
384
395
  const state = await this._inspectPage();
396
+ const failureHint = this._extractUploadFailureHint(state);
397
+ if (failureHint) throw new Error(`UPLOAD_FAILED: ${failureHint}`);
398
+
385
399
  const text = state.text ?? '';
386
- if (/上传失败|上传出错|格式不支持|文件过大/.test(text)) {
387
- throw new Error(`UPLOAD_FAILED: ${(state.errors ?? []).join(';') || '页面提示上传失败'}`);
400
+ const processing = /上传中|处理中|转码中|正在上传|解析中|合成中|压缩中|扫描中|校验中/.test(text);
401
+ if (processing) sawProgress = true;
402
+
403
+ const composer = await this._probeComposerEditability();
404
+ const editorReady = composer.contentReady;
405
+ const draftReady = composer.draftButtonExists && composer.draftButtonDisabled !== true;
406
+ const uploadedHint = /上传成功|重新上传|更换|替换|已上传|继续编辑|移除/.test(text);
407
+ const completionSignal = sawProgress || uploadedHint || composer.mediaActionVisible;
408
+
409
+ lastHint = this._formatUploadWaitHint(state, composer, {
410
+ processing,
411
+ sawProgress,
412
+ completionSignal,
413
+ stableReadyRounds,
414
+ });
415
+
416
+ if (!processing && editorReady && draftReady && completionSignal) {
417
+ stableReadyRounds += 1;
418
+ if (stableReadyRounds >= 2) return;
419
+ } else if (!processing && editorReady && draftReady && (Date.now() - startedAt) >= 25_000) {
420
+ stableReadyRounds += 1;
421
+ if (stableReadyRounds >= 4) return;
422
+ } else {
423
+ stableReadyRounds = 0;
424
+ }
425
+
426
+ if (pollCount % 10 === 0) {
427
+ console.error(`[DouyinAdapter] wait-upload poll=${pollCount} ${lastHint}`);
388
428
  }
389
- if (/上传中|处理中|转码中|正在上传|解析中|合成中/.test(text)) continue;
390
- if (state.hasPublishEditor || /上传成功|重新上传|更换|已上传|封面|发布作品/.test(text)) return;
391
- if (expectedCount === 0) return;
392
429
  }
393
- throw new Error('UPLOAD_TIMEOUT: 等待抖音上传完成超时');
430
+ throw new Error(`UPLOAD_TIMEOUT: 等待抖音上传完成超时;${lastHint}`);
394
431
  }
395
432
 
396
- async _waitForPublishResult(timeoutMs) {
433
+ async _waitForDraftSaved(timeoutMs) {
397
434
  const deadline = Date.now() + timeoutMs;
398
435
  let lastState = null;
399
436
  while (Date.now() < deadline) {
@@ -401,15 +438,99 @@ export class DouyinAdapter {
401
438
  const state = await this._inspectPage();
402
439
  lastState = state;
403
440
  const text = state.text ?? '';
404
- if (state.postUrl) return { url: state.url, postUrl: state.postUrl };
405
- if (/发布成功|提交成功|审核中|已发布/.test(text)) {
406
- return { url: state.url, postUrl: state.postUrl || state.url };
441
+ if (!state.url.includes('/creator-micro/content/upload') && !state.hasLoginHint) {
442
+ return { url: state.url };
407
443
  }
408
- const blocking = (state.errors ?? []).find(t => /失败|错误|异常|不可|不能|未通过|风控|审核|违规|实名|认证|权限/.test(t));
444
+
445
+ if (/暂存成功|草稿已保存|保存成功|已保存草稿/.test(text)) {
446
+ return { url: state.url };
447
+ }
448
+ const blocking = (state.errors ?? []).find(t => /暂存失败|保存失败|提交失败|发布失败|系统异常|网络错误/.test(t));
409
449
  if (blocking) throw new Error(`PUBLISH_FAILED: ${blocking}`);
410
450
  }
411
- const hint = (lastState?.errors ?? []).join(';') || '没有检测到成功跳转或成功提示';
412
- throw new Error(`PUBLISH_TIMEOUT: ${hint}`);
451
+ const hint = this._formatPageHint(lastState ?? { url: await this._getUrl(), text: '', buttons: [], links: [], uploadish: [] });
452
+ throw new Error(`DRAFT_TIMEOUT: 点击暂存后未检测到成功提示;${hint}`);
453
+ }
454
+
455
+ async _probeComposerEditability() {
456
+ const result = await this._cdp.send('Runtime.evaluate', {
457
+ expression: `
458
+ (function() {
459
+ const visible = (el) => {
460
+ if (!el) return false;
461
+ const r = el.getBoundingClientRect();
462
+ const s = getComputedStyle(el);
463
+ return r.width > 0 && r.height > 0 && r.left > -1000 && s.display !== 'none' && s.visibility !== 'hidden';
464
+ };
465
+ const editable = (el) => visible(el)
466
+ && !el.disabled
467
+ && el.getAttribute('aria-disabled') !== 'true'
468
+ && el.getAttribute('readonly') !== 'true';
469
+ const textOf = (el) => (el.innerText || el.textContent || el.getAttribute('aria-label') || el.getAttribute('title') || '').trim();
470
+
471
+ const titleNodes = [...document.querySelectorAll(${JSON.stringify(TITLE_SELECTOR)})].filter(editable);
472
+ const strictContentNodes = [...document.querySelectorAll(${JSON.stringify(CONTENT_EDITOR_SELECTOR)})].filter(editable);
473
+ const genericContentNodes = [...document.querySelectorAll(${JSON.stringify(CONTENT_SELECTOR)})].filter(editable);
474
+
475
+ const buttonNodes = [...document.querySelectorAll('button, [role="button"], a, div, span')]
476
+ .filter(visible)
477
+ .map(el => ({
478
+ text: textOf(el),
479
+ disabled: !!el.disabled || el.getAttribute('aria-disabled') === 'true' || (el.className?.toString?.() || '').includes('disabled'),
480
+ }))
481
+ .filter(item => item.text);
482
+ const draftButton = buttonNodes.find(item => /暂存|暂存离开|保存草稿/.test(item.text));
483
+ const publishButton = buttonNodes.find(item => /发布/.test(item.text));
484
+ const mediaActionVisible = buttonNodes.some(item => /重新上传|更换|替换|移除|删除/.test(item.text));
485
+
486
+ return {
487
+ titleReady: titleNodes.length > 0,
488
+ contentReady: strictContentNodes.length > 0 || genericContentNodes.length > 0,
489
+ draftButtonExists: !!draftButton,
490
+ draftButtonDisabled: draftButton ? draftButton.disabled : null,
491
+ publishButtonExists: !!publishButton,
492
+ publishButtonDisabled: publishButton ? publishButton.disabled : null,
493
+ mediaActionVisible,
494
+ };
495
+ })()
496
+ `,
497
+ returnByValue: true,
498
+ });
499
+ return result.result?.value ?? {
500
+ titleReady: false,
501
+ contentReady: false,
502
+ draftButtonExists: false,
503
+ draftButtonDisabled: null,
504
+ publishButtonExists: false,
505
+ publishButtonDisabled: null,
506
+ mediaActionVisible: false,
507
+ };
508
+ }
509
+
510
+ _extractUploadFailureHint(state) {
511
+ const texts = [
512
+ ...(state.errors ?? []),
513
+ state.text ?? '',
514
+ ];
515
+ const fatal = texts.find(item => /上传失败|上传出错|转码失败|格式不支持|文件过大|上传异常|上传中断|网络异常/.test(item));
516
+ if (!fatal) return null;
517
+ return String(fatal).replace(/\s+/g, ' ').trim().slice(0, 180);
518
+ }
519
+
520
+ _formatUploadWaitHint(state, composer, flags = {}) {
521
+ const errors = (state.errors ?? []).join(';') || 'none';
522
+ return [
523
+ `url=${state.url}`,
524
+ `processing=${flags.processing === true}`,
525
+ `sawProgress=${flags.sawProgress === true}`,
526
+ `completionSignal=${flags.completionSignal === true}`,
527
+ `titleReady=${composer.titleReady === true}`,
528
+ `contentReady=${composer.contentReady === true}`,
529
+ `draftButton=${composer.draftButtonExists === true}`,
530
+ `draftDisabled=${composer.draftButtonDisabled === true}`,
531
+ `stableReadyRounds=${flags.stableReadyRounds ?? 0}`,
532
+ `errors=${errors.slice(0, 180)}`,
533
+ ].join('; ');
413
534
  }
414
535
 
415
536
  async _fillField(selector, value, kind = 'content') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightcone-ai/daemon",
3
- "version": "0.15.2",
3
+ "version": "0.15.3",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -124,6 +124,24 @@ function normalizeObject(value) {
124
124
  return isPlainObject(value) ? value : {};
125
125
  }
126
126
 
127
+ function normalizePublishStage(value, fallback = 'unknown') {
128
+ const normalized = String(value ?? '').trim().toLowerCase();
129
+ if (!normalized) return fallback;
130
+ return normalized.replace(/[^a-z0-9:_-]/g, '_');
131
+ }
132
+
133
+ function buildPublishJobFailureResult(error, fallbackStage = 'unknown') {
134
+ const stage = normalizePublishStage(error?.publish_stage, fallbackStage);
135
+ const message = String(error?.publish_error ?? error?.message ?? error ?? 'unknown_error');
136
+ const context = isPlainObject(error?.publish_context) ? error.publish_context : {};
137
+ return {
138
+ ok: false,
139
+ stage,
140
+ error: message,
141
+ context,
142
+ };
143
+ }
144
+
127
145
  function replacePlaceholders(text, replacements) {
128
146
  if (typeof text !== 'string') return text;
129
147
  let next = text;
@@ -330,6 +348,79 @@ export class AgentManager {
330
348
  return 'system';
331
349
  }
332
350
 
351
+ _normalizeToolName(name) {
352
+ return String(name ?? '').trim().toLowerCase();
353
+ }
354
+
355
+ _isSendMessageToolName(name) {
356
+ const normalized = this._normalizeToolName(name);
357
+ return normalized === 'send_message'
358
+ || normalized.endsWith('__send_message')
359
+ || normalized.endsWith('.send_message');
360
+ }
361
+
362
+ _trackVisibleAssistantText(agent, text) {
363
+ if (!agent) return;
364
+ const snippet = String(text ?? '').trim();
365
+ if (!snippet) return;
366
+ agent.visibleTextSeen = true;
367
+ const previous = typeof agent.visibleTextBuffer === 'string' ? agent.visibleTextBuffer : '';
368
+ const next = previous ? `${previous}\n${snippet}` : snippet;
369
+ // Keep a bounded buffer for fallback delivery.
370
+ agent.visibleTextBuffer = next.length > 6000 ? next.slice(-6000) : next;
371
+ }
372
+
373
+ _markSendMessageToolUsed(agent) {
374
+ if (!agent) return;
375
+ agent.sendMessageUsed = true;
376
+ }
377
+
378
+ _resetVisibleReplyTracking(agent) {
379
+ if (!agent) return;
380
+ agent.visibleTextSeen = false;
381
+ agent.sendMessageUsed = false;
382
+ agent.visibleTextBuffer = '';
383
+ }
384
+
385
+ _rememberInboundMessage(agent, message) {
386
+ if (!agent || !message || typeof message !== 'object') return;
387
+ agent.lastInboundMessage = message;
388
+ }
389
+
390
+ async _recoverVisibleTextAsMessage({ agentId, workspaceId, message, content }) {
391
+ const text = String(content ?? '').trim();
392
+ if (!text || !message) return false;
393
+ const target = this._formatDeliveryTarget(message);
394
+ if (!target || target.includes('unknown')) return false;
395
+ const url = `${this.serverUrl}/internal/${encodeURIComponent(agentId)}/send`;
396
+ try {
397
+ const res = await fetch(url, {
398
+ method: 'POST',
399
+ headers: {
400
+ 'Content-Type': 'application/json',
401
+ 'Authorization': `Bearer ${this.machineApiKey}`,
402
+ },
403
+ body: JSON.stringify({ target, content: text }),
404
+ });
405
+ if (!res.ok) {
406
+ const detail = (await res.text()).slice(0, 300);
407
+ console.warn(
408
+ `[AgentManager][${agentId}][claude] fallback_send_message failed workspace=${workspaceId ?? 'none'} status=${res.status} detail=${detail}`
409
+ );
410
+ return false;
411
+ }
412
+ console.warn(
413
+ `[AgentManager][${agentId}][claude] fallback_send_message sent workspace=${workspaceId ?? 'none'} target=${target}`
414
+ );
415
+ return true;
416
+ } catch (err) {
417
+ console.warn(
418
+ `[AgentManager][${agentId}][claude] fallback_send_message error workspace=${workspaceId ?? 'none'} err=${err?.message ?? 'unknown'}`
419
+ );
420
+ return false;
421
+ }
422
+ }
423
+
333
424
  _formatDeliveryHeaderValue(value) {
334
425
  const normalized = String(value ?? '').trim();
335
426
  if (!normalized) return 'unknown';
@@ -1123,6 +1214,10 @@ export class AgentManager {
1123
1214
  directive,
1124
1215
  requiredCredentials,
1125
1216
  lastStartupMessage: startupMsg?.message ?? null,
1217
+ lastInboundMessage: startupMsg?.message ?? null,
1218
+ visibleTextSeen: false,
1219
+ sendMessageUsed: false,
1220
+ visibleTextBuffer: '',
1126
1221
  stopCause: null,
1127
1222
  });
1128
1223
  this.starting.delete(key);
@@ -1172,9 +1267,13 @@ export class AgentManager {
1172
1267
  runtime: 'codex',
1173
1268
  codexVisibleTextSeen: false,
1174
1269
  codexSendMessageUsed: false,
1270
+ visibleTextSeen: false,
1271
+ sendMessageUsed: false,
1272
+ visibleTextBuffer: '',
1175
1273
  directive,
1176
1274
  requiredCredentials,
1177
1275
  lastStartupMessage: startupMsg?.message ?? null,
1276
+ lastInboundMessage: startupMsg?.message ?? null,
1178
1277
  stopCause: null,
1179
1278
  });
1180
1279
  this.starting.delete(key);
@@ -1197,9 +1296,13 @@ export class AgentManager {
1197
1296
  sessionId: config.sessionId ?? null,
1198
1297
  proc,
1199
1298
  runtime: 'claude',
1299
+ visibleTextSeen: false,
1300
+ sendMessageUsed: false,
1301
+ visibleTextBuffer: '',
1200
1302
  directive,
1201
1303
  requiredCredentials,
1202
1304
  lastStartupMessage: startupMsg?.message ?? null,
1305
+ lastInboundMessage: startupMsg?.message ?? null,
1203
1306
  stopCause: null,
1204
1307
  });
1205
1308
  this.starting.delete(key);
@@ -1500,6 +1603,12 @@ export class AgentManager {
1500
1603
  }
1501
1604
  if (stage === 'precheck_error') {
1502
1605
  console.error(`${logPrefix} precheck-error error=${payload.error ?? 'unknown_error'}`);
1606
+ return;
1607
+ }
1608
+ if (stage === 'publish_action_error') {
1609
+ console.error(
1610
+ `${logPrefix} publish-action-error stage=${payload.stage ?? 'unknown'} error=${payload.error ?? 'unknown_error'}`
1611
+ );
1503
1612
  }
1504
1613
  },
1505
1614
  });
@@ -1523,13 +1632,14 @@ export class AgentManager {
1523
1632
  });
1524
1633
  } catch (err) {
1525
1634
  const errorMessage = err?.message ?? String(err);
1526
- console.error(`${logPrefix} failed error=${errorMessage}`);
1635
+ const failureResult = buildPublishJobFailureResult(err, 'publish_job');
1636
+ console.error(`${logPrefix} failed stage=${failureResult.stage} error=${errorMessage}`);
1527
1637
  try {
1528
1638
  console.log(`${logPrefix} completion-post ok=false`);
1529
1639
  await this._postInternalPublishJobComplete({
1530
1640
  jobId,
1531
1641
  ok: false,
1532
- result: null,
1642
+ result: failureResult,
1533
1643
  error: errorMessage,
1534
1644
  });
1535
1645
  console.log(`${logPrefix} completion-post posted ok=false`);
@@ -1546,6 +1656,7 @@ export class AgentManager {
1546
1656
  status: 'failed',
1547
1657
  completed_at: new Date().toISOString(),
1548
1658
  error: errorMessage,
1659
+ result: failureResult,
1549
1660
  });
1550
1661
  }
1551
1662
  }
@@ -1591,6 +1702,7 @@ export class AgentManager {
1591
1702
  console.log(`[AgentManager] Queued seq=${seq} for codex next turn pending=${queueSize}`);
1592
1703
  return;
1593
1704
  }
1705
+ this._rememberInboundMessage(agent, message);
1594
1706
  this._write(key, text);
1595
1707
  }
1596
1708
 
@@ -1612,6 +1724,7 @@ export class AgentManager {
1612
1724
  const text = this._formatDeliveryText(message);
1613
1725
  const agent = this.agents.get(key);
1614
1726
  console.log(`[AgentManager] Flushing queued seq=${seq} to agent ${agent?.config?.displayName ?? agentId.slice(0,8)} workspace=${message.workspace_name ?? workspaceId}`);
1727
+ this._rememberInboundMessage(agent, message);
1615
1728
  this._write(key, text);
1616
1729
  }
1617
1730
  }
@@ -1671,11 +1784,13 @@ export class AgentManager {
1671
1784
  break;
1672
1785
  case 'text':
1673
1786
  agent.codexVisibleTextSeen = true;
1787
+ this._trackVisibleAssistantText(agent, evt.text);
1674
1788
  console.log(`[AgentManager][${agent.config?.displayName ?? agentId.slice(0, 8)}][codex] <text> ${String(evt.text ?? '').slice(0, 500)}`);
1675
1789
  break;
1676
1790
  case 'tool_call':
1677
- if (evt.name === 'send_message') {
1791
+ if (this._isSendMessageToolName(evt.name)) {
1678
1792
  agent.codexSendMessageUsed = true;
1793
+ this._markSendMessageToolUsed(agent);
1679
1794
  this._recordSuccessfulOutboundMessage({
1680
1795
  key,
1681
1796
  agent,
@@ -1698,6 +1813,7 @@ export class AgentManager {
1698
1813
  break;
1699
1814
  case 'turn_end':
1700
1815
  agent.kimiIdle = true;
1816
+ this._resetVisibleReplyTracking(agent);
1701
1817
  this._emitLifecycle(connection, {
1702
1818
  agentId,
1703
1819
  workspaceId,
@@ -1747,13 +1863,15 @@ export class AgentManager {
1747
1863
  break;
1748
1864
  case 'text':
1749
1865
  agent.codexVisibleTextSeen = true;
1866
+ this._trackVisibleAssistantText(agent, evt.text);
1750
1867
  console.log(
1751
1868
  `[AgentManager][${agent.config?.displayName ?? agentId.slice(0, 8)}][codex][text] ${String(evt.text ?? '').slice(0, 1200)}`
1752
1869
  );
1753
1870
  break;
1754
1871
  case 'tool_call':
1755
- if (evt.name === 'send_message') {
1872
+ if (this._isSendMessageToolName(evt.name)) {
1756
1873
  agent.codexSendMessageUsed = true;
1874
+ this._markSendMessageToolUsed(agent);
1757
1875
  this._recordSuccessfulOutboundMessage({
1758
1876
  key,
1759
1877
  agent,
@@ -1787,6 +1905,7 @@ export class AgentManager {
1787
1905
  }
1788
1906
  agent.codexVisibleTextSeen = false;
1789
1907
  agent.codexSendMessageUsed = false;
1908
+ this._resetVisibleReplyTracking(agent);
1790
1909
  this._emitLifecycle(connection, {
1791
1910
  agentId,
1792
1911
  workspaceId,
@@ -1813,10 +1932,10 @@ export class AgentManager {
1813
1932
  let event;
1814
1933
  try { event = JSON.parse(line); }
1815
1934
  catch { return; }
1935
+ const agent = this.agents.get(key);
1816
1936
 
1817
1937
  // Capture and sync session ID
1818
1938
  if (event.session_id) {
1819
- const agent = this.agents.get(key);
1820
1939
  if (agent && agent.sessionId !== event.session_id) {
1821
1940
  agent.sessionId = event.session_id;
1822
1941
  connection.send({ type: 'agent:session', agentId, workspaceId, sessionId: event.session_id });
@@ -1824,27 +1943,26 @@ export class AgentManager {
1824
1943
  }
1825
1944
 
1826
1945
  // Activity state machine
1827
- const displayName = this.agents.get(key)?.config?.displayName ?? agentId.slice(0, 8);
1946
+ const displayName = agent?.config?.displayName ?? agentId.slice(0, 8);
1828
1947
  if (event.type === 'assistant') {
1829
1948
  const content = event.message?.content ?? [];
1830
1949
  for (const block of content) {
1831
1950
  if (block.type === 'thinking') {
1832
1951
  console.log(`[AgentManager][${displayName}] <thinking> ${block.thinking?.slice(0, 500)}`);
1833
1952
  } else if (block.type === 'text') {
1953
+ this._trackVisibleAssistantText(agent, block.text);
1834
1954
  console.log(`[AgentManager][${displayName}] <text> ${block.text?.slice(0, 500)}`);
1835
1955
  } else if (block.type === 'tool_use') {
1836
1956
  console.log(`[AgentManager][${displayName}] <tool_use> ${block.name} params=${JSON.stringify(block.input ?? {}).slice(0, 500)}`);
1837
- if (block.name === 'send_message') {
1838
- const agent = this.agents.get(key);
1839
- if (agent) {
1840
- this._recordSuccessfulOutboundMessage({
1841
- key,
1842
- agent,
1843
- agentId,
1844
- workspaceId,
1845
- connection,
1846
- });
1847
- }
1957
+ if (this._isSendMessageToolName(block.name) && agent) {
1958
+ this._markSendMessageToolUsed(agent);
1959
+ this._recordSuccessfulOutboundMessage({
1960
+ key,
1961
+ agent,
1962
+ agentId,
1963
+ workspaceId,
1964
+ connection,
1965
+ });
1848
1966
  }
1849
1967
  this._emitLifecycle(connection, {
1850
1968
  agentId,
@@ -1854,7 +1972,7 @@ export class AgentManager {
1854
1972
  availability: 'busy',
1855
1973
  runtimeState: 'running',
1856
1974
  reason: `tool:${block.name ?? 'unknown_tool'}`,
1857
- sessionId: this.agents.get(key)?.sessionId ?? null,
1975
+ sessionId: agent?.sessionId ?? null,
1858
1976
  });
1859
1977
  connection.send({ type: 'agent:activity', agentId, workspaceId, activity: 'working', detail: block.name, entries: [] });
1860
1978
  }
@@ -1868,7 +1986,7 @@ export class AgentManager {
1868
1986
  availability: 'busy',
1869
1987
  runtimeState: 'running',
1870
1988
  reason: 'thinking',
1871
- sessionId: this.agents.get(key)?.sessionId ?? null,
1989
+ sessionId: agent?.sessionId ?? null,
1872
1990
  });
1873
1991
  connection.send({ type: 'agent:activity', agentId, workspaceId, activity: 'thinking', detail: '', entries: [] });
1874
1992
  }
@@ -1888,6 +2006,23 @@ export class AgentManager {
1888
2006
  console.log(`[AgentManager][${displayName}] <tool_result> id=${tr.tool_use_id ?? '?'} → ${resultStr}`);
1889
2007
  }
1890
2008
  } else if (event.type === 'result') {
2009
+ const inboundSenderType = this._normalizeDeliverySenderType(agent?.lastInboundMessage?.sender_type);
2010
+ if (inboundSenderType === 'user' && agent?.visibleTextSeen && !agent?.sendMessageUsed) {
2011
+ agent.stopCause = 'contract_violation';
2012
+ console.warn(
2013
+ `[AgentManager][${displayName}][claude] contract_violation: visible text emitted without send_message workspace=${workspaceId ?? 'none'}`
2014
+ );
2015
+ const fallbackText = String(agent.visibleTextBuffer ?? '').trim();
2016
+ if (fallbackText) {
2017
+ void this._recoverVisibleTextAsMessage({
2018
+ agentId,
2019
+ workspaceId,
2020
+ message: agent.lastInboundMessage,
2021
+ content: fallbackText,
2022
+ });
2023
+ }
2024
+ }
2025
+ this._resetVisibleReplyTracking(agent);
1891
2026
  console.log(`[AgentManager][${displayName}] turn done (stop_reason=${event.stop_reason ?? '?'})`);
1892
2027
  this._emitLifecycle(connection, {
1893
2028
  agentId,
@@ -1897,7 +2032,7 @@ export class AgentManager {
1897
2032
  availability: 'available',
1898
2033
  runtimeState: 'running',
1899
2034
  reason: 'turn_end',
1900
- sessionId: this.agents.get(key)?.sessionId ?? null,
2035
+ sessionId: agent?.sessionId ?? null,
1901
2036
  });
1902
2037
  connection.send({ type: 'agent:activity', agentId, workspaceId, activity: 'online', detail: '', entries: [] });
1903
2038
  }
@@ -46,7 +46,7 @@ CRITICAL RULES:
46
46
 
47
47
  ## Startup sequence
48
48
 
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
+ 1. If this turn already includes a concrete incoming message with \`from=user\`, your first user-visible action MUST be through ${t("send_message")} (or \`${t("create_tasks")}\` immediately followed by ${t("send_message")} for primary-agent execution dispatch).
50
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.
51
51
  3. If there is no concrete incoming message to handle, stop and wait. New messages will be delivered to you automatically via stdin.
52
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.
@@ -73,7 +73,7 @@ Header fields:
73
73
 
74
74
  Each incoming message has a \`from=\` tag and should be handled as follows:
75
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.
76
+ - **from=user**: this is a real user request. Your first user-visible output MUST go through ${t("send_message")} (for execution dispatch as primary agent: \`${t("create_tasks")}\` first, then immediate ${t("send_message")} acknowledgment).
77
77
  - **from=peer-agent**: default is **no reply**. Reply only when at least one condition holds:
78
78
  1. You were explicitly @mentioned.
79
79
  2. You have materially new information that changes execution decisions.
@@ -136,10 +136,11 @@ Only top-level workspace / DM messages can become tasks. Messages inside threads
136
136
  5. After approval (e.g. "looks good", "merge it"), set status to \`done\`
137
137
 
138
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")}\`.
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\`, then immediately send a visible acknowledgment/update via \`${t("send_message")}\`.
140
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
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
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
+ - If the request is a direct Q&A (no downstream execution dispatch needed), reply directly with \`${t("send_message")}\` and do not force \`${t("create_tasks")}\`.
143
144
 
144
145
  **What \`${t("create_tasks")}\` really means:**
145
146
  - Tasks live in the same chat flow as messages. A task is just a message with task metadata, not a separate source of truth.
@@ -186,6 +187,9 @@ Keep the user informed. They cannot see your internal reasoning, so:
186
187
 
187
188
  - **Respect ongoing conversations.** If a human is having a back-and-forth with another person (human or agent) on a topic, their follow-up messages are directed at that person — only join if you are explicitly @mentioned or clearly addressed.
188
189
  - **Only respond when relevant.** If a message does not @mention you and is not clearly directed at you or your expertise, do NOT respond. Let the appropriate agent handle it.
190
+ - **No "cannot execute" noise.** Do not send standalone refusal/deflection messages like "I can't execute this", "this is not my job", or "ask another agent". These add noise.
191
+ - **Out-of-scope without @mention = full silence.** If a message is outside your role scope and you were not directly @mentioned, produce no visible output.
192
+ - **Direct @mention exception.** If you are directly @mentioned on out-of-scope work, send one short reroute message and include an @mention of the correct agent.
189
193
  - **Only the person doing the work should report on it.** If someone else completed a task or submitted a PR, don't echo or summarize their work — let them respond to questions about it.
190
194
  - **Claim before you start.** Always call \`${t("claim_tasks")}\` before doing any work on a task. If the claim fails, stop immediately and pick a different task.
191
195
  - **Before stopping, check for concrete blockers you own.** If you still owe a specific handoff, review, decision, or reply that is currently blocking a specific person, send one minimal actionable message to that person or workspace before stopping.
@@ -512,6 +512,30 @@ function emitPublishJobProgress(onProgress, stage, payload = {}) {
512
512
  }
513
513
  }
514
514
 
515
+ function errorMessageOf(error) {
516
+ if (error?.message) return String(error.message);
517
+ return String(error);
518
+ }
519
+
520
+ function attachPublishStage(error, stage, context = {}) {
521
+ const err = error instanceof Error ? error : new Error(errorMessageOf(error));
522
+ const normalizedStage = String(stage ?? '').trim() || 'unknown';
523
+ const existingContext = err.publish_context && typeof err.publish_context === 'object'
524
+ ? err.publish_context
525
+ : {};
526
+
527
+ try { err.publish_stage = err.publish_stage || normalizedStage; } catch {}
528
+ try { err.publish_error = err.publish_error || errorMessageOf(error); } catch {}
529
+ try {
530
+ err.publish_context = {
531
+ ...existingContext,
532
+ ...context,
533
+ stage: normalizedStage,
534
+ };
535
+ } catch {}
536
+ return err;
537
+ }
538
+
515
539
  export async function runPublishJob({ serverUrl, machineApiKey, agentId, workspaceDir, job, onProgress }) {
516
540
  if (!serverUrl || !machineApiKey || !agentId) {
517
541
  throw new Error('runPublishJob requires serverUrl, machineApiKey, and agentId');
@@ -530,81 +554,107 @@ export async function runPublishJob({ serverUrl, machineApiKey, agentId, workspa
530
554
  cover,
531
555
  } = buildJobInput(job);
532
556
  const ownerId = normalizeText(job?.owner_id ?? job?.ownerId);
557
+ const jobId = normalizeText(job?.id);
558
+ const stageContext = {
559
+ platform: platform || null,
560
+ contentType: contentType || null,
561
+ jobId: jobId ?? null,
562
+ ownerId: ownerId ?? null,
563
+ };
533
564
 
534
- if (!platform) throw new Error('publish job missing platform');
535
- if (!contentType) throw new Error('publish job missing content_type');
536
- if (typeof text !== 'string' || !text.trim()) throw new Error('publish job missing text');
565
+ if (!platform) throw attachPublishStage(new Error('publish job missing platform'), 'input_validation', stageContext);
566
+ if (!contentType) throw attachPublishStage(new Error('publish job missing content_type'), 'input_validation', stageContext);
567
+ if (typeof text !== 'string' || !text.trim()) {
568
+ throw attachPublishStage(new Error('publish job missing text'), 'input_validation', stageContext);
569
+ }
537
570
 
538
571
  const resolvedWorkspaceDir = workspaceDir ?? process.cwd();
539
572
  const workspaceRootDir = path.dirname(resolvedWorkspaceDir);
573
+ stageContext.platform = platform;
574
+ stageContext.contentType = contentType;
540
575
 
576
+ let currentStage = 'precheck';
541
577
  let precheck = null;
542
578
  try {
543
- precheck = await runPublishPrecheck({
579
+ currentStage = 'precheck';
580
+ try {
581
+ precheck = await runPublishPrecheck({
582
+ platform,
583
+ title,
584
+ text,
585
+ tags,
586
+ payload,
587
+ callTool: callOfficialTool,
588
+ });
589
+ } catch (error) {
590
+ emitPublishJobProgress(onProgress, 'precheck_error', {
591
+ platform,
592
+ error: errorMessageOf(error),
593
+ });
594
+ throw attachPublishStage(error, currentStage, stageContext);
595
+ }
596
+ emitPublishJobProgress(onProgress, 'precheck_done', {
544
597
  platform,
545
- title,
546
- text,
547
- tags,
548
- payload,
549
- callTool: callOfficialTool,
598
+ ok: precheck.ok,
599
+ blockerCount: precheck.blockers.length,
600
+ warningCount: precheck.warnings.length,
550
601
  });
551
- } catch (error) {
552
- emitPublishJobProgress(onProgress, 'precheck_error', {
553
- platform,
554
- error: error.message ?? String(error),
602
+ if (!precheck.ok) {
603
+ currentStage = 'precheck_validate';
604
+ const blockerSummary = precheck.blockers.map(item => `${item.code}: ${item.message}`).join(' | ');
605
+ throw attachPublishStage(new Error(`PUBLISH_PRECHECK_BLOCKED:${blockerSummary || 'precheck failed'}`), currentStage, stageContext);
606
+ }
607
+
608
+ currentStage = 'materialize_media';
609
+ const localMedia = await materializeMedia({
610
+ images,
611
+ video,
612
+ cover,
613
+ workspaceId,
614
+ workspaceRootDir,
615
+ serverUrl,
616
+ machineApiKey,
617
+ jobId: job?.id,
555
618
  });
556
- throw error;
557
- }
558
- emitPublishJobProgress(onProgress, 'precheck_done', {
559
- platform,
560
- ok: precheck.ok,
561
- blockerCount: precheck.blockers.length,
562
- warningCount: precheck.warnings.length,
563
- });
564
- if (!precheck.ok) {
565
- const blockerSummary = precheck.blockers.map(item => `${item.code}: ${item.message}`).join(' | ');
566
- throw new Error(`PUBLISH_PRECHECK_BLOCKED:${blockerSummary || 'precheck failed'}`);
567
- }
568
619
 
569
- const localMedia = await materializeMedia({
570
- images,
571
- video,
572
- cover,
573
- workspaceId,
574
- workspaceRootDir,
575
- serverUrl,
576
- machineApiKey,
577
- jobId: job?.id,
578
- });
579
- const staticAdapter = createStaticAdapter(platform);
580
- const req = staticAdapter.getRequirements(contentType);
581
- if (!req) throw new Error(`INPUT_UNSUPPORTED_CONTENT_TYPE: ${platform} does not support ${contentType}`);
582
- emitPublishJobProgress(onProgress, 'publish_action_start', { platform, contentType });
620
+ currentStage = 'resolve_requirements';
621
+ const staticAdapter = createStaticAdapter(platform);
622
+ const req = staticAdapter.getRequirements(contentType);
623
+ if (!req) throw new Error(`INPUT_UNSUPPORTED_CONTENT_TYPE: ${platform} does not support ${contentType}`);
624
+ emitPublishJobProgress(onProgress, 'publish_action_start', { platform, contentType });
583
625
 
584
- const media = validateMedia({
585
- platform,
586
- contentType,
587
- ...localMedia,
588
- workspaceDir: resolvedWorkspaceDir,
589
- workspaceRootDir,
590
- });
626
+ currentStage = 'validate_media';
627
+ const media = validateMedia({
628
+ platform,
629
+ contentType,
630
+ ...localMedia,
631
+ workspaceDir: resolvedWorkspaceDir,
632
+ workspaceRootDir,
633
+ });
591
634
 
592
- try {
635
+ currentStage = 'publish_profile';
593
636
  const { publishResult, healthCheck } = await withPublisherProfile(platform, { ownerId }, async () => {
637
+ currentStage = 'adapter_session';
594
638
  const adapter = await getAdapter(platform, { ownerId });
639
+
640
+ currentStage = 'pre_publish_login';
595
641
  const prePublishLogin = await adapter.checkLoginStatus();
596
642
  if (prePublishLogin?.loggedIn === false) {
597
643
  throw new Error(`LOGIN_EXPIRED:${platform}`);
598
644
  }
645
+
599
646
  let publishResult = null;
600
647
  if (contentType === 'image_text') {
648
+ currentStage = 'adapter_publish_image_text';
601
649
  publishResult = await adapter.publishImageText({ title, text, tags, images: media.images });
602
650
  } else {
651
+ currentStage = 'adapter_publish_short_video';
603
652
  publishResult = await adapter.publishShortVideo({ title, text, tags, video: media.video, cover: media.cover });
604
653
  }
605
654
 
606
655
  let healthCheck = null;
607
656
  try {
657
+ currentStage = 'post_publish_health';
608
658
  healthCheck = await adapter.checkLoginStatus();
609
659
  } catch (error) {
610
660
  healthCheck = {
@@ -631,10 +681,18 @@ export async function runPublishJob({ serverUrl, machineApiKey, agentId, workspa
631
681
  precheck,
632
682
  healthCheck,
633
683
  };
634
- } catch (err) {
635
- if (err.message?.startsWith('LOGIN_EXPIRED')) {
684
+ } catch (error) {
685
+ const stagedError = attachPublishStage(error, currentStage, stageContext);
686
+ const errorMessage = errorMessageOf(stagedError);
687
+ if (errorMessage.startsWith('LOGIN_EXPIRED')) {
636
688
  closeSession(platform);
637
689
  }
638
- throw err;
690
+ emitPublishJobProgress(onProgress, 'publish_action_error', {
691
+ platform,
692
+ contentType,
693
+ stage: stagedError.publish_stage ?? currentStage,
694
+ error: stagedError.publish_error ?? errorMessage,
695
+ });
696
+ throw stagedError;
639
697
  }
640
698
  }