@tomflow/chatloop-extension 0.0.0-stage → 0.3.28
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 +172 -2
- package/bin/cli.mjs +88 -0
- package/bin/native-host.mjs +29 -0
- package/extension/acceptance.js +389 -0
- package/extension/actions/clock.js +29 -0
- package/extension/actions/common.js +137 -0
- package/extension/actions/continue.js +264 -0
- package/extension/actions/dom.js +25 -0
- package/extension/actions/handoff.js +302 -0
- package/extension/actions/reload.js +20 -0
- package/extension/actions/screenshot.js +69 -0
- package/extension/actions/text.js +25 -0
- package/extension/background.js +946 -0
- package/extension/clock.html +4 -0
- package/extension/content.js +40 -0
- package/extension/continue.html +9 -0
- package/extension/dom.html +17 -0
- package/extension/handoff.html +9 -0
- package/extension/loop/core.js +37 -0
- package/extension/loop/observer.js +440 -0
- package/extension/manifest.json +38 -0
- package/extension/popup.html +26 -0
- package/extension/popup.js +70 -0
- package/extension/reload.html +17 -0
- package/extension/screenshot.html +17 -0
- package/extension/text.html +17 -0
- package/lib/bridge.mjs +104 -0
- package/lib/mcp-tools.json +26 -0
- package/package.json +23 -4
|
@@ -0,0 +1,946 @@
|
|
|
1
|
+
// Chat Loop browser scheduler. It never types or clicks in ChatGPT.
|
|
2
|
+
// Only extension-owned Action pages execute Continue or cross-Chat Handoff.
|
|
3
|
+
importScripts("loop/core.js");
|
|
4
|
+
const RUN_KEY = "chat-loop.action.run.v1";
|
|
5
|
+
const HOUR_MS = 60 * 60 * 1000; // Acceptance wall-clock hours are independent of the MVP timeout.
|
|
6
|
+
const RUN_BUDGET_MS = 10 * 60 * 1000; // Ten minutes per Chat; never interrupt a running Turn.
|
|
7
|
+
const ALARM_NAME = "chat-loop.turn-watch.v1";
|
|
8
|
+
// One-shot deadline on the same MV3 alarm facility; never sends a Chat message.
|
|
9
|
+
const DEADLINE_ALARM_NAME = "chat-loop.handoff-deadline.v1";
|
|
10
|
+
const COUNT_ALARM_NAME = "chat-loop.count-wait.v1";
|
|
11
|
+
const COUNT_INTERVAL_MS = 60000;
|
|
12
|
+
const CONTINUE_TEXT = "继续当前任务,完成本轮后只在末行写一个状态:[CHAT_LOOP_STATUS:CONTINUE]、[CHAT_LOOP_STATUS:DONE] 或 [CHAT_LOOP_STATUS:BLOCKED]。";
|
|
13
|
+
// Explicit acceptance setup only, dispatched by the same formal Continue page.
|
|
14
|
+
// Fixed harmless prompt; no arbitrary text or external write interface.
|
|
15
|
+
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。";
|
|
16
|
+
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。";
|
|
17
|
+
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。";
|
|
18
|
+
// The arithmetic progression belongs ONLY to the controlled 10m acceptance
|
|
19
|
+
// Chat. Each confirmed continuation asks a different, easily checked problem.
|
|
20
|
+
// No prompt variation is a substitute for respecting website security checks.
|
|
21
|
+
const wallArithmeticText = step => {
|
|
22
|
+
const left = Math.ceil(step / 2);
|
|
23
|
+
const right = step === 2 ? 3 : step;
|
|
24
|
+
return `第${step}题:${left}+${right}=?只写得数,末行写 [CHAT_LOOP_STATUS:CONTINUE]。`;
|
|
25
|
+
};
|
|
26
|
+
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。";
|
|
27
|
+
const countText = n => `上一轮确认数字是${n}。本轮只加1,第一行只输出${n + 1};第二行数字未到100写 [CHAT_LOOP_STATUS:CONTINUE],达到100才写 [CHAT_LOOP_STATUS:DONE]。`;
|
|
28
|
+
const readCount = reply => {
|
|
29
|
+
const m = String(reply).trim().match(/(?:^|\n)\s*(\d{2,3})\s*\r?\n\s*\[CHAT_LOOP_STATUS:(?:CONTINUE|DONE)\]$/);
|
|
30
|
+
return m ? Number(m[1]) : null;
|
|
31
|
+
};
|
|
32
|
+
let queue = Promise.resolve();
|
|
33
|
+
const serial = fn => {
|
|
34
|
+
const work = queue.then(fn);
|
|
35
|
+
queue = work.catch(() => {});
|
|
36
|
+
return work;
|
|
37
|
+
};
|
|
38
|
+
const pathOf = url => {
|
|
39
|
+
try {
|
|
40
|
+
const u = new URL(url);
|
|
41
|
+
const path = u.pathname.replace(/\/$/, "");
|
|
42
|
+
return u.origin === "https://chatgpt.com"
|
|
43
|
+
&& /^\/(?:c\/[0-9a-f-]{12,}|g\/[^/]+\/c\/[0-9a-f-]{12,})$/i.test(path)
|
|
44
|
+
? path : "";
|
|
45
|
+
} catch { return ""; }
|
|
46
|
+
};
|
|
47
|
+
const readRun = async () => (await chrome.storage.local.get(RUN_KEY))[RUN_KEY] || null;
|
|
48
|
+
const windowMsOf = run => Number.isSafeInteger(run?.windowMs) && run.windowMs > 0
|
|
49
|
+
? run.windowMs : run?.timedTest && run.wallTestMinutes === 20
|
|
50
|
+
? 20 * 60 * 1000 : RUN_BUDGET_MS;
|
|
51
|
+
const deadlineOf = run => Number.isSafeInteger(run?.startedAt) && run.startedAt > 0
|
|
52
|
+
? run.startedAt + windowMsOf(run) : null;
|
|
53
|
+
const clockActive = run => run && ["RESERVED", "PENDING", "WAITING_REPLY", "WAITING_INTERVAL"].includes(run.phase)
|
|
54
|
+
&& !run.testSetup && !run.wallTestHours;
|
|
55
|
+
async function saveRun(run) {
|
|
56
|
+
const now = Date.now(), deadlineAt = deadlineOf(run);
|
|
57
|
+
// Persist the intent even when a delayed alarm arrives after an Action
|
|
58
|
+
// receipt. This never revokes a Continue already reserved before deadline.
|
|
59
|
+
const next = {...run, ...(deadlineAt ? {deadlineAt} : {})};
|
|
60
|
+
if (clockActive(next) && deadlineAt !== null && now >= deadlineAt
|
|
61
|
+
&& !next.handoffPending) {
|
|
62
|
+
next.handoffPending = true;
|
|
63
|
+
next.handoffPendingAt = now;
|
|
64
|
+
}
|
|
65
|
+
await chrome.storage.local.set({[RUN_KEY]: next});
|
|
66
|
+
try {
|
|
67
|
+
if (clockActive(next) && !next.handoffPending && deadlineAt > Date.now()) {
|
|
68
|
+
await chrome.alarms?.create?.(DEADLINE_ALARM_NAME, {when: deadlineAt});
|
|
69
|
+
} else {
|
|
70
|
+
await chrome.alarms?.clear?.(DEADLINE_ALARM_NAME);
|
|
71
|
+
}
|
|
72
|
+
if (next.phase === "WAITING_INTERVAL" && next.countMode && !next.handoffPending)
|
|
73
|
+
await chrome.alarms?.create?.(COUNT_ALARM_NAME, {when: next.countReleaseAt});
|
|
74
|
+
else await chrome.alarms?.clear?.(COUNT_ALARM_NAME);
|
|
75
|
+
} catch { /* The existing periodic alarm is a conservative backup. */ }
|
|
76
|
+
}
|
|
77
|
+
async function prepareHandoffIfDue() {
|
|
78
|
+
const run = await readRun();
|
|
79
|
+
const deadlineAt = deadlineOf(run);
|
|
80
|
+
if (!clockActive(run) || run.handoffPending || deadlineAt === null
|
|
81
|
+
|| Date.now() < deadlineAt) return;
|
|
82
|
+
await saveRun({...run, handoffPending: true,
|
|
83
|
+
handoffPendingAt: Date.now()});
|
|
84
|
+
}
|
|
85
|
+
// Chrome preserves a content script's original document sender.url across SPA
|
|
86
|
+
// / → /c/... navigation. Authenticate its origin, then read the CURRENT tab URL.
|
|
87
|
+
const eligible = async (sender, page) => {
|
|
88
|
+
if (!Number.isInteger(sender.tab?.id) || typeof page !== "string"
|
|
89
|
+
|| pathOf("https://chatgpt.com" + page) !== page) return false;
|
|
90
|
+
try {
|
|
91
|
+
if (new URL(sender.url).origin !== "https://chatgpt.com") return false;
|
|
92
|
+
const tab = await chrome.tabs.get(sender.tab.id);
|
|
93
|
+
return pathOf(tab.url) === page;
|
|
94
|
+
} catch { return false; }
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// A transient partial UI snapshot may end with UNKNOWN even when the same
|
|
98
|
+
// already-confirmed reply later settles on a valid final status. Only resample
|
|
99
|
+
// the exact in-flight user/assistant turn, read-only, before failing closed.
|
|
100
|
+
async function recheckUnknownTurn(message, previous, tabId) {
|
|
101
|
+
if (previous?.phase !== "WAITING_REPLY" || previous.tabId !== tabId
|
|
102
|
+
|| previous.page !== message.page || previous.newUserKey !== message.userKey
|
|
103
|
+
|| previous.assistantKey === message.assistantKey) return null;
|
|
104
|
+
const read = async () => {
|
|
105
|
+
const tab = await chrome.tabs.get(tabId).catch(() => null);
|
|
106
|
+
if (pathOf(tab?.url) !== message.page) return null;
|
|
107
|
+
const proof = await chrome.tabs.sendMessage(tabId,
|
|
108
|
+
{type: "CHAT_LOOP_TURN_VERIFY_V1"}).catch(() => null);
|
|
109
|
+
if (proof?.contract !== "chat-loop.turn-observer.v1" || proof.status !== "READY"
|
|
110
|
+
|| proof.page !== message.page || proof.userKey !== message.userKey
|
|
111
|
+
|| proof.assistantKey !== message.assistantKey
|
|
112
|
+
|| typeof proof.replyText !== "string") return null;
|
|
113
|
+
return proof.replyText;
|
|
114
|
+
};
|
|
115
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
116
|
+
const first = await read();
|
|
117
|
+
if (first === null) return null;
|
|
118
|
+
const parsed = globalThis.ChatLoopCoreV1.parseStatus(first);
|
|
119
|
+
if (parsed !== "UNKNOWN") {
|
|
120
|
+
await new Promise(resolve => setTimeout(resolve, 2300));
|
|
121
|
+
const second = await read();
|
|
122
|
+
if (second === first) return {status: parsed, replyText: second};
|
|
123
|
+
} else {
|
|
124
|
+
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
async function onTurnBlocked(message, sender) {
|
|
130
|
+
const reason = message.reason;
|
|
131
|
+
if (!["PAGE_ERROR", "CONTEXT_LIMIT", "SECURITY_CHALLENGE"].includes(reason)
|
|
132
|
+
|| !await eligible(sender, message.page)
|
|
133
|
+
|| typeof message.userKey !== "string" || !/^user:[^\s]{8,}$/.test(message.userKey))
|
|
134
|
+
return {status: "DENIED", code: "ERROR_SOURCE_UNVERIFIED"};
|
|
135
|
+
const previous = await readRun();
|
|
136
|
+
if (previous?.phase !== "WAITING_REPLY" || previous.tabId !== sender.tab.id
|
|
137
|
+
|| previous.page !== message.page || previous.newUserKey !== message.userKey)
|
|
138
|
+
return {status: "IGNORED", code: "NOT_OWNED_RUNNING_TURN"};
|
|
139
|
+
const verify = async () => {
|
|
140
|
+
const proof = await chrome.tabs.sendMessage(previous.tabId,
|
|
141
|
+
{type: "CHAT_LOOP_TURN_OBSERVE_V1", poll: true}).catch(() => null);
|
|
142
|
+
return proof?.contract === "chat-loop.turn-observer.v1"
|
|
143
|
+
&& proof.status === "BLOCKED" && proof.reason === reason
|
|
144
|
+
&& proof.page === previous.page && proof.userKey === previous.newUserKey
|
|
145
|
+
? proof : null;
|
|
146
|
+
};
|
|
147
|
+
const first = await verify();
|
|
148
|
+
if (!first) return {status: "IGNORED", code: "ERROR_NOT_CONFIRMED"};
|
|
149
|
+
await new Promise(resolve => setTimeout(resolve, 400));
|
|
150
|
+
const second = await verify();
|
|
151
|
+
if (!second || first.retryAvailable !== second.retryAvailable)
|
|
152
|
+
return {status: "IGNORED", code: "ERROR_NOT_STABLE"};
|
|
153
|
+
// A security challenge is an explicit stop condition, not a prompt to retry
|
|
154
|
+
// or evade site access controls. Keep the original Chat and user Turn intact.
|
|
155
|
+
if (reason === "SECURITY_CHALLENGE") {
|
|
156
|
+
await saveRun({...previous, phase:"BLOCKED", reason,
|
|
157
|
+
errorUserKey:message.userKey, errorAt:Date.now()});
|
|
158
|
+
return {status:"BLOCKED", code:reason};
|
|
159
|
+
}
|
|
160
|
+
// The second capacity failure immediately after an ERROR Handoff must not
|
|
161
|
+
// start an infinite series of new ChatGPT conversations.
|
|
162
|
+
const errorHandoffCount = previous.errorHandoffCount || 0;
|
|
163
|
+
if (reason === "CONTEXT_LIMIT" && errorHandoffCount < 1
|
|
164
|
+
|| reason === "PAGE_ERROR" && previous.retryCount !== 1 && second.retryAvailable === true) {
|
|
165
|
+
const retry = reason === "PAGE_ERROR";
|
|
166
|
+
const token = crypto.randomUUID();
|
|
167
|
+
const next = {...previous, phase: "RESERVED", action: retry ? "CONTINUE" : "HANDOFF",
|
|
168
|
+
token, reason: "", ...(retry ? {retryMode: "NATIVE", retryCount: 1}
|
|
169
|
+
: {handoffMode: "ERROR", errorReason: reason})};
|
|
170
|
+
await saveRun(next); // Durable before opening the only authorized Action.
|
|
171
|
+
try {
|
|
172
|
+
const url = chrome.runtime.getURL(retry ? "continue.html" : "handoff.html")
|
|
173
|
+
+ "?tabId=" + previous.tabId + "&token=" + encodeURIComponent(token);
|
|
174
|
+
const actionTab = await chrome.tabs.create({url, active: false, windowId: sender.tab.windowId});
|
|
175
|
+
return {status: "QUEUED", actionTabId: actionTab.id, code: retry ? "NATIVE_RETRY_ONCE" : "CONTEXT_HANDOFF"};
|
|
176
|
+
} catch {
|
|
177
|
+
await saveRun({...next, phase: "BLOCKED", reason: "ERROR_ACTION_CREATE_FAILED"});
|
|
178
|
+
return {status: "BLOCKED", code: "ERROR_ACTION_CREATE_FAILED"};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
await saveRun({...previous, phase: "BLOCKED", reason: reason === "PAGE_ERROR"
|
|
182
|
+
? previous.retryCount === 1 ? "PAGE_ERROR_AFTER_RETRY" : "PAGE_ERROR_NO_RETRY_BUTTON"
|
|
183
|
+
: "CONTEXT_HANDOFF_REPEATED", errorUserKey: message.userKey, errorAt: Date.now()});
|
|
184
|
+
return {status: "BLOCKED", code: reason === "PAGE_ERROR" ? "PAGE_ERROR" : "CONTEXT_HANDOFF_REPEATED"};
|
|
185
|
+
}
|
|
186
|
+
async function onTurnFinished(message, sender) {
|
|
187
|
+
if (!await eligible(sender, message.page)
|
|
188
|
+
|| typeof message.userKey !== "string" || !/^user:[^\s]{8,}$/.test(message.userKey)
|
|
189
|
+
|| typeof message.assistantKey !== "string"
|
|
190
|
+
|| !/^assistant:[^\s]{8,}$/.test(message.assistantKey)
|
|
191
|
+
|| typeof message.replyText !== "string" || message.replyText.length > 100000) {
|
|
192
|
+
return {status: "DENIED", code: "UNVERIFIED_TURN_SOURCE"};
|
|
193
|
+
}
|
|
194
|
+
const tabId = sender.tab.id;
|
|
195
|
+
const previous = await readRun();
|
|
196
|
+
// A Chat is owned only after an extension Action has explicitly reserved
|
|
197
|
+
// and confirmed its user Turn. A status marker on an arbitrary Chat never
|
|
198
|
+
// starts or reactivates a loop, including after DONE/BLOCKED.
|
|
199
|
+
if (previous?.testSetup && previous.tabId === tabId)
|
|
200
|
+
return {status: "IGNORED", code: "TEST_SETUP_UNVERIFIED"};
|
|
201
|
+
if (!previous || ["DONE", "BLOCKED"].includes(previous.phase))
|
|
202
|
+
return {status: "IGNORED", code: "NO_ACTIVE_OWNER"};
|
|
203
|
+
if (previous.tabId !== tabId || previous.page !== message.page)
|
|
204
|
+
return {status: "IGNORED", code: "ANOTHER_CHAT_OWNS_LOOP"};
|
|
205
|
+
if (["PENDING", "RESERVED"].includes(previous.phase))
|
|
206
|
+
return {status: "IGNORED", code: previous.phase};
|
|
207
|
+
if (previous.assistantKey === message.assistantKey
|
|
208
|
+
|| previous.userKey === message.userKey)
|
|
209
|
+
return {status: "IGNORED", code: "ALREADY_HANDLED"};
|
|
210
|
+
if (previous.phase !== "WAITING_REPLY")
|
|
211
|
+
return {status: "IGNORED", code: "INVALID_PHASE"};
|
|
212
|
+
if (message.userKey !== previous.newUserKey) {
|
|
213
|
+
await saveRun({...previous, phase: "BLOCKED", reason: "REPLY_USER_MISMATCH"});
|
|
214
|
+
return {status: "BLOCKED", code: "REPLY_USER_MISMATCH"};
|
|
215
|
+
}
|
|
216
|
+
let replyText = message.replyText;
|
|
217
|
+
let status = globalThis.ChatLoopCoreV1.parseStatus(replyText);
|
|
218
|
+
if (status === "UNKNOWN") {
|
|
219
|
+
const recovery = await recheckUnknownTurn(message, previous, tabId);
|
|
220
|
+
if (recovery) {status = recovery.status; replyText = recovery.replyText;}
|
|
221
|
+
}
|
|
222
|
+
// Dedicated acceptance Chats only: retain the exact unrecognized reply to
|
|
223
|
+
// diagnose real UI text wrappers. Never persist private user Chat text.
|
|
224
|
+
let parseEvidence = null;
|
|
225
|
+
if (status === "UNKNOWN") {
|
|
226
|
+
const owned = (await chrome.storage.local.get("chat-loop.acceptance.owned.v1"))[
|
|
227
|
+
"chat-loop.acceptance.owned.v1"];
|
|
228
|
+
if (Array.isArray(owned) && owned.some(item => item.id === tabId || item.id === previous?.sourceTabId)) {
|
|
229
|
+
parseEvidence = {replyText: message.replyText.slice(0, 2500),
|
|
230
|
+
length: message.replyText.length, capturedAt: Date.now()};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const countMode = previous?.countMode === true;
|
|
234
|
+
let verifiedCount = null;
|
|
235
|
+
if (countMode && (status === "CONTINUE" || status === "DONE")) {
|
|
236
|
+
verifiedCount = readCount(replyText);
|
|
237
|
+
if (verifiedCount !== previous.countValue + 1
|
|
238
|
+
|| status !== (verifiedCount === 100 ? "DONE" : "CONTINUE")) {
|
|
239
|
+
await saveRun({...previous, phase: "BLOCKED", reason: "COUNT_STEP_UNVERIFIED"});
|
|
240
|
+
return {status: "BLOCKED", code: "COUNT_STEP_UNVERIFIED"};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const now = Date.now();
|
|
244
|
+
// Keep the same clock throughout one Chat. A successful Handoff resets it.
|
|
245
|
+
const initial = Number.isSafeInteger(message.startedAt) && message.startedAt > 0
|
|
246
|
+
&& message.startedAt <= now ? message.startedAt : now;
|
|
247
|
+
const startedAt = previous?.phase === "WAITING_REPLY" ? previous.startedAt || now : initial;
|
|
248
|
+
const timeExpired = now >= startedAt + windowMsOf(previous);
|
|
249
|
+
// Deadline intent can be persisted by the timer even while a Turn RUNS.
|
|
250
|
+
// The decision below still waits for the observer's complete Turn proof.
|
|
251
|
+
let nextAction = status === "HANDOFF" ? "UNSUPPORTED_MODEL_HANDOFF"
|
|
252
|
+
: (previous?.handoffPending || timeExpired) && status === "CONTINUE"
|
|
253
|
+
? "HANDOFF" : status;
|
|
254
|
+
if (nextAction !== "CONTINUE" && nextAction !== "HANDOFF") {
|
|
255
|
+
if (previous?.phase === "WAITING_REPLY" && previous.tabId === tabId) {
|
|
256
|
+
await saveRun({...previous, phase: status === "DONE" ? "DONE" : "BLOCKED",
|
|
257
|
+
reason: status, userKey: message.userKey, assistantKey: message.assistantKey,
|
|
258
|
+
...(countMode && verifiedCount !== null ? {countValue: verifiedCount} : {}),
|
|
259
|
+
...(parseEvidence ? {parseEvidence} : {})});
|
|
260
|
+
}
|
|
261
|
+
return {status: "IGNORED", code: nextAction === "UNSUPPORTED_MODEL_HANDOFF"
|
|
262
|
+
? "MODEL_HANDOFF_DISABLED" : status};
|
|
263
|
+
}
|
|
264
|
+
// Historical 1h/2h endurance fixtures explicitly park the Chat to avoid
|
|
265
|
+
// huge message counts; the real 10m acceptance MUST use the normal running
|
|
266
|
+
// loop: each finished CONTINUE immediately dispatches Continue, without a hold.
|
|
267
|
+
if (previous?.wallTestHours
|
|
268
|
+
&& status === "CONTINUE" && !timeExpired
|
|
269
|
+
&& !previous.wallReleasedAt && previous.phase === "WAITING_REPLY") {
|
|
270
|
+
const releaseAt = startedAt + (previous.wallTestMinutes === 10
|
|
271
|
+
? RUN_BUDGET_MS : previous.wallTestHours * HOUR_MS);
|
|
272
|
+
await saveRun({...previous, phase: "WALL_CLOCK_HOLD", reason: "ACCEPTANCE_WALL_WAIT",
|
|
273
|
+
userKey: message.userKey, assistantKey: message.assistantKey,
|
|
274
|
+
wallHoldAt: now, wallReleaseAt: releaseAt});
|
|
275
|
+
return {status: "WAITING_DEADLINE", releaseAt};
|
|
276
|
+
}
|
|
277
|
+
// Count acceptance: completion is recorded now, but the next Continue is
|
|
278
|
+
// not authorized until 60 real seconds after the previous confirmed send.
|
|
279
|
+
if (countMode && nextAction === "CONTINUE" && !timeExpired
|
|
280
|
+
&& now < previous.countLastSendAt + COUNT_INTERVAL_MS) {
|
|
281
|
+
const countReleaseAt = previous.countLastSendAt + COUNT_INTERVAL_MS;
|
|
282
|
+
await saveRun({...previous, phase: "WAITING_INTERVAL", reason: "COUNT_INTERVAL",
|
|
283
|
+
userKey: message.userKey, assistantKey: message.assistantKey,
|
|
284
|
+
countValue: verifiedCount, countReleaseAt});
|
|
285
|
+
return {status: "WAITING_INTERVAL", countReleaseAt};
|
|
286
|
+
}
|
|
287
|
+
// Recheck at the last new-authorization boundary, even if a periodic or
|
|
288
|
+
// one-shot alarm was delayed while the Worker handled completion evidence.
|
|
289
|
+
const newlyDue = Date.now() >= startedAt + windowMsOf(previous);
|
|
290
|
+
if (nextAction === "CONTINUE" && newlyDue) nextAction = "HANDOFF";
|
|
291
|
+
const token = crypto.randomUUID();
|
|
292
|
+
const run = {tabId, page: message.page, userKey: message.userKey,
|
|
293
|
+
assistantKey: message.assistantKey, phase: "RESERVED", token,
|
|
294
|
+
action: nextAction, startedAt,
|
|
295
|
+
...(countMode ? {countMode:true, countValue:verifiedCount,
|
|
296
|
+
countLastSendAt:previous.countLastSendAt,
|
|
297
|
+
countHandoffs:previous.countHandoffs || 0} : {}),
|
|
298
|
+
...((previous?.handoffPending || newlyDue) ? {
|
|
299
|
+
handoffPending: true,
|
|
300
|
+
handoffPendingAt: previous?.handoffPendingAt || Date.now()} : {}),
|
|
301
|
+
// A completed Turn is a successful recovery and clears the consecutive error-Handoff guard.
|
|
302
|
+
errorHandoffCount: 0,
|
|
303
|
+
// Carry only dedicated acceptance evidence across the finished Turn. The
|
|
304
|
+
// evidence proves the deadline was crossed while Observer was RUNNING.
|
|
305
|
+
...(previous?.timedTest && previous?.clockAdvancedAt ? {
|
|
306
|
+
timedTest: true, clockAdvancedAt: previous.clockAdvancedAt,
|
|
307
|
+
clockAdvanceMinutes: previous.clockAdvanceMinutes,
|
|
308
|
+
clockRunningProof: previous.clockRunningProof} : {}),
|
|
309
|
+
// Keep the 10m continuous-flow test identity only for audit evidence;
|
|
310
|
+
// unlike historical hour-long endurance it never delays the next Continue.
|
|
311
|
+
...(previous?.timedTest && previous?.wallTestMinutes === 10 ? {
|
|
312
|
+
timedTest: true, wallTestMinutes: 10,
|
|
313
|
+
testTurnIndex: (previous.testTurnIndex || 1) + 1} : {}),
|
|
314
|
+
// Preserve historical wall-clock hold receipt across its subsequent Turn.
|
|
315
|
+
...(previous?.timedTest && previous?.wallReleasedAt ? {
|
|
316
|
+
timedTest: true, wallTestHours: previous.wallTestHours,
|
|
317
|
+
wallHoldAt: previous.wallHoldAt, wallReleaseAt: previous.wallReleaseAt,
|
|
318
|
+
wallReleasedAt: previous.wallReleasedAt,
|
|
319
|
+
...([10, 20].includes(previous.wallTestMinutes) ? {
|
|
320
|
+
wallTestMinutes: previous.wallTestMinutes,
|
|
321
|
+
...(previous.wallTestMinutes === 20 ? {wallAdjustedAt: previous.wallAdjustedAt,
|
|
322
|
+
wallOriginalReleaseAt: previous.wallOriginalReleaseAt} : {})} : {})} : {}),
|
|
323
|
+
// A timed Handoff uses an Action-local read-only snapshot. Do not keep
|
|
324
|
+
// the entire conversation in storage or embed it in the Action URL.
|
|
325
|
+
handoffMode: nextAction === "HANDOFF" ? "TIMED" : null};
|
|
326
|
+
// Durable before opening an Action; an uncertain outcome must never be retried.
|
|
327
|
+
await saveRun(run);
|
|
328
|
+
try {
|
|
329
|
+
const url = chrome.runtime.getURL(nextAction === "HANDOFF" ? "handoff.html" : "continue.html")
|
|
330
|
+
+ "?tabId=" + tabId + "&token=" + encodeURIComponent(token);
|
|
331
|
+
const actionTab = await chrome.tabs.create({url, active: false, windowId: sender.tab.windowId});
|
|
332
|
+
return {status: "QUEUED", actionTabId: actionTab.id};
|
|
333
|
+
} catch (error) {
|
|
334
|
+
await saveRun({...run, phase: "BLOCKED", reason: "ACTION_PAGE_CREATE_FAILED"});
|
|
335
|
+
return {status: "BLOCKED", code: error.message || "ACTION_PAGE_CREATE_FAILED"};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// Release a verified completed count Turn only after the persisted 60s gate.
|
|
339
|
+
// The independent 10m cutoff wins over an unissued Continue.
|
|
340
|
+
async function releaseCountInterval() {
|
|
341
|
+
const old = await readRun();
|
|
342
|
+
if (old?.phase !== "WAITING_INTERVAL" || old.countMode !== true) return;
|
|
343
|
+
const now = Date.now(), deadline = deadlineOf(old);
|
|
344
|
+
if (now < old.countReleaseAt && now < deadline && !old.handoffPending) return;
|
|
345
|
+
const tab = await chrome.tabs.get(old.tabId).catch(() => null);
|
|
346
|
+
if (pathOf(tab?.url) !== old.page) {
|
|
347
|
+
await saveRun({...old, phase:"BLOCKED", reason:"COUNT_TAB_CHANGED"});
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const proof = await chrome.tabs.sendMessage(old.tabId,
|
|
351
|
+
{type:"CHAT_LOOP_TURN_VERIFY_V1"}).catch(() => null);
|
|
352
|
+
if (proof?.status !== "READY" || proof.page !== old.page
|
|
353
|
+
|| proof.userKey !== old.userKey || proof.assistantKey !== old.assistantKey
|
|
354
|
+
|| readCount(proof.replyText) !== old.countValue) {
|
|
355
|
+
await saveRun({...old, phase:"BLOCKED", reason:"COUNT_TURN_CHANGED"});
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const action = old.handoffPending || Date.now() >= deadline ? "HANDOFF" : "CONTINUE";
|
|
359
|
+
const run = {...old, phase:"RESERVED", action, token:crypto.randomUUID(),
|
|
360
|
+
handoffMode: action === "HANDOFF" ? "TIMED" : null};
|
|
361
|
+
delete run.countReleaseAt;
|
|
362
|
+
await saveRun(run);
|
|
363
|
+
try {
|
|
364
|
+
await chrome.tabs.create({url:chrome.runtime.getURL(action === "HANDOFF"
|
|
365
|
+
? "handoff.html" : "continue.html") + "?tabId=" + old.tabId
|
|
366
|
+
+ "&token=" + encodeURIComponent(run.token), active:false, windowId:tab.windowId});
|
|
367
|
+
} catch {
|
|
368
|
+
await saveRun({...run,phase:"BLOCKED",reason:"COUNT_ACTION_OPEN_FAILED"});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// Opening a formal Action page manually is a supported test/debug entry.
|
|
372
|
+
// No token in URL: the Worker verifies a single explicit target and reserves
|
|
373
|
+
// this exact Action tab. It never sends or creates the new Chat itself.
|
|
374
|
+
async function onManualPrepare(message, sender) {
|
|
375
|
+
const requested = message.action;
|
|
376
|
+
if (!["CONTINUE", "HANDOFF"].includes(requested)
|
|
377
|
+
|| !Number.isInteger(sender.tab?.id)) return {status: "DENIED", code: "INVALID_MANUAL_ACTION"};
|
|
378
|
+
const filename = requested === "HANDOFF" ? "handoff.html" : "continue.html";
|
|
379
|
+
let url;
|
|
380
|
+
try { url = new URL(sender.url); } catch {return {status: "DENIED", code: "INVALID_ACTION_URL"};}
|
|
381
|
+
const rawTabId = url.searchParams.get("tabId");
|
|
382
|
+
if (url.origin !== new URL(chrome.runtime.getURL(filename)).origin
|
|
383
|
+
|| url.pathname !== "/" + filename || url.searchParams.has("token")
|
|
384
|
+
|| Array.from(url.searchParams.keys()).some(key => key !== "tabId")
|
|
385
|
+
|| (rawTabId !== null && (!/^[1-9][0-9]*$/.test(rawTabId)
|
|
386
|
+
|| message.tabId !== Number(rawTabId))))
|
|
387
|
+
return {status: "DENIED", code: "INVALID_ACTION_URL"};
|
|
388
|
+
const previous = await readRun();
|
|
389
|
+
if (previous && !["DONE", "BLOCKED"].includes(previous.phase))
|
|
390
|
+
return {status: "DENIED", code: "LOOP_ALREADY_ACTIVE"};
|
|
391
|
+
const candidates = await chrome.tabs.query({url: "https://chatgpt.com/*"});
|
|
392
|
+
const all = candidates.filter(tab => Number.isInteger(tab.id) && pathOf(tab.url));
|
|
393
|
+
let tab;
|
|
394
|
+
if (Number.isInteger(message.tabId)) tab = all.find(item => item.id === message.tabId);
|
|
395
|
+
else if (all.length === 1) tab = all[0];
|
|
396
|
+
else return {status: "DENIED", code: all.length ? "AMBIGUOUS_CHATGPT_TABS" : "NO_CHATGPT_TAB"};
|
|
397
|
+
if (!tab) return {status: "DENIED", code: "TARGET_CHAT_NOT_FOUND"};
|
|
398
|
+
const query = () => chrome.tabs.sendMessage(tab.id, {type: "CHAT_LOOP_TURN_VERIFY_V1"});
|
|
399
|
+
let first;
|
|
400
|
+
try {first = await query();}
|
|
401
|
+
catch {return {status: "DENIED", code: "OBSERVER_NOT_READY"};}
|
|
402
|
+
if (first?.contract !== "chat-loop.turn-observer.v1" || first.status !== "READY"
|
|
403
|
+
|| first.page !== pathOf(tab.url)) return {status: "DENIED", code: "TURN_NOT_FINISHED"};
|
|
404
|
+
// A manually opened page is still forbidden from interrupting late updates.
|
|
405
|
+
await new Promise(resolve => setTimeout(resolve, 2300));
|
|
406
|
+
const second = await query().catch(() => null);
|
|
407
|
+
if (second?.status !== "READY" || second.page !== first.page
|
|
408
|
+
|| second.userKey !== first.userKey || second.assistantKey !== first.assistantKey
|
|
409
|
+
|| second.replyText !== first.replyText) return {status: "DENIED", code: "TURN_NOT_STABLE"};
|
|
410
|
+
const modelStatus = globalThis.ChatLoopCoreV1.parseStatus(first.replyText);
|
|
411
|
+
if (requested === "CONTINUE" && modelStatus !== "CONTINUE"
|
|
412
|
+
|| requested === "HANDOFF" && !["HANDOFF", "CONTINUE"].includes(modelStatus))
|
|
413
|
+
return {status: "DENIED", code: "ACTION_MARKER_MISMATCH"};
|
|
414
|
+
if (previous && previous.tabId === tab.id && previous.page === first.page
|
|
415
|
+
&& (previous.userKey === first.userKey || previous.assistantKey === first.assistantKey))
|
|
416
|
+
return {status: "DENIED", code: "ALREADY_HANDLED"};
|
|
417
|
+
const summary = modelStatus === "HANDOFF"
|
|
418
|
+
? first.replyText.replace(/\r?\n\[CHAT_LOOP_STATUS:HANDOFF\]\s*$/, "").trim() : null;
|
|
419
|
+
if (summary && (summary.length < 120 || summary.length > 16000
|
|
420
|
+
|| !/目标/.test(summary) || !/已完成|进度/.test(summary)
|
|
421
|
+
|| !/下一步|待办/.test(summary)))
|
|
422
|
+
return {status: "DENIED", code: "HANDOFF_PACKET_MISSING"};
|
|
423
|
+
const token = crypto.randomUUID();
|
|
424
|
+
await saveRun({tabId: tab.id, page: first.page, userKey: first.userKey,
|
|
425
|
+
assistantKey: first.assistantKey, phase: "RESERVED", token,
|
|
426
|
+
action: requested, startedAt: Date.now(), manualActionTabId: sender.tab.id,
|
|
427
|
+
handoffMode: requested === "HANDOFF"
|
|
428
|
+
? (modelStatus === "HANDOFF" ? "EXPLICIT" : "TIMED") : null,
|
|
429
|
+
...(summary ? {handoffText: summary} : {})});
|
|
430
|
+
return {status: "PREPARED", token, tabId: tab.id};
|
|
431
|
+
}
|
|
432
|
+
// This is a test preparation permit, not a successful Continue regression.
|
|
433
|
+
// Both readiness probes are read-only; PENDING must be durable before any edit.
|
|
434
|
+
async function onTestPrepare(message, sender) {
|
|
435
|
+
const tabId = message.tabId;
|
|
436
|
+
if (!Number.isInteger(tabId) || !Number.isInteger(sender.tab?.id)
|
|
437
|
+
|| ![chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1",
|
|
438
|
+
chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&discardDraft=1",
|
|
439
|
+
chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&timed=1",
|
|
440
|
+
chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&discardDraft=1&timed=1",
|
|
441
|
+
chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&timed=1&wall=1",
|
|
442
|
+
chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&discardDraft=1&timed=1&wall=1",
|
|
443
|
+
chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&timed=1&wall=2",
|
|
444
|
+
chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&discardDraft=1&timed=1&wall=2",
|
|
445
|
+
chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&timed=1&wall=10m",
|
|
446
|
+
chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&discardDraft=1&timed=1&wall=10m",
|
|
447
|
+
chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&timed=1&wall=10m&count=86",
|
|
448
|
+
chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&discardDraft=1&timed=1&wall=10m&count=86"].includes(sender.url))
|
|
449
|
+
return {status: "DENIED", code: "INVALID_TEST_ACTION"};
|
|
450
|
+
const previous = await readRun();
|
|
451
|
+
if (previous && !["DONE", "BLOCKED"].includes(previous.phase))
|
|
452
|
+
return {status: "DENIED", code: "LOOP_ALREADY_ACTIVE"};
|
|
453
|
+
if (previous?.tabId === tabId)
|
|
454
|
+
return {status: "DENIED", code: "TEST_TARGET_ALREADY_USED"};
|
|
455
|
+
const tab = await chrome.tabs.get(tabId).catch(() => null);
|
|
456
|
+
if (tab?.url !== "https://chatgpt.com/")
|
|
457
|
+
return {status: "DENIED", code: "TEST_TARGET_NOT_BLANK"};
|
|
458
|
+
const params = new URL(sender.url).searchParams;
|
|
459
|
+
const discardDraft = params.get("discardDraft") === "1";
|
|
460
|
+
const timedTest = params.get("timed") === "1";
|
|
461
|
+
const wall = params.get("wall");
|
|
462
|
+
const wallTestHours = wall && wall !== "10m" ? Number(wall) : null;
|
|
463
|
+
const wallTestMinutes = wall === "10m" ? 10 : null;
|
|
464
|
+
const countMode = params.get("count") === "86";
|
|
465
|
+
if (params.has("count") && (!countMode || !timedTest || wallTestMinutes !== 10))
|
|
466
|
+
return {status:"DENIED",code:"COUNT_TEST_INVALID"};
|
|
467
|
+
if (wall !== null && (!timedTest || !(wallTestMinutes === 10 || [1, 2].includes(wallTestHours))))
|
|
468
|
+
return {status: "DENIED", code: "INVALID_WALL_TEST"};
|
|
469
|
+
if (timedTest) {
|
|
470
|
+
const registry = (await chrome.storage.local.get("chat-loop.acceptance.owned.v1"))[
|
|
471
|
+
"chat-loop.acceptance.owned.v1"];
|
|
472
|
+
if (!Array.isArray(registry) || !registry.some(item => item.id === tabId))
|
|
473
|
+
return {status: "DENIED", code: "TIMED_TEST_OWNERSHIP_REQUIRED"};
|
|
474
|
+
}
|
|
475
|
+
const probe = async () => {
|
|
476
|
+
const [read] = await chrome.scripting.executeScript({target: {tabId}, func: allowDiscard => {
|
|
477
|
+
const visible = node => Boolean(node?.getClientRects?.().length);
|
|
478
|
+
const mains = [...document.querySelectorAll("main")].filter(visible);
|
|
479
|
+
const input = document.querySelector('[contenteditable="true"][data-composer-markdown]')
|
|
480
|
+
|| document.querySelector('#prompt-textarea[contenteditable="true"]');
|
|
481
|
+
const draft = (input?.innerText || input?.textContent || "").trim();
|
|
482
|
+
const knownTestDraft = (draft.includes("LOOP-V037-FAST-3C1H") || (draft.startsWith("你正在接手上一 Chat 未完成的任务。旧 Chat 的网页历史仍可核实")
|
|
483
|
+
&& draft.includes("【交接参考:只读网页片段,可能截断】")
|
|
484
|
+
&& draft.includes("【原始任务用户消息】")
|
|
485
|
+
&& draft.includes("【最近轮次证据】")
|
|
486
|
+
&& draft.endsWith("是否跨 Chat 由扩展决定,不要主动要求 Handoff。")
|
|
487
|
+
&& draft.length >= 450 && draft.length <= 8192)) && (
|
|
488
|
+
(draft.startsWith("这是 Chat Loop 短轮回归专用测试") && draft.length <= 800)
|
|
489
|
+
|| (draft.startsWith("你正在接手上一 Chat 尚未完成的同一个任务")
|
|
490
|
+
&& draft.length <= 8192 && (draft.length <= 2000
|
|
491
|
+
|| (draft.includes("【原始任务用户消息】") && draft.includes("【最近轮次证据】"))))
|
|
492
|
+
|| (draft.startsWith("你正在接手上一 Chat 未完成的任务。旧 Chat 的网页历史仍可核实")
|
|
493
|
+
&& draft.includes("【原始任务用户消息】") && draft.includes("【最近轮次证据】")
|
|
494
|
+
&& draft.endsWith("是否跨 Chat 由扩展决定,不要主动要求 Handoff。")
|
|
495
|
+
&& draft.length >= 450 && draft.length <= 8192)
|
|
496
|
+
|| (draft.startsWith("这是 Chat Loop 的计数隔离验收")
|
|
497
|
+
&& draft.includes("COUNT-86-100") && draft.endsWith("WALL-CLOCK-TEST。"))
|
|
498
|
+
|| ((draft.startsWith("这是 Chat Loop 的长回复与一小时边界隔离测试")
|
|
499
|
+
|| draft.startsWith("这是 Chat Loop 的墙钟边界隔离测试"))
|
|
500
|
+
&& (draft.includes("MODE-TIMED-1H") || draft.includes("MODE-WALL-10M"))));
|
|
501
|
+
return location.href === "https://chatgpt.com/" && mains.length === 1
|
|
502
|
+
&& !mains[0].querySelector('[data-chatgpt-search-message-ids],[data-message-author-role]')
|
|
503
|
+
&& visible(input) && (!draft || (allowDiscard && knownTestDraft))
|
|
504
|
+
&& ![...document.querySelectorAll('button[data-testid="stop-button"]')].some(visible);
|
|
505
|
+
}, args: [discardDraft]});
|
|
506
|
+
return read?.result === true;
|
|
507
|
+
};
|
|
508
|
+
if (!await probe()) return {status: "DENIED", code: "TEST_TARGET_NOT_EMPTY"};
|
|
509
|
+
await new Promise(resolve => setTimeout(resolve, 2300));
|
|
510
|
+
if (!await probe()) return {status: "DENIED", code: "TEST_TARGET_NOT_STABLE"};
|
|
511
|
+
const token = crypto.randomUUID();
|
|
512
|
+
await saveRun({tabId, page: "/", userKey: null, assistantKey: null,
|
|
513
|
+
phase: "RESERVED", token, action: "CONTINUE", testSetup: true, discardDraft,
|
|
514
|
+
...(timedTest ? {timedTest: true} : {}),
|
|
515
|
+
...(wallTestHours ? {wallTestHours} : {}),
|
|
516
|
+
...(wallTestMinutes ? {wallTestMinutes} : {}),
|
|
517
|
+
...(countMode ? {countMode:true, countValue:86, countHandoffs:0} : {}),
|
|
518
|
+
// The first Turn starts with a real clock, including acceptance tests.
|
|
519
|
+
// A dedicated test Action may advance it later only while Observer RUNNING.
|
|
520
|
+
startedAt: Date.now(),
|
|
521
|
+
manualActionTabId: sender.tab.id});
|
|
522
|
+
return {status: "PREPARED", token, tabId};
|
|
523
|
+
}
|
|
524
|
+
async function onAcceptanceShortenWallHold(message, sender) {
|
|
525
|
+
// User-requested shortening of an existing *owned acceptance* wait.
|
|
526
|
+
// The real elapsed time must already exceed 20 min; never backdate time.
|
|
527
|
+
const tabId = message.tabId, now = Date.now();
|
|
528
|
+
if (!Number.isSafeInteger(tabId) || tabId <= 0
|
|
529
|
+
|| message.minutes !== 20 || !Number.isSafeInteger(sender.tab?.id)
|
|
530
|
+
|| sender.url !== chrome.runtime.getURL("clock.html")
|
|
531
|
+
+ "?tabId=" + tabId + "&minutes=20")
|
|
532
|
+
return {status: "DENIED", code: "SHORTEN_SOURCE_INVALID"};
|
|
533
|
+
const run = await readRun();
|
|
534
|
+
if (run?.phase !== "WALL_CLOCK_HOLD" || run.tabId !== tabId
|
|
535
|
+
|| run.timedTest !== true || run.wallTestHours !== 2
|
|
536
|
+
|| run.wallAdjustedAt || !run.userKey || !run.assistantKey
|
|
537
|
+
|| !Number.isSafeInteger(run.startedAt)
|
|
538
|
+
|| now < run.startedAt + 20 * 60 * 1000)
|
|
539
|
+
return {status: "DENIED", code: "SHORTEN_NOT_ELIGIBLE"};
|
|
540
|
+
const owned = (await chrome.storage.local.get("chat-loop.acceptance.owned.v1"))[
|
|
541
|
+
"chat-loop.acceptance.owned.v1"];
|
|
542
|
+
if (!Array.isArray(owned) || !owned.some(item => item.id === tabId))
|
|
543
|
+
return {status: "DENIED", code: "SHORTEN_NOT_OWNED"};
|
|
544
|
+
const tab = await chrome.tabs.get(tabId).catch(() => null);
|
|
545
|
+
if (!tab || pathOf(tab.url) !== run.page)
|
|
546
|
+
return {status: "DENIED", code: "SHORTEN_TARGET_CHANGED"};
|
|
547
|
+
const verify = async () => {
|
|
548
|
+
const proof = await chrome.tabs.sendMessage(tabId,
|
|
549
|
+
{type: "CHAT_LOOP_TURN_VERIFY_V1"}).catch(() => null);
|
|
550
|
+
return proof?.contract === "chat-loop.turn-observer.v1"
|
|
551
|
+
&& proof.status === "READY" && proof.page === run.page
|
|
552
|
+
&& proof.userKey === run.userKey
|
|
553
|
+
&& proof.assistantKey === run.assistantKey
|
|
554
|
+
&& globalThis.ChatLoopCoreV1.parseStatus(proof.replyText) === "CONTINUE";
|
|
555
|
+
};
|
|
556
|
+
if (!await verify()) return {status: "DENIED", code: "SHORTEN_NOT_STABLE"};
|
|
557
|
+
await new Promise(resolve => setTimeout(resolve, 2300));
|
|
558
|
+
if (!await verify()) return {status: "DENIED", code: "SHORTEN_IDENTITY_CHANGED"};
|
|
559
|
+
const latest = await readRun();
|
|
560
|
+
if (latest?.phase !== "WALL_CLOCK_HOLD" || latest.tabId !== run.tabId
|
|
561
|
+
|| latest.wallHoldAt !== run.wallHoldAt || latest.userKey !== run.userKey)
|
|
562
|
+
return {status: "DENIED", code: "SHORTEN_OWNER_CHANGED"};
|
|
563
|
+
const changed = {...latest,
|
|
564
|
+
wallOriginalReleaseAt: latest.wallReleaseAt,
|
|
565
|
+
wallReleaseAt: latest.startedAt + 20 * 60 * 1000,
|
|
566
|
+
wallTestMinutes: 20, wallAdjustedAt: Date.now()};
|
|
567
|
+
await saveRun(changed);
|
|
568
|
+
return {status: "SHORTENED_AFTER_REAL_20MIN", tabId,
|
|
569
|
+
startedAt: changed.startedAt, releaseAt: changed.wallReleaseAt,
|
|
570
|
+
adjustedAt: changed.wallAdjustedAt};
|
|
571
|
+
}
|
|
572
|
+
async function onAcceptanceClockAdvance(message, sender) {
|
|
573
|
+
if (message?.minutes === 20) return onAcceptanceShortenWallHold(message, sender);
|
|
574
|
+
// Test-only clock mutation. All production time comparisons remain unchanged.
|
|
575
|
+
// This must happen DURING the assistant Turn, never before the first Turn.
|
|
576
|
+
const tabId = message.tabId, minutes = message.minutes;
|
|
577
|
+
if (!Number.isSafeInteger(tabId) || tabId <= 0
|
|
578
|
+
|| !Number.isSafeInteger(minutes) || minutes < 60 || minutes > 180
|
|
579
|
+
|| sender.url !== chrome.runtime.getURL("clock.html")
|
|
580
|
+
+ "?tabId=" + tabId + "&minutes=" + minutes
|
|
581
|
+
|| !Number.isSafeInteger(sender.tab?.id))
|
|
582
|
+
return {status: "DENIED", code: "CLOCK_ACTION_SOURCE_INVALID"};
|
|
583
|
+
const run = await readRun();
|
|
584
|
+
if (run?.phase !== "WAITING_REPLY" || run?.action !== "CONTINUE"
|
|
585
|
+
|| run?.tabId !== tabId || run?.timedTest !== true
|
|
586
|
+
|| run?.clockAdvancedAt || !run.newUserKey || !run.page
|
|
587
|
+
|| run.testSetup) return {status: "DENIED", code: "CLOCK_TEST_NOT_WAITING"};
|
|
588
|
+
const owned = (await chrome.storage.local.get("chat-loop.acceptance.owned.v1"))[
|
|
589
|
+
"chat-loop.acceptance.owned.v1"];
|
|
590
|
+
if (!Array.isArray(owned) || !owned.some(item => item.id === tabId))
|
|
591
|
+
return {status: "DENIED", code: "CLOCK_TEST_NOT_OWNED"};
|
|
592
|
+
const live = await chrome.tabs.get(tabId).catch(() => null);
|
|
593
|
+
if (!live || pathOf(live.url) !== run.page)
|
|
594
|
+
return {status: "DENIED", code: "CLOCK_TARGET_CHANGED"};
|
|
595
|
+
const sample = async () => chrome.tabs.sendMessage(tabId,
|
|
596
|
+
{type: "CHAT_LOOP_TURN_OBSERVE_V1", poll: true}).catch(() => null);
|
|
597
|
+
const isRunning = x => x?.contract === "chat-loop.turn-observer.v1"
|
|
598
|
+
&& x.status === "RUNNING" && x.page === run.page
|
|
599
|
+
&& x.userKey === run.newUserKey;
|
|
600
|
+
const first = await sample();
|
|
601
|
+
if (!isRunning(first)) return {status: "DENIED", code: "CLOCK_TURN_NOT_RUNNING"};
|
|
602
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
603
|
+
const second = await sample();
|
|
604
|
+
if (!isRunning(second)) return {status: "DENIED", code: "CLOCK_TURN_NOT_STABLE_RUNNING"};
|
|
605
|
+
const now = Date.now();
|
|
606
|
+
// Advance by a test-chosen elapsed duration, not by a hard-coded 61 minutes.
|
|
607
|
+
// No scheduler event or writing action is dispatched at this moment.
|
|
608
|
+
await saveRun({...run, startedAt: now - minutes * 60000,
|
|
609
|
+
clockAdvancedAt: now, clockAdvanceMinutes: minutes,
|
|
610
|
+
clockRunningProof: {first: first.reason, second: second.reason,
|
|
611
|
+
userKey: run.newUserKey, observedAt: second.observedAt}});
|
|
612
|
+
return {status: "ADVANCED_WHILE_RUNNING", minutes, tabId,
|
|
613
|
+
firstReason: first.reason, secondReason: second.reason};
|
|
614
|
+
}
|
|
615
|
+
const isAction = (sender, message, run) => {
|
|
616
|
+
if (!Number.isInteger(sender.tab?.id) || typeof sender.url !== "string") return false;
|
|
617
|
+
try {
|
|
618
|
+
const file = run?.action === "HANDOFF" ? "handoff.html" : "continue.html";
|
|
619
|
+
const u = new URL(sender.url);
|
|
620
|
+
const manual = Number.isInteger(run?.manualActionTabId)
|
|
621
|
+
&& run.manualActionTabId === sender.tab.id && !u.searchParams.has("token")
|
|
622
|
+
&& (!u.searchParams.has("tabId") || u.searchParams.get("tabId") === String(message.tabId));
|
|
623
|
+
return u.origin === new URL(chrome.runtime.getURL(file)).origin
|
|
624
|
+
&& u.pathname === "/" + file
|
|
625
|
+
&& (manual || u.searchParams.get("token") === message.token
|
|
626
|
+
&& u.searchParams.get("tabId") === String(message.tabId));
|
|
627
|
+
} catch { return false; }
|
|
628
|
+
};
|
|
629
|
+
async function onTake(message, sender) {
|
|
630
|
+
const run = await readRun();
|
|
631
|
+
if (!isAction(sender, message, run)) return {status: "DENIED", code: "ACTION_SOURCE_INVALID"};
|
|
632
|
+
if (run?.countMode && run.action === "CONTINUE" && !run.testSetup
|
|
633
|
+
&& Date.now() < run.countLastSendAt + COUNT_INTERVAL_MS)
|
|
634
|
+
return {status:"DENIED",code:"COUNT_INTERVAL_NOT_DUE"};
|
|
635
|
+
if (run?.phase !== "RESERVED" || run.token !== message.token
|
|
636
|
+
|| run.tabId !== message.tabId) return {status: "DENIED", code: "ACTION_NOT_RESERVED"};
|
|
637
|
+
const tab = await chrome.tabs.get(run.tabId).catch(() => null);
|
|
638
|
+
if (!tab || (run.testSetup ? tab.url !== "https://chatgpt.com/" : pathOf(tab.url) !== run.page)) {
|
|
639
|
+
await saveRun({...run, phase: "BLOCKED", reason: "CHAT_TARGET_CHANGED"});
|
|
640
|
+
return {status: "DENIED", code: "CHAT_TARGET_CHANGED"};
|
|
641
|
+
}
|
|
642
|
+
await saveRun({...run, phase: "PENDING"});
|
|
643
|
+
return {status: "OK", action: run.action || "CONTINUE",
|
|
644
|
+
handoffMode: run.handoffMode || null, retryMode: run.retryMode || null,
|
|
645
|
+
countMode: run.countMode === true,
|
|
646
|
+
countValue: run.countMode ? run.countValue : null,
|
|
647
|
+
target: {tabId: run.tabId, page: run.page,
|
|
648
|
+
assistantKey: run.assistantKey,
|
|
649
|
+
userKey: run.retryMode === "NATIVE" || run.handoffMode === "ERROR"
|
|
650
|
+
? run.newUserKey : run.userKey,
|
|
651
|
+
testSetup: run.testSetup === true, discardDraft: run.discardDraft === true},
|
|
652
|
+
text: run.testSetup ? (run.countMode ? COUNT_SETUP_TEXT
|
|
653
|
+
: run.wallTestHours || run.wallTestMinutes === 10 ? ACTION_WALL_TEST_TEXT
|
|
654
|
+
: run.timedTest ? ACTION_TIMED_TEST_TEXT : ACTION_TEST_TEXT)
|
|
655
|
+
: run.action === "HANDOFF" ? run.handoffText || null
|
|
656
|
+
: run.countMode ? countText(run.countValue)
|
|
657
|
+
: run.timedTest && run.wallTestMinutes === 10
|
|
658
|
+
? wallArithmeticText(run.testTurnIndex || 2) : CONTINUE_TEXT};
|
|
659
|
+
}
|
|
660
|
+
async function onResult(message, sender) {
|
|
661
|
+
const run = await readRun();
|
|
662
|
+
if (!isAction(sender, message, run)) return {status: "DENIED", code: "ACTION_SOURCE_INVALID"};
|
|
663
|
+
if (run?.phase !== "PENDING" || run.token !== message.token)
|
|
664
|
+
return {status: "DENIED", code: "ACTION_NOT_PENDING"};
|
|
665
|
+
// Native Retry does not submit another user message. One-time click is
|
|
666
|
+
// confirmed separately and re-arms the already-owned pending user Turn.
|
|
667
|
+
if (run.retryMode === "NATIVE") {
|
|
668
|
+
const tab = await chrome.tabs.get(run.tabId).catch(() => null);
|
|
669
|
+
const confirmedRetry = message.result === "RETRY_CLICKED" && tab
|
|
670
|
+
&& pathOf(tab.url) === run.page && run.retryCount === 1;
|
|
671
|
+
const nextRetry = {...run, phase: confirmedRetry ? "WAITING_REPLY" : "BLOCKED",
|
|
672
|
+
reason: confirmedRetry ? "" : "RETRY_UNVERIFIED"};
|
|
673
|
+
delete nextRetry.retryMode;
|
|
674
|
+
await saveRun(nextRetry);
|
|
675
|
+
if (confirmedRetry) {
|
|
676
|
+
const reset = await chrome.tabs.sendMessage(run.tabId,
|
|
677
|
+
{type: "CHAT_LOOP_TURN_RETRY_MONITOR_V1", page: run.page,
|
|
678
|
+
userKey: run.newUserKey}).catch(() => null);
|
|
679
|
+
if (reset?.status !== "REARMED") {
|
|
680
|
+
await saveRun({...nextRetry, phase: "BLOCKED", reason: "RETRY_OBSERVER_UNVERIFIED"});
|
|
681
|
+
return {status: "BLOCKED", code: "RETRY_OBSERVER_UNVERIFIED"};
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return {status: confirmedRetry ? "OK" : "BLOCKED"};
|
|
685
|
+
}
|
|
686
|
+
let confirmed = message.result === "SENT_CONFIRMED"
|
|
687
|
+
&& typeof message.newUserKey === "string"
|
|
688
|
+
&& /^user:[^\s]{8,}$/.test(message.newUserKey)
|
|
689
|
+
&& message.newUserKey !== run.userKey;
|
|
690
|
+
const failure = typeof message.reason === "string" && /^[A-Z0-9_]{4,100}$/.test(message.reason)
|
|
691
|
+
? message.reason : "SEND_UNVERIFIED";
|
|
692
|
+
let next = {...run, phase: "BLOCKED", reason: failure};
|
|
693
|
+
// Diagnostic only: retain the possibly-created target without granting ownership.
|
|
694
|
+
if (run.action === "HANDOFF" && Number.isInteger(message.newTabId))
|
|
695
|
+
next.attemptedNewTabId = message.newTabId;
|
|
696
|
+
if (confirmed && run.action === "HANDOFF") {
|
|
697
|
+
const newTab = Number.isInteger(message.newTabId)
|
|
698
|
+
? await chrome.tabs.get(message.newTabId).catch(() => null) : null;
|
|
699
|
+
confirmed = Boolean(newTab && message.newTabId !== run.tabId
|
|
700
|
+
&& pathOf(newTab.url) && pathOf(newTab.url) === message.newPage);
|
|
701
|
+
if (confirmed) {
|
|
702
|
+
// Transfer the single loop owner. The old Chat remains open for audit.
|
|
703
|
+
next = {...run, action: "HANDOFF", sourceTabId: run.tabId,
|
|
704
|
+
sourcePage: run.page, tabId: newTab.id, page: message.newPage,
|
|
705
|
+
// Keep source identities as the already-processed baseline; the new
|
|
706
|
+
// user's first reply must not be suppressed by duplicate detection.
|
|
707
|
+
newUserKey: message.newUserKey, phase: "WAITING_REPLY", reason: "",
|
|
708
|
+
// The new Chat has no previous assistant message. Never authenticate
|
|
709
|
+
// its first Retry/Error Handoff using the OLD Chat's assistant ID.
|
|
710
|
+
userKey: null, assistantKey: null, startedAt: Date.now(),
|
|
711
|
+
errorHandoffCount: run.handoffMode === "ERROR" ? (run.errorHandoffCount || 0) + 1 : 0,
|
|
712
|
+
...(run.countMode ? {countMode:true, countValue:run.countValue,
|
|
713
|
+
countHandoffs:(run.countHandoffs || 0) + 1,
|
|
714
|
+
countLastSendAt:Date.now()} : {})};
|
|
715
|
+
delete next.handoffText;
|
|
716
|
+
delete next.errorReason;
|
|
717
|
+
delete next.handoffPending;
|
|
718
|
+
delete next.handoffPendingAt;
|
|
719
|
+
delete next.deadlineAt;
|
|
720
|
+
}
|
|
721
|
+
} else if (confirmed && run.testSetup) {
|
|
722
|
+
const tab = await chrome.tabs.get(run.tabId).catch(() => null);
|
|
723
|
+
confirmed = Boolean(tab && pathOf(tab.url) && pathOf(tab.url) === message.newPage);
|
|
724
|
+
if (confirmed) {
|
|
725
|
+
next = {...run, page: message.newPage, phase: "WAITING_REPLY", reason: "",
|
|
726
|
+
newUserKey: message.newUserKey,
|
|
727
|
+
...(run.wallTestMinutes === 10 ? {testTurnIndex: 1} : {}),
|
|
728
|
+
...(run.countMode ? {countLastSendAt:Date.now()} : {}),
|
|
729
|
+
...(run.wallTestHours ? {startedAt: Date.now()} : {})};
|
|
730
|
+
delete next.testSetup;
|
|
731
|
+
delete next.discardDraft;
|
|
732
|
+
}
|
|
733
|
+
} else if (confirmed) {
|
|
734
|
+
next = {...run, phase: "WAITING_REPLY", reason: "",
|
|
735
|
+
newUserKey: message.newUserKey,
|
|
736
|
+
...(run.countMode ? {countLastSendAt:Date.now()} : {})};
|
|
737
|
+
}
|
|
738
|
+
await saveRun(next);
|
|
739
|
+
return {status: confirmed ? "OK" : "BLOCKED"};
|
|
740
|
+
}
|
|
741
|
+
// Extension updates invalidate previous injected listeners. Reconnect only its own observers.
|
|
742
|
+
async function reconnectOpenChats() {
|
|
743
|
+
const tabs = await chrome.tabs.query({url: "https://chatgpt.com/*"});
|
|
744
|
+
for (const tab of tabs) {
|
|
745
|
+
if (!Number.isInteger(tab.id) || !pathOf(tab.url) && !/^https:\/\/chatgpt\.com\/$/.test(tab.url)) continue;
|
|
746
|
+
try {
|
|
747
|
+
await chrome.scripting.executeScript({target: {tabId: tab.id},
|
|
748
|
+
files: ["content.js", "loop/observer.js"]});
|
|
749
|
+
const owner = await readRun();
|
|
750
|
+
if (owner?.phase === "WAITING_REPLY" && owner.tabId === tab.id
|
|
751
|
+
&& owner.page === pathOf(tab.url) && owner.newUserKey) {
|
|
752
|
+
const res = await chrome.tabs.sendMessage(tab.id, {
|
|
753
|
+
type: "CHAT_LOOP_TURN_RESUME_V1", page: owner.page,
|
|
754
|
+
userKey: owner.newUserKey, assistantKey: owner.assistantKey || null,
|
|
755
|
+
startedAt: owner.startedAt
|
|
756
|
+
}).catch(() => null);
|
|
757
|
+
if (res?.status !== "RESUMED" && res?.status !== "ALREADY_MONITORING")
|
|
758
|
+
console.warn("Chat Loop pending Turn resume not yet verified:", res?.reason || res?.status);
|
|
759
|
+
}
|
|
760
|
+
} catch (error) {console.warn("Chat Loop observer reconnect unavailable:", error?.message);}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
async function releaseWallClockHold(run) {
|
|
764
|
+
const liveRun = await readRun();
|
|
765
|
+
if (liveRun?.phase !== "WALL_CLOCK_HOLD" || liveRun.tabId !== run?.tabId
|
|
766
|
+
|| liveRun.wallHoldAt !== run?.wallHoldAt
|
|
767
|
+
|| liveRun.userKey !== run?.userKey) return {status: "IGNORED", code: "HOLD_ALREADY_CHANGED"};
|
|
768
|
+
if (run?.phase !== "WALL_CLOCK_HOLD" || !([1, 2].includes(run.wallTestHours)
|
|
769
|
+
|| run.wallTestMinutes === 10)
|
|
770
|
+
|| !run.timedTest || !Number.isSafeInteger(run.wallReleaseAt)
|
|
771
|
+
|| Date.now() < run.wallReleaseAt) return {status: "NOT_DUE"};
|
|
772
|
+
const owned = (await chrome.storage.local.get("chat-loop.acceptance.owned.v1"))[
|
|
773
|
+
"chat-loop.acceptance.owned.v1"];
|
|
774
|
+
if (!Array.isArray(owned) || !owned.some(t => t.id === run.tabId))
|
|
775
|
+
return {status: "DENIED", code: "NOT_TEST_OWNED"};
|
|
776
|
+
const tab = await chrome.tabs.get(run.tabId).catch(() => null);
|
|
777
|
+
if (!tab || pathOf(tab.url) !== run.page) {
|
|
778
|
+
await saveRun({...run, phase: "BLOCKED", reason: "WALL_CHAT_CHANGED"});
|
|
779
|
+
return {status: "BLOCKED", code: "WALL_CHAT_CHANGED"};
|
|
780
|
+
}
|
|
781
|
+
const verify = async () => {
|
|
782
|
+
const p = await chrome.tabs.sendMessage(run.tabId,
|
|
783
|
+
{type: "CHAT_LOOP_TURN_VERIFY_V1"}).catch(() => null);
|
|
784
|
+
return p?.contract === "chat-loop.turn-observer.v1" && p.status === "READY"
|
|
785
|
+
&& p.page === run.page && p.userKey === run.userKey
|
|
786
|
+
&& p.assistantKey === run.assistantKey
|
|
787
|
+
&& globalThis.ChatLoopCoreV1.parseStatus(p.replyText) === "CONTINUE";
|
|
788
|
+
};
|
|
789
|
+
if (!await verify()) return {status: "UNVERIFIED", code: "WALL_TURN_NOT_STABLE"};
|
|
790
|
+
await new Promise(resolve => setTimeout(resolve, 2300));
|
|
791
|
+
if (!await verify()) return {status: "UNVERIFIED", code: "WALL_TURN_CHANGED"};
|
|
792
|
+
const latest = await readRun();
|
|
793
|
+
if (latest?.phase !== "WALL_CLOCK_HOLD" || latest.tabId !== run.tabId
|
|
794
|
+
|| latest.userKey !== run.userKey || latest.wallHoldAt !== run.wallHoldAt)
|
|
795
|
+
return {status: "IGNORED", code: "HOLD_CHANGED_DURING_VERIFICATION"};
|
|
796
|
+
const token = crypto.randomUUID();
|
|
797
|
+
const next = {...run, phase: "RESERVED", action: "CONTINUE", token,
|
|
798
|
+
wallReleasedAt: Date.now(), reason: ""};
|
|
799
|
+
await saveRun(next); // reserve once, then Action page exclusively sends
|
|
800
|
+
try {
|
|
801
|
+
const action = await chrome.tabs.create({
|
|
802
|
+
url: chrome.runtime.getURL("continue.html") + "?tabId=" + run.tabId
|
|
803
|
+
+ "&token=" + encodeURIComponent(token), active: false, windowId: tab.windowId
|
|
804
|
+
});
|
|
805
|
+
return {status: "QUEUED", actionTabId: action.id};
|
|
806
|
+
} catch {
|
|
807
|
+
await saveRun({...next, phase: "BLOCKED", reason: "WALL_ACTION_CREATE_FAILED"});
|
|
808
|
+
return {status: "BLOCKED", code: "WALL_ACTION_CREATE_FAILED"};
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
// MV3 background service workers can suspend, and hidden-page setInterval
|
|
812
|
+
// may be throttled. An alarm asks the Observer to sample, never acts directly.
|
|
813
|
+
async function ensureAlarm() {
|
|
814
|
+
if (!chrome.alarms?.create) return;
|
|
815
|
+
const existing = await chrome.alarms.get?.(ALARM_NAME).catch(() => null);
|
|
816
|
+
if (!existing) await chrome.alarms.create(ALARM_NAME, {periodInMinutes: 1});
|
|
817
|
+
}
|
|
818
|
+
async function watchActiveTurn() {
|
|
819
|
+
// Normal periodic wakeups also reconcile a delayed one-shot deadline.
|
|
820
|
+
// All state changes share the same serial queue as Turn/Action receipts.
|
|
821
|
+
await serial(prepareHandoffIfDue);
|
|
822
|
+
const run = await readRun();
|
|
823
|
+
// While an Action is pending, never dispatch competing operations.
|
|
824
|
+
if (run?.phase === "WALL_CLOCK_HOLD") {
|
|
825
|
+
void serial(() => releaseWallClockHold(run));
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (run?.phase === "WAITING_INTERVAL") {
|
|
829
|
+
void serial(releaseCountInterval);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
if (run && ["RESERVED", "PENDING"].includes(run.phase)) return;
|
|
833
|
+
const tabs = run?.phase === "WAITING_REPLY"
|
|
834
|
+
? [await chrome.tabs.get(run.tabId).catch(() => null)]
|
|
835
|
+
: await chrome.tabs.query({url: "https://chatgpt.com/*"});
|
|
836
|
+
for (const tab of tabs) {
|
|
837
|
+
if (!tab?.id || !pathOf(tab.url)) continue;
|
|
838
|
+
try {
|
|
839
|
+
const proof = await chrome.tabs.sendMessage(tab.id,
|
|
840
|
+
{type: "CHAT_LOOP_TURN_OBSERVE_V1", poll: true});
|
|
841
|
+
// A reloaded Observer may have mistaken the in-flight user Turn for
|
|
842
|
+
// historical baseline. Its IDLE state is not proof of completion.
|
|
843
|
+
// Rebind the exact persisted owner using read-only verification.
|
|
844
|
+
if (run?.phase === "WAITING_REPLY" && run.tabId === tab.id
|
|
845
|
+
&& proof?.page === run.page && proof.userKey === run.newUserKey
|
|
846
|
+
&& (proof.status === "IDLE"
|
|
847
|
+
|| proof.status === "UNKNOWN"
|
|
848
|
+
&& ["ATTACHING_CHAT", "WAITING_CHAT_HYDRATION"].includes(proof.reason))) {
|
|
849
|
+
await chrome.tabs.sendMessage(tab.id, {
|
|
850
|
+
type: "CHAT_LOOP_TURN_RESUME_V1", page: run.page,
|
|
851
|
+
userKey: run.newUserKey, assistantKey: run.assistantKey || null,
|
|
852
|
+
startedAt: run.startedAt
|
|
853
|
+
}).catch(() => null);
|
|
854
|
+
}
|
|
855
|
+
// Alarm is still read-only. It may reconcile a verified page failure if
|
|
856
|
+
// the observer's original notification was lost while Worker slept.
|
|
857
|
+
if (run?.phase === "WAITING_REPLY" && run.tabId === tab.id
|
|
858
|
+
&& proof?.status === "BLOCKED" && proof.reason === "PAGE_ERROR"
|
|
859
|
+
&& proof.page === run.page && proof.userKey === run.newUserKey) {
|
|
860
|
+
void serial(() => onTurnBlocked({page: run.page,
|
|
861
|
+
userKey: run.newUserKey, reason: "PAGE_ERROR"},
|
|
862
|
+
{tab, url: tab.url}));
|
|
863
|
+
}
|
|
864
|
+
} catch { /* Observer unavailable; never infer Turn completion. */ }
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
chrome.alarms?.onAlarm?.addListener(alarm => {
|
|
868
|
+
if (alarm?.name === ALARM_NAME) void watchActiveTurn();
|
|
869
|
+
if (alarm?.name === DEADLINE_ALARM_NAME)
|
|
870
|
+
void serial(async () => {await prepareHandoffIfDue();await releaseCountInterval();});
|
|
871
|
+
if (alarm?.name === COUNT_ALARM_NAME) void serial(releaseCountInterval);
|
|
872
|
+
});
|
|
873
|
+
chrome.runtime.onInstalled?.addListener(() => {
|
|
874
|
+
void ensureAlarm();
|
|
875
|
+
void reconnectOpenChats();
|
|
876
|
+
});
|
|
877
|
+
chrome.runtime.onStartup?.addListener(() => {
|
|
878
|
+
void ensureAlarm();
|
|
879
|
+
void reconnectOpenChats();
|
|
880
|
+
});
|
|
881
|
+
void ensureAlarm();
|
|
882
|
+
// Reconcile an elapsed deadline after MV3 Worker suspension/reload.
|
|
883
|
+
void serial(prepareHandoffIfDue);
|
|
884
|
+
// A manual extension Reload does not necessarily fire onInstalled/onStartup.
|
|
885
|
+
// Reattach the two idempotent read-only scripts to existing ChatGPT tabs.
|
|
886
|
+
void reconnectOpenChats();
|
|
887
|
+
// Read-only target resolver for a CLI-created formal Eye Action.
|
|
888
|
+
// This never starts a Loop, changes Owner, or sends a chat message.
|
|
889
|
+
async function onExtensionCliTarget(message,sender) {
|
|
890
|
+
const pages={chat_text:"text.html",chat_dom:"dom.html",
|
|
891
|
+
chat_screenshot:"screenshot.html",reload:"reload.html"};
|
|
892
|
+
const page=pages[message?.action];
|
|
893
|
+
if(!page || !/^[a-f0-9]{32}$/.test(message?.id||"")
|
|
894
|
+
|| sender.url !== chrome.runtime.getURL(page)+"?requestId="+message.id)
|
|
895
|
+
return {status:"DENIED",code:"INVALID_EXTENSION_ACTION_SOURCE"};
|
|
896
|
+
if(message.action==="reload")return {status:"OK"};
|
|
897
|
+
const run=await readRun();
|
|
898
|
+
if(run?.phase!=="WAITING_REPLY" || !Number.isInteger(run.tabId)
|
|
899
|
+
|| typeof run.page!=="string")
|
|
900
|
+
return {status:"DENIED",code:"NO_ACTIVE_CHAT_OWNER"};
|
|
901
|
+
const tab=await chrome.tabs.get(run.tabId).catch(()=>null);
|
|
902
|
+
if(!tab || pathOf(tab.url)!==run.page)
|
|
903
|
+
return {status:"DENIED",code:"OWNER_CHANGED"};
|
|
904
|
+
return {status:"OK",tabId:run.tabId,page:run.page};
|
|
905
|
+
}
|
|
906
|
+
chrome.runtime.onMessage.addListener((message, sender, reply) => {
|
|
907
|
+
const type = message?.type;
|
|
908
|
+
if (["CHAT_LOOP_TURN_FINISHED_V1", "CHAT_LOOP_TURN_BLOCKED_V1",
|
|
909
|
+
"CHAT_LOOP_ACTION_TAKE_V1",
|
|
910
|
+
"CHAT_LOOP_ACTION_RESULT_V1", "CHAT_LOOP_ACTION_MANUAL_PREPARE_V1",
|
|
911
|
+
"CHAT_LOOP_ACTION_TEST_PREPARE_V1",
|
|
912
|
+
"CHAT_LOOP_ACCEPTANCE_CLOCK_ADVANCE_V1"].includes(type)) {
|
|
913
|
+
const work = serial(() => type === "CHAT_LOOP_TURN_FINISHED_V1"
|
|
914
|
+
? onTurnFinished(message, sender) : type === "CHAT_LOOP_TURN_BLOCKED_V1"
|
|
915
|
+
? onTurnBlocked(message, sender) : type === "CHAT_LOOP_ACTION_TAKE_V1"
|
|
916
|
+
? onTake(message, sender) : type === "CHAT_LOOP_ACTION_MANUAL_PREPARE_V1"
|
|
917
|
+
? onManualPrepare(message, sender) : type === "CHAT_LOOP_ACTION_TEST_PREPARE_V1"
|
|
918
|
+
? onTestPrepare(message, sender)
|
|
919
|
+
: type === "CHAT_LOOP_ACCEPTANCE_CLOCK_ADVANCE_V1"
|
|
920
|
+
? onAcceptanceClockAdvance(message, sender) : onResult(message, sender));
|
|
921
|
+
void work.then(reply, error => reply({status: "BLOCKED", code: error.message || "SCHEDULER_ERROR"}));
|
|
922
|
+
return true;
|
|
923
|
+
}
|
|
924
|
+
if (type === "CHAT_LOOP_EXTENSION_TARGET_V1") {
|
|
925
|
+
void onExtensionCliTarget(message,sender).then(reply,
|
|
926
|
+
()=>reply({status:"DENIED",code:"OWNER_UNVERIFIED"}));
|
|
927
|
+
return true;
|
|
928
|
+
}
|
|
929
|
+
if (type !== "CHAT_LOOP_EYE_RELOAD_V1") return false;
|
|
930
|
+
const id = sender.tab?.id;
|
|
931
|
+
void (async () => {
|
|
932
|
+
try {
|
|
933
|
+
if (!Number.isInteger(id)) throw Error("NO_ACTION_TAB");
|
|
934
|
+
const tab = await chrome.tabs.get(id);
|
|
935
|
+
const reloadBase=chrome.runtime.getURL("reload.html");
|
|
936
|
+
if (!tab.url.startsWith(reloadBase) ||
|
|
937
|
+
(tab.url!==reloadBase &&
|
|
938
|
+
!/^\?requestId=[a-f0-9]{32}$/.test(tab.url.slice(reloadBase.length))))
|
|
939
|
+
throw Error("INVALID_RELOAD_SOURCE");
|
|
940
|
+
reply({status: "ACCEPTED"});
|
|
941
|
+
await chrome.tabs.remove(id);
|
|
942
|
+
chrome.runtime.reload();
|
|
943
|
+
} catch (error) {reply({status: "FAILED", code: error.message});}
|
|
944
|
+
})();
|
|
945
|
+
return true;
|
|
946
|
+
});
|