relay-companion 0.1.199 → 0.1.201

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.
@@ -4874,6 +4874,15 @@
4874
4874
  roomScrollEl.scrollTop += currentOffset - (Number(saved.anchorOffset) || 0);
4875
4875
  }
4876
4876
  function openReader(id, source) {
4877
+ // A notification banner remains `peeking` even after the user moves into
4878
+ // Requests. Both prepareReaderMorph() and syncCardSize() deliberately
4879
+ // refuse to resize in that state, so opening a row could paint the full
4880
+ // reader into the 400x118 banner window. The click explicitly asks for the
4881
+ // full surface: retire the transient banner before taking the snapshot.
4882
+ if (peeking) {
4883
+ clearPeek();
4884
+ presented = true;
4885
+ }
4877
4886
  const morphFromList = prepareReaderMorph(activeView);
4878
4887
  readerId = id;
4879
4888
  const opened = readerRow(id);
package/overlay/main.cjs CHANGED
@@ -3411,7 +3411,9 @@ function createWindow() {
3411
3411
  win.once("ready-to-show", () => {
3412
3412
  // First paint is instant: relays come from local state.json; the sent + contacts
3413
3413
  // background loads kicked off here each re-push when they land (buildPayload).
3414
- pushInbox(true).catch((error) => console.error("[overlay] initial push failed:", error && error.message));
3414
+ pushInbox(true)
3415
+ .then(() => reconcileCanonicalCompletionMonitors())
3416
+ .catch((error) => console.error("[overlay] initial push failed:", error && error.message));
3415
3417
  pillReady = true;
3416
3418
  // Relay is independently useful and searchable even when no host app happens to
3417
3419
  // be open. A normal login launch still respects the persisted dismissed preference.
@@ -4159,6 +4161,7 @@ async function startTaskFromPreview(input) {
4159
4161
  const stamped = new Date().toISOString();
4160
4162
  updateStagedPacket(id, isRequest ? { taskStartedAt: stamped } : { workStartedAt: stamped, workCompletedAt: null });
4161
4163
  pushInbox(true);
4164
+ void ensureCanonicalCompletionMonitor(id);
4162
4165
  return { ok: true, taskStartedAt: stamped, replied: reply.ok, running, runError };
4163
4166
  } catch {
4164
4167
  return { ok: true, taskStartedAt: new Date().toISOString(), replied: reply.ok, running, runError };
@@ -4275,6 +4278,14 @@ function providerWorkIdentity(relayId) {
4275
4278
  };
4276
4279
  }
4277
4280
 
4281
+ function expectedProviderTurnId(identity) {
4282
+ return identity?.provider === "codex" ? String(identity.sessionRef?.turnId || "") : "";
4283
+ }
4284
+
4285
+ function hasWireCompletionTarget(relayId) {
4286
+ return Boolean(relayId) && !String(relayId).startsWith("relay_local_");
4287
+ }
4288
+
4278
4289
  function canonicalizeCodexWorkAttachments(relayId, events) {
4279
4290
  const metadata = Array.isArray(rowById(relayId)?.workAttachmentMetadata)
4280
4291
  ? rowById(relayId).workAttachmentMetadata
@@ -4474,6 +4485,94 @@ async function reconnectCanonicalWorkFeed(relayId) {
4474
4485
  }
4475
4486
  }
4476
4487
 
4488
+ const canonicalCompletionMonitors = new Map();
4489
+
4490
+ async function stopCanonicalCompletionMonitor(relayId) {
4491
+ const id = String(relayId || "");
4492
+ const current = canonicalCompletionMonitors.get(id);
4493
+ if (!current) return false;
4494
+ canonicalCompletionMonitors.delete(id);
4495
+ const bridge = await canonicalWorkBridge();
4496
+ return bridge.unsubscribe({ relayId: id, sessionId: current.sessionId, subscriberId: current.subscriberId });
4497
+ }
4498
+
4499
+ async function settleCanonicalWorkEnvelope(relayId, identity, envelope) {
4500
+ const id = String(relayId || "");
4501
+ const row = rowById(id);
4502
+ const isRequest = row?.relayNotificationKind === "task";
4503
+ const completedField = isRequest ? "taskCompletedAt" : "workCompletedAt";
4504
+ const startedAt = String((isRequest ? row?.taskStartedAt : row?.workStartedAt) || "");
4505
+ if (!row || !startedAt) return false;
4506
+ const { canonicalProviderCompletionCandidate } = await import("../src/provider-completion.js");
4507
+ const candidate = canonicalProviderCompletionCandidate({
4508
+ provider: identity.provider,
4509
+ presentation: envelope?.presentation,
4510
+ expectedTurnId: expectedProviderTurnId(identity),
4511
+ startedAfter: startedAt,
4512
+ });
4513
+ if (!candidate) return false;
4514
+
4515
+ // Native terminal truth is local truth. Persist Done before attempting the
4516
+ // separate wire receipt: an offline API (or a local-only fixture id) cannot
4517
+ // turn a completed provider run back into "Didn't finish".
4518
+ const latest = rowById(id) || row;
4519
+ if (!completionAfter(latest[completedField], startedAt)) {
4520
+ updateStagedPacket(id, { [completedField]: candidate.completedAt });
4521
+ pushInbox(true).catch(() => {});
4522
+ }
4523
+ if (isRequest && hasWireCompletionTarget(id) && !latest.providerCompletionRelayId && !latest.coworkCompletionRelayId) {
4524
+ queueProviderCompletionBridge(id, identity.provider, identity.sessionId, candidate);
4525
+ }
4526
+ await stopCanonicalCompletionMonitor(id);
4527
+ return true;
4528
+ }
4529
+
4530
+ async function ensureCanonicalCompletionMonitor(relayId) {
4531
+ const id = String(relayId || "");
4532
+ const row = rowById(id);
4533
+ const isRequest = row?.relayNotificationKind === "task";
4534
+ const startedAt = isRequest ? row?.taskStartedAt : row?.workStartedAt;
4535
+ if (!row || !startedAt || (isRequest && row.providerCompletionRelayId)) return false;
4536
+ const identity = providerWorkIdentity(id);
4537
+ if (!identity) return false;
4538
+ const previous = canonicalCompletionMonitors.get(id);
4539
+ if (previous?.sessionId === identity.sessionId) return true;
4540
+ if (previous) await stopCanonicalCompletionMonitor(id);
4541
+ const subscriberId = `completion:${id}`;
4542
+ canonicalCompletionMonitors.set(id, { sessionId: identity.sessionId, subscriberId });
4543
+ const bridge = await canonicalWorkBridge();
4544
+ try {
4545
+ await bridge.subscribe({
4546
+ relayId: id,
4547
+ sessionId: identity.sessionId,
4548
+ subscriberId,
4549
+ send: (envelope) => { void settleCanonicalWorkEnvelope(id, identity, envelope); },
4550
+ isAlive: () => {
4551
+ const latest = rowById(id);
4552
+ return Boolean(latest && (isRequest ? latest.taskStartedAt : latest.workStartedAt));
4553
+ },
4554
+ });
4555
+ return true;
4556
+ } catch (error) {
4557
+ canonicalCompletionMonitors.delete(id);
4558
+ console.error("[overlay] canonical completion monitor failed:", id, error && error.message);
4559
+ return false;
4560
+ }
4561
+ }
4562
+
4563
+ function reconcileCanonicalCompletionMonitors() {
4564
+ const packets = readStore().packets || {};
4565
+ for (const [id, value] of Object.entries(packets)) {
4566
+ const row = { id, ...value };
4567
+ const isRequest = row.relayNotificationKind === "task";
4568
+ const startedAt = isRequest ? row.taskStartedAt : row.workStartedAt;
4569
+ const completedAt = isRequest ? row.taskCompletedAt : row.workCompletedAt;
4570
+ const wirePending = isRequest && hasWireCompletionTarget(id) && !row.providerCompletionRelayId && !row.coworkCompletionRelayId;
4571
+ if (!startedAt || (!wirePending && completionAfter(completedAt, startedAt))) continue;
4572
+ if (providerWorkIdentity(id)) void ensureCanonicalCompletionMonitor(id);
4573
+ }
4574
+ }
4575
+
4477
4576
  function workEventAuthorized(event, relayId) {
4478
4577
  if (!event?.sender || event.sender.isDestroyed?.()) return false;
4479
4578
  if (win && !win.isDestroyed() && event.sender === win.webContents) return true;
@@ -4591,7 +4690,7 @@ async function taskRunFeed(relayId) {
4591
4690
  turnStartedAt: turn.startedAt,
4592
4691
  turnCompletedAt: turn.completedAt,
4593
4692
  turnDurationMs: turn.durationMs,
4594
- completedAt: completedAt || session.taskCompletedAt || session.nativeCompletedAt || null,
4693
+ completedAt: completedAt || session.taskCompletedAt || null,
4595
4694
  };
4596
4695
  }
4597
4696
 
@@ -4612,7 +4711,6 @@ async function previewTaskSession(relayId) {
4612
4711
  readCoworkSession,
4613
4712
  coworkEventsToRecords,
4614
4713
  coworkSessionLifecycle,
4615
- coworkFinalAssistantText,
4616
4714
  } = await import("../src/cowork-sessions.js");
4617
4715
  const remote = await readCoworkSession(coworkSessionId);
4618
4716
  const lifecycle = coworkSessionLifecycle(remote.session, remote.events);
@@ -4622,22 +4720,7 @@ async function previewTaskSession(relayId) {
4622
4720
  updateStagedPacket(id, {
4623
4721
  coworkLiveState: lifecycle.liveState,
4624
4722
  coworkLiveCheckedAt: new Date().toISOString(),
4625
- ...(terminalAt ? (isRequest ? { taskCompletedAt: terminalAt } : { workCompletedAt: terminalAt }) : {}),
4626
4723
  });
4627
- // Cowork's cloud worker has no Relay MCP, so its result cannot stamp the
4628
- // sender's Done receipt itself. Bridge the final assistant answer once,
4629
- // using a stable idempotency key so every UI poll converges safely.
4630
- if (isRequest && lifecycle.terminal && !row.providerCompletionRelayId && !row.coworkCompletionRelayId) {
4631
- const finalText = coworkFinalAssistantText(remote.events);
4632
- const { providerCompletionCandidate } = await import("../src/provider-completion.js");
4633
- const candidate = providerCompletionCandidate({
4634
- provider, records: coworkEventsToRecords(remote.events), liveState: lifecycle.liveState,
4635
- terminal: true, endedAt: terminalAt,
4636
- });
4637
- queueProviderCompletionBridge(id, provider, coworkSessionId, candidate || {
4638
- body: finalText || "Claude Cowork ended without a final answer.", outcome: finalText ? "completed" : "failed", completedAt: terminalAt,
4639
- });
4640
- }
4641
4724
  return {
4642
4725
  ok: true,
4643
4726
  provider,
@@ -4646,7 +4729,7 @@ async function previewTaskSession(relayId) {
4646
4729
  endedAt: terminalAt,
4647
4730
  records: coworkEventsToRecords(remote.events),
4648
4731
  taskStartedAt: (isRequest ? row.taskStartedAt : row.workStartedAt) || null,
4649
- taskCompletedAt: terminalAt || (isRequest ? row.taskCompletedAt : row.workCompletedAt) || null,
4732
+ taskCompletedAt: (isRequest ? row.taskCompletedAt : row.workCompletedAt) || null,
4650
4733
  };
4651
4734
  }
4652
4735
  const { inspectAiSession } = await import("../src/ai-session-transcript.js");
@@ -4679,8 +4762,6 @@ async function previewTaskSession(relayId) {
4679
4762
  }
4680
4763
  }
4681
4764
  let endedAt = null;
4682
- let nativeCompletedAt = null;
4683
- let nativeTerminalStatus = "";
4684
4765
  if (provider === "codex" && row.codexRuntimeSessionRef?.logPath) {
4685
4766
  const { codexAppServerActivity } = await import("../src/codex-app-server-activity.js");
4686
4767
  const native = codexAppServerActivity(row.codexRuntimeSessionRef.logPath, {
@@ -4695,49 +4776,18 @@ async function previewTaskSession(relayId) {
4695
4776
  records.sort((left, right) => Date.parse(String(right?.at || "")) - Date.parse(String(left?.at || "")));
4696
4777
  }
4697
4778
  endedAt = native.terminalAt;
4698
- nativeTerminalStatus = native.terminalStatus;
4699
- if (native.terminalStatus === "completed") nativeCompletedAt = native.terminalAt;
4700
4779
  }
4701
4780
  const liveState = await previewTaskLiveState(row, provider, records);
4702
- const providerSessionId = provider === "claude"
4703
- ? String(row.claudeNativeSession?.sessionId || "")
4704
- : String(row.codexRuntimeSessionRef?.turnId || row.codexThreadId || "");
4705
- const runActive = provider === "claude" && relayOwnedClaudeRuns.has(providerSessionId);
4706
- if (isRequest && !row.providerCompletionRelayId && !row.coworkCompletionRelayId) {
4707
- const { providerCompletionCandidate } = await import("../src/provider-completion.js");
4708
- const candidate = providerCompletionCandidate({
4709
- provider, records, liveState, runActive,
4710
- terminal: Boolean(nativeTerminalStatus), endedAt,
4711
- error: nativeTerminalStatus && nativeTerminalStatus !== "completed"
4712
- ? `Native turn ended ${nativeTerminalStatus}`
4713
- : "",
4714
- });
4715
- if (candidate) queueProviderCompletionBridge(id, provider, providerSessionId, candidate);
4716
- }
4717
- if (!isRequest) {
4718
- const { providerCompletionCandidate } = await import("../src/provider-completion.js");
4719
- const localCandidate = providerCompletionCandidate({
4720
- provider, records, liveState, runActive,
4721
- terminal: Boolean(nativeTerminalStatus), endedAt,
4722
- error: nativeTerminalStatus && nativeTerminalStatus !== "completed"
4723
- ? `Native turn ended ${nativeTerminalStatus}`
4724
- : "",
4725
- });
4726
- if (localCandidate?.completedAt && !row.workCompletedAt) {
4727
- updateStagedPacket(id, { workCompletedAt: localCandidate.completedAt });
4728
- }
4729
- }
4730
4781
  return {
4731
4782
  ok: true,
4732
4783
  provider,
4733
4784
  liveState,
4734
4785
  records,
4735
4786
  endedAt,
4736
- nativeCompletedAt,
4737
- // Receipts ride the poll: the content payload is pushed once, and the
4738
- // Done flip should not wait for a window reopen.
4787
+ // This legacy reader is presentation-only. Canonical push settlement is
4788
+ // the sole path allowed to persist Done or queue its wire receipt.
4739
4789
  taskStartedAt: (isRequest ? row.taskStartedAt : row.workStartedAt) || null,
4740
- taskCompletedAt: (isRequest ? row.taskCompletedAt : row.workCompletedAt) || nativeCompletedAt || null,
4790
+ taskCompletedAt: (isRequest ? row.taskCompletedAt : row.workCompletedAt) || null,
4741
4791
  };
4742
4792
  } catch (error) {
4743
4793
  return { ok: false, error: (error && error.message) || String(error) };
@@ -4790,7 +4840,6 @@ async function bridgeProviderCompletion(relayId, provider, sessionId, candidate)
4790
4840
  source: { host: provider === "cowork" ? "claude-cowork" : provider, sessionId },
4791
4841
  });
4792
4842
  updateStagedPacket(key, {
4793
- taskCompletedAt: candidate?.completedAt || new Date().toISOString(),
4794
4843
  providerCompletionRelayId: String(sent?.relayId || "sent"),
4795
4844
  ...(provider === "cowork" ? { coworkCompletionRelayId: String(sent?.relayId || "sent") } : {}),
4796
4845
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.199",
3
+ "version": "0.1.201",
4
4
  "description": "Companion CLI for Relay (sendrelays.com): pairs this machine with your Relay account so coding agents like Claude Code and Codex can send and receive Relay messages.",
5
5
  "homepage": "https://sendrelays.com",
6
6
  "repository": {
@@ -34,6 +34,34 @@ function hasToolResult(row) {
34
34
  return contentBlocks(row).some((block) => block?.type === "tool_result");
35
35
  }
36
36
 
37
+ function isUserAttachmentBlock(block) {
38
+ return block && /^(?:image|file|attachment)$/i.test(string(block.type));
39
+ }
40
+
41
+ function hasUserAttachment(row) {
42
+ return contentBlocks(row).some(isUserAttachmentBlock);
43
+ }
44
+
45
+ function safeClaudeUserAttachments(row) {
46
+ return contentBlocks(row).filter(isUserAttachmentBlock).slice(0, 20).map((block, index) => {
47
+ const source = block?.source && typeof block.source === "object" ? block.source : {};
48
+ const mimeType = string(
49
+ block.mimeType || block.contentType || block.mediaType
50
+ || source.media_type || source.mimeType || source.contentType,
51
+ ).trim();
52
+ const kind = /^image(?:\/|$)/i.test(mimeType) || /^image$/i.test(string(block.type)) ? "image" : "file";
53
+ return {
54
+ id: `claude-attachment:${hash(`${string(row?.uuid || row?.promptId)}\0${index}`)}`,
55
+ kind,
56
+ name: string(block.name || block.fileName || block.filename).trim() || (kind === "image" ? "Image" : `Attachment ${index + 1}`),
57
+ mimeType: mimeType || null,
58
+ size: Number.isFinite(Number(block.size ?? block.bytes)) ? Math.max(0, Number(block.size ?? block.bytes)) : null,
59
+ path: null,
60
+ url: null,
61
+ };
62
+ });
63
+ }
64
+
37
65
  function internalRelayPrompt(value) {
38
66
  const body = string(value).trim();
39
67
  return /^<relay-envelope>/i.test(body)
@@ -47,7 +75,7 @@ function isHumanPrompt(row) {
47
75
  if (row.sourceToolAssistantUUID || row.toolUseResult) return false;
48
76
  if (["task-notification", "hook", "system", "compact-summary"].includes(string(row?.origin?.kind))) return false;
49
77
  const body = textContent(row.message?.content);
50
- if (!body || internalRelayPrompt(body) || /<task-notification>[\s\S]*<\/task-notification>/i.test(body)) return false;
78
+ if ((!body && !hasUserAttachment(row)) || internalRelayPrompt(body) || /<task-notification>[\s\S]*<\/task-notification>/i.test(body)) return false;
51
79
  return true;
52
80
  }
53
81
 
@@ -341,6 +369,7 @@ function parseClaudeRows(inputRows, options) {
341
369
  uuid: string(row.uuid) || null,
342
370
  promptId: string(row.promptId) || null,
343
371
  parentUuid: string(row.parentUuid) || null,
372
+ attachments: safeClaudeUserAttachments(row),
344
373
  });
345
374
  continue;
346
375
  }
@@ -584,7 +613,7 @@ function canonicalEvents(turns) {
584
613
  if (!item.text) continue;
585
614
  const lifecycle = item.status === "completed" ? "completed" : "started";
586
615
  events.push({
587
- eventId: `${item.id}:${lifecycle}:${hash(item.text)}`, method: `item/${lifecycle}`, emittedAtMs: item.completedAtMs ?? item.startedAtMs,
616
+ eventId: `${item.id}:${lifecycle}:${item.phase}:${hash(item.text)}`, method: `item/${lifecycle}`, emittedAtMs: item.completedAtMs ?? item.startedAtMs,
588
617
  params: {
589
618
  turnId: turn.id,
590
619
  [lifecycle === "completed" ? "completedAtMs" : "startedAtMs"]: item.completedAtMs ?? item.startedAtMs,
@@ -677,8 +706,8 @@ export function createClaudeNativeWorkEventReconciler(initialRows = [], options
677
706
  push(row, nextOptions = {}) {
678
707
  if (!row || typeof row !== "object") return [];
679
708
  currentOptions = { ...currentOptions, ...nextOptions };
680
- if (isHumanPrompt(row)) currentRows = [row];
681
- else currentRows.push(row);
709
+ const humanBoundary = isHumanPrompt(row);
710
+ currentRows.push(row);
682
711
  if (currentRows.length > MAX_ROWS) currentRows = currentRows.slice(-MAX_ROWS);
683
712
  const events = claudeNativeEventsToWorkEvents(currentRows, currentOptions);
684
713
  const fresh = [];
@@ -687,6 +716,10 @@ export function createClaudeNativeWorkEventReconciler(initialRows = [], options
687
716
  emitted.add(event.eventId);
688
717
  fresh.push(event);
689
718
  }
719
+ // Parse the boundary together with the preceding live turn once so its
720
+ // end-turn text can transition from commentary to final and the turn can
721
+ // settle. Subsequent rows only need the new native turn.
722
+ if (humanBoundary) currentRows = [row];
690
723
  return fresh;
691
724
  },
692
725
  };
@@ -58,6 +58,43 @@ export function providerCompletionCandidate({
58
58
  };
59
59
  }
60
60
 
61
+ /**
62
+ * Convert a canonical Work envelope into local terminal truth. Unlike the
63
+ * legacy transcript reader, this requires the exact provider-authored native
64
+ * start and can be scoped to the Relay-owned turn/cutoff. Materialized session
65
+ * history therefore cannot finish (or even start) a Request.
66
+ */
67
+ export function canonicalProviderCompletionCandidate({
68
+ provider,
69
+ presentation,
70
+ expectedTurnId = "",
71
+ startedAfter = "",
72
+ } = {}) {
73
+ const turns = Array.isArray(presentation?.turns) ? presentation.turns : [];
74
+ const expected = clean(expectedTurnId);
75
+ const cutoff = Date.parse(clean(startedAfter));
76
+ const turn = [...turns].reverse().find((candidate) => {
77
+ if (!candidate?.nativeStarted || !candidate?.settled) return false;
78
+ if (expected && clean(candidate.key) !== expected) return false;
79
+ const startedAt = Number(candidate.startedAtMs);
80
+ const completedAt = Number(candidate.completedAtMs);
81
+ if (!Number.isFinite(cutoff)) return true;
82
+ if (!Number.isFinite(completedAt) || completedAt < cutoff) return false;
83
+ // Codex supplies the exact Relay-owned turn id. Claude/Cowork return the
84
+ // run timestamp only after native launch, so allow a small start-handshake
85
+ // skew while still rejecting materialized history that settled pre-Start.
86
+ return expected || (Number.isFinite(startedAt) && startedAt >= cutoff - 60_000);
87
+ });
88
+ if (!turn) return null;
89
+ const completedAtMs = Number(turn.completedAtMs);
90
+ const completedAt = Number.isFinite(completedAtMs)
91
+ ? new Date(completedAtMs).toISOString()
92
+ : new Date().toISOString();
93
+ const body = clean(turn.final?.text);
94
+ if (clean(turn.status).toLowerCase() !== "completed" || !turn.finalEligible || !body) return null;
95
+ return { body, outcome: "completed", completedAt, turnId: clean(turn.key) };
96
+ }
97
+
61
98
  export function providerCompletionIdempotencyKey({ relayId, provider, sessionId } = {}) {
62
99
  return ["provider-completion", clean(relayId), clean(provider) || "unknown", clean(sessionId) || "session"]
63
100
  .map((part) => part.replace(/[^0-9A-Za-z._-]+/g, "-"))
@@ -202,6 +202,9 @@ function itemIdOf(event) {
202
202
  function makeTurn(id, startedAtMs = null) {
203
203
  return {
204
204
  id,
205
+ // Only a provider-authored turn/started establishes a Work turn. Session
206
+ // materialization and historical terminal rows must never manufacture one.
207
+ nativeStarted: false,
205
208
  status: "inProgress",
206
209
  startedAtMs,
207
210
  completedAtMs: null,
@@ -647,18 +650,43 @@ export function reduceWorkEvent(inputState, event) {
647
650
  resolveRequest(state, event);
648
651
  return state;
649
652
  }
653
+ // App-server logs contain many conversation-adjacent notifications (MCP
654
+ // startup, account rate limits, thread status, skill changes, and so on)
655
+ // that do not belong to a provider turn. Never allocate a synthetic turn
656
+ // merely because one of those protocol notifications was observed. An
657
+ // empty `turn:unknown` otherwise remains inProgress forever and restart
658
+ // recovery can falsely report a completed historical run as disconnected.
659
+ const isTurnEvent = method === "turn/started"
660
+ || method === "provider/state"
661
+ || method === "turn/completed"
662
+ || method === "item/started"
663
+ || method === "item/completed"
664
+ || /\/delta$/i.test(method)
665
+ || /Delta$/.test(method)
666
+ || method === "error"
667
+ || method.endsWith("/error")
668
+ || method === "relay/connectionClosed"
669
+ || isRequestMethod(method);
670
+ if (!isTurnEvent) return state;
650
671
  const id = turnIdOf(event) || state.turnOrder.at(-1) || "turn:unknown";
651
672
  const timestamp = atMs(event, event?.params?.startedAtMs);
652
673
  if (method === "turn/started") {
653
674
  const turn = bindOptimisticTurn(state, id, timestamp);
654
675
  if (TERMINAL.has(turn.status)) return state; // overlapping historical tail
655
676
  turn.status = "inProgress";
677
+ turn.nativeStarted = true;
656
678
  turn.startedAtMs = turn.startedAtMs ?? timestamp;
657
679
  turn.completedAtMs = null;
658
680
  turn.error = null;
659
681
  return state;
660
682
  }
661
- const turn = ensureTurn(state, id, timestamp);
683
+ // Exact provider rows can be observed out of order during hydration, but a
684
+ // row without its native start is not evidence that Relay started Work.
685
+ // Ignore it; authoritative hydration/reconnect will replay the bounded turn
686
+ // from turn/started. An optimistic user turn may exist, but it likewise does
687
+ // not become provider Work until the native start binds it above.
688
+ const turn = state.turns[id] || null;
689
+ if (!turn?.nativeStarted) return state;
662
690
  if (method === "provider/state") {
663
691
  turn.providerState = compact(event?.params?.nativeState || "", 64) || null;
664
692
  turn.providerLabel = compact(event?.params?.nativeLabel || "", 96) || null;
@@ -1106,6 +1134,9 @@ export function turnPresentation(turn, nowMs = Date.now()) {
1106
1134
  && timing.state !== "cancelled";
1107
1135
  return {
1108
1136
  key: turn?.id || "",
1137
+ nativeStarted: Boolean(turn?.nativeStarted),
1138
+ startedAtMs: Number.isFinite(turn?.startedAtMs) ? turn.startedAtMs : null,
1139
+ completedAtMs: Number.isFinite(turn?.completedAtMs) ? turn.completedAtMs : null,
1109
1140
  status: turn?.status || "",
1110
1141
  active: timing.state === "active",
1111
1142
  settled: timing.state === "settled",