@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,264 @@
1
+ // Sole writer for one autonomous continuation. Never imported by the Chat page.
2
+ // The background schedules an Action page; this page alone performs the DOM mutation.
3
+ const statusNode = document.getElementById("status");
4
+ const detailsNode = document.getElementById("details");
5
+ function show(status, details) {
6
+ statusNode.textContent = status;
7
+ detailsNode.textContent = details;
8
+ document.body.dataset.result = status === "SUCCESS" ? "success" : "error";
9
+ }
10
+ async function sendOnce(target, text) {
11
+ const [execution] = await chrome.scripting.executeScript({
12
+ target: {tabId: target.tabId},
13
+ func: async (expected, instruction) => {
14
+ try {
15
+ if (location.origin !== "https://chatgpt.com" || location.pathname !== expected.page)
16
+ throw Error("CHAT_PAGE_CHANGED");
17
+ const visible = node => Boolean(node?.getClientRects?.().length)
18
+ && !node.closest?.('[hidden],[aria-hidden="true"]')
19
+ && getComputedStyle(node).display !== "none";
20
+ const mains = [...document.querySelectorAll("main")].filter(visible);
21
+ if (mains.length !== 1) throw Error("CHAT_MAIN_UNVERIFIED");
22
+ const main = mains[0];
23
+ const modern = [...main.querySelectorAll(
24
+ '[data-chatgpt-search-unit-key][data-chatgpt-search-message-ids]')];
25
+ const legacy = [...main.querySelectorAll('[data-message-author-role]')];
26
+ const roles = modern.length ? modern : legacy;
27
+ const role = node => node.getAttribute("data-message-author-role")
28
+ || node.getAttribute("data-chatgpt-search-unit-key")?.split(":").at(-1);
29
+ const idOf = node => node?.getAttribute("data-message-id")
30
+ || node?.getAttribute("data-chatgpt-search-message-ids")?.trim().split(/\s+/)[0]
31
+ || node?.closest('[data-testid^="conversation-turn-"]')?.getAttribute("data-testid");
32
+ const user = roles.filter(node => role(node) === "user").at(-1);
33
+ const answer = roles.filter(node => role(node) === "assistant").at(-1);
34
+ if (expected.testSetup) {
35
+ if (expected.page !== "/" || roles.length) throw Error("TEST_CHAT_NOT_EMPTY");
36
+ } else if ("user:" + idOf(user) !== expected.userKey
37
+ || "assistant:" + idOf(answer) !== expected.assistantKey) {
38
+ throw Error("TURN_IDENTITY_CHANGED");
39
+ }
40
+ if (!expected.testSetup) {
41
+ const scope = answer.closest('[data-testid^="conversation-turn-"]')
42
+ || answer.closest('[data-talvt-turn-state]') || answer;
43
+ const replyText = (answer.querySelector('.markdown')?.innerText || answer.innerText || '').trim();
44
+ const lines = replyText.split(/\r?\n/);
45
+ if (lines.at(-1)?.trim() !== "[CHAT_LOOP_STATUS:CONTINUE]"
46
+ || (replyText.match(/\[CHAT_LOOP_STATUS:(?:CONTINUE|DONE|BLOCKED)\]/g) || []).length !== 1) {
47
+ throw Error("CONTINUE_STATUS_CHANGED");
48
+ }
49
+ const turnState = scope.getAttribute?.('data-talvt-turn-state');
50
+ if (turnState && turnState !== "complete") throw Error("TURN_NOT_COMPLETE");
51
+ if ([...document.querySelectorAll('button[data-testid="stop-button"],button[aria-label="停止响应"],button[aria-label="Stop generating"]')].some(visible))
52
+ throw Error("GENERATION_STILL_RUNNING");
53
+ if (![...scope.querySelectorAll('button[aria-label="复制"],button[aria-label="Copy"],button[data-testid="copy-turn-action-button"]')].some(visible))
54
+ throw Error("FINAL_UI_EVIDENCE_MISSING");
55
+ }
56
+ let input = document.querySelector('[contenteditable="true"][data-composer-markdown]')
57
+ || document.querySelector('#prompt-textarea[contenteditable="true"]')
58
+ || document.querySelector('[data-testid="composer-input"][contenteditable="true"]');
59
+ if (!visible(input) || input.closest('[aria-disabled="true"]'))
60
+ throw Error("COMPOSER_UNAVAILABLE");
61
+ if ((input.innerText || input.textContent || '').trim()) {
62
+ const draft = (input.innerText || input.textContent || '').trim();
63
+ if (!expected.testSetup || !expected.discardDraft
64
+ || !(draft.includes("LOOP-V037-FAST-3C1H") || (draft.startsWith("你正在接手上一 Chat 未完成的任务。旧 Chat 的网页历史仍可核实")
65
+ && draft.includes("【交接参考:只读网页片段,可能截断】")
66
+ && draft.includes("【原始任务用户消息】")
67
+ && draft.includes("【最近轮次证据】")
68
+ && draft.endsWith("是否跨 Chat 由扩展决定,不要主动要求 Handoff。")
69
+ && draft.length >= 450 && draft.length <= 8192))
70
+ || !((draft.startsWith("这是 Chat Loop 短轮回归专用测试")
71
+ && draft.length >= 150 && draft.length <= 800)
72
+ || (draft.startsWith("你正在接手上一 Chat 尚未完成的同一个任务")
73
+ && draft.length >= 450 && (draft.length <= 2000
74
+ || (draft.length <= 8192 && draft.includes("【原始任务用户消息】")
75
+ && draft.includes("【最近轮次证据】"))))
76
+ || (draft.startsWith("你正在接手上一 Chat 未完成的任务。旧 Chat 的网页历史仍可核实")
77
+ && draft.includes("【原始任务用户消息】") && draft.includes("【最近轮次证据】")
78
+ && draft.endsWith("是否跨 Chat 由扩展决定,不要主动要求 Handoff。")
79
+ && draft.length >= 450 && draft.length <= 8192)
80
+ || (draft.startsWith("这是 Chat Loop 的长回复与一小时边界隔离测试")
81
+ && draft.includes("MODE-TIMED-1H") && draft.length >= 350 && draft.length <= 800)
82
+ || (draft.startsWith("这是 Chat Loop 的墙钟边界隔离测试")
83
+ && draft.includes("MODE-WALL-10M") && draft.endsWith("WALL-CLOCK-TEST。")
84
+ && draft.length >= 250 && draft.length <= 800)))
85
+ throw Error("COMPOSER_NOT_EMPTY");
86
+ // Explicit test preparation on the caller-created empty Chat only.
87
+ // Never clear the source Chat or use this branch for normal continuation.
88
+ input.focus();
89
+ const range = document.createRange();
90
+ range.selectNodeContents(input);
91
+ const selection = window.getSelection();
92
+ selection.removeAllRanges();
93
+ selection.addRange(range);
94
+ if (!document.execCommand("delete", false)) throw Error("TEST_DRAFT_CLEAR_REJECTED");
95
+ await new Promise(resolve => setTimeout(resolve, 500));
96
+ if ((input.innerText || input.textContent || '').trim())
97
+ throw Error("TEST_DRAFT_CLEAR_UNVERIFIED");
98
+ }
99
+ input.focus();
100
+ if (!document.execCommand("insertText", false, instruction)) throw Error("INPUT_NOT_ACCEPTED");
101
+ // DOM writes can precede React composer state. Do not click a stale
102
+ // enabled button in the same task as insertion (especially after reset).
103
+ await new Promise(resolve => setTimeout(resolve, 400));
104
+ const canonical = value => String(value || '').replace(/\s+/g, '');
105
+ let verified = false;
106
+ for (let n = 0; n < 30; n++) {
107
+ if (canonical(input.innerText) === canonical(instruction)
108
+ || canonical(input.textContent) === canonical(instruction)) {verified = true; break;}
109
+ await new Promise(resolve => setTimeout(resolve, 120));
110
+ }
111
+ if (!verified) throw Error("INPUT_NOT_VERIFIED");
112
+ let button;
113
+ for (let n = 0; n < 16; n++) {
114
+ button = document.querySelector('button[type="submit"][aria-label="发送"]')
115
+ || document.querySelector('button[data-testid="send-button"]')
116
+ || document.querySelector('button[data-testid="composer-send-button"]')
117
+ || document.querySelector('button[aria-label="Send message"]');
118
+ if (button && !button.disabled && visible(button)) break;
119
+ await new Promise(resolve => setTimeout(resolve, 120));
120
+ }
121
+ if (!button || button.disabled || !visible(button)) throw Error("SEND_BUTTON_UNAVAILABLE");
122
+ if (location.pathname !== expected.page) throw Error("CHAT_PAGE_CHANGED_BEFORE_SEND");
123
+ if (canonical(input.innerText) !== canonical(instruction)
124
+ && canonical(input.textContent) !== canonical(instruction)) throw Error("INPUT_CHANGED_BEFORE_SEND");
125
+ button.click(); // Once, after durable PENDING commit in the worker. Never retry.
126
+ for (let n = 0; n < 32; n++) {
127
+ if (expected.testSetup ? location.pathname !== "/"
128
+ && !/^\/c\/(?:[0-9a-f-]{12,}|local-chatgpt%3A[0-9a-f-]{36})$/i.test(location.pathname)
129
+ : location.pathname !== expected.page) throw Error("CHAT_PAGE_CHANGED_AFTER_SEND");
130
+ const visibleMain = [...document.querySelectorAll("main")].filter(visible);
131
+ if (visibleMain.length !== 1) throw Error("CHAT_MAIN_CHANGED_AFTER_SEND");
132
+ const rows = [...visibleMain[0].querySelectorAll(
133
+ '[data-chatgpt-search-unit-key][data-chatgpt-search-message-ids], [data-message-author-role]')];
134
+ const latest = rows.filter(node => role(node) === "user").at(-1);
135
+ const newId = idOf(latest);
136
+ // On live ChatGPT, the collapsed bubble contains its own "显示更多"
137
+ // button; only the inner actual message text is eligible as a receipt.
138
+ const message = latest?.querySelector('[data-search-result-target]')
139
+ || latest?.querySelector('[data-user-message-bubble="true"]') || latest;
140
+ if (newId && 'user:' + newId !== expected.userKey
141
+ && (canonical(message?.innerText) === canonical(instruction)
142
+ || canonical(message?.textContent) === canonical(instruction))) {
143
+ if (expected.testSetup && !/^\/c\/[0-9a-f-]{12,}$/i.test(location.pathname)) {
144
+ await new Promise(resolve => setTimeout(resolve, 250));
145
+ continue;
146
+ }
147
+ return {status: "SENT_CONFIRMED", newUserKey: 'user:' + newId, newPage: location.pathname};
148
+ }
149
+ await new Promise(resolve => setTimeout(resolve, 250));
150
+ }
151
+ throw Error("SEND_UNVERIFIED"); // Side effect may exist: block, don't retry.
152
+ } catch (error) {return {status: "UNVERIFIED", reason: error.message || "SEND_ERROR"};}
153
+ },
154
+ args: [target, text]
155
+ });
156
+ if (!execution?.result || execution.result.status !== "SENT_CONFIRMED")
157
+ throw Error(execution?.result?.reason || "SEND_RESULT_UNVERIFIED");
158
+ return execution.result;
159
+ }
160
+ // Reuse the formal Continue Action page for a *different* one-shot recovery
161
+ // operation. Native Retry targets the existing user Turn: never type or send.
162
+ async function retryNativeOnce(target) {
163
+ const [execution] = await chrome.scripting.executeScript({
164
+ target: {tabId: target.tabId},
165
+ func: async expected => {
166
+ try {
167
+ if (location.origin !== "https://chatgpt.com" || location.pathname !== expected.page)
168
+ throw Error("RETRY_CHAT_CHANGED");
169
+ const visible = node => Boolean(node?.getClientRects?.().length)
170
+ && !node.closest?.('[hidden],[aria-hidden="true"]')
171
+ && getComputedStyle(node).display !== "none";
172
+ const mains = [...document.querySelectorAll('main')].filter(visible);
173
+ if (mains.length !== 1) throw Error("RETRY_MAIN_UNVERIFIED");
174
+ const main = mains[0];
175
+ const rows = [...main.querySelectorAll('[data-chatgpt-search-unit-key][data-chatgpt-search-message-ids]')];
176
+ const messages = rows.length ? rows : [...main.querySelectorAll('[data-message-author-role]')];
177
+ const role = node => node.getAttribute('data-message-author-role')
178
+ || node.getAttribute('data-chatgpt-search-unit-key')?.split(':').at(-1);
179
+ const id = node => node?.getAttribute('data-message-id')
180
+ || node?.getAttribute('data-chatgpt-search-message-ids')?.trim().split(/\s+/)[0]
181
+ || node?.closest('[data-testid^="conversation-turn-"]')?.getAttribute('data-testid');
182
+ const latestUser = messages.filter(node => role(node) === 'user').at(-1);
183
+ const latestAssistant = messages.filter(node => role(node) === 'assistant').at(-1);
184
+ if ('user:' + id(latestUser) !== expected.userKey
185
+ || (latestAssistant ? 'assistant:' + id(latestAssistant) : null) !== expected.assistantKey)
186
+ throw Error("RETRY_TURN_CHANGED");
187
+ if ([...document.querySelectorAll('button[data-testid="stop-button"],button[aria-label="停止响应"],button[aria-label="Stop generating"]')].some(visible))
188
+ throw Error("RETRY_STILL_GENERATING");
189
+ const alerts = [...main.querySelectorAll('[role="alert"],[role="status"],[data-testid="conversation-error"]')]
190
+ .filter(visible);
191
+ const failed = alerts.filter(el => /unknown error|something went wrong|an error occurred|unable to load|connection (?:lost|interrupted)|请求失败|网络错误|发生错误|出错了/i.test(el.innerText || el.textContent || ''));
192
+ if (failed.length !== 1) throw Error("RETRY_ERROR_NOT_UNIQUE");
193
+ const buttons = [...failed[0].querySelectorAll('button')].filter(button => visible(button)
194
+ && /^(?:retry|try again|重试|再试一次)$/i.test(
195
+ (button.innerText || button.textContent || button.getAttribute('aria-label') || '').trim()));
196
+ if (buttons.length !== 1 || buttons[0].disabled) throw Error("RETRY_BUTTON_UNVERIFIED");
197
+ buttons[0].click(); // Exact native retry on the existing user Turn, once.
198
+ // Require visible evidence that the page accepted the native retry,
199
+ // not merely that the click function returned without throwing.
200
+ for (let attempt = 0; attempt < 24; attempt++) {
201
+ await new Promise(resolve => setTimeout(resolve, 250));
202
+ if (location.pathname !== expected.page) throw Error("RETRY_PAGE_CHANGED_AFTER_CLICK");
203
+ const current = [...document.querySelectorAll('main')].filter(visible);
204
+ if (current.length !== 1) throw Error("RETRY_MAIN_CHANGED_AFTER_CLICK");
205
+ const latest = [...current[0].querySelectorAll('[role="alert"],[role="status"],[data-testid="conversation-error"]')]
206
+ .filter(visible).some(el => /unknown error|something went wrong|an error occurred|unable to load|connection (?:lost|interrupted)|请求失败|网络错误|发生错误|出错了/i.test(el.innerText || el.textContent || ''));
207
+ if (!latest) return {status: 'RETRY_CLICKED'};
208
+ }
209
+ throw Error("NATIVE_RETRY_NOT_ACKNOWLEDGED");
210
+ } catch(error) {return {status:'UNVERIFIED',reason:error.message||'RETRY_FAILED'};}
211
+ }, args: [target]
212
+ });
213
+ return execution?.result || {status:'UNVERIFIED',reason:'RETRY_RESULT_UNKNOWN'};
214
+ }
215
+ async function run() {
216
+ const params = new URLSearchParams(location.search);
217
+ const raw = params.get("tabId") || "";
218
+ let token = params.get("token") || "";
219
+ let tabId = Number(raw);
220
+ const setup = params.get("setup") === "1";
221
+ if (params.has("setup") && (!setup || !raw || token)) throw Error("TEST_SETUP_PARAMETERS_INVALID");
222
+ if (params.has("wall") && (!setup || params.get("timed") !== "1"
223
+ || !["1", "2", "10m"].includes(params.get("wall")))) throw Error("TEST_WALL_PARAMETERS_INVALID");
224
+ if (params.has("count") && (!setup || params.get("count") !== "86"
225
+ || params.get("wall") !== "10m" || params.get("timed") !== "1"))
226
+ throw Error("COUNT_TEST_PARAMETERS_INVALID");
227
+ if (raw && !/^[1-9][0-9]*$/.test(raw)
228
+ || token && !/^[0-9a-f-]{36}$/.test(token)
229
+ || token && !raw) throw Error("ACTION_PARAMETERS_INVALID");
230
+ const own = await chrome.tabs.getCurrent();
231
+ if (!own?.id || own.url !== location.href) throw Error("ACTION_TAB_UNVERIFIED");
232
+ if (!token) {
233
+ show("VERIFYING", setup
234
+ ? "测试首回合准备(不计作 Continue 验收):核对指定空白测试 Chat,申请一次固定测试消息许可。"
235
+ : "手动打开 Continue:核对当前已完成的 Turn,并申请单次发送许可。");
236
+ const prepared = await chrome.runtime.sendMessage({
237
+ type: setup ? "CHAT_LOOP_ACTION_TEST_PREPARE_V1" : "CHAT_LOOP_ACTION_MANUAL_PREPARE_V1", action: "CONTINUE",
238
+ ...(raw ? {tabId} : {})
239
+ });
240
+ if (prepared?.status !== "PREPARED")
241
+ throw Error(prepared?.code || "MANUAL_PREPARE_DENIED");
242
+ tabId = prepared.tabId;
243
+ token = prepared.token;
244
+ }
245
+ const request = {token, tabId};
246
+ const permit = await chrome.runtime.sendMessage({type: "CHAT_LOOP_ACTION_TAKE_V1", ...request});
247
+ if (permit?.status !== "OK" || permit.target?.tabId !== tabId)
248
+ throw Error(permit?.code || "ACTION_NOT_AUTHORIZED");
249
+ show("RUNNING", "已独占一次发送许可,正在核验目标 Chat 和最新已完成 Turn。");
250
+ let result;
251
+ try { result = permit.retryMode === "NATIVE"
252
+ ? await retryNativeOnce(permit.target) : await sendOnce(permit.target, permit.text); }
253
+ catch (error) { result = {status: "UNVERIFIED", reason: error.message || "SEND_ERROR"}; }
254
+ const final = await chrome.runtime.sendMessage({type: "CHAT_LOOP_ACTION_RESULT_V1", ...request,
255
+ result: result.status, reason: result.reason, newUserKey: result.newUserKey, newPage: result.newPage});
256
+ if (!(result.status === "SENT_CONFIRMED" || permit.retryMode === "NATIVE"
257
+ && result.status === "RETRY_CLICKED") || final?.status !== "OK")
258
+ throw Error(result.reason || final?.code || "ACTION_RESULT_NOT_COMMITTED");
259
+ show("SUCCESS", permit.retryMode === "NATIVE" ? "已点击原消息的网页重试一次;没有发送新用户消息。"
260
+ : setup ? "测试首回合已确认;等待正式 Continue 与 Handoff 独立执行。"
261
+ : "已确认新增一次用户消息;Action 已完成,等待下一轮 Observer。");
262
+ await chrome.tabs.remove(own.id);
263
+ }
264
+ run().catch(error => show("BLOCKED", error.message || "ACTION_FAILED"));
@@ -0,0 +1,25 @@
1
+ // DOM Action: read one selected ChatGPT tab and copy its content.
2
+ (() => {
3
+ const {show, fail, findTarget, readChat, copyText, done, requireOwnTab,
4
+ machineBinding, machineTarget, machineResult, machineFailure}
5
+ = globalThis.ChatLoopActionCommonV1;
6
+ let machine;
7
+ async function run() {
8
+ machine = await machineBinding("chat_dom");
9
+ const own = requireOwnTab(await chrome.tabs.getCurrent());
10
+ const target = machine ? await machineTarget(machine) : await findTarget(own);
11
+ show("RUNNING", "dom → ChatGPT tab " + target.id);
12
+ const value = await readChat(target, "dom");
13
+ if (!value.length) throw Error("EMPTY_CONTENT");
14
+ if(machine) await machineResult(machine,value);
15
+ else copyText(value);
16
+ // Let Chrome publish selections to macOS pasteboard before closing.
17
+ if(!machine) await new Promise(resolve => setTimeout(resolve, 1000));
18
+ await done(own, "dom", target);
19
+ }
20
+ run().catch(async error => {
21
+ await machineFailure("chat_dom",error);
22
+ fail(error.message || "UNEXPECTED_ERROR",
23
+ "操作未完成;动作页已保留,未对其他 ChatGPT 标签页进行操作。");
24
+ });
25
+ })();
@@ -0,0 +1,302 @@
1
+ // Sole executor for a cross-Chat handoff. Worker only schedules/authorizes.
2
+ // Never auto-retry a write or a tab creation after the result is uncertain.
3
+ const statusNode = document.getElementById("status");
4
+ const detailsNode = document.getElementById("details");
5
+ const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
6
+ function show(status, details) {
7
+ statusNode.textContent = status;
8
+ detailsNode.textContent = details;
9
+ document.body.dataset.result = status === "SUCCESS" ? "success" : "error";
10
+ }
11
+ async function verifyOldChat(target, summary, mode = "EXPLICIT") {
12
+ const [execution] = await chrome.scripting.executeScript({
13
+ target: {tabId: target.tabId},
14
+ func: (expected, handoffText, handoffMode) => {
15
+ if (location.origin !== "https://chatgpt.com" || location.pathname !== expected.page)
16
+ throw Error("SOURCE_CHAT_CHANGED");
17
+ const visible = node => Boolean(node?.getClientRects?.().length)
18
+ && !node.closest?.('[hidden],[aria-hidden="true"]')
19
+ && getComputedStyle(node).display !== "none";
20
+ const mains = [...document.querySelectorAll("main")].filter(visible);
21
+ if (mains.length !== 1) throw Error("SOURCE_MAIN_UNVERIFIED");
22
+ const main = mains[0];
23
+ const modern = [...main.querySelectorAll('[data-chatgpt-search-unit-key][data-chatgpt-search-message-ids]')];
24
+ const legacy = [...main.querySelectorAll('[data-message-author-role]')];
25
+ const role = n => n.getAttribute("data-message-author-role")
26
+ || n.getAttribute("data-chatgpt-search-unit-key")?.split(":").at(-1);
27
+ const id = n => n?.getAttribute("data-message-id")
28
+ || n?.getAttribute("data-chatgpt-search-message-ids")?.trim().split(/\s+/)[0]
29
+ || n?.closest('[data-testid^="conversation-turn-"]')?.getAttribute("data-testid");
30
+ const rows = modern.length ? modern : legacy;
31
+ const user = rows.filter(n => role(n) === "user").at(-1);
32
+ const assistant = rows.filter(n => role(n) === "assistant").at(-1);
33
+ if ("user:" + id(user) !== expected.userKey
34
+ || (assistant ? "assistant:" + id(assistant) : null) !== expected.assistantKey)
35
+ throw Error("SOURCE_TURN_CHANGED");
36
+ const content = (assistant?.querySelector('.markdown')?.innerText || assistant?.innerText || '').trim();
37
+ if (handoffMode === "ERROR") {
38
+ // No completed assistant response after the pending user Turn.
39
+ // Require an explicit context limit on the *same* visible chat.
40
+ const lastUserIndex = rows.findLastIndex(n => role(n) === "user");
41
+ if (rows.slice(lastUserIndex + 1).some(n => role(n) === "assistant"))
42
+ throw Error("ERROR_HANDOFF_NEW_ASSISTANT_EXISTS");
43
+ const contextPattern = /(?:conversation (?:has (?:reached|exceeded) (?:its |the )?(?:maximum|context)|is too long)|context (?:window|length|limit) (?:has been |was )?(?:exceeded|reached)|maximum conversation (?:length|limit) (?:reached|exceeded)|对话(?:上下文|长度)(?:已)?(?:达到|超过)(?:上限|限制)|上下文(?:窗口|长度)(?:已)?(?:达到|超过)(?:上限|限制))/i;
44
+ if (![...main.querySelectorAll('[role="alert"],[role="status"],[data-testid="conversation-error"]')]
45
+ .some(el => visible(el) && contextPattern.test(el.innerText || el.textContent || '')))
46
+ throw Error("CONTEXT_ERROR_NOT_CONFIRMED");
47
+ } else if (handoffMode === "EXPLICIT") {
48
+ if (!content.endsWith('\n[CHAT_LOOP_STATUS:HANDOFF]')
49
+ || content.slice(0, -'[CHAT_LOOP_STATUS:HANDOFF]'.length).trim() !== handoffText)
50
+ throw Error("SOURCE_HANDOFF_TEXT_CHANGED");
51
+ } else if (handoffMode !== "TIMED" || !content
52
+ || /\[CHAT_LOOP_STATUS:(?:DONE|BLOCKED)\]$/.test(content)) {
53
+ throw Error("TIMED_HANDOFF_NOT_ELIGIBLE");
54
+ }
55
+ const scope = assistant?.closest('[data-testid^="conversation-turn-"]')
56
+ || assistant?.closest('[data-talvt-turn-state]') || assistant;
57
+ const state = scope?.getAttribute?.('data-talvt-turn-state');
58
+ if (handoffMode !== "ERROR" && state && state !== "complete") throw Error("SOURCE_TURN_STILL_RUNNING");
59
+ if ([...document.querySelectorAll('button[data-testid="stop-button"],button[aria-label="Stop generating"],button[aria-label="停止响应"]')].some(visible)
60
+ || [...scope?.querySelectorAll?.('[aria-busy="true"],[data-tool-status="running"]') || []].some(visible))
61
+ throw Error("SOURCE_GENERATION_STILL_RUNNING");
62
+ if (handoffMode !== "ERROR" && ![...scope.querySelectorAll('button[aria-label="复制"],button[aria-label="Copy"],button[data-testid="copy-turn-action-button"]')].some(visible))
63
+ throw Error("SOURCE_NOT_FINAL");
64
+ // Timed handoff must not modify the original Chat. Capture a bounded
65
+ // read-only evidence snapshot here, not through URL or Worker storage.
66
+ if (handoffMode === "TIMED" || handoffMode === "ERROR") {
67
+ const entries = rows.map(node => ({role: role(node),
68
+ text: (node.querySelector?.('.markdown')?.innerText
69
+ || node.innerText || node.textContent || '').trim()}))
70
+ .filter(item => (item.role === "user" || item.role === "assistant") && item.text);
71
+ const head = entries.find(item => item.role === "user");
72
+ if (!head) throw Error("SOURCE_CONTEXT_TOO_SHORT");
73
+ const headText = head.text.slice(0, 6000);
74
+ const tailText = entries.slice(-24).filter(item => item !== head).map(item =>
75
+ (item.role === "user" ? "用户:" : "助手:") + item.text.slice(-3000))
76
+ .join("\n\n").slice(-21000);
77
+ const excerpt = "【原始任务用户消息】\n" + headText
78
+ + "\n\n【最近轮次证据】\n" + tailText;
79
+ if (excerpt.length < 120) throw Error("SOURCE_CONTEXT_TOO_SHORT");
80
+ return {ok: true, snapshot: "【交接参考:只读网页片段,可能截断】\n" + excerpt
81
+ + (handoffMode === "ERROR" ? "\n【中断说明】当前 Chat 明确出现上下文容量错误,最后一条用户消息未收到完整助手回复;任何上轮工具副作用均需先核实,不得盲目重复。" : "")
82
+ + "\n【证据边界】此片段不等于完整记录;必须通过实际工具、仓库和来源核对任务进展。"};
83
+ }
84
+ return {ok: true};
85
+ }, args: [target, summary, mode]
86
+ });
87
+ if (execution?.result?.ok !== true) throw Error("SOURCE_HANDOFF_UNVERIFIED");
88
+ if (["TIMED", "ERROR"].includes(mode) && (typeof execution.result.snapshot !== "string"
89
+ || execution.result.snapshot.length < 120)) throw Error("SOURCE_SNAPSHOT_UNVERIFIED");
90
+ return execution.result.snapshot || summary;
91
+ }
92
+ async function waitForNewChat(tabId, allowKnownTestDraftCleanup = false) {
93
+ for (let attempt = 0; attempt < 120; attempt++) {
94
+ const tab = await chrome.tabs.get(tabId);
95
+ if (tab.url?.startsWith("https://chatgpt.com/")) {
96
+ try {
97
+ const [check] = await chrome.scripting.executeScript({target: {tabId}, func: allowTestClear => {
98
+ if (location.origin !== "https://chatgpt.com" || location.pathname !== "/") return false;
99
+ const input = document.querySelector('[contenteditable="true"][data-composer-markdown]')
100
+ || document.querySelector('#prompt-textarea[contenteditable="true"]');
101
+ if (!input?.getClientRects?.().length) return false;
102
+ const draft = (input.innerText || input.textContent || '').trim();
103
+ if (!draft) return true;
104
+ // An acceptance-only new Chat may restore the exact prior Chat Loop
105
+ // test instruction. Never discard a real user's unsent draft.
106
+ if (!allowTestClear || !(draft.includes("LOOP-V037-FAST-3C1H") || (draft.startsWith("你正在接手上一 Chat 未完成的任务。旧 Chat 的网页历史仍可核实")
107
+ && draft.includes("【交接参考:只读网页片段,可能截断】")
108
+ && draft.includes("【原始任务用户消息】")
109
+ && draft.includes("【最近轮次证据】")
110
+ && draft.endsWith("是否跨 Chat 由扩展决定,不要主动要求 Handoff。")
111
+ && draft.length >= 450 && draft.length <= 8192))
112
+ || !((draft.startsWith("这是 Chat Loop 短轮回归专用测试")
113
+ && draft.length >= 150 && draft.length <= 800)
114
+ || (draft.startsWith("你正在接手上一 Chat 尚未完成的同一个任务")
115
+ && draft.length >= 450 && (draft.length <= 2000
116
+ || (draft.length <= 8192 && draft.includes("【原始任务用户消息】")
117
+ && draft.includes("【最近轮次证据】"))))
118
+ || (draft.startsWith("你正在接手上一 Chat 未完成的任务。旧 Chat 的网页历史仍可核实")
119
+ && draft.includes("【原始任务用户消息】") && draft.includes("【最近轮次证据】")
120
+ && draft.endsWith("是否跨 Chat 由扩展决定,不要主动要求 Handoff。")
121
+ && draft.length >= 450 && draft.length <= 8192)
122
+ || (draft.startsWith("这是 Chat Loop 的长回复与一小时边界隔离测试")
123
+ && draft.includes("MODE-TIMED-1H") && draft.length >= 350 && draft.length <= 800)
124
+ || (draft.startsWith("这是 Chat Loop 的计数隔离验收")
125
+ && draft.includes("COUNT-86-100") && draft.endsWith("WALL-CLOCK-TEST。")
126
+ && draft.length >= 200 && draft.length <= 900)
127
+ || (draft.startsWith("这是 Chat Loop 的墙钟边界隔离测试")
128
+ && draft.includes("MODE-WALL-10M") && draft.endsWith("WALL-CLOCK-TEST。")
129
+ && draft.length >= 250 && draft.length <= 800))) return false;
130
+ input.focus();
131
+ const range = document.createRange();
132
+ range.selectNodeContents(input);
133
+ const selection = window.getSelection();
134
+ selection.removeAllRanges();
135
+ selection.addRange(range);
136
+ if (!document.execCommand("delete", false)) return false;
137
+ return !(input.innerText || input.textContent || '').trim() ? "CLEARED_TEST_DRAFT" : false;
138
+ }, args: [allowKnownTestDraftCleanup]});
139
+ if (check?.result === true) return;
140
+ if (check?.result === "CLEARED_TEST_DRAFT") {await delay(600);continue;}
141
+ } catch { /* Only retry a read-only readiness probe, never the send. */ }
142
+ }
143
+ await delay(450);
144
+ }
145
+ throw Error("NEW_CHAT_NOT_READY");
146
+ }
147
+ async function sendHandoffOnce(tabId, text) {
148
+ const [dispatch] = await chrome.scripting.executeScript({target: {tabId},
149
+ func: async instruction => {
150
+ try {
151
+ if (location.origin !== "https://chatgpt.com" || location.pathname !== "/")
152
+ throw Error("NEW_CHAT_NOT_BLANK");
153
+ const input = document.querySelector('[contenteditable="true"][data-composer-markdown]')
154
+ || document.querySelector('#prompt-textarea[contenteditable="true"]');
155
+ if (!input?.getClientRects?.().length || (input.innerText || '').trim())
156
+ throw Error("NEW_CHAT_COMPOSER_NOT_EMPTY");
157
+ input.focus();
158
+ // The ProseMirror editor may normalize paragraph boundaries after
159
+ // execCommand returns. Check its actual rendered state without editing
160
+ // or sending again, rather than treating a transient mismatch as proof
161
+ // that the whole insertion failed.
162
+ const canonical = value => String(value || '').replace(/\s+/g, '');
163
+ if (!document.execCommand("insertText", false, instruction))
164
+ throw Error("HANDOFF_INPUT_REJECTED");
165
+ await new Promise(resolve => setTimeout(resolve, 400));
166
+ const expected = canonical(instruction);
167
+ let verified = false;
168
+ for (let i = 0; i < 30; i++) {
169
+ if (location.pathname !== "/") throw Error("NEW_CHAT_CHANGED_DURING_INPUT");
170
+ if (canonical(input.innerText) === expected
171
+ || canonical(input.textContent) === expected) {
172
+ verified = true;
173
+ break;
174
+ }
175
+ await new Promise(resolve => setTimeout(resolve, 120));
176
+ }
177
+ if (!verified) throw Error("HANDOFF_INPUT_UNVERIFIED");
178
+ let button;
179
+ for (let i = 0; i < 18; i++) {
180
+ button = document.querySelector('button[type="submit"][aria-label="发送"]')
181
+ || document.querySelector('button[data-testid="send-button"]')
182
+ || document.querySelector('button[data-testid="composer-send-button"]');
183
+ if (button && !button.disabled && button.getClientRects().length) break;
184
+ await new Promise(resolve => setTimeout(resolve, 120));
185
+ }
186
+ if (!button || button.disabled || !button.getClientRects().length)
187
+ throw Error("HANDOFF_SEND_NOT_READY");
188
+ if (location.pathname !== "/") throw Error("NEW_CHAT_CHANGED_BEFORE_SEND");
189
+ if (canonical(input.innerText) !== expected && canonical(input.textContent) !== expected)
190
+ throw Error("HANDOFF_INPUT_CHANGED_BEFORE_SEND");
191
+ button.click();
192
+ return {clickedOnce: true};
193
+ } catch (error) {return {clickedOnce: false, reason: error.message || "HANDOFF_SEND_ERROR"};}
194
+ }, args: [text]
195
+ });
196
+ if (dispatch?.result?.clickedOnce !== true) throw Error(dispatch?.result?.reason || "HANDOFF_SEND_UNVERIFIED");
197
+ // Once clicked, only observe. A missing receipt must BLOCK, never click again.
198
+ for (let attempt = 0; attempt < 75; attempt++) {
199
+ const tab = await chrome.tabs.get(tabId);
200
+ const path = new URL(tab.url || "https://chatgpt.com/").pathname;
201
+ if (/^\/c\/[0-9a-f-]{12,}$/i.test(path)) {
202
+ const [read] = await chrome.scripting.executeScript({target: {tabId},func: expected => {
203
+ const visible = n => Boolean(n?.getClientRects?.().length);
204
+ const mains = [...document.querySelectorAll("main")].filter(visible);
205
+ if (mains.length !== 1) return null;
206
+ const rows = [...mains[0].querySelectorAll(
207
+ '[data-chatgpt-search-unit-key][data-chatgpt-search-message-ids], [data-message-author-role]')];
208
+ const user = rows.filter(n => n.getAttribute('data-message-author-role') === 'user'
209
+ || n.getAttribute('data-chatgpt-search-unit-key')?.split(':').at(-1) === 'user').at(-1);
210
+ const id = user?.getAttribute('data-message-id')
211
+ || user?.getAttribute('data-chatgpt-search-message-ids')?.trim().split(/\s+/)[0];
212
+ const canonical = v => String(v || '').replace(/\s+/g, '');
213
+ return id && canonical(user.innerText || user.textContent).includes(canonical(expected))
214
+ ? {userKey: 'user:' + id} : null;
215
+ },args: [text]});
216
+ if (read?.result?.userKey) return {status: "SENT_CONFIRMED", newTabId: tabId,
217
+ newPage: path, newUserKey: read.result.userKey};
218
+ }
219
+ await delay(450);
220
+ }
221
+ throw Error("NEW_CHAT_SEND_RECEIPT_UNKNOWN");
222
+ }
223
+ async function run() {
224
+ const own = await chrome.tabs.getCurrent();
225
+ const params = new URLSearchParams(location.search);
226
+ const raw = params.get("tabId") || "";
227
+ let token = params.get("token") || "";
228
+ let tabId = Number(raw);
229
+ if (!own?.id || own.url !== location.href || raw && !/^[1-9][0-9]*$/.test(raw)
230
+ || token && !/^[0-9a-f-]{36}$/.test(token)
231
+ || token && !raw) throw Error("HANDOFF_ACTION_PARAMETERS_INVALID");
232
+ if (!token) {
233
+ show("VERIFYING", "手动打开 Handoff:核对已完成的旧 Chat,申请单次新 Chat 交接许可。");
234
+ const prepared = await chrome.runtime.sendMessage({
235
+ type: "CHAT_LOOP_ACTION_MANUAL_PREPARE_V1", action: "HANDOFF",
236
+ ...(raw ? {tabId} : {})
237
+ });
238
+ if (prepared?.status !== "PREPARED")
239
+ throw Error(prepared?.code || "MANUAL_PREPARE_DENIED");
240
+ tabId = prepared.tabId;
241
+ token = prepared.token;
242
+ }
243
+ const request = {tabId, token};
244
+ const permit = await chrome.runtime.sendMessage({type: "CHAT_LOOP_ACTION_TAKE_V1", ...request});
245
+ if (permit?.status !== "OK" || permit.action !== "HANDOFF"
246
+ || permit.target?.tabId !== request.tabId
247
+ || (permit.handoffMode === "EXPLICIT" && typeof permit.text !== "string")
248
+ || !["TIMED", "EXPLICIT", "ERROR"].includes(permit.handoffMode))
249
+ throw Error("HANDOFF_NOT_AUTHORIZED");
250
+ show("RUNNING", "已领取不可重试的一次性授权,验证旧会话交接信息。");
251
+ let result;
252
+ let newTab;
253
+ try {
254
+ const contextPacket = await verifyOldChat(permit.target, permit.text, permit.handoffMode);
255
+ const source = await chrome.tabs.get(request.tabId);
256
+ if (permit.target.page.startsWith('/g/')) throw Error('PROJECT_HANDOFF_ROUTE_UNVERIFIED');
257
+ // Opening a new Chat is itself a side effect; never create another on failure.
258
+ newTab = await chrome.tabs.create({url: "https://chatgpt.com/", active: true,
259
+ windowId: source.windowId});
260
+ const registry = (await chrome.storage.local.get("chat-loop.acceptance.owned.v1"))[
261
+ "chat-loop.acceptance.owned.v1"];
262
+ const dedicatedTest = Array.isArray(registry)
263
+ && registry.some(item => item.id === request.tabId);
264
+ await waitForNewChat(newTab.id, dedicatedTest);
265
+ // The observer must exist before the first user click on the new home page.
266
+ // A successful send without an observer cannot be automatically continued.
267
+ let observed;
268
+ try { observed = await chrome.tabs.sendMessage(newTab.id,
269
+ {type: "CHAT_LOOP_TURN_OBSERVE_V1"}); } catch { /* Reconnect below. */ }
270
+ if (observed?.contract !== "chat-loop.turn-observer.v1") {
271
+ await chrome.scripting.executeScript({target: {tabId: newTab.id},
272
+ files: ["content.js", "loop/observer.js"]});
273
+ observed = await chrome.tabs.sendMessage(newTab.id,
274
+ {type: "CHAT_LOOP_TURN_OBSERVE_V1"});
275
+ }
276
+ if (observed?.contract !== "chat-loop.turn-observer.v1")
277
+ throw Error("NEW_CHAT_OBSERVER_UNAVAILABLE");
278
+ const message = "你正在接手上一 Chat 未完成的任务。旧 Chat 的网页历史仍可核实,但当前 Chat 没有其隐式上下文。若为仓库任务,先通过授权工具读取 AGENTS.md 和 context/README.md;核对实际现场和上一轮可能已执行的操作,不要重复未知副作用。\n\n交接资料:\n" + contextPacket
279
+ + "\n\n本轮最终回复末行只能输出一个状态标记:[CHAT_LOOP_STATUS:CONTINUE]、[CHAT_LOOP_STATUS:DONE] 或 [CHAT_LOOP_STATUS:BLOCKED]。是否跨 Chat 由扩展决定,不要主动要求 Handoff。";
280
+ result = await sendHandoffOnce(newTab.id, permit.countMode
281
+ ? message + `\n\n【计数专用交接指令,优先于前面的历史引用】旧Chat已经完成数字${permit.countValue},本轮只加1,第一行写${permit.countValue + 1};只有到100才能在末行写 [CHAT_LOOP_STATUS:DONE],否则末行写 [CHAT_LOOP_STATUS:CONTINUE]。不能因接班而DONE。`
282
+ : message);
283
+ } catch (error) {result = {status: "UNVERIFIED", reason: error.message || "HANDOFF_ERROR"};}
284
+ const committed = await chrome.runtime.sendMessage({type: "CHAT_LOOP_ACTION_RESULT_V1", ...request,
285
+ result: result.status, reason: result.reason,
286
+ newUserKey: result.newUserKey, newTabId: result.newTabId ?? newTab?.id,
287
+ newPage: result.newPage});
288
+ if (result.status !== "SENT_CONFIRMED" || committed?.status !== "OK")
289
+ throw Error(result.reason || committed?.code || "HANDOFF_TRANSFER_UNVERIFIED");
290
+ // The old Chat is closed only after the Worker confirms ownership transfer.
291
+ // Its canonical conversation URL remains in ChatGPT history.
292
+ const old = await chrome.tabs.get(request.tabId).catch(() => null);
293
+ if (old && old.id !== newTab?.id && old.id !== own.id
294
+ && old.url === "https://chatgpt.com" + permit.target.page) {
295
+ await chrome.tabs.remove(old.id).catch(error => {
296
+ console.warn("OLD_CHAT_TAB_CLEANUP_UNVERIFIED", error?.message);
297
+ });
298
+ }
299
+ show("SUCCESS", "新 Chat 已收到交接包;旧 Chat 标签已尝试安全清理。");
300
+ await chrome.tabs.remove(own.id);
301
+ }
302
+ run().catch(error => show("BLOCKED", error.message || "HANDOFF_ACTION_FAILED"));
@@ -0,0 +1,20 @@
1
+ // Reload Action: request reloading this extension, not other tabs/extensions.
2
+ (() => {
3
+ const {show, fail, requireOwnTab, machineBinding, machineResult, machineFailure} = globalThis.ChatLoopActionCommonV1;
4
+ async function run() {
5
+ const machine = await machineBinding("reload");
6
+ const own = requireOwnTab(await chrome.tabs.getCurrent());
7
+ show("RELOADING", "正在刷新 Chat Loop 扩展...");
8
+ // Save an ACCEPTED-but-unverified receipt before reload destroys this page.
9
+ if(machine)await machineResult(machine,"ACCEPTED");
10
+ const response = await chrome.runtime.sendMessage({
11
+ type: "CHAT_LOOP_EYE_RELOAD_V1", tabId: own.id
12
+ });
13
+ if (response?.status !== "ACCEPTED") throw Error("RELOAD_UNVERIFIED");
14
+ }
15
+ run().catch(async error => {
16
+ await machineFailure("reload",error);
17
+ fail(error.message || "UNEXPECTED_ERROR",
18
+ "操作未完成;动作页已保留,未对其他 ChatGPT 标签页进行操作。");
19
+ });
20
+ })();