vibe-coding-master 0.6.2 → 0.6.4

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.
@@ -52,6 +52,17 @@ export function registerTranslationRoutes(app, deps) {
52
52
  text: request.body?.text ?? ""
53
53
  });
54
54
  });
55
+ app.post("/api/tasks/:taskSlug/sessions/:role/translation/latest-reply", async (request) => {
56
+ const project = await requireCurrentProject(deps.projectService);
57
+ const role = parseRole(request.params.role);
58
+ const task = await deps.taskService.loadTask(project.repoRoot, request.params.taskSlug);
59
+ return deps.translationService.translateLatestReply({
60
+ repoRoot: project.repoRoot,
61
+ taskRepoRoot: getTaskRuntimeRepoRoot(task),
62
+ taskSlug: request.params.taskSlug,
63
+ role
64
+ });
65
+ });
55
66
  app.post("/api/tasks/:taskSlug/sessions/:role/translation/send", async (request) => {
56
67
  const project = await requireCurrentProject(deps.projectService);
57
68
  const role = parseRole(request.params.role);
@@ -13,6 +13,8 @@ const POLL_LONG_BACKOFF_MS = 30_000;
13
13
  const MAX_FAILURES_BEFORE_LONG_BACKOFF = 3;
14
14
  const DEFAULT_POLL_TIMEOUT_MS = 35_000;
15
15
  const LARK_REGISTRATION_CONFIRM_TIMEOUT_MS = 15_000;
16
+ const GATEWAY_ROUND_FINAL_WAIT_MS = 12_000;
17
+ const GATEWAY_ROUND_FINAL_POLL_MS = 250;
16
18
  const GATEWAY_TRANSLATION_FAILURE_TEXT = "PM 回复已收到,但翻译失败。\n发送 /retry 重新翻译。";
17
19
  const COMMANDS_ALLOWED_WHEN_DISABLED = new Set([
18
20
  "help",
@@ -663,12 +665,15 @@ export function createGatewayService(deps) {
663
665
  }
664
666
  async function enableGatewayTranslationRuntime() {
665
667
  const preferences = await deps.appSettings.getPreferences();
666
- if (preferences.translationEnabled && preferences.translationAutoSendEnabled) {
668
+ if (preferences.translationEnabled &&
669
+ preferences.translationAutoSendEnabled &&
670
+ preferences.translationOutputMode === "round-final") {
667
671
  return;
668
672
  }
669
673
  await deps.appSettings.updatePreferences({
670
674
  translationEnabled: true,
671
- translationAutoSendEnabled: true
675
+ translationAutoSendEnabled: true,
676
+ translationOutputMode: "round-final"
672
677
  });
673
678
  }
674
679
  async function startGateway() {
@@ -1111,19 +1116,12 @@ export function createGatewayService(deps) {
1111
1116
  return;
1112
1117
  }
1113
1118
  const events = await readTranscriptTextEvents(transcriptPath);
1114
- const latestReply = selectLatestTurnReply(events, input.session);
1115
- if (latestReply) {
1116
- await saveLatestPmReply(input, latestReply);
1117
- }
1118
- const settings = await deps.settings.loadSettings();
1119
- const account = toAccount(settings);
1120
- const boundUserId = settings.binding.boundUserId;
1121
- // A disarmed gateway never touches the channel: skip the outbound push.
1122
- // The latest reply was already cached above and replays on the next /start.
1123
- if (!connectionEnabled || !settings.enabled || !account || !boundUserId) {
1119
+ const round = await waitForGatewayRoundFinal(input.repoRoot, input.taskSlug);
1120
+ if (!round) {
1124
1121
  return;
1125
1122
  }
1126
1123
  const cursorKey = `${input.taskSlug}:project-manager:${input.session.claudeSessionId}`;
1124
+ const settings = await deps.settings.loadSettings();
1127
1125
  const cursor = settings.pushCursors[cursorKey];
1128
1126
  const nextEvents = selectEventsAfterCursor(events, cursor?.lastTranscriptEventId)
1129
1127
  .filter(isFinalTurnTextEvent);
@@ -1134,7 +1132,18 @@ export function createGatewayService(deps) {
1134
1132
  if (!text) {
1135
1133
  return;
1136
1134
  }
1137
- const roundNotice = await getGatewayRoundNotice(input.repoRoot, input.taskSlug);
1135
+ const latestReply = selectLatestTurnReply(nextEvents, input.session);
1136
+ if (latestReply) {
1137
+ await saveLatestPmReply(input, latestReply);
1138
+ }
1139
+ const account = toAccount(settings);
1140
+ const boundUserId = settings.binding.boundUserId;
1141
+ // A disarmed gateway never touches the channel: skip the outbound push.
1142
+ // The round-final PM reply was already cached above and replays on the next /start.
1143
+ if (!connectionEnabled || !settings.enabled || !account || !boundUserId) {
1144
+ return;
1145
+ }
1146
+ const roundNotice = formatGatewayRoundNotice(round);
1138
1147
  const originalMessage = formatGatewayPmOriginalReply(text, roundNotice);
1139
1148
  await sendGatewayText(settings, boundUserId, originalMessage);
1140
1149
  let output;
@@ -1182,6 +1191,40 @@ export function createGatewayService(deps) {
1182
1191
  error: output?.translationError
1183
1192
  });
1184
1193
  },
1194
+ async handleRoleStopFailure(input) {
1195
+ const round = await getGatewayRoundState(input.repoRoot, input.taskSlug);
1196
+ if (round?.stopReason === "manual-interrupt") {
1197
+ return;
1198
+ }
1199
+ const settings = await deps.settings.loadSettings();
1200
+ const account = toAccount(settings);
1201
+ const boundUserId = settings.binding.boundUserId;
1202
+ if (!connectionEnabled || !settings.enabled || !account || !boundUserId) {
1203
+ return;
1204
+ }
1205
+ const message = formatGatewayRoleStopFailure(input, round);
1206
+ await sendGatewayText(settings, boundUserId, message);
1207
+ const current = await deps.settings.loadSettings();
1208
+ await deps.settings.saveSettings({
1209
+ ...current,
1210
+ lastMessageStatus: {
1211
+ checkedAt: now(),
1212
+ direction: "outbound",
1213
+ result: "error",
1214
+ command: "role-stop-failure",
1215
+ preview: message.slice(0, 160),
1216
+ error: input.error
1217
+ },
1218
+ updatedAt: now()
1219
+ });
1220
+ await deps.audit.record({
1221
+ type: "gateway.role_stop_failure",
1222
+ result: "error",
1223
+ command: "role-stop-failure",
1224
+ preview: message,
1225
+ error: input.error
1226
+ });
1227
+ },
1185
1228
  getDiagnostics() {
1186
1229
  return {
1187
1230
  polling: isRunning()
@@ -1194,7 +1237,21 @@ export function createGatewayService(deps) {
1194
1237
  }
1195
1238
  return settings.latestPmReplies[latestPmReplyKey(settings.currentProjectId, settings.currentTaskSlug)];
1196
1239
  }
1197
- async function getGatewayRoundNotice(repoRoot, taskSlug) {
1240
+ async function waitForGatewayRoundFinal(repoRoot, taskSlug) {
1241
+ const startedAt = Date.now();
1242
+ while (Date.now() - startedAt <= GATEWAY_ROUND_FINAL_WAIT_MS) {
1243
+ const round = await getGatewayRoundState(repoRoot, taskSlug);
1244
+ if (round && isGatewayRoundFinal(round)) {
1245
+ return round;
1246
+ }
1247
+ if (round && isGatewayNonFinalTerminalRound(round)) {
1248
+ return undefined;
1249
+ }
1250
+ await delay(GATEWAY_ROUND_FINAL_POLL_MS);
1251
+ }
1252
+ return undefined;
1253
+ }
1254
+ async function getGatewayRoundState(repoRoot, taskSlug) {
1198
1255
  try {
1199
1256
  const [projectConfig, task] = await Promise.all([
1200
1257
  deps.projectService.loadConfig(repoRoot),
@@ -1206,12 +1263,24 @@ export function createGatewayService(deps) {
1206
1263
  stateRoot: projectConfig.stateRoot,
1207
1264
  taskSlug
1208
1265
  });
1209
- return formatGatewayRoundNotice(round);
1266
+ return round;
1210
1267
  }
1211
1268
  catch {
1212
1269
  return undefined;
1213
1270
  }
1214
1271
  }
1272
+ function isGatewayRoundFinal(round) {
1273
+ return round.status === "stopped"
1274
+ && Boolean(round.roundId)
1275
+ && !round.stopReason
1276
+ && round.roleRecovery?.status !== "failed"
1277
+ && round.flowPause?.reason !== "role-recovery-failed";
1278
+ }
1279
+ function isGatewayNonFinalTerminalRound(round) {
1280
+ return round.status === "stopped" && (Boolean(round.stopReason) ||
1281
+ round.roleRecovery?.status === "failed" ||
1282
+ round.flowPause?.reason === "role-recovery-failed");
1283
+ }
1215
1284
  function formatGatewayRoundNotice(round) {
1216
1285
  const roundLabel = round.roundSequence ? `第 ${round.roundSequence} 轮` : "当前 round";
1217
1286
  if (round.status === "stopped") {
@@ -1220,6 +1289,27 @@ export function createGatewayService(deps) {
1220
1289
  const activeRole = round.activeRole ? `,当前角色:${round.activeRole}` : "";
1221
1290
  return `${roundLabel}运行中${activeRole}。`;
1222
1291
  }
1292
+ function formatGatewayRoleStopFailure(input, round) {
1293
+ const roundLabel = round?.roundSequence ? `第 ${round.roundSequence} 轮` : "当前 round";
1294
+ const retryLine = input.maxAttempts !== undefined
1295
+ ? `Retry: ${input.attempt ?? 0}/${input.maxAttempts}`
1296
+ : undefined;
1297
+ return [
1298
+ "VCM 角色异常中断,流程已暂停。",
1299
+ `Task: ${input.taskSlug}`,
1300
+ `Role: ${input.role}`,
1301
+ `Round: ${roundLabel}`,
1302
+ input.error ? `Reason: ${input.error}` : undefined,
1303
+ retryLine,
1304
+ input.errorDetails ? `Details: ${truncateGatewayFailureDetails(input.errorDetails)}` : undefined,
1305
+ "",
1306
+ "请在 VCM 中检查状态,或修复问题后继续给出下一步指令。"
1307
+ ].filter((line) => line !== undefined).join("\n");
1308
+ }
1309
+ function truncateGatewayFailureDetails(value) {
1310
+ const trimmed = value.trim();
1311
+ return trimmed.length > 600 ? `${trimmed.slice(0, 600)}...` : trimmed;
1312
+ }
1223
1313
  function formatGatewayPmOriginalReply(text, roundNotice) {
1224
1314
  return [
1225
1315
  "PM final reply 原文:",
@@ -1442,6 +1532,9 @@ function sleep(ms, signal) {
1442
1532
  }, { once: true });
1443
1533
  });
1444
1534
  }
1535
+ function delay(ms) {
1536
+ return new Promise((resolve) => setTimeout(resolve, ms));
1537
+ }
1445
1538
  function normalizeBaseUrl(input, fallback) {
1446
1539
  const trimmed = input.trim();
1447
1540
  if (!trimmed) {
@@ -277,6 +277,7 @@ export function createDefaultServerDeps(options = {}) {
277
277
  translationWorkerService,
278
278
  fs,
279
279
  projectService,
280
+ roundService,
280
281
  appSettings
281
282
  });
282
283
  const gatewayChannels = createGatewayChannelRegistry([
@@ -241,15 +241,27 @@ export function createClaudeHookService(deps) {
241
241
  }
242
242
  const failure = parseStopFailureDiagnostic(input.event);
243
243
  if (!failure.retryable) {
244
+ if (await isManualInterruptedStopFailure(context, input.role)) {
245
+ return recordTurnEnd(input, context, eventName, {
246
+ dispatchRouteFiles: false,
247
+ notifyGateway: false,
248
+ settleGuard: false
249
+ });
250
+ }
244
251
  await markStopFailureRecoveryFailed(input, context, failure, 0);
245
252
  return recordTurnEnd(input, context, eventName, {
246
253
  dispatchRouteFiles: false,
247
254
  notifyGateway: false,
248
- settleGuard: false
255
+ settleGuard: false,
256
+ gatewayStopFailure: {
257
+ ...failure,
258
+ attempt: 0,
259
+ maxAttempts: MAX_ROLE_RETRY_ATTEMPTS
260
+ }
249
261
  });
250
262
  }
251
- const retryScheduled = await scheduleStopFailureRetry(input, context, failure);
252
- if (retryScheduled) {
263
+ const retryResult = await scheduleStopFailureRetry(input, context, failure);
264
+ if (retryResult === "scheduled") {
253
265
  return {
254
266
  ok: true,
255
267
  eventName,
@@ -262,7 +274,8 @@ export function createClaudeHookService(deps) {
262
274
  return recordTurnEnd(input, context, eventName, {
263
275
  dispatchRouteFiles: false,
264
276
  notifyGateway: false,
265
- settleGuard: false
277
+ settleGuard: false,
278
+ ...(retryResult === "manual-interrupt" ? {} : { gatewayStopFailure: failure })
266
279
  });
267
280
  }
268
281
  async function processPostCompactHook(input) {
@@ -344,6 +357,17 @@ export function createClaudeHookService(deps) {
344
357
  session
345
358
  }).catch(() => undefined);
346
359
  }
360
+ if (boundToTask && options.gatewayStopFailure) {
361
+ void deps.gatewayService?.handleRoleStopFailure({
362
+ repoRoot: context.project.repoRoot,
363
+ taskSlug: context.taskSlug,
364
+ role: input.role,
365
+ error: options.gatewayStopFailure.error,
366
+ errorDetails: options.gatewayStopFailure.errorDetails,
367
+ attempt: options.gatewayStopFailure.attempt,
368
+ maxAttempts: options.gatewayStopFailure.maxAttempts
369
+ }).catch(() => undefined);
370
+ }
347
371
  const dispatched = options.dispatchRouteFiles
348
372
  ? await deps.messageService.scanAndDispatchPendingRouteFiles(scopedRouteDispatchInput)
349
373
  : [];
@@ -370,14 +394,14 @@ export function createClaudeHookService(deps) {
370
394
  async function scheduleStopFailureRetry(input, context, failure) {
371
395
  const preferences = await deps.appSettings.getPreferences();
372
396
  if (!preferences.roleRetryEnabled || !deps.runtime) {
373
- return false;
397
+ return "not-scheduled";
374
398
  }
375
399
  const stateInput = createRoundStateInput(context);
376
400
  const currentRoundState = await deps.roundService.getSessionRoundState(stateInput);
377
401
  if (currentRoundState.stopReason === "manual-interrupt"
378
402
  && currentRoundState.status === "stopped"
379
403
  && currentRoundState.activeRole === input.role) {
380
- return false;
404
+ return "manual-interrupt";
381
405
  }
382
406
  const previousAttempt = currentRoundState.roleRecovery?.role === input.role &&
383
407
  currentRoundState.roleRecovery.status !== "failed"
@@ -388,7 +412,7 @@ export function createClaudeHookService(deps) {
388
412
  if (attempt > MAX_ROLE_RETRY_ATTEMPTS) {
389
413
  clearStopFailureRetryTimer(context.project.repoRoot, context.taskSlug, input.role);
390
414
  await markStopFailureRecoveryFailed(input, context, failure, MAX_ROLE_RETRY_ATTEMPTS, timestamp);
391
- return false;
415
+ return "not-scheduled";
392
416
  }
393
417
  const nextRetryAt = new Date(Date.parse(timestamp) + attempt * ROLE_RETRY_BASE_DELAY_MS).toISOString();
394
418
  await deps.roundService.setRoleRecovery({
@@ -408,7 +432,18 @@ export function createClaudeHookService(deps) {
408
432
  });
409
433
  await deps.sessionService.markRoleActivityRunning(context.project.repoRoot, context.taskSlug, input.role);
410
434
  scheduleStopFailureRetryTimer(input, context, attempt, nextRetryAt);
411
- return true;
435
+ return "scheduled";
436
+ }
437
+ async function isManualInterruptedStopFailure(context, role) {
438
+ try {
439
+ const state = await deps.roundService.getSessionRoundState(createRoundStateInput(context));
440
+ return state.status === "stopped" &&
441
+ state.stopReason === "manual-interrupt" &&
442
+ state.activeRole === role;
443
+ }
444
+ catch {
445
+ return false;
446
+ }
412
447
  }
413
448
  function scheduleStopFailureRetryTimer(input, context, attempt, nextRetryAt) {
414
449
  const key = stopFailureRecoveryKey(context.project.repoRoot, context.taskSlug, input.role);
@@ -441,7 +476,15 @@ export function createClaudeHookService(deps) {
441
476
  await recordTurnEnd(input, context, "StopFailure", {
442
477
  dispatchRouteFiles: false,
443
478
  notifyGateway: false,
444
- settleGuard: false
479
+ settleGuard: false,
480
+ gatewayStopFailure: {
481
+ error: recovery.error ?? "stop_failure",
482
+ errorDetails: recovery.errorDetails,
483
+ lastAssistantMessage: recovery.lastAssistantMessage,
484
+ retryable: recovery.retryable ?? true,
485
+ attempt: recovery.attempt,
486
+ maxAttempts: recovery.maxAttempts
487
+ }
445
488
  });
446
489
  return;
447
490
  }
@@ -3,6 +3,7 @@ import { isVcmRoleName } from "../../shared/constants.js";
3
3
  import { TRANSLATION_ENTRY_RETENTION_LIMIT } from "../../shared/types/translation.js";
4
4
  import { VcmError } from "../errors.js";
5
5
  import { submitTerminalInput } from "../runtime/terminal-submit.js";
6
+ import { readLatestRoleTurnReply } from "./claude-transcript-reply.js";
6
7
  import { createTranslationQueueRegistry } from "./translation-queue.js";
7
8
  const TRANSLATION_SOURCE_LANGUAGE = "auto";
8
9
  const TRANSLATION_INPUT_MODE = "review-before-send";
@@ -243,11 +244,13 @@ export function createTranslationService(deps) {
243
244
  let displayed = false;
244
245
  if (event.kind === "text") {
245
246
  const shouldTranslate = shouldTranslateTextTranscriptEvent(state, event, config);
247
+ const metadata = getTextTranscriptEntryMetadata(event);
246
248
  displayed = shouldTranslate
247
249
  ? processClaudeOutputText(sessionId, event.text, config, event.id, {
248
- flushImmediately: event.stopReason === "end_turn"
250
+ flushImmediately: event.stopReason === "end_turn",
251
+ metadata
249
252
  })
250
- : pushPreservedProseEntry(sessionId, event.id, event.text, config);
253
+ : pushPreservedProseEntry(sessionId, event.id, event.text, config, metadata);
251
254
  if (displayed && shouldTranslate) {
252
255
  state.lastAssistantText = event.text;
253
256
  }
@@ -269,6 +272,9 @@ export function createTranslationService(deps) {
269
272
  if (config.outputMode === "all") {
270
273
  return true;
271
274
  }
275
+ if (config.outputMode === "round-final") {
276
+ return false;
277
+ }
272
278
  if (event.stopReason !== "end_turn") {
273
279
  return false;
274
280
  }
@@ -281,9 +287,16 @@ export function createTranslationService(deps) {
281
287
  return startClaudeOutputTranslation(sessionId, rawText, config, {
282
288
  entryId,
283
289
  replaceExisting: false,
284
- flushImmediately: options.flushImmediately === true
290
+ flushImmediately: options.flushImmediately === true,
291
+ metadata: options.metadata
285
292
  }) !== undefined;
286
293
  }
294
+ function getTextTranscriptEntryMetadata(event) {
295
+ return {
296
+ ...(event.stopReason !== undefined ? { transcriptStopReason: event.stopReason } : {}),
297
+ transcriptTimestamp: event.timestamp
298
+ };
299
+ }
287
300
  function startClaudeOutputTranslation(sessionId, rawText, config, options) {
288
301
  const session = deps.runtime.getSession(sessionId);
289
302
  const roleSession = deps.sessionRegistry.get(sessionId);
@@ -304,7 +317,8 @@ export function createTranslationService(deps) {
304
317
  config,
305
318
  status: "queued",
306
319
  contextUsed: false,
307
- id: options.entryId
320
+ id: options.entryId,
321
+ ...options.metadata
308
322
  })
309
323
  };
310
324
  if (options.replaceExisting) {
@@ -439,10 +453,10 @@ export function createTranslationService(deps) {
439
453
  function pushPreservedTranscriptEntry(sessionId, entryId, sourceText, config) {
440
454
  return pushPreservedOutputEntry(sessionId, entryId, sourceText, "tool-output", config);
441
455
  }
442
- function pushPreservedProseEntry(sessionId, entryId, sourceText, config) {
443
- return pushPreservedOutputEntry(sessionId, entryId, sourceText, "prose", config);
456
+ function pushPreservedProseEntry(sessionId, entryId, sourceText, config, metadata = {}) {
457
+ return pushPreservedOutputEntry(sessionId, entryId, sourceText, "prose", config, metadata);
444
458
  }
445
- function pushPreservedOutputEntry(sessionId, entryId, sourceText, sourceKind, config) {
459
+ function pushPreservedOutputEntry(sessionId, entryId, sourceText, sourceKind, config, metadata = {}) {
446
460
  if (!sourceText.trim()) {
447
461
  return false;
448
462
  }
@@ -462,7 +476,8 @@ export function createTranslationService(deps) {
462
476
  contextUsed: false,
463
477
  id: entryId,
464
478
  translatedText: sourceText,
465
- completedAt: now()
479
+ completedAt: now(),
480
+ ...metadata
466
481
  });
467
482
  pushEntry(sessionId, entry);
468
483
  return true;
@@ -552,7 +567,11 @@ export function createTranslationService(deps) {
552
567
  markFailureRetrying(sessionId, existingFailure);
553
568
  const retrying = startClaudeOutputTranslation(sessionId, original.sourceText, config, {
554
569
  entryId: original.id,
555
- replaceExisting: true
570
+ replaceExisting: true,
571
+ metadata: {
572
+ transcriptStopReason: original.transcriptStopReason,
573
+ transcriptTimestamp: original.transcriptTimestamp
574
+ }
556
575
  });
557
576
  if (!retrying) {
558
577
  throw new VcmError({
@@ -576,6 +595,8 @@ export function createTranslationService(deps) {
576
595
  translatedText: input.translatedText ?? "",
577
596
  status: input.status,
578
597
  contextUsed: input.contextUsed,
598
+ transcriptStopReason: input.transcriptStopReason,
599
+ transcriptTimestamp: input.transcriptTimestamp,
579
600
  boundaryKind: input.boundaryKind,
580
601
  conversationTurn: input.conversationTurn,
581
602
  occurredAt: input.occurredAt,
@@ -676,6 +697,7 @@ export function createTranslationService(deps) {
676
697
  };
677
698
  },
678
699
  async pollTaskFeed(input) {
700
+ const config = await loadConfig();
679
701
  const cursor = Number.isFinite(input.after) ? Math.max(1, Math.floor(input.after)) : 1;
680
702
  const maxEvents = Math.min(Math.max(1, Math.floor(input.limit ?? 500)), 1000);
681
703
  const roleSessions = await deps.sessionService.listRoleSessions(input.repoRoot, input.taskSlug);
@@ -700,6 +722,9 @@ export function createTranslationService(deps) {
700
722
  status: state.status
701
723
  });
702
724
  }
725
+ if (config.outputMode === "round-final") {
726
+ await translateRoundFinalReplyIfReady(input, config);
727
+ }
703
728
  const feed = getTaskFeed(input.taskRepoRoot, input.taskSlug);
704
729
  const events = feed.events
705
730
  .filter((event) => event.seq >= cursor)
@@ -882,6 +907,51 @@ export function createTranslationService(deps) {
882
907
  }
883
908
  return entry;
884
909
  },
910
+ async translateLatestReply(input) {
911
+ const config = await loadConfig();
912
+ const roleSession = await deps.sessionService.getRoleSession(input.repoRoot, input.taskSlug, input.role);
913
+ if (!roleSession || roleSession.status !== "running") {
914
+ throw new VcmError({
915
+ code: "SESSION_NOT_RUNNING",
916
+ message: `${input.role} session is not running.`,
917
+ statusCode: 409
918
+ });
919
+ }
920
+ const reply = await readLatestRoleTurnReply(roleSession);
921
+ if (!reply?.text.trim()) {
922
+ throw new VcmError({
923
+ code: "TRANSLATION_REPLY_NOT_FOUND",
924
+ message: `No completed final reply was found for ${input.role}.`,
925
+ statusCode: 404,
926
+ hint: "Wait until the role finishes a Claude Code turn, then try again."
927
+ });
928
+ }
929
+ await prepareCache({
930
+ repoRoot: input.taskRepoRoot ?? input.repoRoot,
931
+ baseRepoRoot: input.repoRoot,
932
+ taskSlug: input.taskSlug,
933
+ role: input.role,
934
+ sessionId: roleSession.id
935
+ });
936
+ startTranscriptTail(roleSession);
937
+ const entry = startClaudeOutputTranslation(roleSession.id, reply.text, config, {
938
+ replaceExisting: false,
939
+ flushImmediately: true,
940
+ metadata: {
941
+ transcriptStopReason: "end_turn",
942
+ ...(reply.transcriptTimestamp ? { transcriptTimestamp: reply.transcriptTimestamp } : {})
943
+ }
944
+ });
945
+ if (!entry) {
946
+ throw new VcmError({
947
+ code: "TRANSLATION_NOT_STARTED",
948
+ message: "Latest reply translation could not be queued.",
949
+ statusCode: 409,
950
+ hint: "Check that the role session is still running and try again."
951
+ });
952
+ }
953
+ return entry;
954
+ },
885
955
  async sendTranslatedInput(input) {
886
956
  await writeToCurrentRole(input.repoRoot, input.taskSlug, input.role, input.englishText);
887
957
  },
@@ -1039,6 +1109,86 @@ export function createTranslationService(deps) {
1039
1109
  };
1040
1110
  }
1041
1111
  };
1112
+ async function translateRoundFinalReplyIfReady(input, config) {
1113
+ if (!deps.roundService || !deps.projectService) {
1114
+ return;
1115
+ }
1116
+ const projectConfig = await deps.projectService.loadConfig(input.repoRoot);
1117
+ const round = await deps.roundService.getSessionRoundState({
1118
+ repoRoot: input.repoRoot,
1119
+ stateRepoRoot: input.taskRepoRoot,
1120
+ stateRoot: projectConfig.stateRoot,
1121
+ taskSlug: input.taskSlug
1122
+ });
1123
+ if (!isNormalStoppedRound(round)) {
1124
+ return;
1125
+ }
1126
+ const candidate = findRoundFinalReplyCandidate(input, round);
1127
+ if (!candidate || candidate.entry.status !== "preserved") {
1128
+ return;
1129
+ }
1130
+ startClaudeOutputTranslation(candidate.sessionId, candidate.entry.sourceText, config, {
1131
+ entryId: candidate.entry.id,
1132
+ replaceExisting: true,
1133
+ flushImmediately: true,
1134
+ metadata: {
1135
+ transcriptStopReason: candidate.entry.transcriptStopReason,
1136
+ transcriptTimestamp: candidate.entry.transcriptTimestamp
1137
+ }
1138
+ });
1139
+ }
1140
+ function isNormalStoppedRound(round) {
1141
+ return round.status === "stopped"
1142
+ && Boolean(round.roundId)
1143
+ && !round.stopReason
1144
+ && round.roleRecovery?.status !== "failed"
1145
+ && round.flowPause?.reason !== "role-recovery-failed";
1146
+ }
1147
+ function findRoundFinalReplyCandidate(input, round) {
1148
+ const candidates = [];
1149
+ for (const [sessionId, state] of sessionStates) {
1150
+ if (!isRoundFinalCandidateState(state, input, round.activeRole)) {
1151
+ continue;
1152
+ }
1153
+ for (const entry of state.entries) {
1154
+ if (isRoundFinalCandidateEntry(entry, input, round.stoppedAt)) {
1155
+ candidates.push({ sessionId, entry });
1156
+ }
1157
+ }
1158
+ }
1159
+ return candidates
1160
+ .sort((left, right) => compareRoundFinalCandidates(left.entry, right.entry))
1161
+ .at(-1);
1162
+ }
1163
+ function isRoundFinalCandidateState(state, input, activeRole) {
1164
+ if (state.repoRoot !== input.taskRepoRoot || state.taskSlug !== input.taskSlug || !state.role) {
1165
+ return false;
1166
+ }
1167
+ return !activeRole || state.role === activeRole;
1168
+ }
1169
+ function isRoundFinalCandidateEntry(entry, input, stoppedAt) {
1170
+ if (entry.taskSlug !== input.taskSlug ||
1171
+ entry.direction !== "cc-output-to-user" ||
1172
+ entry.sourceKind !== "prose" ||
1173
+ entry.transcriptStopReason !== "end_turn") {
1174
+ return false;
1175
+ }
1176
+ if (entry.status !== "preserved" && entry.status !== "queued" && entry.status !== "translating" && entry.status !== "translated") {
1177
+ return false;
1178
+ }
1179
+ if (!stoppedAt || !entry.transcriptTimestamp) {
1180
+ return true;
1181
+ }
1182
+ return entry.transcriptTimestamp <= stoppedAt;
1183
+ }
1184
+ function compareRoundFinalCandidates(left, right) {
1185
+ const leftTime = left.transcriptTimestamp ?? left.createdAt;
1186
+ const rightTime = right.transcriptTimestamp ?? right.createdAt;
1187
+ if (leftTime === rightTime) {
1188
+ return left.id.localeCompare(right.id);
1189
+ }
1190
+ return leftTime.localeCompare(rightTime);
1191
+ }
1042
1192
  async function findReusableGatewayOutputTranslation(input, config) {
1043
1193
  const graceDeadline = Date.now() + (input.sourceEntryIds?.length ? GATEWAY_TRANSLATION_REUSE_GRACE_MS : 0);
1044
1194
  while (true) {
@@ -12,6 +12,7 @@ export const TRANSLATION_TARGET_LANGUAGE_OPTIONS = [
12
12
  { value: "es", label: "Spanish" }
13
13
  ];
14
14
  export const TRANSLATION_OUTPUT_MODE_OPTIONS = [
15
+ { value: "round-final", label: "Round final reply" },
15
16
  { value: "pm-final-only", label: "PM final reply" },
16
17
  { value: "final-only", label: "Each role final reply" },
17
18
  { value: "all", label: "All replies" }