@tomflow/chatloop-extension 0.0.0-stage → 0.3.27

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.
@@ -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
+ })();