@tomflow/chatloop-extension 0.3.47 → 0.3.48
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/README.md +8 -7
- package/bin/cli.mjs +1 -1
- package/bin/native-host.mjs +1 -5
- package/extension/actions/clean.js +4 -10
- package/extension/actions/common.js +24 -1
- package/extension/actions/continue.js +10 -64
- package/extension/actions/dom.js +2 -25
- package/extension/actions/handoff.js +11 -50
- package/extension/actions/text.js +2 -25
- package/extension/background.js +36 -446
- package/extension/loop/core.js +3 -16
- package/extension/loop/resources.js +7 -13
- package/extension/manifest.json +1 -1
- package/extension/popup.html +0 -1
- package/extension/popup.js +1 -1
- package/lib/bridge.mjs +1 -10
- package/lib/session-file.mjs +1 -1
- package/package.json +1 -1
- package/extension/acceptance.js +0 -641
- package/extension/actions/clock.js +0 -29
- package/extension/clock.html +0 -4
- package/lib/mcp-tools.json +0 -26
package/extension/background.js
CHANGED
|
@@ -3,37 +3,11 @@
|
|
|
3
3
|
importScripts("loop/core.js", "loop/resources.js");
|
|
4
4
|
const RUN_KEY = "chat-loop.action.run.v1";
|
|
5
5
|
const SESSION_HOST = "com.tomflow.chatloop.extension";
|
|
6
|
-
const
|
|
7
|
-
const RUN_BUDGET_MS = 10 * 60 * 1000; // Ten minutes per Chat; never interrupt a running Turn.
|
|
6
|
+
const RUN_BUDGET_MS = 10 * 60 * 1000;
|
|
8
7
|
const ALARM_NAME = "chat-loop.turn-watch.v1";
|
|
9
|
-
// One-shot deadline on the same MV3 alarm facility; never sends a Chat message.
|
|
10
8
|
const DEADLINE_ALARM_NAME = "chat-loop.handoff-deadline.v1";
|
|
11
|
-
const COUNT_ALARM_NAME = "chat-loop.count-wait.v1";
|
|
12
|
-
const COUNT_INTERVAL_MS = 60000;
|
|
13
9
|
const CONTINUE_TEXT = "继续当前任务,完成本轮后只在末行写一个状态:[CHAT_LOOP_STATUS:CONTINUE]、[CHAT_LOOP_STATUS:DONE] 或 [CHAT_LOOP_STATUS:BLOCKED]。";
|
|
14
|
-
// The model cannot authenticate its originating Chat through MCP alone.
|
|
15
|
-
// This per-Turn capability is sent only to the newly confirmed loop-owned Chat.
|
|
16
|
-
// It is never a browser-send authorization; it only permits staging one intent.
|
|
17
10
|
const turnToolNote = token => "\n\n【Chat Loop 本轮工具凭证】只有本轮需要自定下一轮内容或提前交接时,才能调用 chat_loop_extension__continue / chat_loop_extension__handoff;参数 turnToken 填写 " + token + "。工具只登记意图,不等待本轮结束,不直接发送;请照常在本轮结束时输出 CONTINUE、DONE 或 BLOCKED 标记。不要在助手正文重复该凭证。";
|
|
18
|
-
// Explicit acceptance setup only, dispatched by the same formal Continue page.
|
|
19
|
-
// Fixed harmless prompt; no arbitrary text or external write interface.
|
|
20
|
-
const ACTION_TEST_TEXT = "这是 Chat Loop 短轮回归专用测试,不调用工具、不改文件。每次仅回复一句短话,最后一行单独输出一个协议状态标记。\n第1条消息:OK1,然后末行 [CHAT_LOOP_STATUS:CONTINUE]。\n第2条消息:OK2,然后末行 [CHAT_LOOP_STATUS:CONTINUE]。\n第3条消息:OK3,然后末行 [CHAT_LOOP_STATUS:DONE]。\n不主动交接,也不追加解释。校验串:LOOP-V037-FAST-3C1H。";
|
|
21
|
-
const ACTION_TIMED_TEST_TEXT = "这是 Chat Loop 的长回复与一小时边界隔离测试,不能使用工具,不访问其他聊天,不改文件。请认真完成以下技术文字任务:用至少十八个独立自然段,以不同角度解释长任务的状态管理、异步任务完成检测、最终回执、一次性发送、跨会话交接、故障隔离与日志证据。段落各包含具体推理或例子,持续生成直到全部十八段完成。即使你看到与“时间到期”有关的叙述,也不要主动停止、跳过段落或交接。在全部正文结束后,最后一行只能输出 [CHAT_LOOP_STATUS:CONTINUE];不能提前写出这个标记。当前 Chat 不再收到新的用户消息。\n\n如果你是在新 Chat 收到了包含【原始任务用户消息】【最近轮次证据】的定时交接,请忽略上述长文要求,只回复“定时交接接收完成”,并在最后一行输出 [CHAT_LOOP_STATUS:DONE]。\n\n本测试仅用于验证真正完成一个回复之后才触发交接,模型生成需要多久并没有固定值。校验串 LOOP-V037-FAST-3C1H / MODE-TIMED-1H。";
|
|
22
|
-
const ACTION_WALL_TEST_TEXT = "这是 Chat Loop 的墙钟边界隔离测试,不调用工具、不改文件。原 Chat 每轮计算当前算式,第一行只写得数,末行单独写 [CHAT_LOOP_STATUS:CONTINUE]。第一题:1+1=?后续题目由 Continue 提供。新 Chat 若收到【原始任务用户消息】和【最近轮次证据】,只回复“已接班”,末行写 [CHAT_LOOP_STATUS:DONE]。交接仅由扩展按时钟决定。校验串 LOOP-V037-FAST-3C1H / MODE-WALL-10M / WALL-CLOCK-TEST。";
|
|
23
|
-
// The arithmetic progression belongs ONLY to the controlled 10m acceptance
|
|
24
|
-
// Chat. Each confirmed continuation asks a different, easily checked problem.
|
|
25
|
-
// No prompt variation is a substitute for respecting website security checks.
|
|
26
|
-
const wallArithmeticText = step => {
|
|
27
|
-
const left = Math.ceil(step / 2);
|
|
28
|
-
const right = step === 2 ? 3 : step;
|
|
29
|
-
return `第${step}题:${left}+${right}=?只写得数,末行写 [CHAT_LOOP_STATUS:CONTINUE]。`;
|
|
30
|
-
};
|
|
31
|
-
const COUNT_SETUP_TEXT = "这是 Chat Loop 的计数隔离验收,不调用工具、不改文件。初始整数86,本轮加1后输出87。以后每收到一条Continue才加1。每轮只能输出两行:第一行是整数;第二行是状态,整数小于100输出 [CHAT_LOOP_STATUS:CONTINUE],等于100才输出 [CHAT_LOOP_STATUS:DONE]。新Chat从交接包的最后已确认数值继续加1,交接本身不得DONE。十分钟交接由扩展执行,60秒发送间隔由扩展控制。校验串 LOOP-V037-FAST-3C1H / MODE-WALL-10M / COUNT-86-100 / WALL-CLOCK-TEST。";
|
|
32
|
-
const countText = n => `上一轮确认数字是${n}。本轮只加1,第一行只输出${n + 1};第二行数字未到100写 [CHAT_LOOP_STATUS:CONTINUE],达到100才写 [CHAT_LOOP_STATUS:DONE]。`;
|
|
33
|
-
const readCount = reply => {
|
|
34
|
-
const m = String(reply).trim().match(/(?:^|\n)\s*(\d{2,3})\s*\r?\n\s*\[CHAT_LOOP_STATUS:(?:CONTINUE|DONE)\]$/);
|
|
35
|
-
return m ? Number(m[1]) : null;
|
|
36
|
-
};
|
|
37
11
|
let queue = Promise.resolve();
|
|
38
12
|
const serial = fn => {
|
|
39
13
|
const work = queue.then(fn);
|
|
@@ -69,12 +43,10 @@ const pathOf = url => {
|
|
|
69
43
|
};
|
|
70
44
|
const readRun = async () => (await chrome.storage.local.get(RUN_KEY))[RUN_KEY] || null;
|
|
71
45
|
const windowMsOf = run => Number.isSafeInteger(run?.windowMs) && run.windowMs > 0
|
|
72
|
-
? run.windowMs :
|
|
73
|
-
? 20 * 60 * 1000 : RUN_BUDGET_MS;
|
|
46
|
+
? run.windowMs : RUN_BUDGET_MS;
|
|
74
47
|
const deadlineOf = run => Number.isSafeInteger(run?.startedAt) && run.startedAt > 0
|
|
75
48
|
? run.startedAt + windowMsOf(run) : null;
|
|
76
|
-
const clockActive = run => run && ["RESERVED", "PENDING", "WAITING_REPLY"
|
|
77
|
-
&& !run.testSetup && !run.wallTestHours;
|
|
49
|
+
const clockActive = run => run && ["RESERVED", "PENDING", "WAITING_REPLY"].includes(run.phase);
|
|
78
50
|
// Durable, bounded metadata for model intent receipts. The prompt text remains only
|
|
79
51
|
// in the active run while its Action is pending; history never stores the prompt.
|
|
80
52
|
const MODEL_RECEIPT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
@@ -139,9 +111,6 @@ async function saveRun(run) {
|
|
|
139
111
|
} else {
|
|
140
112
|
await chrome.alarms?.clear?.(DEADLINE_ALARM_NAME);
|
|
141
113
|
}
|
|
142
|
-
if (next.phase === "WAITING_INTERVAL" && next.countMode && !next.handoffPending)
|
|
143
|
-
await chrome.alarms?.create?.(COUNT_ALARM_NAME, {when: next.countReleaseAt});
|
|
144
|
-
else await chrome.alarms?.clear?.(COUNT_ALARM_NAME);
|
|
145
114
|
} catch { /* The existing periodic alarm is a conservative backup. */ }
|
|
146
115
|
}
|
|
147
116
|
// Browser Owner remains the only send authority. Native Host mirrors a minimal
|
|
@@ -492,8 +461,7 @@ async function onModelIntent(message,sender) {
|
|
|
492
461
|
||/[\x00-\x1f]/.test(intent.reason)))
|
|
493
462
|
return {status:'DENIED',code:'INVALID_MODEL_INTENT'};
|
|
494
463
|
const run=await readRun();
|
|
495
|
-
if(!run||run.phase!=='WAITING_REPLY'||run.
|
|
496
|
-
||run.timedTest||run.wallTestHours||run.modelTurnToken!==intent.turnToken)
|
|
464
|
+
if(!run||run.phase!=='WAITING_REPLY'||run.modelTurnToken!==intent.turnToken)
|
|
497
465
|
return {status:'DENIED',code:'MODEL_INTENT_OWNER_NOT_RUNNING'};
|
|
498
466
|
if(run.modelIntent)
|
|
499
467
|
return {status:'DENIED',code:'MODEL_INTENT_ALREADY_QUEUED'};
|
|
@@ -551,8 +519,6 @@ async function onTurnFinished(message, sender) {
|
|
|
551
519
|
// A Chat is owned only after an extension Action has explicitly reserved
|
|
552
520
|
// and confirmed its user Turn. A status marker on an arbitrary Chat never
|
|
553
521
|
// starts or reactivates a loop, including after DONE/BLOCKED.
|
|
554
|
-
if (previous?.testSetup && previous.tabId === tabId)
|
|
555
|
-
return {status: "IGNORED", code: "TEST_SETUP_UNVERIFIED"};
|
|
556
522
|
if (!previous || ["DONE", "BLOCKED"].includes(previous.phase))
|
|
557
523
|
return {status: "IGNORED", code: "NO_ACTIVE_OWNER"};
|
|
558
524
|
if (previous.tabId !== tabId || previous.page !== message.page)
|
|
@@ -574,27 +540,6 @@ async function onTurnFinished(message, sender) {
|
|
|
574
540
|
const recovery = await recheckUnknownTurn(message, previous, tabId);
|
|
575
541
|
if (recovery) {status = recovery.status; replyText = recovery.replyText;}
|
|
576
542
|
}
|
|
577
|
-
// Dedicated acceptance Chats only: retain the exact unrecognized reply to
|
|
578
|
-
// diagnose real UI text wrappers. Never persist private user Chat text.
|
|
579
|
-
let parseEvidence = null;
|
|
580
|
-
if (status === "UNKNOWN") {
|
|
581
|
-
const owned = (await chrome.storage.local.get("chat-loop.acceptance.owned.v1"))[
|
|
582
|
-
"chat-loop.acceptance.owned.v1"];
|
|
583
|
-
if (Array.isArray(owned) && owned.some(item => item.id === tabId || item.id === previous?.sourceTabId)) {
|
|
584
|
-
parseEvidence = {replyText: message.replyText.slice(0, 2500),
|
|
585
|
-
length: message.replyText.length, capturedAt: Date.now()};
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
const countMode = previous?.countMode === true;
|
|
589
|
-
let verifiedCount = null;
|
|
590
|
-
if (countMode && (status === "CONTINUE" || status === "DONE")) {
|
|
591
|
-
verifiedCount = readCount(replyText);
|
|
592
|
-
if (verifiedCount !== previous.countValue + 1
|
|
593
|
-
|| status !== (verifiedCount === 100 ? "DONE" : "CONTINUE")) {
|
|
594
|
-
await saveRun({...previous, phase: "BLOCKED", reason: "COUNT_STEP_UNVERIFIED"});
|
|
595
|
-
return {status: "BLOCKED", code: "COUNT_STEP_UNVERIFIED"};
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
543
|
const now = Date.now();
|
|
599
544
|
// Keep the same clock throughout one Chat. A successful Handoff resets it.
|
|
600
545
|
const initial = Number.isSafeInteger(message.startedAt) && message.startedAt > 0
|
|
@@ -615,35 +560,11 @@ async function onTurnFinished(message, sender) {
|
|
|
615
560
|
if (previous?.phase === "WAITING_REPLY" && previous.tabId === tabId) {
|
|
616
561
|
await saveRun({...previous, phase: status === "DONE" ? "DONE" : "BLOCKED",
|
|
617
562
|
reason: status, userKey: message.userKey, assistantKey: message.assistantKey,
|
|
618
|
-
|
|
619
|
-
...(parseEvidence ? {parseEvidence} : {})});
|
|
563
|
+
});
|
|
620
564
|
}
|
|
621
565
|
return {status: "IGNORED", code: nextAction === "UNSUPPORTED_MODEL_HANDOFF"
|
|
622
566
|
? "MODEL_HANDOFF_DISABLED" : status};
|
|
623
567
|
}
|
|
624
|
-
// Historical 1h/2h endurance fixtures explicitly park the Chat to avoid
|
|
625
|
-
// huge message counts; the real 10m acceptance MUST use the normal running
|
|
626
|
-
// loop: each finished CONTINUE immediately dispatches Continue, without a hold.
|
|
627
|
-
if (previous?.wallTestHours
|
|
628
|
-
&& status === "CONTINUE" && !timeExpired
|
|
629
|
-
&& !previous.wallReleasedAt && previous.phase === "WAITING_REPLY") {
|
|
630
|
-
const releaseAt = startedAt + (previous.wallTestMinutes === 10
|
|
631
|
-
? RUN_BUDGET_MS : previous.wallTestHours * HOUR_MS);
|
|
632
|
-
await saveRun({...previous, phase: "WALL_CLOCK_HOLD", reason: "ACCEPTANCE_WALL_WAIT",
|
|
633
|
-
userKey: message.userKey, assistantKey: message.assistantKey,
|
|
634
|
-
wallHoldAt: now, wallReleaseAt: releaseAt});
|
|
635
|
-
return {status: "WAITING_DEADLINE", releaseAt};
|
|
636
|
-
}
|
|
637
|
-
// Count acceptance: completion is recorded now, but the next Continue is
|
|
638
|
-
// not authorized until 60 real seconds after the previous confirmed send.
|
|
639
|
-
if (countMode && nextAction === "CONTINUE" && !timeExpired
|
|
640
|
-
&& now < previous.countLastSendAt + COUNT_INTERVAL_MS) {
|
|
641
|
-
const countReleaseAt = previous.countLastSendAt + COUNT_INTERVAL_MS;
|
|
642
|
-
await saveRun({...previous, phase: "WAITING_INTERVAL", reason: "COUNT_INTERVAL",
|
|
643
|
-
userKey: message.userKey, assistantKey: message.assistantKey,
|
|
644
|
-
countValue: verifiedCount, countReleaseAt});
|
|
645
|
-
return {status: "WAITING_INTERVAL", countReleaseAt};
|
|
646
|
-
}
|
|
647
568
|
// Recheck at the last new-authorization boundary, even if a periodic or
|
|
648
569
|
// one-shot alarm was delayed while the Worker handled completion evidence.
|
|
649
570
|
const newlyDue = Date.now() >= startedAt + windowMsOf(previous);
|
|
@@ -655,34 +576,11 @@ async function onTurnFinished(message, sender) {
|
|
|
655
576
|
assistantKey: message.assistantKey, phase: "RESERVED", token,
|
|
656
577
|
action: nextAction, startedAt, modelReceipts:receiptHistory(previous),
|
|
657
578
|
...(previous?.lastFaultProof ? {lastFaultProof:previous.lastFaultProof} : {}),
|
|
658
|
-
...(countMode ? {countMode:true, countValue:verifiedCount,
|
|
659
|
-
countLastSendAt:previous.countLastSendAt,
|
|
660
|
-
countHandoffs:previous.countHandoffs || 0} : {}),
|
|
661
579
|
...((previous?.handoffPending || newlyDue) ? {
|
|
662
580
|
handoffPending: true,
|
|
663
581
|
handoffPendingAt: previous?.handoffPendingAt || Date.now()} : {}),
|
|
664
582
|
// A completed Turn is a successful recovery and clears the consecutive error-Handoff guard.
|
|
665
583
|
errorHandoffCount: 0,
|
|
666
|
-
// Carry only dedicated acceptance evidence across the finished Turn. The
|
|
667
|
-
// evidence proves the deadline was crossed while Observer was RUNNING.
|
|
668
|
-
...(previous?.timedTest && previous?.clockAdvancedAt ? {
|
|
669
|
-
timedTest: true, clockAdvancedAt: previous.clockAdvancedAt,
|
|
670
|
-
clockAdvanceMinutes: previous.clockAdvanceMinutes,
|
|
671
|
-
clockRunningProof: previous.clockRunningProof} : {}),
|
|
672
|
-
// Keep the 10m continuous-flow test identity only for audit evidence;
|
|
673
|
-
// unlike historical hour-long endurance it never delays the next Continue.
|
|
674
|
-
...(previous?.timedTest && previous?.wallTestMinutes === 10 ? {
|
|
675
|
-
timedTest: true, wallTestMinutes: 10,
|
|
676
|
-
testTurnIndex: (previous.testTurnIndex || 1) + 1} : {}),
|
|
677
|
-
// Preserve historical wall-clock hold receipt across its subsequent Turn.
|
|
678
|
-
...(previous?.timedTest && previous?.wallReleasedAt ? {
|
|
679
|
-
timedTest: true, wallTestHours: previous.wallTestHours,
|
|
680
|
-
wallHoldAt: previous.wallHoldAt, wallReleaseAt: previous.wallReleaseAt,
|
|
681
|
-
wallReleasedAt: previous.wallReleasedAt,
|
|
682
|
-
...([10, 20].includes(previous.wallTestMinutes) ? {
|
|
683
|
-
wallTestMinutes: previous.wallTestMinutes,
|
|
684
|
-
...(previous.wallTestMinutes === 20 ? {wallAdjustedAt: previous.wallAdjustedAt,
|
|
685
|
-
wallOriginalReleaseAt: previous.wallOriginalReleaseAt} : {})} : {})} : {}),
|
|
686
584
|
// A timed Handoff uses an Action-local read-only snapshot. Do not keep
|
|
687
585
|
// the entire conversation in storage or embed it in the Action URL.
|
|
688
586
|
handoffMode: nextAction === "HANDOFF" ? "TIMED" : null,
|
|
@@ -710,42 +608,6 @@ async function onTurnFinished(message, sender) {
|
|
|
710
608
|
return {status: "BLOCKED", code: error.message || "ACTION_PAGE_CREATE_FAILED"};
|
|
711
609
|
}
|
|
712
610
|
}
|
|
713
|
-
// Release a verified completed count Turn only after the persisted 60s gate.
|
|
714
|
-
// The independent 10m cutoff wins over an unissued Continue.
|
|
715
|
-
async function releaseCountInterval() {
|
|
716
|
-
const old = await readRun();
|
|
717
|
-
if (old?.phase !== "WAITING_INTERVAL" || old.countMode !== true) return;
|
|
718
|
-
const now = Date.now(), deadline = deadlineOf(old);
|
|
719
|
-
if (now < old.countReleaseAt && now < deadline && !old.handoffPending) return;
|
|
720
|
-
const tab = await chrome.tabs.get(old.tabId).catch(() => null);
|
|
721
|
-
if (pathOf(tab?.url) !== old.page) {
|
|
722
|
-
await saveRun({...old, phase:"BLOCKED", reason:"COUNT_TAB_CHANGED"});
|
|
723
|
-
return;
|
|
724
|
-
}
|
|
725
|
-
const proof = await chrome.tabs.sendMessage(old.tabId,
|
|
726
|
-
{type:"CHAT_LOOP_TURN_VERIFY_V1"}).catch(() => null);
|
|
727
|
-
if (proof?.status !== "READY" || proof.page !== old.page
|
|
728
|
-
|| proof.userKey !== old.userKey || proof.assistantKey !== old.assistantKey
|
|
729
|
-
|| readCount(proof.replyText) !== old.countValue) {
|
|
730
|
-
await saveRun({...old, phase:"BLOCKED", reason:"COUNT_TURN_CHANGED"});
|
|
731
|
-
return;
|
|
732
|
-
}
|
|
733
|
-
const action = old.handoffPending || Date.now() >= deadline ? "HANDOFF" : "CONTINUE";
|
|
734
|
-
const run = {...old, phase:"RESERVED", action, token:crypto.randomUUID(),
|
|
735
|
-
handoffMode: action === "HANDOFF" ? "TIMED" : null};
|
|
736
|
-
delete run.countReleaseAt;
|
|
737
|
-
await saveRun(run);
|
|
738
|
-
try {
|
|
739
|
-
await createBoundedAction({url:chrome.runtime.getURL(action === "HANDOFF"
|
|
740
|
-
? "handoff.html" : "continue.html") + "?tabId=" + old.tabId
|
|
741
|
-
+ "&token=" + encodeURIComponent(run.token), active:false, windowId:tab.windowId});
|
|
742
|
-
} catch {
|
|
743
|
-
await saveRun({...run,phase:"BLOCKED",reason:"COUNT_ACTION_OPEN_FAILED"});
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
// Opening a formal Action page manually is a supported test/debug entry.
|
|
747
|
-
// No token in URL: the Worker verifies a single explicit target and reserves
|
|
748
|
-
// this exact Action tab. It never sends or creates the new Chat itself.
|
|
749
611
|
async function onManualPrepare(message, sender) {
|
|
750
612
|
const requested = message.action;
|
|
751
613
|
if (!["CONTINUE", "HANDOFF"].includes(requested)
|
|
@@ -805,190 +667,6 @@ async function onManualPrepare(message, sender) {
|
|
|
805
667
|
...(summary ? {handoffText: summary} : {})});
|
|
806
668
|
return {status: "PREPARED", token, tabId: tab.id};
|
|
807
669
|
}
|
|
808
|
-
// This is a test preparation permit, not a successful Continue regression.
|
|
809
|
-
// Both readiness probes are read-only; PENDING must be durable before any edit.
|
|
810
|
-
async function onTestPrepare(message, sender) {
|
|
811
|
-
const tabId = message.tabId;
|
|
812
|
-
if (!Number.isInteger(tabId) || !Number.isInteger(sender.tab?.id)
|
|
813
|
-
|| ![chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1",
|
|
814
|
-
chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&discardDraft=1",
|
|
815
|
-
chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&timed=1",
|
|
816
|
-
chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&discardDraft=1&timed=1",
|
|
817
|
-
chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&timed=1&wall=1",
|
|
818
|
-
chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&discardDraft=1&timed=1&wall=1",
|
|
819
|
-
chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&timed=1&wall=2",
|
|
820
|
-
chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&discardDraft=1&timed=1&wall=2",
|
|
821
|
-
chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&timed=1&wall=10m",
|
|
822
|
-
chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&discardDraft=1&timed=1&wall=10m",
|
|
823
|
-
chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&timed=1&wall=10m&count=86",
|
|
824
|
-
chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&discardDraft=1&timed=1&wall=10m&count=86"].includes(sender.url))
|
|
825
|
-
return {status: "DENIED", code: "INVALID_TEST_ACTION"};
|
|
826
|
-
const previous = await readRun();
|
|
827
|
-
if (previous && (previous.sessionId&&previous.phase!=="DONE"
|
|
828
|
-
|| !["DONE", "BLOCKED"].includes(previous.phase)))
|
|
829
|
-
return {status: "DENIED", code: "LOOP_ALREADY_ACTIVE"};
|
|
830
|
-
if (previous?.tabId === tabId)
|
|
831
|
-
return {status: "DENIED", code: "TEST_TARGET_ALREADY_USED"};
|
|
832
|
-
const tab = await chrome.tabs.get(tabId).catch(() => null);
|
|
833
|
-
if (tab?.url !== "https://chatgpt.com/")
|
|
834
|
-
return {status: "DENIED", code: "TEST_TARGET_NOT_BLANK"};
|
|
835
|
-
const params = new URL(sender.url).searchParams;
|
|
836
|
-
const discardDraft = params.get("discardDraft") === "1";
|
|
837
|
-
const timedTest = params.get("timed") === "1";
|
|
838
|
-
const wall = params.get("wall");
|
|
839
|
-
const wallTestHours = wall && wall !== "10m" ? Number(wall) : null;
|
|
840
|
-
const wallTestMinutes = wall === "10m" ? 10 : null;
|
|
841
|
-
const countMode = params.get("count") === "86";
|
|
842
|
-
if (params.has("count") && (!countMode || !timedTest || wallTestMinutes !== 10))
|
|
843
|
-
return {status:"DENIED",code:"COUNT_TEST_INVALID"};
|
|
844
|
-
if (wall !== null && (!timedTest || !(wallTestMinutes === 10 || [1, 2].includes(wallTestHours))))
|
|
845
|
-
return {status: "DENIED", code: "INVALID_WALL_TEST"};
|
|
846
|
-
if (timedTest) {
|
|
847
|
-
const registry = (await chrome.storage.local.get("chat-loop.acceptance.owned.v1"))[
|
|
848
|
-
"chat-loop.acceptance.owned.v1"];
|
|
849
|
-
if (!Array.isArray(registry) || !registry.some(item => item.id === tabId))
|
|
850
|
-
return {status: "DENIED", code: "TIMED_TEST_OWNERSHIP_REQUIRED"};
|
|
851
|
-
}
|
|
852
|
-
const probe = async () => {
|
|
853
|
-
const [read] = await chrome.scripting.executeScript({target: {tabId}, func: allowDiscard => {
|
|
854
|
-
const visible = node => Boolean(node?.getClientRects?.().length);
|
|
855
|
-
const mains = [...document.querySelectorAll("main")].filter(visible);
|
|
856
|
-
const input = document.querySelector('[contenteditable="true"][data-composer-markdown]')
|
|
857
|
-
|| document.querySelector('#prompt-textarea[contenteditable="true"]');
|
|
858
|
-
const draft = (input?.innerText || input?.textContent || "").trim();
|
|
859
|
-
const knownTestDraft = (draft.includes("LOOP-V037-FAST-3C1H") || (draft.startsWith("你正在接手上一 Chat 未完成的任务。旧 Chat 的网页历史仍可核实")
|
|
860
|
-
&& draft.includes("【交接参考:只读网页片段,可能截断】")
|
|
861
|
-
&& draft.includes("【原始任务用户消息】")
|
|
862
|
-
&& draft.includes("【最近轮次证据】")
|
|
863
|
-
&& draft.endsWith("是否跨 Chat 由扩展决定,不要主动要求 Handoff。")
|
|
864
|
-
&& draft.length >= 450 && draft.length <= 8192)) && (
|
|
865
|
-
(draft.startsWith("这是 Chat Loop 短轮回归专用测试") && draft.length <= 800)
|
|
866
|
-
|| (draft.startsWith("你正在接手上一 Chat 尚未完成的同一个任务")
|
|
867
|
-
&& draft.length <= 8192 && (draft.length <= 2000
|
|
868
|
-
|| (draft.includes("【原始任务用户消息】") && draft.includes("【最近轮次证据】"))))
|
|
869
|
-
|| (draft.startsWith("你正在接手上一 Chat 未完成的任务。旧 Chat 的网页历史仍可核实")
|
|
870
|
-
&& draft.includes("【原始任务用户消息】") && draft.includes("【最近轮次证据】")
|
|
871
|
-
&& draft.endsWith("是否跨 Chat 由扩展决定,不要主动要求 Handoff。")
|
|
872
|
-
&& draft.length >= 450 && draft.length <= 8192)
|
|
873
|
-
|| (draft.startsWith("这是 Chat Loop 的计数隔离验收")
|
|
874
|
-
&& draft.includes("COUNT-86-100") && draft.endsWith("WALL-CLOCK-TEST。"))
|
|
875
|
-
|| ((draft.startsWith("这是 Chat Loop 的长回复与一小时边界隔离测试")
|
|
876
|
-
|| draft.startsWith("这是 Chat Loop 的墙钟边界隔离测试"))
|
|
877
|
-
&& (draft.includes("MODE-TIMED-1H") || draft.includes("MODE-WALL-10M"))));
|
|
878
|
-
return location.href === "https://chatgpt.com/" && mains.length === 1
|
|
879
|
-
&& !mains[0].querySelector('[data-chatgpt-search-message-ids],[data-message-author-role]')
|
|
880
|
-
&& visible(input) && (!draft || (allowDiscard && knownTestDraft))
|
|
881
|
-
&& ![...document.querySelectorAll('button[data-testid="stop-button"]')].some(visible);
|
|
882
|
-
}, args: [discardDraft]});
|
|
883
|
-
return read?.result === true;
|
|
884
|
-
};
|
|
885
|
-
if (!await probe()) return {status: "DENIED", code: "TEST_TARGET_NOT_EMPTY"};
|
|
886
|
-
await new Promise(resolve => setTimeout(resolve, 2300));
|
|
887
|
-
if (!await probe()) return {status: "DENIED", code: "TEST_TARGET_NOT_STABLE"};
|
|
888
|
-
const token = crypto.randomUUID();
|
|
889
|
-
await saveRun({tabId, page: "/", userKey: null, assistantKey: null,
|
|
890
|
-
phase: "RESERVED", token, action: "CONTINUE", testSetup: true, discardDraft,
|
|
891
|
-
...(timedTest ? {timedTest: true} : {}),
|
|
892
|
-
...(wallTestHours ? {wallTestHours} : {}),
|
|
893
|
-
...(wallTestMinutes ? {wallTestMinutes} : {}),
|
|
894
|
-
...(countMode ? {countMode:true, countValue:86, countHandoffs:0} : {}),
|
|
895
|
-
// The first Turn starts with a real clock, including acceptance tests.
|
|
896
|
-
// A dedicated test Action may advance it later only while Observer RUNNING.
|
|
897
|
-
startedAt: Date.now(),
|
|
898
|
-
manualActionTabId: sender.tab.id});
|
|
899
|
-
return {status: "PREPARED", token, tabId};
|
|
900
|
-
}
|
|
901
|
-
async function onAcceptanceShortenWallHold(message, sender) {
|
|
902
|
-
// User-requested shortening of an existing *owned acceptance* wait.
|
|
903
|
-
// The real elapsed time must already exceed 20 min; never backdate time.
|
|
904
|
-
const tabId = message.tabId, now = Date.now();
|
|
905
|
-
if (!Number.isSafeInteger(tabId) || tabId <= 0
|
|
906
|
-
|| message.minutes !== 20 || !Number.isSafeInteger(sender.tab?.id)
|
|
907
|
-
|| sender.url !== chrome.runtime.getURL("clock.html")
|
|
908
|
-
+ "?tabId=" + tabId + "&minutes=20")
|
|
909
|
-
return {status: "DENIED", code: "SHORTEN_SOURCE_INVALID"};
|
|
910
|
-
const run = await readRun();
|
|
911
|
-
if (run?.phase !== "WALL_CLOCK_HOLD" || run.tabId !== tabId
|
|
912
|
-
|| run.timedTest !== true || run.wallTestHours !== 2
|
|
913
|
-
|| run.wallAdjustedAt || !run.userKey || !run.assistantKey
|
|
914
|
-
|| !Number.isSafeInteger(run.startedAt)
|
|
915
|
-
|| now < run.startedAt + 20 * 60 * 1000)
|
|
916
|
-
return {status: "DENIED", code: "SHORTEN_NOT_ELIGIBLE"};
|
|
917
|
-
const owned = (await chrome.storage.local.get("chat-loop.acceptance.owned.v1"))[
|
|
918
|
-
"chat-loop.acceptance.owned.v1"];
|
|
919
|
-
if (!Array.isArray(owned) || !owned.some(item => item.id === tabId))
|
|
920
|
-
return {status: "DENIED", code: "SHORTEN_NOT_OWNED"};
|
|
921
|
-
const tab = await chrome.tabs.get(tabId).catch(() => null);
|
|
922
|
-
if (!tab || pathOf(tab.url) !== run.page)
|
|
923
|
-
return {status: "DENIED", code: "SHORTEN_TARGET_CHANGED"};
|
|
924
|
-
const verify = async () => {
|
|
925
|
-
const proof = await chrome.tabs.sendMessage(tabId,
|
|
926
|
-
{type: "CHAT_LOOP_TURN_VERIFY_V1"}).catch(() => null);
|
|
927
|
-
return proof?.contract === "chat-loop.turn-observer.v1"
|
|
928
|
-
&& proof.status === "READY" && proof.page === run.page
|
|
929
|
-
&& proof.userKey === run.userKey
|
|
930
|
-
&& proof.assistantKey === run.assistantKey
|
|
931
|
-
&& globalThis.ChatLoopCoreV1.parseStatus(proof.replyText) === "CONTINUE";
|
|
932
|
-
};
|
|
933
|
-
if (!await verify()) return {status: "DENIED", code: "SHORTEN_NOT_STABLE"};
|
|
934
|
-
await new Promise(resolve => setTimeout(resolve, 2300));
|
|
935
|
-
if (!await verify()) return {status: "DENIED", code: "SHORTEN_IDENTITY_CHANGED"};
|
|
936
|
-
const latest = await readRun();
|
|
937
|
-
if (latest?.phase !== "WALL_CLOCK_HOLD" || latest.tabId !== run.tabId
|
|
938
|
-
|| latest.wallHoldAt !== run.wallHoldAt || latest.userKey !== run.userKey)
|
|
939
|
-
return {status: "DENIED", code: "SHORTEN_OWNER_CHANGED"};
|
|
940
|
-
const changed = {...latest,
|
|
941
|
-
wallOriginalReleaseAt: latest.wallReleaseAt,
|
|
942
|
-
wallReleaseAt: latest.startedAt + 20 * 60 * 1000,
|
|
943
|
-
wallTestMinutes: 20, wallAdjustedAt: Date.now()};
|
|
944
|
-
await saveRun(changed);
|
|
945
|
-
return {status: "SHORTENED_AFTER_REAL_20MIN", tabId,
|
|
946
|
-
startedAt: changed.startedAt, releaseAt: changed.wallReleaseAt,
|
|
947
|
-
adjustedAt: changed.wallAdjustedAt};
|
|
948
|
-
}
|
|
949
|
-
async function onAcceptanceClockAdvance(message, sender) {
|
|
950
|
-
if (message?.minutes === 20) return onAcceptanceShortenWallHold(message, sender);
|
|
951
|
-
// Test-only clock mutation. All production time comparisons remain unchanged.
|
|
952
|
-
// This must happen DURING the assistant Turn, never before the first Turn.
|
|
953
|
-
const tabId = message.tabId, minutes = message.minutes;
|
|
954
|
-
if (!Number.isSafeInteger(tabId) || tabId <= 0
|
|
955
|
-
|| !Number.isSafeInteger(minutes) || minutes < 60 || minutes > 180
|
|
956
|
-
|| sender.url !== chrome.runtime.getURL("clock.html")
|
|
957
|
-
+ "?tabId=" + tabId + "&minutes=" + minutes
|
|
958
|
-
|| !Number.isSafeInteger(sender.tab?.id))
|
|
959
|
-
return {status: "DENIED", code: "CLOCK_ACTION_SOURCE_INVALID"};
|
|
960
|
-
const run = await readRun();
|
|
961
|
-
if (run?.phase !== "WAITING_REPLY" || run?.action !== "CONTINUE"
|
|
962
|
-
|| run?.tabId !== tabId || run?.timedTest !== true
|
|
963
|
-
|| run?.clockAdvancedAt || !run.newUserKey || !run.page
|
|
964
|
-
|| run.testSetup) return {status: "DENIED", code: "CLOCK_TEST_NOT_WAITING"};
|
|
965
|
-
const owned = (await chrome.storage.local.get("chat-loop.acceptance.owned.v1"))[
|
|
966
|
-
"chat-loop.acceptance.owned.v1"];
|
|
967
|
-
if (!Array.isArray(owned) || !owned.some(item => item.id === tabId))
|
|
968
|
-
return {status: "DENIED", code: "CLOCK_TEST_NOT_OWNED"};
|
|
969
|
-
const live = await chrome.tabs.get(tabId).catch(() => null);
|
|
970
|
-
if (!live || pathOf(live.url) !== run.page)
|
|
971
|
-
return {status: "DENIED", code: "CLOCK_TARGET_CHANGED"};
|
|
972
|
-
const sample = async () => chrome.tabs.sendMessage(tabId,
|
|
973
|
-
{type: "CHAT_LOOP_TURN_OBSERVE_V1", poll: true}).catch(() => null);
|
|
974
|
-
const isRunning = x => x?.contract === "chat-loop.turn-observer.v1"
|
|
975
|
-
&& x.status === "RUNNING" && x.page === run.page
|
|
976
|
-
&& x.userKey === run.newUserKey;
|
|
977
|
-
const first = await sample();
|
|
978
|
-
if (!isRunning(first)) return {status: "DENIED", code: "CLOCK_TURN_NOT_RUNNING"};
|
|
979
|
-
await new Promise(resolve => setTimeout(resolve, 300));
|
|
980
|
-
const second = await sample();
|
|
981
|
-
if (!isRunning(second)) return {status: "DENIED", code: "CLOCK_TURN_NOT_STABLE_RUNNING"};
|
|
982
|
-
const now = Date.now();
|
|
983
|
-
// Advance by a test-chosen elapsed duration, not by a hard-coded 61 minutes.
|
|
984
|
-
// No scheduler event or writing action is dispatched at this moment.
|
|
985
|
-
await saveRun({...run, startedAt: now - minutes * 60000,
|
|
986
|
-
clockAdvancedAt: now, clockAdvanceMinutes: minutes,
|
|
987
|
-
clockRunningProof: {first: first.reason, second: second.reason,
|
|
988
|
-
userKey: run.newUserKey, observedAt: second.observedAt}});
|
|
989
|
-
return {status: "ADVANCED_WHILE_RUNNING", minutes, tabId,
|
|
990
|
-
firstReason: first.reason, secondReason: second.reason};
|
|
991
|
-
}
|
|
992
670
|
const isAction = (sender, message, run) => {
|
|
993
671
|
if (!Number.isInteger(sender.tab?.id) || typeof sender.url !== "string") return false;
|
|
994
672
|
try {
|
|
@@ -1006,21 +684,17 @@ const isAction = (sender, message, run) => {
|
|
|
1006
684
|
async function onTake(message, sender) {
|
|
1007
685
|
const run = await readRun();
|
|
1008
686
|
if (!isAction(sender, message, run)) return {status: "DENIED", code: "ACTION_SOURCE_INVALID"};
|
|
1009
|
-
if (run?.countMode && run.action === "CONTINUE" && !run.testSetup
|
|
1010
|
-
&& Date.now() < run.countLastSendAt + COUNT_INTERVAL_MS)
|
|
1011
|
-
return {status:"DENIED",code:"COUNT_INTERVAL_NOT_DUE"};
|
|
1012
687
|
if (run?.phase !== "RESERVED" || run.token !== message.token
|
|
1013
688
|
|| run.tabId !== message.tabId) return {status: "DENIED", code: "ACTION_NOT_RESERVED"};
|
|
1014
689
|
const tab = await chrome.tabs.get(run.tabId).catch(() => null);
|
|
1015
|
-
if (!tab ||
|
|
690
|
+
if (!tab || pathOf(tab.url) !== run.page) {
|
|
1016
691
|
await saveRun({...run, phase: "BLOCKED", reason: "CHAT_TARGET_CHANGED"});
|
|
1017
692
|
return {status: "DENIED", code: "CHAT_TARGET_CHANGED"};
|
|
1018
693
|
}
|
|
1019
694
|
// Bind a model-control capability to the next *user* Turn, not to a tab or to another Chat.
|
|
1020
695
|
// Acceptance fixtures retain their existing prompts; native Retry keeps its original Turn token.
|
|
1021
|
-
const modelTurnToken = run.retryMode === "NATIVE"
|
|
1022
|
-
|
|
1023
|
-
? null : crypto.randomUUID();
|
|
696
|
+
const modelTurnToken = run.retryMode === "NATIVE"
|
|
697
|
+
? run.modelTurnToken || null : crypto.randomUUID();
|
|
1024
698
|
const pending={...run, phase: "PENDING", modelTurnToken};
|
|
1025
699
|
await saveRun(run.modelIntent ? updateModelReceipt(pending,run.modelIntent.id,{
|
|
1026
700
|
status:run.action==="HANDOFF"&&run.modelIntent.action==="CONTINUE"
|
|
@@ -1029,21 +703,16 @@ async function onTake(message, sender) {
|
|
|
1029
703
|
modelTurnToken,
|
|
1030
704
|
modelTurnNote: modelTurnToken ? turnToolNote(modelTurnToken) : "",
|
|
1031
705
|
handoffMode: run.handoffMode || null, retryMode: run.retryMode || null,
|
|
1032
|
-
countMode: run.countMode === true,
|
|
1033
|
-
countValue: run.countMode ? run.countValue : null,
|
|
1034
706
|
target: {tabId: run.tabId, page: run.page,
|
|
1035
707
|
assistantKey: run.assistantKey,
|
|
1036
708
|
userKey: run.retryMode === "NATIVE" || run.handoffMode === "ERROR"
|
|
1037
709
|
? run.newUserKey : run.userKey,
|
|
1038
|
-
|
|
710
|
+
},
|
|
1039
711
|
modelIntent: run.action==="HANDOFF" && run.modelIntent?.action==="HANDOFF"
|
|
1040
712
|
? {id:run.modelIntent.id,reason:run.modelIntent.reason||""} : null,
|
|
1041
713
|
pendingPlan: run.action==="HANDOFF" && run.modelIntent?.action==="CONTINUE"
|
|
1042
714
|
? {id:run.modelIntent.id,nextPrompt:run.modelIntent.nextPrompt} : null,
|
|
1043
|
-
text: run.
|
|
1044
|
-
: run.wallTestHours || run.wallTestMinutes === 10 ? ACTION_WALL_TEST_TEXT
|
|
1045
|
-
: run.timedTest ? ACTION_TIMED_TEST_TEXT : ACTION_TEST_TEXT)
|
|
1046
|
-
: run.action === "HANDOFF" ? run.handoffText || null
|
|
715
|
+
text: run.action === "HANDOFF" ? run.handoffText || null
|
|
1047
716
|
: run.modelIntent?.action==="CONTINUE" ? (
|
|
1048
717
|
"【Chat Loop 续接来源】上一轮由模型登记的 Continue 请求 " + run.modelIntent.id
|
|
1049
718
|
+ " 已被扩展唯一 Action 领取。\n"
|
|
@@ -1051,9 +720,7 @@ async function onTake(message, sender) {
|
|
|
1051
720
|
+ run.modelIntent.nextPrompt + "\n\n"
|
|
1052
721
|
+ "执行前核对当前项目状态、既有用户授权和已完成副作用,不重做不明操作。"
|
|
1053
722
|
+ "本轮完成后,末行只输出一个状态标记:[CHAT_LOOP_STATUS:CONTINUE]、[CHAT_LOOP_STATUS:DONE] 或 [CHAT_LOOP_STATUS:BLOCKED]。")
|
|
1054
|
-
:
|
|
1055
|
-
: run.timedTest && run.wallTestMinutes === 10
|
|
1056
|
-
? wallArithmeticText(run.testTurnIndex || 2) : CONTINUE_TEXT};
|
|
723
|
+
: CONTINUE_TEXT};
|
|
1057
724
|
if (modelTurnToken && typeof permit.text === "string")
|
|
1058
725
|
permit.text += permit.modelTurnNote;
|
|
1059
726
|
return permit;
|
|
@@ -1111,31 +778,16 @@ async function onResult(message, sender) {
|
|
|
1111
778
|
// its first Retry/Error Handoff using the OLD Chat's assistant ID.
|
|
1112
779
|
userKey: null, assistantKey: null, startedAt: Date.now(),
|
|
1113
780
|
errorHandoffCount: run.handoffMode === "ERROR" ? (run.errorHandoffCount || 0) + 1 : 0,
|
|
1114
|
-
|
|
1115
|
-
countHandoffs:(run.countHandoffs || 0) + 1,
|
|
1116
|
-
countLastSendAt:Date.now()} : {})};
|
|
781
|
+
};
|
|
1117
782
|
delete next.handoffText;
|
|
1118
783
|
delete next.errorReason;
|
|
1119
784
|
delete next.handoffPending;
|
|
1120
785
|
delete next.handoffPendingAt;
|
|
1121
786
|
delete next.deadlineAt;
|
|
1122
787
|
}
|
|
1123
|
-
} else if (confirmed && run.testSetup) {
|
|
1124
|
-
const tab = await chrome.tabs.get(run.tabId).catch(() => null);
|
|
1125
|
-
confirmed = Boolean(tab && pathOf(tab.url) && pathOf(tab.url) === message.newPage);
|
|
1126
|
-
if (confirmed) {
|
|
1127
|
-
next = {...run, page: message.newPage, phase: "WAITING_REPLY", reason: "",
|
|
1128
|
-
newUserKey: message.newUserKey,
|
|
1129
|
-
...(run.wallTestMinutes === 10 ? {testTurnIndex: 1} : {}),
|
|
1130
|
-
...(run.countMode ? {countLastSendAt:Date.now()} : {}),
|
|
1131
|
-
...(run.wallTestHours ? {startedAt: Date.now()} : {})};
|
|
1132
|
-
delete next.testSetup;
|
|
1133
|
-
delete next.discardDraft;
|
|
1134
|
-
}
|
|
1135
788
|
} else if (confirmed) {
|
|
1136
789
|
next = {...run, phase: "WAITING_REPLY", reason: "",
|
|
1137
|
-
newUserKey: message.newUserKey
|
|
1138
|
-
...(run.countMode ? {countLastSendAt:Date.now()} : {})};
|
|
790
|
+
newUserKey: message.newUserKey};
|
|
1139
791
|
}
|
|
1140
792
|
// A model prompt is one-shot. Keep only a short receipt, not the prompt
|
|
1141
793
|
// body, after the Action has reached a confirmed or blocked terminal state.
|
|
@@ -1179,54 +831,6 @@ async function reconnectOpenChats() {
|
|
|
1179
831
|
} catch (error) {console.warn("Chat Loop observer reconnect unavailable:", error?.message);}
|
|
1180
832
|
}
|
|
1181
833
|
}
|
|
1182
|
-
async function releaseWallClockHold(run) {
|
|
1183
|
-
const liveRun = await readRun();
|
|
1184
|
-
if (liveRun?.phase !== "WALL_CLOCK_HOLD" || liveRun.tabId !== run?.tabId
|
|
1185
|
-
|| liveRun.wallHoldAt !== run?.wallHoldAt
|
|
1186
|
-
|| liveRun.userKey !== run?.userKey) return {status: "IGNORED", code: "HOLD_ALREADY_CHANGED"};
|
|
1187
|
-
if (run?.phase !== "WALL_CLOCK_HOLD" || !([1, 2].includes(run.wallTestHours)
|
|
1188
|
-
|| run.wallTestMinutes === 10)
|
|
1189
|
-
|| !run.timedTest || !Number.isSafeInteger(run.wallReleaseAt)
|
|
1190
|
-
|| Date.now() < run.wallReleaseAt) return {status: "NOT_DUE"};
|
|
1191
|
-
const owned = (await chrome.storage.local.get("chat-loop.acceptance.owned.v1"))[
|
|
1192
|
-
"chat-loop.acceptance.owned.v1"];
|
|
1193
|
-
if (!Array.isArray(owned) || !owned.some(t => t.id === run.tabId))
|
|
1194
|
-
return {status: "DENIED", code: "NOT_TEST_OWNED"};
|
|
1195
|
-
const tab = await chrome.tabs.get(run.tabId).catch(() => null);
|
|
1196
|
-
if (!tab || pathOf(tab.url) !== run.page) {
|
|
1197
|
-
await saveRun({...run, phase: "BLOCKED", reason: "WALL_CHAT_CHANGED"});
|
|
1198
|
-
return {status: "BLOCKED", code: "WALL_CHAT_CHANGED"};
|
|
1199
|
-
}
|
|
1200
|
-
const verify = async () => {
|
|
1201
|
-
const p = await chrome.tabs.sendMessage(run.tabId,
|
|
1202
|
-
{type: "CHAT_LOOP_TURN_VERIFY_V1"}).catch(() => null);
|
|
1203
|
-
return p?.contract === "chat-loop.turn-observer.v1" && p.status === "READY"
|
|
1204
|
-
&& p.page === run.page && p.userKey === run.userKey
|
|
1205
|
-
&& p.assistantKey === run.assistantKey
|
|
1206
|
-
&& globalThis.ChatLoopCoreV1.parseStatus(p.replyText) === "CONTINUE";
|
|
1207
|
-
};
|
|
1208
|
-
if (!await verify()) return {status: "UNVERIFIED", code: "WALL_TURN_NOT_STABLE"};
|
|
1209
|
-
await new Promise(resolve => setTimeout(resolve, 2300));
|
|
1210
|
-
if (!await verify()) return {status: "UNVERIFIED", code: "WALL_TURN_CHANGED"};
|
|
1211
|
-
const latest = await readRun();
|
|
1212
|
-
if (latest?.phase !== "WALL_CLOCK_HOLD" || latest.tabId !== run.tabId
|
|
1213
|
-
|| latest.userKey !== run.userKey || latest.wallHoldAt !== run.wallHoldAt)
|
|
1214
|
-
return {status: "IGNORED", code: "HOLD_CHANGED_DURING_VERIFICATION"};
|
|
1215
|
-
const token = crypto.randomUUID();
|
|
1216
|
-
const next = {...run, phase: "RESERVED", action: "CONTINUE", token,
|
|
1217
|
-
wallReleasedAt: Date.now(), reason: ""};
|
|
1218
|
-
await saveRun(next); // reserve once, then Action page exclusively sends
|
|
1219
|
-
try {
|
|
1220
|
-
const action = await createBoundedAction({
|
|
1221
|
-
url: chrome.runtime.getURL("continue.html") + "?tabId=" + run.tabId
|
|
1222
|
-
+ "&token=" + encodeURIComponent(token), active: false, windowId: tab.windowId
|
|
1223
|
-
});
|
|
1224
|
-
return {status: "QUEUED", actionTabId: action.id};
|
|
1225
|
-
} catch {
|
|
1226
|
-
await saveRun({...next, phase: "BLOCKED", reason: "WALL_ACTION_CREATE_FAILED"});
|
|
1227
|
-
return {status: "BLOCKED", code: "WALL_ACTION_CREATE_FAILED"};
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1230
834
|
// MV3 background service workers can suspend, and hidden-page setInterval
|
|
1231
835
|
// may be throttled. An alarm asks the Observer to sample, never acts directly.
|
|
1232
836
|
async function ensureAlarm() {
|
|
@@ -1243,14 +847,6 @@ async function watchActiveTurn() {
|
|
|
1243
847
|
await serial(async()=>{await expireStaleModelIntent();await prepareHandoffIfDue();});
|
|
1244
848
|
const run = await readRun();
|
|
1245
849
|
// While an Action is pending, never dispatch competing operations.
|
|
1246
|
-
if (run?.phase === "WALL_CLOCK_HOLD") {
|
|
1247
|
-
void serial(() => releaseWallClockHold(run));
|
|
1248
|
-
return;
|
|
1249
|
-
}
|
|
1250
|
-
if (run?.phase === "WAITING_INTERVAL") {
|
|
1251
|
-
void serial(releaseCountInterval);
|
|
1252
|
-
return;
|
|
1253
|
-
}
|
|
1254
850
|
if (run && ["RESERVED", "PENDING"].includes(run.phase)) return;
|
|
1255
851
|
const tabs = run?.phase === "WAITING_REPLY"
|
|
1256
852
|
? [await chrome.tabs.get(run.tabId).catch(() => null)]
|
|
@@ -1289,8 +885,7 @@ async function watchActiveTurn() {
|
|
|
1289
885
|
chrome.alarms?.onAlarm?.addListener(alarm => {
|
|
1290
886
|
if (alarm?.name === ALARM_NAME) void watchActiveTurn();
|
|
1291
887
|
if (alarm?.name === DEADLINE_ALARM_NAME)
|
|
1292
|
-
void serial(
|
|
1293
|
-
if (alarm?.name === COUNT_ALARM_NAME) void serial(releaseCountInterval);
|
|
888
|
+
void serial(prepareHandoffIfDue);
|
|
1294
889
|
});
|
|
1295
890
|
chrome.runtime.onInstalled?.addListener(() => {
|
|
1296
891
|
void ensureAlarm();
|
|
@@ -1356,34 +951,29 @@ async function onExtensionCliTarget(message,sender) {
|
|
|
1356
951
|
return {status:"DENIED",code:"OWNER_CHANGED"};
|
|
1357
952
|
return {status:"OK",tabId:run.tabId,page:run.page};
|
|
1358
953
|
}
|
|
954
|
+
// One explicit router for authenticated, serialized state changes.
|
|
955
|
+
// The registered handler still verifies source, token, Owner and Turn identity.
|
|
956
|
+
const SERIAL_ROUTES=new Map([
|
|
957
|
+
['CHAT_LOOP_TURN_FINISHED_V1',onTurnFinished],
|
|
958
|
+
['CHAT_LOOP_TURN_BLOCKED_V1',onTurnBlocked],
|
|
959
|
+
['CHAT_LOOP_ACTION_TAKE_V1',onTake],
|
|
960
|
+
['CHAT_LOOP_ACTION_RESULT_V1',onResult],
|
|
961
|
+
['CHAT_LOOP_ACTION_MANUAL_PREPARE_V1',onManualPrepare],
|
|
962
|
+
['CHAT_LOOP_MODEL_INTENT_V1',onModelIntent],
|
|
963
|
+
['CHAT_LOOP_MODEL_INTENT_STATUS_V1',onModelIntentStatus],
|
|
964
|
+
['CHAT_LOOP_SESSION_START_PREPARE_V1',sessionStartPrepare],
|
|
965
|
+
['CHAT_LOOP_SESSION_START_TAKE_V1',sessionStartTake],
|
|
966
|
+
['CHAT_LOOP_SESSION_START_RESULT_V1',sessionStartResult],
|
|
967
|
+
['CHAT_LOOP_SESSION_STATUS_V1',sessionStatus],
|
|
968
|
+
['CHAT_LOOP_RESOURCE_CHAT_V1',resourceChatGate]
|
|
969
|
+
|
|
970
|
+
]);
|
|
1359
971
|
chrome.runtime.onMessage.addListener((message, sender, reply) => {
|
|
1360
|
-
const type
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
"CHAT_LOOP_ACTION_TEST_PREPARE_V1",
|
|
1366
|
-
"CHAT_LOOP_SESSION_START_PREPARE_V1", "CHAT_LOOP_SESSION_START_TAKE_V1",
|
|
1367
|
-
"CHAT_LOOP_SESSION_START_RESULT_V1", "CHAT_LOOP_SESSION_STATUS_V1",
|
|
1368
|
-
"CHAT_LOOP_RESOURCE_CHAT_V1",
|
|
1369
|
-
"CHAT_LOOP_ACCEPTANCE_CLOCK_ADVANCE_V1"].includes(type)) {
|
|
1370
|
-
const work = serial(() => type === "CHAT_LOOP_RESOURCE_CHAT_V1"
|
|
1371
|
-
? resourceChatGate(message,sender)
|
|
1372
|
-
: type === "CHAT_LOOP_SESSION_START_PREPARE_V1"
|
|
1373
|
-
? sessionStartPrepare(message,sender) : type === "CHAT_LOOP_SESSION_START_TAKE_V1"
|
|
1374
|
-
? sessionStartTake(message,sender) : type === "CHAT_LOOP_SESSION_START_RESULT_V1"
|
|
1375
|
-
? sessionStartResult(message,sender) : type === "CHAT_LOOP_SESSION_STATUS_V1"
|
|
1376
|
-
? sessionStatus(message,sender) : type === "CHAT_LOOP_MODEL_INTENT_V1"
|
|
1377
|
-
? onModelIntent(message, sender) : type === "CHAT_LOOP_MODEL_INTENT_STATUS_V1"
|
|
1378
|
-
? onModelIntentStatus(message,sender) : type === "CHAT_LOOP_TURN_FINISHED_V1"
|
|
1379
|
-
? onTurnFinished(message, sender) : type === "CHAT_LOOP_TURN_BLOCKED_V1"
|
|
1380
|
-
? onTurnBlocked(message, sender) : type === "CHAT_LOOP_ACTION_TAKE_V1"
|
|
1381
|
-
? onTake(message, sender) : type === "CHAT_LOOP_ACTION_MANUAL_PREPARE_V1"
|
|
1382
|
-
? onManualPrepare(message, sender) : type === "CHAT_LOOP_ACTION_TEST_PREPARE_V1"
|
|
1383
|
-
? onTestPrepare(message, sender)
|
|
1384
|
-
: type === "CHAT_LOOP_ACCEPTANCE_CLOCK_ADVANCE_V1"
|
|
1385
|
-
? onAcceptanceClockAdvance(message, sender) : onResult(message, sender));
|
|
1386
|
-
void work.then(reply, error => reply({status: "BLOCKED", code: error.message || "SCHEDULER_ERROR"}));
|
|
972
|
+
const type=message?.type;
|
|
973
|
+
const handle=SERIAL_ROUTES.get(type);
|
|
974
|
+
if(handle){
|
|
975
|
+
void serial(()=>handle(message,sender)).then(reply,
|
|
976
|
+
error=>reply({status:'BLOCKED',code:error.message||'SCHEDULER_ERROR'}));
|
|
1387
977
|
return true;
|
|
1388
978
|
}
|
|
1389
979
|
if (type === 'CHAT_LOOP_POPUP_CLEAN_OPEN_V1') {
|