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.
- package/overlay/inbox.html +9 -0
- package/overlay/main.cjs +105 -56
- package/package.json +1 -1
- package/src/claude-native-work-feed.js +37 -4
- package/src/provider-completion.js +37 -0
- package/src/work-conversation.js +32 -1
package/overlay/inbox.html
CHANGED
|
@@ -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)
|
|
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 ||
|
|
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:
|
|
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
|
-
|
|
4737
|
-
//
|
|
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) ||
|
|
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.
|
|
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
|
-
|
|
681
|
-
|
|
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, "-"))
|
package/src/work-conversation.js
CHANGED
|
@@ -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
|
-
|
|
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",
|