@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,389 @@
|
|
|
1
|
+
// Read-only acceptance inventory and strictly scoped test-tab lifecycle.
|
|
2
|
+
(() => {
|
|
3
|
+
const q = new URLSearchParams(location.search);
|
|
4
|
+
const modes = ["audit", "newTest", "cleanup", "cleanupVerified"].filter(k => q.has(k));
|
|
5
|
+
if (!modes.length) return; // Ordinary popup remains unchanged.
|
|
6
|
+
const runKey = "chat-loop.action.run.v1";
|
|
7
|
+
const ownedKey = "chat-loop.acceptance.owned.v1";
|
|
8
|
+
const extensionOrigin = new URL(chrome.runtime.getURL("popup.html")).origin;
|
|
9
|
+
const status = document.getElementById("status");
|
|
10
|
+
const detail = document.getElementById("detail");
|
|
11
|
+
const isChat = url => /^https:\/\/chatgpt\.com\/(?:c\/[0-9a-f-]{12,}|g\/[^/]+\/c\/[0-9a-f-]{12,})?$/i.test(url || "");
|
|
12
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
13
|
+
async function report() {
|
|
14
|
+
if (modes.length !== 1 || [...q.keys()].some(k => !modes.includes(k)))
|
|
15
|
+
throw Error("INVALID_ACCEPTANCE_PARAMS");
|
|
16
|
+
const mode = modes[0], own = await chrome.tabs.getCurrent();
|
|
17
|
+
if (!own?.id || !own.url?.startsWith(chrome.runtime.getURL("popup.html")))
|
|
18
|
+
throw Error("INVALID_ACCEPTANCE_PAGE");
|
|
19
|
+
const [state, allBeforeCleanup] = await Promise.all([
|
|
20
|
+
chrome.storage.local.get([runKey, ownedKey]), chrome.tabs.query({})
|
|
21
|
+
]);
|
|
22
|
+
// Only an old read-only acceptance audit page may be automatically reaped.
|
|
23
|
+
// These are diagnostics, not Chat conversations or formal Action pages.
|
|
24
|
+
// Verify id, exact URL and inactive/unpinned state before closing.
|
|
25
|
+
const removedStaleAudits = [];
|
|
26
|
+
for (const item of allBeforeCleanup) {
|
|
27
|
+
if (item.id === own.id || item.active || item.pinned
|
|
28
|
+
|| item.url !== chrome.runtime.getURL("popup.html?audit=1")) continue;
|
|
29
|
+
const live = await chrome.tabs.get(item.id).catch(() => null);
|
|
30
|
+
if (live && !live.active && !live.pinned && live.url === item.url) {
|
|
31
|
+
await chrome.tabs.remove(item.id);
|
|
32
|
+
removedStaleAudits.push(item.id);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const all = removedStaleAudits.length ? await chrome.tabs.query({}) : allBeforeCleanup;
|
|
36
|
+
const run = state[runKey] || null;
|
|
37
|
+
const owned = Array.isArray(state[ownedKey]) ? state[ownedKey] : [];
|
|
38
|
+
const audit = {at: new Date().toISOString(), mode,
|
|
39
|
+
removedStaleAuditTabs: removedStaleAudits,
|
|
40
|
+
version: chrome.runtime.getManifest().version, totalTabs: all.length,
|
|
41
|
+
otherTabCount: all.filter(t => !isChat(t.url) && !t.url?.startsWith(extensionOrigin)).length,
|
|
42
|
+
chatTabs: all.filter(t => isChat(t.url)).map(t => ({
|
|
43
|
+
id: t.id, windowId: t.windowId, url: t.url, title: t.title, active: t.active,
|
|
44
|
+
pinned: t.pinned
|
|
45
|
+
})),
|
|
46
|
+
extensionTabs: all.filter(t => t.url?.startsWith(extensionOrigin) && t.id !== own.id)
|
|
47
|
+
.map(t => ({id: t.id, windowId: t.windowId, url: t.url, title: t.title})),
|
|
48
|
+
ownedTestTabs: owned,
|
|
49
|
+
messageEvidence: {}, actionEvidence: {},
|
|
50
|
+
run: run ? Object.fromEntries(["phase", "action", "reason", "tabId", "page",
|
|
51
|
+
"sourceTabId", "sourcePage", "attemptedNewTabId", "newUserKey",
|
|
52
|
+
"userKey", "assistantKey", "startedAt", "deadlineAt", "handoffPending",
|
|
53
|
+
"handoffPendingAt", "handoffMode", "testSetup", "timedTest", "testTurnIndex",
|
|
54
|
+
"clockAdvancedAt", "clockAdvanceMinutes", "clockRunningProof",
|
|
55
|
+
"wallTestHours", "wallHoldAt", "wallReleaseAt", "wallReleasedAt",
|
|
56
|
+
"wallTestMinutes", "wallAdjustedAt", "wallOriginalReleaseAt",
|
|
57
|
+
"countMode", "countValue", "countHandoffs", "countLastSendAt", "countReleaseAt"]
|
|
58
|
+
.filter(k => run[k] !== undefined).map(k => [k, run[k]])) : null
|
|
59
|
+
};
|
|
60
|
+
if (run?.parseEvidence && owned.some(item => item.id === run.tabId || item.id === run.sourceTabId)) {
|
|
61
|
+
audit.run.parseEvidence = run.parseEvidence;
|
|
62
|
+
}
|
|
63
|
+
// Inspect only this extension's own open action pages: a failed test setup
|
|
64
|
+
// must expose a readable failure code, never trigger a second send.
|
|
65
|
+
for (const tab of audit.extensionTabs) {
|
|
66
|
+
if (!/\/(?:continue|handoff|clock|screenshot|dom|text|reload)\.html(?:\?|$)/.test(tab.url)) continue;
|
|
67
|
+
try {
|
|
68
|
+
const [read] = await chrome.scripting.executeScript({target: {tabId: tab.id},
|
|
69
|
+
func: () => ({status: document.getElementById("status")?.innerText || "",
|
|
70
|
+
details: (document.getElementById("details")?.innerText || "").slice(0, 300)})});
|
|
71
|
+
audit.actionEvidence[tab.id] = read?.result || {status: "UNAVAILABLE"};
|
|
72
|
+
} catch {audit.actionEvidence[tab.id] = {status: "PROBE_UNAVAILABLE"};}
|
|
73
|
+
}
|
|
74
|
+
// Inspect only the active run and tests created by this extension. Browser
|
|
75
|
+
// message evidence is read-only; this page never types into ChatGPT.
|
|
76
|
+
const inspectedFailedHandoff = owned.some(item => item.id === run?.tabId || item.id === run?.sourceTabId)
|
|
77
|
+
? run?.attemptedNewTabId : null;
|
|
78
|
+
const inspectIds = [...new Set([run?.tabId, run?.sourceTabId, inspectedFailedHandoff,
|
|
79
|
+
...owned.map(item => item.id)])]
|
|
80
|
+
.filter(id => audit.chatTabs.some(tab => tab.id === id));
|
|
81
|
+
for (const id of inspectIds) {
|
|
82
|
+
try {
|
|
83
|
+
const [read] = await chrome.scripting.executeScript({
|
|
84
|
+
target: {tabId: id},
|
|
85
|
+
func: () => {
|
|
86
|
+
const mains = [...document.querySelectorAll("main")]
|
|
87
|
+
.filter(node => node.getClientRects().length);
|
|
88
|
+
if (mains.length !== 1) return {status: "MAIN_NOT_UNIQUE"};
|
|
89
|
+
const main = mains[0];
|
|
90
|
+
let rows = [...main.querySelectorAll(
|
|
91
|
+
"[data-chatgpt-search-unit-key][data-chatgpt-search-message-ids]")];
|
|
92
|
+
if (!rows.length) rows = [...main.querySelectorAll(
|
|
93
|
+
"[data-message-author-role]")];
|
|
94
|
+
const role = node => node.getAttribute("data-message-author-role")
|
|
95
|
+
|| node.getAttribute("data-chatgpt-search-unit-key")?.split(":").at(-1);
|
|
96
|
+
const idOf = node => node.getAttribute("data-message-id")
|
|
97
|
+
|| node.getAttribute("data-chatgpt-search-message-ids")?.trim().split(/\s+/)[0];
|
|
98
|
+
const users = rows.filter(node => role(node) === "user").map(idOf);
|
|
99
|
+
const assistants = rows.filter(node => role(node) === "assistant").map(idOf);
|
|
100
|
+
const last = rows.filter(node => role(node) === "assistant").at(-1);
|
|
101
|
+
const editor = document.querySelector('[contenteditable="true"][data-composer-markdown]')
|
|
102
|
+
|| document.querySelector('#prompt-textarea[contenteditable="true"]');
|
|
103
|
+
const editorVisible = !!editor?.getClientRects().length;
|
|
104
|
+
const draft = (editor?.innerText || editor?.textContent || "").trim();
|
|
105
|
+
const restoredTimedTestDraft = draft.includes("LOOP-V037-FAST-3C1H")
|
|
106
|
+
&& draft.includes("【原始任务用户消息】") && draft.includes("【最近轮次证据】")
|
|
107
|
+
&& draft.length >= 450 && draft.length <= 8192
|
|
108
|
+
&& (draft.startsWith("你正在接手上一 Chat 尚未完成的同一个任务")
|
|
109
|
+
|| (draft.startsWith("你正在接手上一 Chat 未完成的任务。旧 Chat 的网页历史仍可核实")
|
|
110
|
+
&& draft.endsWith("是否跨 Chat 由扩展决定,不要主动要求 Handoff。")));
|
|
111
|
+
const text = last?.querySelector(".markdown")?.innerText || last?.innerText || "";
|
|
112
|
+
const marker = text.trim().split(/\r?\n/).at(-1)?.trim();
|
|
113
|
+
return {status: "OK", url: location.href,
|
|
114
|
+
editorVisible, restoredTimedTestDraft, draftLength: draft.length,
|
|
115
|
+
draftPreview: (editor?.innerText || editor?.textContent || "").trim().slice(0, 160),
|
|
116
|
+
draftSuffix: draft.slice(-200), testMarker: draft.includes("LOOP-V037-FAST-3C1H"),
|
|
117
|
+
userCount: users.length, assistantCount: assistants.length,
|
|
118
|
+
userIds: users, assistantIds: assistants,
|
|
119
|
+
lastAssistantStatus: /^\[CHAT_LOOP_STATUS:(?:CONTINUE|DONE|BLOCKED|HANDOFF)\]$/.test(marker)
|
|
120
|
+
? marker : null};
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
audit.messageEvidence[id] = read?.result || {status: "EMPTY_RESULT"};
|
|
124
|
+
// Only dedicated tests created by this diagnostic may expose their
|
|
125
|
+
// harmless assistant text, for verifying strict status parsing.
|
|
126
|
+
if (owned.some(item => item.id === id || (item.id === run?.sourceTabId && id === run?.tabId))) {
|
|
127
|
+
const proof = await chrome.tabs.sendMessage(id,
|
|
128
|
+
{type: "CHAT_LOOP_TURN_VERIFY_V1"}).catch(() => null);
|
|
129
|
+
audit.messageEvidence[id].observerStatus = proof?.status || "UNAVAILABLE";
|
|
130
|
+
const observed = await chrome.tabs.sendMessage(id,
|
|
131
|
+
{type: "CHAT_LOOP_TURN_OBSERVE_V1", poll: true}).catch(() => null);
|
|
132
|
+
audit.messageEvidence[id].turnObservation = observed ? {
|
|
133
|
+
status: observed.status, reason: observed.reason, page: observed.page,
|
|
134
|
+
userKey: observed.userKey, eventCount: observed.eventCount,
|
|
135
|
+
observedAt: observed.observedAt} : null;
|
|
136
|
+
audit.messageEvidence[id].observerReplyText =
|
|
137
|
+
typeof proof?.replyText === "string" ? proof.replyText.slice(0, 3000) : null;
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
audit.messageEvidence[id] = {status: "PROBE_UNAVAILABLE"};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (mode === "newTest") {
|
|
144
|
+
if (run && !["DONE", "BLOCKED"].includes(run.phase))
|
|
145
|
+
throw Error("ACTIVE_LOOP_OWNERSHIP");
|
|
146
|
+
const tab = await chrome.tabs.create({url: "https://chatgpt.com/", active: true,
|
|
147
|
+
windowId: own.windowId});
|
|
148
|
+
const next = [...owned, {id: tab.id, createdAt: Date.now()}];
|
|
149
|
+
await chrome.storage.local.set({[ownedKey]: next});
|
|
150
|
+
audit.newTestTabId = tab.id;
|
|
151
|
+
audit.ownedTestTabs = next;
|
|
152
|
+
// React can hydrate after Chrome reports the tab created. Do not open
|
|
153
|
+
// Continue setup until two stable, read-only home/editor probes pass.
|
|
154
|
+
let firstReadyAt = 0;
|
|
155
|
+
const deadline = Date.now() + 26000;
|
|
156
|
+
while (Date.now() < deadline) {
|
|
157
|
+
let ready = false;
|
|
158
|
+
try {
|
|
159
|
+
const live = await chrome.tabs.get(tab.id);
|
|
160
|
+
if (live.url === "https://chatgpt.com/") {
|
|
161
|
+
const [probe] = await chrome.scripting.executeScript({
|
|
162
|
+
target: {tabId: tab.id},
|
|
163
|
+
func: () => {
|
|
164
|
+
const visible = el => !!el?.getClientRects().length;
|
|
165
|
+
const mains = [...document.querySelectorAll("main")].filter(visible);
|
|
166
|
+
const editor = document.querySelector('[contenteditable="true"][data-composer-markdown]')
|
|
167
|
+
|| document.querySelector('#prompt-textarea[contenteditable="true"]');
|
|
168
|
+
const draft = (editor?.innerText || editor?.textContent || "").trim();
|
|
169
|
+
const knownTestDraft = (draft.includes("LOOP-V037-FAST-3C1H") || (draft.startsWith("你正在接手上一 Chat 未完成的任务。旧 Chat 的网页历史仍可核实")
|
|
170
|
+
&& draft.includes("【交接参考:只读网页片段,可能截断】")
|
|
171
|
+
&& draft.includes("【原始任务用户消息】")
|
|
172
|
+
&& draft.includes("【最近轮次证据】")
|
|
173
|
+
&& draft.endsWith("是否跨 Chat 由扩展决定,不要主动要求 Handoff。")
|
|
174
|
+
&& draft.length >= 450 && draft.length <= 8192))
|
|
175
|
+
&& ((draft.startsWith("这是 Chat Loop 短轮回归专用测试")
|
|
176
|
+
&& draft.length >= 450 && draft.length <= 800)
|
|
177
|
+
|| (draft.startsWith("你正在接手上一 Chat 尚未完成的同一个任务")
|
|
178
|
+
&& draft.length >= 450 && (draft.length <= 2000
|
|
179
|
+
|| (draft.length <= 8192 && draft.includes("【原始任务用户消息】")
|
|
180
|
+
&& draft.includes("【最近轮次证据】"))))
|
|
181
|
+
|| (draft.startsWith("你正在接手上一 Chat 未完成的任务。旧 Chat 的网页历史仍可核实")
|
|
182
|
+
&& draft.includes("【原始任务用户消息】") && draft.includes("【最近轮次证据】")
|
|
183
|
+
&& draft.endsWith("是否跨 Chat 由扩展决定,不要主动要求 Handoff。")
|
|
184
|
+
&& draft.length >= 450 && draft.length <= 8192)
|
|
185
|
+
|| (draft.startsWith("这是 Chat Loop 的长回复与一小时边界隔离测试")
|
|
186
|
+
&& draft.includes("MODE-TIMED-1H") && draft.length >= 350 && draft.length <= 800)
|
|
187
|
+
|| (draft.startsWith("这是 Chat Loop 的计数隔离验收")
|
|
188
|
+
&& draft.includes("COUNT-86-100") && draft.endsWith("WALL-CLOCK-TEST。")
|
|
189
|
+
&& draft.length >= 200 && draft.length <= 900)
|
|
190
|
+
|| (draft.startsWith("这是 Chat Loop 的墙钟边界隔离测试")
|
|
191
|
+
&& draft.includes("MODE-WALL-10M") && draft.endsWith("WALL-CLOCK-TEST。")
|
|
192
|
+
&& draft.length >= 250 && draft.length <= 800));
|
|
193
|
+
return {ready: location.href === "https://chatgpt.com/" && mains.length === 1
|
|
194
|
+
&& visible(editor) && (!draft || knownTestDraft)
|
|
195
|
+
&& !mains[0].querySelector('[data-chatgpt-search-message-ids],[data-message-author-role]'),
|
|
196
|
+
discardTestDraft: knownTestDraft};
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
ready = probe?.result?.ready === true;
|
|
200
|
+
if (ready) audit.newTestNeedsDiscardDraft = probe.result.discardTestDraft === true;
|
|
201
|
+
}
|
|
202
|
+
} catch { /* Fail closed if the home page cannot be inspected. */ }
|
|
203
|
+
if (ready) {
|
|
204
|
+
if (firstReadyAt && Date.now() - firstReadyAt >= 2300) break;
|
|
205
|
+
if (!firstReadyAt) firstReadyAt = Date.now();
|
|
206
|
+
} else firstReadyAt = 0;
|
|
207
|
+
await sleep(500);
|
|
208
|
+
}
|
|
209
|
+
audit.newTestReady = firstReadyAt > 0 && Date.now() - firstReadyAt >= 2300;
|
|
210
|
+
} else if (mode === "cleanupVerified") {
|
|
211
|
+
// User-requested surplus Chat Loop test-tab cleanup. Never close ordinary
|
|
212
|
+
// work chats, pinned/active tabs, unknown ownership, or an in-flight run.
|
|
213
|
+
// Require BOTH a dedicated test title AND a verified test-message body.
|
|
214
|
+
const testTitle = /^(?:建会话测试|完成跨Chat验收|生成跨Chat交接包|验证跨Chat交接|确认跨 Chat 交接|跨 Chat 交接验证|验证聊天续接|验证聊天交接|确认交接完成|Chat Loop 验收|Chat Loop测试.*|Chat Loop交接测试|短轮回归测试|定时交接测试|交接测试确认)$/;
|
|
215
|
+
const testBody = /(?:Chat\s*Loop|CHAT-LOOP-ACTION-001|跨\s*Chat.{0,16}交接|验证聊天交接|验证聊天续接|ChatGPT Observer)/i;
|
|
216
|
+
const inFlight = run && !["DONE", "BLOCKED"].includes(run.phase)
|
|
217
|
+
? new Set([run.tabId, run.sourceTabId, run.attemptedNewTabId])
|
|
218
|
+
: new Set();
|
|
219
|
+
audit.cleanedTestTabs = [];
|
|
220
|
+
audit.protectedTests = [];
|
|
221
|
+
for (const candidate of audit.chatTabs) {
|
|
222
|
+
if (!testTitle.test(candidate.title || "")) continue;
|
|
223
|
+
const reject = why => audit.protectedTests.push({id: candidate.id, reason: why});
|
|
224
|
+
if (candidate.active || candidate.pinned || inFlight.has(candidate.id)) {
|
|
225
|
+
reject("ACTIVE_PINNED_OR_IN_FLIGHT"); continue;
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
const [proof] = await chrome.scripting.executeScript({
|
|
229
|
+
target: {tabId: candidate.id},
|
|
230
|
+
func: () => {
|
|
231
|
+
const main = document.querySelector("main");
|
|
232
|
+
if (!main) return {valid: false, reason: "NO_MAIN"};
|
|
233
|
+
let users = [...main.querySelectorAll('[data-message-author-role="user"]')];
|
|
234
|
+
if (!users.length) users = [...main.querySelectorAll('[data-chatgpt-search-unit-key$=":user"]')];
|
|
235
|
+
const text = users.slice(0, 3).map(n => n.innerText || n.textContent || "").join("\n");
|
|
236
|
+
return {valid: true, url: location.href, hasUser: users.length > 0,
|
|
237
|
+
sample: text.slice(0, 1800)};
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
const evidence = proof?.result;
|
|
241
|
+
if (!evidence?.valid || !evidence.hasUser || !testBody.test(evidence.sample)
|
|
242
|
+
|| evidence.url !== candidate.url) {
|
|
243
|
+
reject("TEST_MESSAGE_NOT_VERIFIED"); continue;
|
|
244
|
+
}
|
|
245
|
+
const live = await chrome.tabs.get(candidate.id).catch(() => null);
|
|
246
|
+
if (!live || live.url !== candidate.url || live.title !== candidate.title
|
|
247
|
+
|| live.active || live.pinned) {
|
|
248
|
+
reject("TAB_CHANGED_BEFORE_CLOSE"); continue;
|
|
249
|
+
}
|
|
250
|
+
await chrome.tabs.remove(candidate.id);
|
|
251
|
+
audit.cleanedTestTabs.push({id: candidate.id, url: candidate.url,
|
|
252
|
+
title: candidate.title});
|
|
253
|
+
} catch (err) {
|
|
254
|
+
reject("PROBE_OR_CLOSE_FAILED: " + (err?.message || "unknown"));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// Surplus ChatGPT home tabs are not historical conversations. Close only
|
|
258
|
+
// verified blank homes, or a home whose entire draft is a known exact
|
|
259
|
+
// Chat Loop acceptance test template. Never discard an arbitrary draft.
|
|
260
|
+
// Never touch a real /c/ conversation, even if its title is generic.
|
|
261
|
+
audit.cleanedEmptyHomeTabs = [];
|
|
262
|
+
audit.protectedEmptyHomeTabs = [];
|
|
263
|
+
for (const candidate of audit.chatTabs) {
|
|
264
|
+
if (candidate.url !== "https://chatgpt.com/"
|
|
265
|
+
|| candidate.title !== "ChatGPT" || candidate.active || candidate.pinned
|
|
266
|
+
|| inFlight.has(candidate.id)) continue;
|
|
267
|
+
try {
|
|
268
|
+
// An inactive unloaded home may deny script injection. Merely focus
|
|
269
|
+
// it once through the extension, retry read-only, then restore focus;
|
|
270
|
+
// failure must NOT become permission to close the unknown tab.
|
|
271
|
+
const inspectEmpty = () => {
|
|
272
|
+
const editor = document.querySelector("#prompt-textarea")
|
|
273
|
+
|| document.querySelector('[contenteditable="true"][role="textbox"]');
|
|
274
|
+
const draft = editor?.value || editor?.innerText || editor?.textContent || "";
|
|
275
|
+
const hasMessages = !!document.querySelector(
|
|
276
|
+
'main [data-message-author-role], main [data-chatgpt-search-unit-key]');
|
|
277
|
+
const hasUploads = !!document.querySelector(
|
|
278
|
+
'[data-testid*="attachment-preview"], [data-testid*="image-preview"], [data-testid="composer-attachment"]');
|
|
279
|
+
const current = draft.trim();
|
|
280
|
+
const knownTestDraft = current.includes("LOOP-V037-FAST-3C1H")
|
|
281
|
+
&& ((current.startsWith("这是 Chat Loop 短轮回归专用测试")
|
|
282
|
+
&& current.length >= 450 && current.length <= 800)
|
|
283
|
+
|| (current.startsWith("你正在接手上一 Chat 尚未完成的同一个任务")
|
|
284
|
+
&& current.length >= 450 && (current.length <= 2000
|
|
285
|
+
|| (current.length <= 8192 && current.includes("【原始任务用户消息】")
|
|
286
|
+
&& current.includes("【最近轮次证据】"))))
|
|
287
|
+
|| (current.startsWith("你正在接手上一 Chat 未完成的任务。旧 Chat 的网页历史仍可核实")
|
|
288
|
+
&& current.includes("【原始任务用户消息】") && current.includes("【最近轮次证据】")
|
|
289
|
+
&& current.endsWith("是否跨 Chat 由扩展决定,不要主动要求 Handoff。")
|
|
290
|
+
&& current.length >= 450 && current.length <= 8192)
|
|
291
|
+
|| (current.startsWith("这是 Chat Loop 的墙钟边界隔离测试")
|
|
292
|
+
&& current.includes("MODE-WALL-10M") && current.endsWith("WALL-CLOCK-TEST。")
|
|
293
|
+
&& current.length >= 250 && current.length <= 800));
|
|
294
|
+
return {url: location.href, ready: document.readyState === "complete",
|
|
295
|
+
empty: !!editor && !current && !hasMessages && !hasUploads,
|
|
296
|
+
knownTestDraft: !!editor && knownTestDraft && !hasMessages && !hasUploads,
|
|
297
|
+
hasEditor: !!editor, draftLength: current.length,
|
|
298
|
+
hasMessages, hasUploads, title: document.title,
|
|
299
|
+
inputCount: document.querySelectorAll('textarea,[contenteditable="true"]').length};
|
|
300
|
+
};
|
|
301
|
+
let proof;
|
|
302
|
+
try {
|
|
303
|
+
[proof] = await chrome.scripting.executeScript({target: {tabId: candidate.id},
|
|
304
|
+
func: inspectEmpty});
|
|
305
|
+
} catch {
|
|
306
|
+
await chrome.tabs.update(candidate.id, {active: true});
|
|
307
|
+
await sleep(1400);
|
|
308
|
+
try {
|
|
309
|
+
[proof] = await chrome.scripting.executeScript({target: {tabId: candidate.id},
|
|
310
|
+
func: inspectEmpty});
|
|
311
|
+
} finally {
|
|
312
|
+
await chrome.tabs.update(own.id, {active: true});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const evidence = proof?.result;
|
|
316
|
+
const live = await chrome.tabs.get(candidate.id).catch(() => null);
|
|
317
|
+
if (evidence?.ready && (evidence.empty || evidence.knownTestDraft)
|
|
318
|
+
&& evidence.url === candidate.url
|
|
319
|
+
&& live?.url === candidate.url && live.title === "ChatGPT"
|
|
320
|
+
&& !live.active && !live.pinned) {
|
|
321
|
+
await chrome.tabs.remove(candidate.id);
|
|
322
|
+
audit.cleanedEmptyHomeTabs.push(candidate.id);
|
|
323
|
+
} else audit.protectedEmptyHomeTabs.push({id: candidate.id,
|
|
324
|
+
reason: "NOT_VERIFIED_EMPTY", evidence, title: live?.title, url: live?.url});
|
|
325
|
+
} catch (err) { audit.protectedEmptyHomeTabs.push({id: candidate.id,
|
|
326
|
+
reason: "PROBE_FAILED", error: err?.message}); }
|
|
327
|
+
}
|
|
328
|
+
// Any previously orphaned read-only acceptance audit page may be closed;
|
|
329
|
+
// do not touch other extension action pages.
|
|
330
|
+
audit.cleanedAuditPages = [];
|
|
331
|
+
for (const item of audit.extensionTabs) {
|
|
332
|
+
if (!item.url?.startsWith(chrome.runtime.getURL("popup.html?audit=1"))) continue;
|
|
333
|
+
const live = await chrome.tabs.get(item.id).catch(() => null);
|
|
334
|
+
if (live && !live.active && !live.pinned && live.url === item.url) {
|
|
335
|
+
await chrome.tabs.remove(item.id);
|
|
336
|
+
audit.cleanedAuditPages.push(item.id);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const left = await chrome.tabs.query({});
|
|
340
|
+
audit.after = {totalTabs: left.length,
|
|
341
|
+
chatTabs: left.filter(t => isChat(t.url)).map(t => ({id:t.id, title:t.title, url:t.url})),
|
|
342
|
+
extensionTabs: left.filter(t => t.url?.startsWith(extensionOrigin) && t.id !== own.id)
|
|
343
|
+
.map(t => ({id:t.id, url:t.url}))};
|
|
344
|
+
await chrome.storage.local.set({[ownedKey]: owned.filter(item =>
|
|
345
|
+
!audit.cleanedTestTabs.some(cleaned => cleaned.id === item.id))});
|
|
346
|
+
} else if (mode === "cleanup") {
|
|
347
|
+
const id = Number(q.get("cleanup"));
|
|
348
|
+
const sourceOwned = owned.some(item => item.id === run?.sourceTabId);
|
|
349
|
+
const tab = Number.isSafeInteger(id) && id > 0
|
|
350
|
+
? await chrome.tabs.get(id).catch(() => null) : null;
|
|
351
|
+
const isRunTarget = id === run?.tabId && tab?.url === "https://chatgpt.com" + run?.page;
|
|
352
|
+
const isRunSource = id === run?.sourceTabId
|
|
353
|
+
&& tab?.url === "https://chatgpt.com" + run?.sourcePage;
|
|
354
|
+
const provenByRegistry = sourceOwned && isRunTarget;
|
|
355
|
+
// Historical test pair is allowed only when both the persisted DONE
|
|
356
|
+
// identity and an unmistakable dedicated-test title match.
|
|
357
|
+
const historicalTest = run?.action === "HANDOFF" && (isRunTarget || isRunSource)
|
|
358
|
+
&& /^(验证聊天交接|确认交接完成)$/.test(tab?.title || "");
|
|
359
|
+
if (!tab || tab.pinned || run?.phase !== "DONE"
|
|
360
|
+
|| !(provenByRegistry || historicalTest))
|
|
361
|
+
throw Error("CLEANUP_TARGET_NOT_PROVEN_OWNED_DONE_CHAT");
|
|
362
|
+
await chrome.tabs.remove(id);
|
|
363
|
+
audit.cleanedTabId = id;
|
|
364
|
+
await chrome.storage.local.set({[ownedKey]:
|
|
365
|
+
owned.filter(item => item.id !== id && item.id !== run.sourceTabId)});
|
|
366
|
+
}
|
|
367
|
+
const name = "ChatLoop/acceptance/audit-" + Date.now() + ".json";
|
|
368
|
+
const payload = JSON.stringify(audit, null, 2);
|
|
369
|
+
const id = await chrome.downloads.download({
|
|
370
|
+
url: "data:application/json;charset=utf-8," + encodeURIComponent(payload),
|
|
371
|
+
filename: name, conflictAction: "uniquify", saveAs: false
|
|
372
|
+
});
|
|
373
|
+
let complete = false;
|
|
374
|
+
for (let i = 0; i < 40; i++) {
|
|
375
|
+
const found = (await chrome.downloads.search({id}))[0];
|
|
376
|
+
if (found?.state === "complete") {complete = true; break;}
|
|
377
|
+
if (found?.state === "interrupted") break;
|
|
378
|
+
await sleep(150);
|
|
379
|
+
}
|
|
380
|
+
if (!complete) throw Error("AUDIT_DOWNLOAD_UNVERIFIED");
|
|
381
|
+
status.textContent = "ACCEPTANCE_SAVED";
|
|
382
|
+
detail.textContent = name;
|
|
383
|
+
await chrome.tabs.remove(own.id);
|
|
384
|
+
}
|
|
385
|
+
report().catch(err => {
|
|
386
|
+
status.textContent = "ACCEPTANCE_BLOCKED";
|
|
387
|
+
detail.textContent = err?.message || "UNKNOWN_ERROR";
|
|
388
|
+
});
|
|
389
|
+
})();
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Test-only Action page. The Worker validates owned RUNNING boundary probes or an owned completed Turn before real 20m wall shortening.
|
|
2
|
+
(async () => {
|
|
3
|
+
const status = document.getElementById('status');
|
|
4
|
+
const details = document.getElementById('details');
|
|
5
|
+
try {
|
|
6
|
+
const q = new URLSearchParams(location.search);
|
|
7
|
+
if ([...q.keys()].sort().join(',') !== 'minutes,tabId') throw Error('INVALID_CLOCK_PARAMS');
|
|
8
|
+
const tabId = Number(q.get('tabId'));
|
|
9
|
+
const minutes = Number(q.get('minutes'));
|
|
10
|
+
if (!Number.isSafeInteger(tabId) || tabId <= 0
|
|
11
|
+
|| !Number.isSafeInteger(minutes) || minutes !== 20 && (minutes < 60 || minutes > 180))
|
|
12
|
+
throw Error('INVALID_CLOCK_RANGE');
|
|
13
|
+
const own = await chrome.tabs.getCurrent();
|
|
14
|
+
if (!own?.id || own.url !== location.href) throw Error('INVALID_CLOCK_ACTION_PAGE');
|
|
15
|
+
const result = await chrome.runtime.sendMessage({
|
|
16
|
+
type: 'CHAT_LOOP_ACCEPTANCE_CLOCK_ADVANCE_V1', tabId, minutes
|
|
17
|
+
});
|
|
18
|
+
if (!['ADVANCED_WHILE_RUNNING', 'SHORTENED_AFTER_REAL_20MIN'].includes(result?.status))
|
|
19
|
+
throw Error(result?.code || 'CLOCK_ADVANCE_UNVERIFIED');
|
|
20
|
+
status.textContent = 'SUCCESS';
|
|
21
|
+
details.textContent = result.status === 'SHORTENED_AFTER_REAL_20MIN'
|
|
22
|
+
? 'Owned wall-clock acceptance threshold shortened after real 20 minutes; existing Worker will release only one verified Continue.'
|
|
23
|
+
: 'Observer still RUNNING; '+minutes+'m elapsed. Await the actual finished Turn.';
|
|
24
|
+
await chrome.tabs.remove(own.id);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
status.textContent = 'BLOCKED';
|
|
27
|
+
details.textContent = error?.message || 'UNKNOWN';
|
|
28
|
+
}
|
|
29
|
+
})();
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Shared Eye Action primitives only. No action is dispatched or executed here.
|
|
2
|
+
(() => {
|
|
3
|
+
const CONTRACT = "chat-loop.eye.v1";
|
|
4
|
+
const params = new URLSearchParams(location.search);
|
|
5
|
+
const statusNode = document.getElementById("status");
|
|
6
|
+
const detailsNode = document.getElementById("details");
|
|
7
|
+
function show(status, message) {
|
|
8
|
+
statusNode.textContent = status;
|
|
9
|
+
detailsNode.textContent = message;
|
|
10
|
+
}
|
|
11
|
+
function fail(code, message) {
|
|
12
|
+
show(code, message);
|
|
13
|
+
document.body.dataset.result = "error";
|
|
14
|
+
console.error("Chat Loop eye:", code, message);
|
|
15
|
+
}
|
|
16
|
+
function isChatGPT(value) {
|
|
17
|
+
try {
|
|
18
|
+
const u = new URL(value);
|
|
19
|
+
return u.protocol === "https:" && u.hostname === "chatgpt.com";
|
|
20
|
+
} catch { return false; }
|
|
21
|
+
}
|
|
22
|
+
async function findTarget(ownTab) {
|
|
23
|
+
// Action pages may open in a different window from the ChatGPT tab.
|
|
24
|
+
const candidates = await chrome.tabs.query({url: "https://chatgpt.com/*"});
|
|
25
|
+
const eligible = candidates.filter(tab => isChatGPT(tab.url));
|
|
26
|
+
const requested = params.get("tabId");
|
|
27
|
+
if (requested !== null) {
|
|
28
|
+
if (!/^[1-9][0-9]*$/.test(requested)) throw Error("INVALID_TAB_ID");
|
|
29
|
+
const match = eligible.find(tab => tab.id === Number(requested));
|
|
30
|
+
if (!match) throw Error("TARGET_NOT_CHATGPT_OR_NOT_FOUND");
|
|
31
|
+
return match;
|
|
32
|
+
}
|
|
33
|
+
// When an action page was opened from a ChatGPT tab, respect its opener.
|
|
34
|
+
const opener = eligible.find(tab => tab.id === ownTab.openerTabId);
|
|
35
|
+
if (opener) return opener;
|
|
36
|
+
if (eligible.length === 0) throw Error("NO_CHATGPT_TAB");
|
|
37
|
+
if (eligible.length !== 1) {
|
|
38
|
+
const ids = eligible.map(tab => tab.id).join(", ");
|
|
39
|
+
throw Error("AMBIGUOUS_CHATGPT_TABS: use ?tabId=... (available: " + ids + ")");
|
|
40
|
+
}
|
|
41
|
+
return eligible[0];
|
|
42
|
+
}
|
|
43
|
+
async function closeAction(ownTab) {
|
|
44
|
+
await chrome.tabs.remove(ownTab.id);
|
|
45
|
+
}
|
|
46
|
+
async function readChat(tab, mode) {
|
|
47
|
+
const request = () => chrome.tabs.sendMessage(tab.id,
|
|
48
|
+
{type: "CHAT_LOOP_EYE_READ_V1", mode});
|
|
49
|
+
let response;
|
|
50
|
+
try {
|
|
51
|
+
response = await request();
|
|
52
|
+
} catch {
|
|
53
|
+
// Reload invalidates old content scripts. Reinject only into the selected ChatGPT tab.
|
|
54
|
+
try {
|
|
55
|
+
await chrome.scripting.executeScript({target: {tabId: tab.id}, files: ["content.js"]});
|
|
56
|
+
response = await request();
|
|
57
|
+
} catch {
|
|
58
|
+
throw Error("CONTENT_SCRIPT_UNAVAILABLE: could not reinject into ChatGPT tab");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (response?.contract !== CONTRACT || response?.status !== "OK"
|
|
62
|
+
|| response?.mode !== mode || typeof response?.value !== "string") {
|
|
63
|
+
throw Error("INVALID_CONTENT_RESPONSE");
|
|
64
|
+
}
|
|
65
|
+
return response.value;
|
|
66
|
+
}
|
|
67
|
+
function copyText(value) {
|
|
68
|
+
// Synchronous copy avoids losing the macOS pasteboard when this action tab closes.
|
|
69
|
+
const field = document.createElement("textarea");
|
|
70
|
+
field.value = value;
|
|
71
|
+
field.setAttribute("readonly", "");
|
|
72
|
+
field.style.position = "fixed";
|
|
73
|
+
field.style.opacity = "0";
|
|
74
|
+
document.body.appendChild(field);
|
|
75
|
+
field.focus();
|
|
76
|
+
field.select();
|
|
77
|
+
const copied = document.execCommand("copy");
|
|
78
|
+
field.remove();
|
|
79
|
+
if (!copied) throw Error("CLIPBOARD_COPY_FAILED");
|
|
80
|
+
}
|
|
81
|
+
// Only requestId generated by the local CLI enables machine-result mode.
|
|
82
|
+
// Manual Action pages keep the verified clipboard/download behavior.
|
|
83
|
+
const nativeHost = "com.tomflow.chatloop.extension";
|
|
84
|
+
const machineId = params.get("requestId");
|
|
85
|
+
async function machineBinding(action) {
|
|
86
|
+
if (machineId === null) return null;
|
|
87
|
+
if (!/^[a-f0-9]{32}$/.test(machineId)) throw Error("INVALID_MACHINE_REQUEST");
|
|
88
|
+
const request = await chrome.runtime.sendNativeMessage(nativeHost,
|
|
89
|
+
{op:"acquire",id:machineId,action});
|
|
90
|
+
if (request?.status !== "OK" || request.id !== machineId || request.action !== action
|
|
91
|
+
|| typeof request.nonce !== "string") throw Error(request?.code || "NATIVE_REQUEST_REJECTED");
|
|
92
|
+
const target = await chrome.runtime.sendMessage({
|
|
93
|
+
type:"CHAT_LOOP_EXTENSION_TARGET_V1",id:machineId,action
|
|
94
|
+
});
|
|
95
|
+
if (target?.status !== "OK") throw Error(target?.code || "OWNER_NOT_AVAILABLE");
|
|
96
|
+
return {id:machineId,nonce:request.nonce,action,
|
|
97
|
+
tabId:target.tabId??null,page:target.page??null};
|
|
98
|
+
}
|
|
99
|
+
async function machineTarget(binding) {
|
|
100
|
+
if (!binding || !Number.isInteger(binding.tabId)
|
|
101
|
+
|| typeof binding.page !== "string") throw Error("OWNER_UNVERIFIED");
|
|
102
|
+
const tab = await chrome.tabs.get(binding.tabId);
|
|
103
|
+
if (!tab?.url || new URL(tab.url).pathname !== binding.page
|
|
104
|
+
|| new URL(tab.url).origin !== "https://chatgpt.com") throw Error("OWNER_CHANGED");
|
|
105
|
+
return tab;
|
|
106
|
+
}
|
|
107
|
+
async function machineResult(binding,payload) {
|
|
108
|
+
const response = await chrome.runtime.sendNativeMessage(nativeHost,{
|
|
109
|
+
op:"complete",id:binding.id,nonce:binding.nonce,action:binding.action,status:"OK",payload
|
|
110
|
+
});
|
|
111
|
+
if(response?.status!=="SAVED"||response.id!==binding.id)throw Error(response?.code||"NATIVE_RESULT_UNVERIFIED");
|
|
112
|
+
}
|
|
113
|
+
async function machineFailure(action,error) {
|
|
114
|
+
if (machineId === null || !/^[a-f0-9]{32}$/.test(machineId)) return;
|
|
115
|
+
try {
|
|
116
|
+
const req=await chrome.runtime.sendNativeMessage(nativeHost,
|
|
117
|
+
{op:"acquire",id:machineId,action});
|
|
118
|
+
if(req?.status==="OK") await chrome.runtime.sendNativeMessage(nativeHost,{
|
|
119
|
+
op:"complete",id:machineId,nonce:req.nonce,action,status:"FAILED",
|
|
120
|
+
code:String(error?.message||"ACTION_FAILED").slice(0,120)
|
|
121
|
+
});
|
|
122
|
+
} catch { /* CLI timeout is UNKNOWN, never success. */ }
|
|
123
|
+
}
|
|
124
|
+
function requireOwnTab(tab) {
|
|
125
|
+
if (!tab?.id) throw Error("ACTION_TAB_UNVERIFIED");
|
|
126
|
+
return tab;
|
|
127
|
+
}
|
|
128
|
+
function done(ownTab, action, target) {
|
|
129
|
+
show("SUCCESS", action + " completed for tab " + target.id);
|
|
130
|
+
document.body.dataset.result = "success";
|
|
131
|
+
return closeAction(ownTab);
|
|
132
|
+
}
|
|
133
|
+
globalThis.ChatLoopActionCommonV1 = Object.freeze({
|
|
134
|
+
show, fail, findTarget, readChat, copyText, closeAction, done, requireOwnTab,
|
|
135
|
+
machineBinding, machineTarget, machineResult, machineFailure
|
|
136
|
+
});
|
|
137
|
+
})();
|