vibe-coding-master 0.5.2 → 0.5.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.
@@ -1,9 +1,9 @@
1
- import { readFile } from "node:fs/promises";
2
1
  import { VCM_ROLE_NAMES } from "../../shared/constants.js";
3
2
  import { VcmError } from "../errors.js";
4
3
  import { submitTerminalInput } from "../runtime/terminal-submit.js";
5
4
  import { getTaskRuntimeRepoRoot } from "../services/task-service.js";
6
- import { parseAssistantContent, resolveExistingClaudeTranscriptPath } from "../services/claude-transcript-service.js";
5
+ import { resolveExistingClaudeTranscriptPath } from "../services/claude-transcript-service.js";
6
+ import { isFinalTurnTextEvent, readTranscriptTextEvents, selectLatestTurnReply } from "../services/claude-transcript-reply.js";
7
7
  import { parseGatewayCommand } from "./gateway-command-parser.js";
8
8
  import { createLarkRegistrationClient } from "./channels/lark-registration.js";
9
9
  const QR_LOGIN_TTL_MS = 8 * 60 * 1000;
@@ -13,7 +13,6 @@ 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 MAX_LATEST_PM_REPLY_CHARS = 8_000;
17
16
  const GATEWAY_TRANSLATION_FAILURE_TEXT = "PM 回复已收到,但翻译失败。\n发送 /retry 重新翻译。";
18
17
  const COMMANDS_ALLOWED_WHEN_DISABLED = new Set([
19
18
  "help",
@@ -1186,29 +1185,6 @@ export function createGatewayService(deps) {
1186
1185
  });
1187
1186
  }
1188
1187
  }
1189
- async function readTranscriptTextEvents(transcriptPath) {
1190
- const raw = await readFile(transcriptPath, "utf8");
1191
- const events = [];
1192
- for (const line of raw.split("\n")) {
1193
- if (!line.trim()) {
1194
- continue;
1195
- }
1196
- for (const event of parseAssistantContent(line)) {
1197
- if (event.kind === "text") {
1198
- events.push({
1199
- id: event.id,
1200
- timestamp: event.timestamp,
1201
- text: event.text,
1202
- stopReason: event.stopReason
1203
- });
1204
- }
1205
- }
1206
- }
1207
- return events;
1208
- }
1209
- function isFinalTurnTextEvent(event) {
1210
- return event.stopReason === "end_turn";
1211
- }
1212
1188
  function selectEventsAfterCursor(events, cursorId) {
1213
1189
  if (events.length === 0) {
1214
1190
  return [];
@@ -1222,56 +1198,9 @@ function selectEventsAfterCursor(events, cursorId) {
1222
1198
  }
1223
1199
  return events.slice(index + 1);
1224
1200
  }
1225
- function selectLatestTurnReply(events, session) {
1226
- if (events.length === 0) {
1227
- return undefined;
1228
- }
1229
- const startMs = timestampMs(session.lastTurnStartedAt);
1230
- const endMs = timestampMs(session.lastTurnEndedAt);
1231
- const finalEvents = events.filter(isFinalTurnTextEvent);
1232
- const selected = startMs === undefined
1233
- ? finalEvents.slice(-1)
1234
- : finalEvents.filter((event) => {
1235
- const eventMs = timestampMs(event.timestamp);
1236
- return eventMs !== undefined
1237
- && eventMs >= startMs - 1_000
1238
- && (endMs === undefined || eventMs <= endMs + 1_000);
1239
- });
1240
- if (selected.length === 0) {
1241
- return undefined;
1242
- }
1243
- const text = selected.map((event) => event.text).join("\n\n").trim();
1244
- if (!text) {
1245
- return undefined;
1246
- }
1247
- const limited = limitLatestPmReply(text);
1248
- const lastEvent = selected.at(-1);
1249
- return {
1250
- transcriptEventId: lastEvent?.id ?? null,
1251
- transcriptTimestamp: lastEvent?.timestamp ?? null,
1252
- text: limited.text,
1253
- truncated: limited.truncated
1254
- };
1255
- }
1256
1201
  function latestPmReplyKey(repoRoot, taskSlug) {
1257
1202
  return JSON.stringify([repoRoot, taskSlug]);
1258
1203
  }
1259
- function limitLatestPmReply(text) {
1260
- if (text.length <= MAX_LATEST_PM_REPLY_CHARS) {
1261
- return { text, truncated: false };
1262
- }
1263
- return {
1264
- text: text.slice(0, MAX_LATEST_PM_REPLY_CHARS).trimEnd(),
1265
- truncated: true
1266
- };
1267
- }
1268
- function timestampMs(value) {
1269
- if (!value) {
1270
- return undefined;
1271
- }
1272
- const parsed = Date.parse(value);
1273
- return Number.isFinite(parsed) ? parsed : undefined;
1274
- }
1275
1204
  function projectsText(projects) {
1276
1205
  if (projects.length === 0) {
1277
1206
  return "No recent projects. Connect a repository from desktop VCM first.";
@@ -1,5 +1,6 @@
1
- import { isGateReviewerRoleName, isHarnessEngineerToolRoleName, isTranslatorToolRoleName, isVcmRoleName } from "../../shared/constants.js";
1
+ import { isGateReviewerRoleName, isHarnessEngineerToolRoleName, isTranslatorToolRoleName, isUserFacingRole, isVcmRoleName } from "../../shared/constants.js";
2
2
  import { VcmError } from "../errors.js";
3
+ import { readLatestRoleTurnReply } from "./claude-transcript-reply.js";
3
4
  import { submitTerminalInput } from "../runtime/terminal-submit.js";
4
5
  import { getTaskRuntimeRepoRoot } from "./task-service.js";
5
6
  const MAX_ROLE_RETRY_ATTEMPTS = 20;
@@ -115,17 +116,29 @@ export function createClaudeHookService(deps) {
115
116
  void repoRoot;
116
117
  return input.taskSlug;
117
118
  }
119
+ // Defensive task-binding guard: only mutate a task's round/status when the
120
+ // posting role's authoritative session record is bound to that task. A session
121
+ // whose record reports a different slug (e.g. a project-scoped sentinel session)
122
+ // must not resume or clobber an unrelated task's flow. Absence of a record is
123
+ // not proof of misattribution, so the normal flow proceeds.
124
+ async function isHookSessionBoundToTask(context, role) {
125
+ const session = await deps.sessionService.getRoleSession(context.project.repoRoot, context.taskSlug, role);
126
+ return !session || session.taskSlug === context.taskSlug;
127
+ }
118
128
  async function handleUserPromptSubmitHook(input) {
119
129
  const eventName = parseHookEvent(input.event.hook_event_name);
120
130
  if (eventName !== "UserPromptSubmit") {
121
131
  throwUnsupportedEvent(eventName);
122
132
  }
123
133
  const context = await getHookContext(input);
124
- deps.jobGuard?.notePromptSubmitted({
125
- repoRoot: context.project.repoRoot,
126
- taskSlug: context.taskSlug,
127
- role: input.role
128
- });
134
+ const boundToTask = await isHookSessionBoundToTask(context, input.role);
135
+ if (boundToTask) {
136
+ deps.jobGuard?.notePromptSubmitted({
137
+ repoRoot: context.project.repoRoot,
138
+ taskSlug: context.taskSlug,
139
+ role: input.role
140
+ });
141
+ }
129
142
  const session = await deps.sessionService.recordClaudeHookEvent(context.project.repoRoot, {
130
143
  taskSlug: context.taskSlug,
131
144
  role: input.role,
@@ -134,14 +147,16 @@ export function createClaudeHookService(deps) {
134
147
  transcriptPath: stringOrUndefined(input.event.transcript_path),
135
148
  cwd: stringOrUndefined(input.event.cwd) ?? stringOrUndefined(input.event.new_cwd)
136
149
  });
137
- await deps.roundService.recordClaudeHookEvent({
138
- repoRoot: context.project.repoRoot,
139
- stateRepoRoot: context.taskRepoRoot,
140
- stateRoot: context.config.stateRoot,
141
- taskSlug: context.taskSlug,
142
- role: input.role,
143
- eventName
144
- });
150
+ if (boundToTask) {
151
+ await deps.roundService.recordClaudeHookEvent({
152
+ repoRoot: context.project.repoRoot,
153
+ stateRepoRoot: context.taskRepoRoot,
154
+ stateRoot: context.config.stateRoot,
155
+ taskSlug: context.taskSlug,
156
+ role: input.role,
157
+ eventName
158
+ });
159
+ }
145
160
  if (session) {
146
161
  await deps.translationService.recordConversationBoundary({
147
162
  repoRoot: context.project.repoRoot,
@@ -276,6 +291,7 @@ export function createClaudeHookService(deps) {
276
291
  async function recordTurnEnd(input, context, eventName, options) {
277
292
  const scopedRouteDispatchInput = createRouteDispatchInput(input, context, input.role);
278
293
  const settleRouteDispatchInput = createRouteDispatchInput(input, context);
294
+ const boundToTask = await isHookSessionBoundToTask(context, input.role);
279
295
  const session = await deps.sessionService.recordClaudeHookEvent(context.project.repoRoot, {
280
296
  taskSlug: context.taskSlug,
281
297
  role: input.role,
@@ -284,28 +300,32 @@ export function createClaudeHookService(deps) {
284
300
  transcriptPath: stringOrUndefined(input.event.transcript_path),
285
301
  cwd: stringOrUndefined(input.event.cwd) ?? stringOrUndefined(input.event.new_cwd)
286
302
  });
287
- await deps.roundService.recordClaudeHookEvent({
288
- repoRoot: context.project.repoRoot,
289
- stateRepoRoot: context.taskRepoRoot,
290
- stateRoot: context.config.stateRoot,
291
- taskSlug: context.taskSlug,
292
- role: input.role,
293
- eventName,
294
- ...(options.settleGuard
295
- ? {
296
- settleGuard: async () => {
297
- const pending = await deps.messageService.listPendingRouteFiles(settleRouteDispatchInput);
298
- if (pending.length === 0) {
299
- return { action: "stop" };
303
+ if (boundToTask) {
304
+ const userFacingReply = await captureUserFacingReply(eventName, input.role, session);
305
+ await deps.roundService.recordClaudeHookEvent({
306
+ repoRoot: context.project.repoRoot,
307
+ stateRepoRoot: context.taskRepoRoot,
308
+ stateRoot: context.config.stateRoot,
309
+ taskSlug: context.taskSlug,
310
+ role: input.role,
311
+ eventName,
312
+ ...(userFacingReply ? { userFacingReply } : {}),
313
+ ...(options.settleGuard
314
+ ? {
315
+ settleGuard: async () => {
316
+ const pending = await deps.messageService.listPendingRouteFiles(settleRouteDispatchInput);
317
+ if (pending.length === 0) {
318
+ return { action: "stop" };
319
+ }
320
+ const retried = await deps.messageService.scanAndDispatchPendingRouteFiles(settleRouteDispatchInput);
321
+ return retried.some((result) => result.delivered)
322
+ ? { action: "continue", reason: "pending route message dispatched" }
323
+ : { action: "stop" };
300
324
  }
301
- const retried = await deps.messageService.scanAndDispatchPendingRouteFiles(settleRouteDispatchInput);
302
- return retried.some((result) => result.delivered)
303
- ? { action: "continue", reason: "pending route message dispatched" }
304
- : { action: "stop" };
305
325
  }
306
- }
307
- : {})
308
- });
326
+ : {})
327
+ });
328
+ }
309
329
  if (session) {
310
330
  await deps.translationService.recordConversationBoundary({
311
331
  repoRoot: context.project.repoRoot,
@@ -582,6 +602,21 @@ function parseHookEvent(value) {
582
602
  hint: "VCM accepts UserPromptSubmit, Stop, StopFailure, and PostCompact hooks only."
583
603
  });
584
604
  }
605
+ // On a user-facing role's Stop, best-effort capture its last user-facing turn
606
+ // text to seed the await-user pause message. Capture failure is non-fatal and
607
+ // must never block turn-end, so a missing/failed read just omits the field.
608
+ async function captureUserFacingReply(eventName, role, session) {
609
+ if (eventName !== "Stop" || !isUserFacingRole(role) || !session) {
610
+ return undefined;
611
+ }
612
+ try {
613
+ const reply = await readLatestRoleTurnReply(session);
614
+ return reply ? { text: reply.text, truncated: reply.truncated } : undefined;
615
+ }
616
+ catch {
617
+ return undefined;
618
+ }
619
+ }
585
620
  function throwUnsupportedEvent(eventName) {
586
621
  throw new VcmError({
587
622
  code: "HOOK_EVENT_UNSUPPORTED",
@@ -0,0 +1,107 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { parseAssistantContent, resolveExistingClaudeTranscriptPath } from "./claude-transcript-service.js";
3
+ /** Default maximum captured-reply length (characters). */
4
+ export const MAX_TURN_REPLY_CHARS = 8_000;
5
+ /** Tolerance applied when matching transcript events to the role's last-turn window. */
6
+ const TURN_WINDOW_TOLERANCE_MS = 1_000;
7
+ /**
8
+ * Best-effort read of a role's latest user-facing turn reply.
9
+ *
10
+ * Resolves the session's transcript, selects the `end_turn` text emitted within
11
+ * the session's last-turn window (`lastTurnStartedAt`..`lastTurnEndedAt`), and
12
+ * truncates to `options.maxLength` (default `MAX_TURN_REPLY_CHARS`). Returns
13
+ * `undefined` when no transcript or no usable text is available — callers must
14
+ * treat a missing reply as non-fatal.
15
+ */
16
+ export async function readLatestRoleTurnReply(session, options) {
17
+ const transcriptPath = resolveExistingClaudeTranscriptPath(session);
18
+ if (!transcriptPath) {
19
+ return undefined;
20
+ }
21
+ let events;
22
+ try {
23
+ events = await readTranscriptTextEvents(transcriptPath);
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ return selectLatestTurnReply(events, session, options?.maxLength ?? MAX_TURN_REPLY_CHARS);
29
+ }
30
+ /** Read all assistant text events from a transcript file (throws on read failure). */
31
+ export async function readTranscriptTextEvents(transcriptPath) {
32
+ const raw = await readFile(transcriptPath, "utf8");
33
+ const events = [];
34
+ for (const line of raw.split("\n")) {
35
+ if (!line.trim()) {
36
+ continue;
37
+ }
38
+ for (const event of parseAssistantContent(line)) {
39
+ if (event.kind === "text") {
40
+ events.push({
41
+ id: event.id,
42
+ timestamp: event.timestamp,
43
+ text: event.text,
44
+ stopReason: event.stopReason
45
+ });
46
+ }
47
+ }
48
+ }
49
+ return events;
50
+ }
51
+ /** True for a text event that completed a turn (assistant stopped of its own accord). */
52
+ export function isFinalTurnTextEvent(event) {
53
+ return event.stopReason === "end_turn";
54
+ }
55
+ /**
56
+ * Select the role's last completed user-facing reply from its transcript events,
57
+ * scoped to the session's last-turn window, joined and truncated. Returns
58
+ * `undefined` when no end_turn text falls in the window or the text is empty.
59
+ */
60
+ export function selectLatestTurnReply(events, session, maxLength = MAX_TURN_REPLY_CHARS) {
61
+ if (events.length === 0) {
62
+ return undefined;
63
+ }
64
+ const startMs = timestampMs(session.lastTurnStartedAt);
65
+ const endMs = timestampMs(session.lastTurnEndedAt);
66
+ const finalEvents = events.filter(isFinalTurnTextEvent);
67
+ const selected = startMs === undefined
68
+ ? finalEvents.slice(-1)
69
+ : finalEvents.filter((event) => {
70
+ const eventMs = timestampMs(event.timestamp);
71
+ return eventMs !== undefined
72
+ && eventMs >= startMs - TURN_WINDOW_TOLERANCE_MS
73
+ && (endMs === undefined || eventMs <= endMs + TURN_WINDOW_TOLERANCE_MS);
74
+ });
75
+ if (selected.length === 0) {
76
+ return undefined;
77
+ }
78
+ const text = selected.map((event) => event.text).join("\n\n").trim();
79
+ if (!text) {
80
+ return undefined;
81
+ }
82
+ const limited = limitTranscriptReply(text, maxLength);
83
+ const lastEvent = selected.at(-1);
84
+ return {
85
+ transcriptEventId: lastEvent?.id ?? null,
86
+ transcriptTimestamp: lastEvent?.timestamp ?? null,
87
+ text: limited.text,
88
+ truncated: limited.truncated
89
+ };
90
+ }
91
+ /** Truncate reply text to `maxLength`, reporting whether truncation occurred. */
92
+ export function limitTranscriptReply(text, maxLength = MAX_TURN_REPLY_CHARS) {
93
+ if (text.length <= maxLength) {
94
+ return { text, truncated: false };
95
+ }
96
+ return {
97
+ text: text.slice(0, maxLength).trimEnd(),
98
+ truncated: true
99
+ };
100
+ }
101
+ function timestampMs(value) {
102
+ if (!value) {
103
+ return undefined;
104
+ }
105
+ const parsed = Date.parse(value);
106
+ return Number.isFinite(parsed) ? parsed : undefined;
107
+ }
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { randomUUID } from "node:crypto";
3
+ import { isUserFacingRole } from "../../shared/constants.js";
3
4
  const DEFAULT_SETTLE_MS = 10_000;
4
5
  const setGlobalTimeout = globalThis.setTimeout.bind(globalThis);
5
6
  const clearGlobalTimeout = globalThis.clearTimeout.bind(globalThis);
@@ -108,6 +109,8 @@ export function createRoundService(deps) {
108
109
  ...state,
109
110
  currentRound: stopped,
110
111
  lastStoppedRound: stopped,
112
+ awaitingUser: resolveAwaitingUserOnStop(state.awaitingUser, current.activeRole, stopped.stoppedAt ?? timestamp, state.pendingUserReply),
113
+ pendingUserReply: undefined,
111
114
  updatedAt: timestamp
112
115
  };
113
116
  await save(input, next);
@@ -193,7 +196,7 @@ export function createRoundService(deps) {
193
196
  const settled = await settleIfNeeded(input, await load(input), timestamp);
194
197
  const current = settled.currentRound;
195
198
  const shouldStartNewRound = input.eventName === "UserPromptSubmit" && (!current || current.status === "stopped");
196
- const next = applyRoundHookEvent({
199
+ const recorded = applyRoundHookEvent({
197
200
  state: settled,
198
201
  taskSlug: input.taskSlug,
199
202
  role: input.role,
@@ -202,6 +205,8 @@ export function createRoundService(deps) {
202
205
  roundId: shouldStartNewRound ? id() : current?.id ?? "",
203
206
  settleMs
204
207
  });
208
+ const answered = clearAwaitingUserIfAnswered(recorded, input.eventName, input.role);
209
+ const next = applyPendingUserReplyStash(answered, input, timestamp);
205
210
  await save(input, next);
206
211
  if (input.eventName === "UserPromptSubmit") {
207
212
  clearSettleTimer(input);
@@ -383,7 +388,7 @@ function toSessionRoundState(state, updatedAt) {
383
388
  totalCcActiveMs: state.totalCcActiveMs,
384
389
  currentRoundCcActiveMs: 0,
385
390
  roleRecovery: state.roleRecovery,
386
- flowPause: computeFlowPause(undefined, state.roleRecovery),
391
+ flowPause: computeFlowPause(undefined, state.roleRecovery, state.awaitingUser),
387
392
  roles: [],
388
393
  updatedAt
389
394
  };
@@ -412,41 +417,147 @@ function toSessionRoundState(state, updatedAt) {
412
417
  totalCcActiveMs: state.totalCcActiveMs + activeDurationMs,
413
418
  currentRoundCcActiveMs,
414
419
  roleRecovery: state.roleRecovery,
415
- flowPause: computeFlowPause(current, state.roleRecovery),
420
+ flowPause: computeFlowPause(current, state.roleRecovery, state.awaitingUser),
416
421
  roles: current.roles,
417
422
  updatedAt
418
423
  };
419
424
  }
420
425
  /**
421
- * Authoritative flow-pause predicate (single source of truth). Returns a paused
422
- * VcmFlowPauseState exactly when a real round has ended and the auto flow has not
423
- * advanced and we are not mid active-recovery — mirroring the decision the GUI
424
- * previously derived. Reason is `role-recovery-failed` when recovery has failed,
425
- * otherwise `stopped-no-next-turn`. Returns a non-paused state (or undefined) when
426
- * the flow is not paused. The frontend consumes this instead of re-deriving it.
426
+ * Authoritative flow-pause predicate (single source of truth). The frontend
427
+ * consumes this instead of re-deriving the pause decision.
427
428
  *
428
- * Intended logic (to implement):
429
- * const recovering = roleRecovery?.status === "waiting" || roleRecovery?.status === "retrying";
430
- * const paused = !!current && current.status === "stopped" && !!current.id && !recovering;
431
- * if (!paused) return undefined; // or { paused: false }
432
- * return {
433
- * paused: true,
434
- * reason: roleRecovery?.status === "failed" ? "role-recovery-failed" : "stopped-no-next-turn",
435
- * role: current.activeRole,
436
- * since: current.stoppedAt ?? current.lastTurnEndedAt
437
- * };
429
+ * Reason precedence: `role-recovery-failed` > `awaiting-user` > `stopped-no-next-turn`.
430
+ * - `awaiting-user` is emitted whenever `awaitingUser` is set and recovery has not
431
+ * failed. It is STICKY: it stays paused regardless of round status (the round may
432
+ * auto-continue under another role while the user's decision is still pending).
433
+ * - `stopped-no-next-turn` is emitted only when a real round has ended and the auto
434
+ * flow has not advanced and we are not mid active-recovery.
438
435
  */
439
- function computeFlowPause(current, roleRecovery) {
436
+ function computeFlowPause(current, roleRecovery, awaitingUser) {
440
437
  const recovering = roleRecovery?.status === "waiting" || roleRecovery?.status === "retrying";
441
- const paused = Boolean(current) && current.status === "stopped" && Boolean(current.id) && !recovering;
442
- if (!paused) {
438
+ if (recovering) {
439
+ return undefined;
440
+ }
441
+ const roundStopped = Boolean(current) && current.status === "stopped" && Boolean(current.id);
442
+ if (roleRecovery?.status === "failed") {
443
+ return roundStopped
444
+ ? {
445
+ paused: true,
446
+ reason: "role-recovery-failed",
447
+ role: current.activeRole,
448
+ since: current.stoppedAt ?? current.lastTurnEndedAt
449
+ }
450
+ : undefined;
451
+ }
452
+ if (awaitingUser) {
453
+ return {
454
+ paused: true,
455
+ reason: "awaiting-user",
456
+ role: awaitingUser.role,
457
+ since: awaitingUser.since,
458
+ message: awaitingUser.message,
459
+ messageTruncated: awaitingUser.messageTruncated
460
+ };
461
+ }
462
+ if (roundStopped) {
463
+ return {
464
+ paused: true,
465
+ reason: "stopped-no-next-turn",
466
+ role: current.activeRole,
467
+ since: current.stoppedAt ?? current.lastTurnEndedAt
468
+ };
469
+ }
470
+ return undefined;
471
+ }
472
+ /**
473
+ * Sticky await-user resolution on settle->stopped: keep any existing anchor
474
+ * untouched, and only set a new one when the settling role addresses the human
475
+ * operator. Non-user-facing roles never raise an await-user anchor.
476
+ */
477
+ function resolveAwaitingUserOnStop(existing, role, since, pendingReply) {
478
+ if (existing) {
479
+ return existing;
480
+ }
481
+ if (!isUserFacingRole(role)) {
482
+ return undefined;
483
+ }
484
+ if (pendingReply && pendingReply.role === role) {
485
+ return {
486
+ role,
487
+ since,
488
+ capturedAt: pendingReply.capturedAt,
489
+ message: pendingReply.text,
490
+ messageTruncated: pendingReply.truncated
491
+ };
492
+ }
493
+ return { role, since };
494
+ }
495
+ /**
496
+ * Maintain the transient `pendingUserReply` stash. A user-facing role's Stop with
497
+ * a captured reply records it until settle promotes it to `awaitingUser.message`.
498
+ * The same role's next UserPromptSubmit obsoletes the stash (turn answered/resumed),
499
+ * so stale text can never attach to a later anchor.
500
+ */
501
+ function applyPendingUserReplyStash(state, input, timestamp) {
502
+ if (input.eventName === "Stop" && isUserFacingRole(input.role) && input.userFacingReply) {
503
+ return {
504
+ ...state,
505
+ pendingUserReply: {
506
+ role: input.role,
507
+ text: input.userFacingReply.text,
508
+ truncated: input.userFacingReply.truncated,
509
+ capturedAt: timestamp
510
+ }
511
+ };
512
+ }
513
+ if (input.eventName === "UserPromptSubmit"
514
+ && state.pendingUserReply?.role === input.role) {
515
+ return { ...state, pendingUserReply: undefined };
516
+ }
517
+ return state;
518
+ }
519
+ /**
520
+ * Clear the await-user anchor only when the awaiting role itself receives its
521
+ * next UserPromptSubmit (the user answered / that role resumed). Other roles,
522
+ * other events, and round auto-continuation never clear it.
523
+ */
524
+ function clearAwaitingUserIfAnswered(state, eventName, role) {
525
+ if (eventName !== "UserPromptSubmit" || state.awaitingUser?.role !== role) {
526
+ return state;
527
+ }
528
+ return { ...state, awaitingUser: undefined };
529
+ }
530
+ function normalizeAwaitingUser(input) {
531
+ if (!input || typeof input !== "object") {
532
+ return undefined;
533
+ }
534
+ const record = input;
535
+ if (typeof record.role !== "string" || typeof record.since !== "string") {
536
+ return undefined;
537
+ }
538
+ return {
539
+ role: record.role,
540
+ since: record.since,
541
+ capturedAt: typeof record.capturedAt === "string" ? record.capturedAt : undefined,
542
+ message: typeof record.message === "string" ? record.message : undefined,
543
+ messageTruncated: record.messageTruncated === true ? true : undefined
544
+ };
545
+ }
546
+ function normalizePendingUserReply(input) {
547
+ if (!input || typeof input !== "object") {
548
+ return undefined;
549
+ }
550
+ const record = input;
551
+ if (typeof record.role !== "string"
552
+ || typeof record.text !== "string"
553
+ || typeof record.capturedAt !== "string") {
443
554
  return undefined;
444
555
  }
445
556
  return {
446
- paused: true,
447
- reason: roleRecovery?.status === "failed" ? "role-recovery-failed" : "stopped-no-next-turn",
448
- role: current.activeRole,
449
- since: current.stoppedAt ?? current.lastTurnEndedAt
557
+ role: record.role,
558
+ text: record.text,
559
+ truncated: record.truncated === true,
560
+ capturedAt: record.capturedAt
450
561
  };
451
562
  }
452
563
  function normalizeRoundFile(input, taskSlug, updatedAt) {
@@ -461,6 +572,8 @@ function normalizeRoundFile(input, taskSlug, updatedAt) {
461
572
  totalCompletedTurnCount: normalizeNumber(input.totalCompletedTurnCount ?? legacy.totalStopCount),
462
573
  totalCcActiveMs: normalizeNumber(input.totalCcActiveMs),
463
574
  roleRecovery: normalizeRoleRecovery(input.roleRecovery),
575
+ awaitingUser: normalizeAwaitingUser(input.awaitingUser),
576
+ pendingUserReply: normalizePendingUserReply(input.pendingUserReply),
464
577
  updatedAt: typeof input.updatedAt === "string" ? input.updatedAt : updatedAt
465
578
  };
466
579
  }