@tomflow/chatloop-extension 0.3.38 → 0.3.44
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 +10 -0
- package/extension/acceptance.js +80 -3
- package/extension/actions/handoff.js +19 -1
- package/extension/actions/start.js +34 -7
- package/extension/background.js +107 -8
- package/extension/loop/resources.js +228 -0
- package/extension/manifest.json +1 -1
- package/extension/popup.js +4 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @tomflow/chatloop-extension
|
|
2
2
|
|
|
3
|
+
## P0 资源生命周期(Extension Worker 唯一仲裁)
|
|
4
|
+
|
|
5
|
+
`extension/loop/resources.js` 在既有 Worker 内维护持久资源索引,不引入第二浏览器驱动。正式 Action 同时最多 3 页,已登记执行 Chat 同时最多 2 页(Owner 与 Handoff successor);新建前拒绝超限。已存在的普通 Chat、未知发送、BLOCKED 或带草稿页面不自动关闭。只对精确登记且测试 projectId 为 `cl-intent-e2e-*` 的 DONE Chat 执行空草稿、非激活、URL 未变化的 TTL 回收。用户真实任务的 Chat 不会自动删除;其配额满时安全拒绝新建,需用户确认归属后清理。机器只读 Action 最多 3 分钟后有界回收;写入 Action 仅凭匹配终态与 token 回收。资源创建/关闭/峰值/拒绝写入同一 `chat-loop.resources.v1`,Acceptance Audit 读取该记录;未知收据不补发。`ACTION_RESULT_TIMEOUT_UNVERIFIED` 为安全 BLOCKED,不是自动重试。
|
|
6
|
+
|
|
7
|
+
**失败 Start 诊断(本地 0.3.44)**:首轮发送前若新 Chat 主页未就绪,Worker 保持 BLOCKED/START_SEND_UNVERIFIED;新增仅审计字段 `lastStartFailureCode` 记录安全白名单里的未就绪原因,`popup.html?audit=1` 的失败页探针只提供 DOM 就绪状态和控件存在布尔值,不读草稿/正文/密钥。它不证明页面可自动绕过挑战,也不能授权重发或清理未确认草稿。
|
|
8
|
+
|
|
9
|
+
**手动入口限额**:Popup 的 Clean 点击也必须经 Worker 同一 Action 配额准入;开发验收 New Test 一次最多保留一个已登记测试/首页,不能从旁路无限创建标签。
|
|
10
|
+
|
|
11
|
+
**验收边界**:源码测试不等于已安装 Chrome 实测。安装 npm 新包并 Reload 后须由正式 Extension Audit 验证 `resources` 字段及资源前后/峰值;未见该字段即表示仍在运行旧扩展。
|
|
12
|
+
|
|
3
13
|
Chrome 扩展(9 个独立 Action,含 Status / Start)及其独立 CLI / Native Messaging Host。执行前先看 Help:
|
|
4
14
|
|
|
5
15
|
```sh
|
package/extension/acceptance.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Read-only acceptance inventory and strictly scoped test-tab lifecycle.
|
|
2
2
|
(() => {
|
|
3
3
|
const q = new URLSearchParams(location.search);
|
|
4
|
-
const modes = ["audit", "newTest", "cleanup", "cleanupVerified", "cleanupStaleMachine", "cleanupTestHome"].filter(k => q.has(k));
|
|
4
|
+
const modes = ["audit", "newTest", "cleanup", "cleanupVerified", "cleanupStaleMachine", "cleanupTestHome", "cleanupFailedStartHome"].filter(k => q.has(k));
|
|
5
5
|
if (!modes.length) return; // Ordinary popup remains unchanged.
|
|
6
6
|
const runKey = "chat-loop.action.run.v1";
|
|
7
7
|
const ownedKey = "chat-loop.acceptance.owned.v1";
|
|
8
|
+
const resourcesKey = "chat-loop.resources.v1";
|
|
8
9
|
const extensionOrigin = chrome.runtime.getURL("").replace(/\/$/, "");
|
|
9
10
|
const status = document.getElementById("status");
|
|
10
11
|
const detail = document.getElementById("detail");
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
if (!own?.id || !own.url?.startsWith(chrome.runtime.getURL("popup.html")))
|
|
18
19
|
throw Error("INVALID_ACCEPTANCE_PAGE");
|
|
19
20
|
const [state, allBeforeCleanup] = await Promise.all([
|
|
20
|
-
chrome.storage.local.get([runKey, ownedKey]), chrome.tabs.query({})
|
|
21
|
+
chrome.storage.local.get([runKey, ownedKey, resourcesKey]), chrome.tabs.query({})
|
|
21
22
|
]);
|
|
22
23
|
// Only an old read-only acceptance audit page may be automatically reaped.
|
|
23
24
|
// These are diagnostics, not Chat conversations or formal Action pages.
|
|
@@ -35,7 +36,21 @@
|
|
|
35
36
|
const all = removedStaleAudits.length ? await chrome.tabs.query({}) : allBeforeCleanup;
|
|
36
37
|
const run = state[runKey] || null;
|
|
37
38
|
const owned = Array.isArray(state[ownedKey]) ? state[ownedKey] : [];
|
|
39
|
+
const resourceState = state[resourcesKey] || {};
|
|
40
|
+
const resourceActions = all.filter(t => t.url?.startsWith(extensionOrigin + "/")
|
|
41
|
+
&& /\/(?:start|continue|handoff|status|text|dom|screenshot|reload|clean|clock)\.html(?:\?|$)/.test(t.url)).length;
|
|
42
|
+
const resourceChats = Array.isArray(resourceState.entries)
|
|
43
|
+
? resourceState.entries.filter(e => e.kind === "chat"
|
|
44
|
+
&& all.some(t => t.id === e.id)).length : 0;
|
|
38
45
|
const audit = {at: new Date().toISOString(), mode,
|
|
46
|
+
resources: {policy:{actionLimit:3,ownedChatLimit:2},
|
|
47
|
+
created:resourceState.created || 0,observed:resourceState.observed || 0,
|
|
48
|
+
closed:resourceState.closed || 0,denied:resourceState.denied || 0,
|
|
49
|
+
peakActions:resourceState.peakActions || 0,
|
|
50
|
+
peakChats:resourceState.peakChats || 0,
|
|
51
|
+
liveActions:resourceActions,liveOwnedChats:resourceChats,
|
|
52
|
+
lastDenial:resourceState.lastDenial || null,
|
|
53
|
+
lastReaped:resourceState.lastReaped || null},
|
|
39
54
|
removedStaleAuditTabs: removedStaleAudits,
|
|
40
55
|
version: chrome.runtime.getManifest().version, totalTabs: all.length,
|
|
41
56
|
otherTabCount: all.filter(t => !isChat(t.url) && !t.url?.startsWith(extensionOrigin)).length,
|
|
@@ -48,7 +63,7 @@
|
|
|
48
63
|
ownedTestTabs: owned,
|
|
49
64
|
messageEvidence: {}, actionEvidence: {},
|
|
50
65
|
run: run ? Object.fromEntries(["phase", "action", "reason", "tabId", "page",
|
|
51
|
-
"sourceTabId", "sourcePage", "attemptedNewTabId", "newUserKey",
|
|
66
|
+
"sourceTabId", "sourcePage", "attemptedNewTabId", "lastStartFailureCode", "newUserKey",
|
|
52
67
|
"sessionId", "projectId", "generation",
|
|
53
68
|
"userKey", "assistantKey", "startedAt", "deadlineAt", "handoffPending",
|
|
54
69
|
"handoffPendingAt", "handoffMode", "testSetup", "timedTest", "testTurnIndex",
|
|
@@ -62,6 +77,33 @@
|
|
|
62
77
|
if (run?.parseEvidence && owned.some(item => item.id === run.tabId || item.id === run.sourceTabId)) {
|
|
63
78
|
audit.run.parseEvidence = run.parseEvidence;
|
|
64
79
|
}
|
|
80
|
+
// Read-only diagnosis of a failed pre-send test home, never a send or cleanup.
|
|
81
|
+
if (run?.phase === 'BLOCKED' && /^cl-intent-e2e-[a-z0-9-]+$/i.test(run.projectId || '')
|
|
82
|
+
&& Number.isInteger(run.attemptedNewTabId)) {
|
|
83
|
+
const candidate=all.find(t=>t.id===run.attemptedNewTabId
|
|
84
|
+
&& t.url==='https://chatgpt.com/');
|
|
85
|
+
if (candidate) {
|
|
86
|
+
try {
|
|
87
|
+
const [p]=await chrome.scripting.executeScript({target:{tabId:candidate.id},
|
|
88
|
+
func:()=>{
|
|
89
|
+
const visible=e=>!!e?.getClientRects?.().length;
|
|
90
|
+
const mains=[...document.querySelectorAll('main')].filter(visible);
|
|
91
|
+
const editor=document.querySelector('[contenteditable="true"][data-composer-markdown]')
|
|
92
|
+
||document.querySelector('#prompt-textarea[contenteditable="true"]');
|
|
93
|
+
return {pathname:location.pathname,mainCount:mains.length,
|
|
94
|
+
readyState:document.readyState,
|
|
95
|
+
editorExists:!!editor,editorVisible:visible(editor),
|
|
96
|
+
editorBlank:editor?!(editor.innerText||editor.textContent||'').trim():null,
|
|
97
|
+
hasOtherEditable:!!document.querySelector('[contenteditable="true"]'),
|
|
98
|
+
hasTextarea:!!document.querySelector('textarea'),
|
|
99
|
+
hasTextbox:!!document.querySelector('[role="textbox"]'),
|
|
100
|
+
hasChallengeFrame:!!document.querySelector('iframe[src*="challenge"],iframe[src*="captcha"]'),
|
|
101
|
+
mainHasMessages:mains.some(m=>!!m.querySelector('[data-chatgpt-search-message-ids],[data-message-author-role]'))};
|
|
102
|
+
}});
|
|
103
|
+
audit.failedStartHome=p?.result||{status:'NO_PROBE_RESULT'};
|
|
104
|
+
} catch {audit.failedStartHome={status:'PROBE_UNAVAILABLE'};}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
65
107
|
// Inspect only this extension's own open action pages: a failed test setup
|
|
66
108
|
// must expose a readable failure code, never trigger a second send.
|
|
67
109
|
for (const tab of audit.extensionTabs) {
|
|
@@ -153,6 +195,11 @@
|
|
|
153
195
|
if (mode === "newTest") {
|
|
154
196
|
if (run && !["DONE", "BLOCKED"].includes(run.phase))
|
|
155
197
|
throw Error("ACTIVE_LOOP_OWNERSHIP");
|
|
198
|
+
// A development-only acceptance entry must never create an unbounded
|
|
199
|
+
// stack of blank/test pages. Existing user homes also consume capacity.
|
|
200
|
+
if (all.some(tab => tab.url === 'https://chatgpt.com/'
|
|
201
|
+
|| owned.some(item => item.id === tab.id)))
|
|
202
|
+
throw Error('ACCEPTANCE_TEST_HOME_LIMIT');
|
|
156
203
|
const tab = await chrome.tabs.create({url: "https://chatgpt.com/", active: true,
|
|
157
204
|
windowId: own.windowId});
|
|
158
205
|
const next = [...owned, {id: tab.id, createdAt: Date.now()}];
|
|
@@ -270,6 +317,36 @@
|
|
|
270
317
|
chatTabs: left.filter(tab => isChat(tab.url)).length,
|
|
271
318
|
extensionTabs: left.filter(tab => tab.url?.startsWith(extensionOrigin)
|
|
272
319
|
&& tab.id !== own.id).length};
|
|
320
|
+
} else if (mode === "cleanupFailedStartHome") {
|
|
321
|
+
// Only a known failed pre-send Start home, never a Chat conversation.
|
|
322
|
+
const id=Number(q.get('cleanupFailedStartHome'));
|
|
323
|
+
if(!run || run.phase!=='BLOCKED' || run.action!=='START'
|
|
324
|
+
|| run.reason!=='START_SEND_UNVERIFIED'
|
|
325
|
+
|| !/^cl-intent-e2e-[a-z0-9-]+$/i.test(run.projectId||'')
|
|
326
|
+
|| !Number.isSafeInteger(id) || id!==run.attemptedNewTabId)
|
|
327
|
+
throw Error('FAILED_START_OWNERSHIP_UNVERIFIED');
|
|
328
|
+
const tab=await chrome.tabs.get(id).catch(()=>null);
|
|
329
|
+
if(tab?.url!=='https://chatgpt.com/' || tab.active || tab.pinned)
|
|
330
|
+
throw Error('FAILED_START_HOME_NOT_SAFE');
|
|
331
|
+
const [p]=await chrome.scripting.executeScript({target:{tabId:id},func:()=>{
|
|
332
|
+
const visible=e=>!!e?.getClientRects?.().length;
|
|
333
|
+
const mains=[...document.querySelectorAll('main')].filter(visible);
|
|
334
|
+
const editor=document.querySelector('[contenteditable="true"][data-composer-markdown]')
|
|
335
|
+
||document.querySelector('#prompt-textarea[contenteditable="true"]');
|
|
336
|
+
const noUploads=!document.querySelector('[data-testid*="attachment-preview"], [data-testid*="image-preview"], [data-testid="composer-attachment"]');
|
|
337
|
+
return location.href==='https://chatgpt.com/' && mains.length===1
|
|
338
|
+
&& visible(editor) && !(editor.innerText||editor.textContent||'').trim()
|
|
339
|
+
&& !mains[0].querySelector('[data-chatgpt-search-message-ids],[data-message-author-role]')
|
|
340
|
+
&& noUploads;
|
|
341
|
+
}});
|
|
342
|
+
if(p?.result!==true)throw Error('FAILED_START_HOME_NOT_BLANK');
|
|
343
|
+
const latest=(await chrome.storage.local.get(runKey))[runKey];
|
|
344
|
+
const live=await chrome.tabs.get(id).catch(()=>null);
|
|
345
|
+
if(latest?.phase!=='BLOCKED' || latest.sessionId!==run.sessionId
|
|
346
|
+
|| latest.attemptedNewTabId!==id || live?.url!==tab.url
|
|
347
|
+
|| live.active || live.pinned)throw Error('FAILED_START_CHANGED');
|
|
348
|
+
await chrome.tabs.remove(id);
|
|
349
|
+
audit.cleanedFailedStartHome=id;
|
|
273
350
|
} else if (mode === "cleanupTestHome") {
|
|
274
351
|
// Close only an unstarted HOME tab created by this acceptance utility.
|
|
275
352
|
// Never discard a personal draft, real conversation or unknown upload.
|
|
@@ -247,6 +247,13 @@ async function run() {
|
|
|
247
247
|
|| (permit.handoffMode === "EXPLICIT" && typeof permit.text !== "string")
|
|
248
248
|
|| !["TIMED", "EXPLICIT", "ERROR"].includes(permit.handoffMode))
|
|
249
249
|
throw Error("HANDOFF_NOT_AUTHORIZED");
|
|
250
|
+
const quota=await chrome.runtime.sendMessage({type:'CHAT_LOOP_RESOURCE_CHAT_V1',
|
|
251
|
+
action:'handoff',stage:'preflight'});
|
|
252
|
+
if(quota?.status!=='OK') {
|
|
253
|
+
await chrome.runtime.sendMessage({type:'CHAT_LOOP_ACTION_RESULT_V1',...request,
|
|
254
|
+
result:'UNVERIFIED',reason:quota?.code||'RESOURCE_QUOTA_UNVERIFIED'}).catch(()=>{});
|
|
255
|
+
throw Error(quota?.code||'RESOURCE_QUOTA_UNVERIFIED');
|
|
256
|
+
}
|
|
250
257
|
show("RUNNING", "已领取不可重试的一次性授权,验证旧会话交接信息。");
|
|
251
258
|
let result;
|
|
252
259
|
let newTab;
|
|
@@ -256,11 +263,22 @@ async function run() {
|
|
|
256
263
|
if (permit.target.page.startsWith('/g/')) throw Error('PROJECT_HANDOFF_ROUTE_UNVERIFIED');
|
|
257
264
|
// Opening a new Chat is itself a side effect; never create another on failure.
|
|
258
265
|
newTab = await chrome.tabs.create({url: "https://chatgpt.com/", active: true,
|
|
259
|
-
windowId: source.windowId});
|
|
266
|
+
windowId: source.windowId, openerTabId: own.id});
|
|
260
267
|
const registry = (await chrome.storage.local.get("chat-loop.acceptance.owned.v1"))[
|
|
261
268
|
"chat-loop.acceptance.owned.v1"];
|
|
262
269
|
const dedicatedTest = Array.isArray(registry)
|
|
263
270
|
&& registry.some(item => item.id === request.tabId);
|
|
271
|
+
// Chrome may initially report about:blank while the requested home URL loads.
|
|
272
|
+
let homeUrlReady=false;
|
|
273
|
+
for(let i=0;i<40;i++){
|
|
274
|
+
const candidate=await chrome.tabs.get(newTab.id).catch(()=>null);
|
|
275
|
+
if(candidate?.url==='https://chatgpt.com/'){homeUrlReady=true;break;}
|
|
276
|
+
await new Promise(resolve=>setTimeout(resolve,250));
|
|
277
|
+
}
|
|
278
|
+
if(!homeUrlReady)throw Error('HANDOFF_HOME_URL_UNVERIFIED');
|
|
279
|
+
const registered=await chrome.runtime.sendMessage({type:'CHAT_LOOP_RESOURCE_CHAT_V1',
|
|
280
|
+
action:'handoff',stage:'register',tabId:newTab.id});
|
|
281
|
+
if(registered?.status!=='OK')throw Error(registered?.code||'RESOURCE_REGISTER_UNVERIFIED');
|
|
264
282
|
await waitForNewChat(newTab.id, dedicatedTest);
|
|
265
283
|
// The observer must exist before the first user click on the new home page.
|
|
266
284
|
// A successful send without an observer cannot be automatically continued.
|
|
@@ -4,6 +4,14 @@ const info=document.getElementById('details'),label=document.getElementById('sta
|
|
|
4
4
|
const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
|
5
5
|
const show=(s,d)=>{label.textContent=s;info.textContent=d;document.body.dataset.result=s==='SUCCESS'?'success':'error';};
|
|
6
6
|
const host='com.tomflow.chatloop.extension';
|
|
7
|
+
async function waitForHomeUrl(id){
|
|
8
|
+
for(let i=0;i<40;i++){
|
|
9
|
+
const tab=await chrome.tabs.get(id).catch(()=>null);
|
|
10
|
+
if(tab?.url==='https://chatgpt.com/')return;
|
|
11
|
+
await pause(250);
|
|
12
|
+
}
|
|
13
|
+
throw Error('START_HOME_URL_UNVERIFIED');
|
|
14
|
+
}
|
|
7
15
|
async function newChatReady(id){
|
|
8
16
|
for(let i=0;i<85;i++){
|
|
9
17
|
const tab=await chrome.tabs.get(id).catch(()=>null);
|
|
@@ -18,7 +26,8 @@ async function newChatReady(id){
|
|
|
18
26
|
&&!mains[0].querySelector('[data-chatgpt-search-message-ids],[data-message-author-role]');
|
|
19
27
|
}}).catch(()=>[]);
|
|
20
28
|
if(found?.result===true)return;
|
|
21
|
-
|
|
29
|
+
// A newly opened ChatGPT home may hydrate slowly; never fail at 6 seconds.
|
|
30
|
+
// Exhaust the bounded 35-second readiness window without sending.
|
|
22
31
|
}
|
|
23
32
|
await pause(420);
|
|
24
33
|
}
|
|
@@ -120,8 +129,16 @@ async function run(){
|
|
|
120
129
|
requestId:id,token:prepared.token});
|
|
121
130
|
if(taken?.status!=='OK'||taken.sessionId!==prepared.sessionId
|
|
122
131
|
||typeof taken.prompt!=='string')throw Error(taken?.code||'START_TAKE_DENIED');
|
|
132
|
+
const quota=await chrome.runtime.sendMessage({type:'CHAT_LOOP_RESOURCE_CHAT_V1',
|
|
133
|
+
action:'start',stage:'preflight'});
|
|
134
|
+
if(quota?.status!=='OK')throw Error(quota?.code||'RESOURCE_QUOTA_UNVERIFIED');
|
|
123
135
|
show('RUNNING','已独占会话,正在创建唯一网页 Chat 并确认首轮发送。');
|
|
124
|
-
newTab=await chrome.tabs.create({url:'https://chatgpt.com/',active:true,
|
|
136
|
+
newTab=await chrome.tabs.create({url:'https://chatgpt.com/',active:true,
|
|
137
|
+
windowId:own.windowId,openerTabId:own.id});
|
|
138
|
+
await waitForHomeUrl(newTab.id);
|
|
139
|
+
const registered=await chrome.runtime.sendMessage({type:'CHAT_LOOP_RESOURCE_CHAT_V1',
|
|
140
|
+
action:'start',stage:'register',tabId:newTab.id});
|
|
141
|
+
if(registered?.status!=='OK')throw Error(registered?.code||'RESOURCE_REGISTER_UNVERIFIED');
|
|
125
142
|
await newChatReady(newTab.id);
|
|
126
143
|
await observerReady(newTab.id);
|
|
127
144
|
const receipt=await sendFirst(newTab.id,taken.prompt);
|
|
@@ -134,12 +151,22 @@ async function run(){
|
|
|
134
151
|
show('SUCCESS','唯一网页 Chat 已确认;执行由 Observer 和 Continue/Handoff 接管。');
|
|
135
152
|
await chrome.tabs.remove(own.id);
|
|
136
153
|
}catch(e){
|
|
137
|
-
if(taken&&committed?.status!=='OK')
|
|
154
|
+
if(taken&&committed?.status!=='OK') {
|
|
155
|
+
// This is diagnostics only; it does NOT authorize a retry or prove a
|
|
156
|
+
// message was never sent. Keep the existing fail-closed Owner semantics.
|
|
157
|
+
const failureCode=['START_HOME_URL_UNVERIFIED','NEW_CHAT_NOT_READY',
|
|
158
|
+
'FIRST_TURN_OBSERVER_UNAVAILABLE'].includes(e.message) ? e.message : null;
|
|
138
159
|
await chrome.runtime.sendMessage({type:'CHAT_LOOP_SESSION_START_RESULT_V1',
|
|
139
|
-
requestId:id,token:prepared.token,result:'UNVERIFIED',newTabId:newTab?.id
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
160
|
+
requestId:id,token:prepared.token,result:'UNVERIFIED',newTabId:newTab?.id,
|
|
161
|
+
...(failureCode?{failureCode}:{})}).catch(()=>{});
|
|
162
|
+
}
|
|
163
|
+
const failed=await chrome.runtime.sendNativeMessage(host,{op:'complete',id,nonce:proof.nonce,
|
|
164
|
+
action:'start',status:'FAILED',code:String(e.message||'START_UNVERIFIED').slice(0,100)}).catch(()=>null);
|
|
165
|
+
// Only the Action page closes after its durable FAILED receipt; the
|
|
166
|
+
// possibly-sent Chat and any unverified draft remain untouched.
|
|
167
|
+
if(failed?.status==='SAVED' && failed.id===id)
|
|
168
|
+
await chrome.tabs.remove(own.id).catch(()=>{});
|
|
169
|
+
throw e; // Never auto-retry a possibly clicked first user Turn.
|
|
143
170
|
}
|
|
144
171
|
}
|
|
145
172
|
void run().catch(e=>show('BLOCKED',e.message||'START_UNVERIFIED'));
|
package/extension/background.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Chat Loop browser scheduler. It never types or clicks in ChatGPT.
|
|
2
2
|
// Only extension-owned Action pages execute Continue or cross-Chat Handoff.
|
|
3
|
-
importScripts("loop/core.js");
|
|
3
|
+
importScripts("loop/core.js", "loop/resources.js");
|
|
4
4
|
const RUN_KEY = "chat-loop.action.run.v1";
|
|
5
5
|
const SESSION_HOST = "com.tomflow.chatloop.extension";
|
|
6
6
|
const HOUR_MS = 60 * 60 * 1000; // Acceptance wall-clock hours are independent of the MVP timeout.
|
|
@@ -40,6 +40,15 @@ const serial = fn => {
|
|
|
40
40
|
queue = work.catch(() => {});
|
|
41
41
|
return work;
|
|
42
42
|
};
|
|
43
|
+
// Admission and recovery run in this same Worker, never another browser executor.
|
|
44
|
+
const resources = globalThis.ChatLoopResourcesV1;
|
|
45
|
+
async function createBoundedAction(options) {
|
|
46
|
+
const quota=await resources.admit(await readRun(),{kind:'action'});
|
|
47
|
+
if (!quota.ok) throw Error(quota.code);
|
|
48
|
+
const tab=await chrome.tabs.create(options);
|
|
49
|
+
if (!await resources.registerAction(tab)) throw Error('ACTION_RESOURCE_UNVERIFIED');
|
|
50
|
+
return tab;
|
|
51
|
+
}
|
|
43
52
|
const pathOf = url => {
|
|
44
53
|
try {
|
|
45
54
|
const u = new URL(url);
|
|
@@ -91,7 +100,12 @@ async function saveRun(run) {
|
|
|
91
100
|
const now = Date.now(), deadlineAt = deadlineOf(run);
|
|
92
101
|
// Persist the intent even when a delayed alarm arrives after an Action
|
|
93
102
|
// receipt. This never revokes a Continue already reserved before deadline.
|
|
94
|
-
const
|
|
103
|
+
const sending = ["STARTING","RESERVED","PENDING"].includes(run.phase);
|
|
104
|
+
const marker = sending && typeof run.token==="string" ? run.token : null;
|
|
105
|
+
const next = {...run, ...(deadlineAt ? {deadlineAt} : {}),
|
|
106
|
+
...(marker ? {actionPendingAt: run.resourceToken===marker
|
|
107
|
+
&& Number.isSafeInteger(run.actionPendingAt) ? run.actionPendingAt : now,
|
|
108
|
+
resourceToken:marker} : {})};
|
|
95
109
|
if (clockActive(next) && deadlineAt !== null && now >= deadlineAt
|
|
96
110
|
&& !next.handoffPending) {
|
|
97
111
|
next.handoffPending = true;
|
|
@@ -106,6 +120,9 @@ async function saveRun(run) {
|
|
|
106
120
|
if(saved?.status!=="OK"||saved.session?.sessionId!==next.sessionId)
|
|
107
121
|
throw Error(saved?.code||"SESSION_PERSISTENCE_UNVERIFIED");
|
|
108
122
|
}
|
|
123
|
+
// The resource inventory is derived evidence; it never grants browser Owner.
|
|
124
|
+
if (["DONE","BLOCKED"].includes(next.phase))
|
|
125
|
+
await resources.terminal(next).catch(()=>{});
|
|
109
126
|
|
|
110
127
|
try {
|
|
111
128
|
if (clockActive(next) && !next.handoffPending && deadlineAt > Date.now()) {
|
|
@@ -132,6 +149,33 @@ function sessionProjection(run) {
|
|
|
132
149
|
const sessionPage=(sender,name,id)=> Number.isInteger(sender.tab?.id)
|
|
133
150
|
&& typeof id==='string'&&/^[a-f0-9]{32}$/.test(id)
|
|
134
151
|
&& sender.url===chrome.runtime.getURL(name+'.html')+'?requestId='+id;
|
|
152
|
+
async function resourceChatGate(message,sender) {
|
|
153
|
+
const run=await readRun();
|
|
154
|
+
if(run?.phase!=='PENDING'
|
|
155
|
+
|| !['start','handoff'].includes(message.action))
|
|
156
|
+
return {status:'DENIED',code:'RESOURCE_OWNER_NOT_PENDING'};
|
|
157
|
+
const authorized=message.action==='start'
|
|
158
|
+
? run.action==='START' && run.machineActionTabId===sender.tab?.id
|
|
159
|
+
&& sessionPage(sender,'start',run.machineRequestId)
|
|
160
|
+
: run.action==='HANDOFF' && isAction(sender,
|
|
161
|
+
{tabId:run.tabId,token:run.token},run);
|
|
162
|
+
if(!authorized)return {status:'DENIED',code:'RESOURCE_ACTION_SOURCE_INVALID'};
|
|
163
|
+
if(message.stage==='preflight') {
|
|
164
|
+
const quota=await resources.admit(run,{kind:'chat'});
|
|
165
|
+
return quota.ok?{status:'OK',code:'RESOURCE_CHAT_PERMITTED'}
|
|
166
|
+
:{status:'DENIED',code:quota.code};
|
|
167
|
+
}
|
|
168
|
+
if(message.stage!=='register' || !Number.isInteger(message.tabId))
|
|
169
|
+
return {status:'DENIED',code:'RESOURCE_REGISTER_INVALID'};
|
|
170
|
+
const tab=await chrome.tabs.get(message.tabId).catch(()=>null);
|
|
171
|
+
if (!tab || tab.id===run.tabId || tab.id===sender.tab.id
|
|
172
|
+
|| !await resources.registerChat(tab,run))
|
|
173
|
+
return {status:'DENIED',code:'RESOURCE_CHAT_NOT_VERIFIED'};
|
|
174
|
+
const inventory=await resources.reconcile(run);
|
|
175
|
+
if(inventory.chatCount>resources.CHAT_LIMIT)
|
|
176
|
+
return {status:'DENIED',code:'RESOURCE_CHAT_LIMIT'};
|
|
177
|
+
return {status:'OK',code:'RESOURCE_CHAT_REGISTERED'};
|
|
178
|
+
}
|
|
135
179
|
async function sessionStartPrepare(message,sender){
|
|
136
180
|
if(!sessionPage(sender,'start',message.requestId))
|
|
137
181
|
return {status:'DENIED',code:'INVALID_START_SOURCE'};
|
|
@@ -162,6 +206,8 @@ async function sessionStartPrepare(message,sender){
|
|
|
162
206
|
return {status:'DENIED',code:'SESSION_REPLACEMENT_MISMATCH'};
|
|
163
207
|
if(disk.session&&disk.session.phase!=='DONE'&&!matchingBlocked)
|
|
164
208
|
return {status:'DENIED',code:'SESSION_ALREADY_ACTIVE'};
|
|
209
|
+
const capacity=await resources.admit(prior,{kind:'chat'});
|
|
210
|
+
if(!capacity.ok)return {status:'DENIED',code:capacity.code};
|
|
165
211
|
// The same blocked task may never be replayed implicitly: a replacement
|
|
166
212
|
// requires a new project ID and leaves the old Chat intact for audit.
|
|
167
213
|
const next={sessionId:crypto.randomUUID(),projectId:intent.projectId,
|
|
@@ -195,13 +241,19 @@ async function sessionStartResult(message,sender){
|
|
|
195
241
|
confirmed=!!tab&&pathOf(tab.url)===message.newPage;
|
|
196
242
|
}
|
|
197
243
|
const next={...run,phase:confirmed?'WAITING_REPLY':'BLOCKED',
|
|
244
|
+
lastStartActionTabId:sender.tab.id,
|
|
198
245
|
reason:confirmed?'':'START_SEND_UNVERIFIED',
|
|
246
|
+
...(!confirmed&&['START_HOME_URL_UNVERIFIED','NEW_CHAT_NOT_READY',
|
|
247
|
+
'FIRST_TURN_OBSERVER_UNAVAILABLE'].includes(message.failureCode)
|
|
248
|
+
?{lastStartFailureCode:message.failureCode}:{}),
|
|
199
249
|
...(confirmed?{tabId:message.newTabId,page:message.newPage,
|
|
200
250
|
newUserKey:message.newUserKey}:{attemptedNewTabId:Number.isInteger(message.newTabId)?message.newTabId:null})};
|
|
201
251
|
delete next.startPrompt;
|
|
202
252
|
delete next.machineRequestId;
|
|
203
253
|
delete next.machineActionTabId;
|
|
204
254
|
await saveRun(next);
|
|
255
|
+
if(confirmed)await resources.confirmChat(message.newTabId,next.sessionId||next.token,
|
|
256
|
+
'https://chatgpt.com'+message.newPage).catch(()=>{});
|
|
205
257
|
return confirmed?{status:'OK',sessionId:next.sessionId,
|
|
206
258
|
activeChatId:sessionProjection(next).activeChatId,
|
|
207
259
|
activeChatUrl:sessionProjection(next).activeChatUrl}
|
|
@@ -231,6 +283,14 @@ async function sessionStatus(message,sender){
|
|
|
231
283
|
}
|
|
232
284
|
return {status:'OK',state:run.phase==='BLOCKED'?'BLOCKED':'ACTIVE',session:record};
|
|
233
285
|
}
|
|
286
|
+
async function expireStaleAction() {
|
|
287
|
+
const run=await readRun();
|
|
288
|
+
if(!['STARTING','RESERVED','PENDING'].includes(run?.phase)
|
|
289
|
+
|| !Number.isSafeInteger(run.actionPendingAt)
|
|
290
|
+
|| Date.now()-run.actionPendingAt<180000) return;
|
|
291
|
+
// No action may be recreated or re-sent on a missing receipt. Keep the Chat.
|
|
292
|
+
await saveRun({...run,phase:'BLOCKED',reason:'ACTION_RESULT_TIMEOUT_UNVERIFIED'});
|
|
293
|
+
}
|
|
234
294
|
async function prepareHandoffIfDue() {
|
|
235
295
|
const run = await readRun();
|
|
236
296
|
const deadlineAt = deadlineOf(run);
|
|
@@ -383,7 +443,7 @@ async function onTurnBlocked(message, sender) {
|
|
|
383
443
|
try {
|
|
384
444
|
const url = chrome.runtime.getURL(retry ? "continue.html" : "handoff.html")
|
|
385
445
|
+ "?tabId=" + previous.tabId + "&token=" + encodeURIComponent(token);
|
|
386
|
-
const actionTab = await
|
|
446
|
+
const actionTab = await createBoundedAction({url, active: false, windowId: sender.tab.windowId});
|
|
387
447
|
return {status: "QUEUED", actionTabId: actionTab.id, code: retry ? "NATIVE_RETRY_ONCE" : "CONTEXT_HANDOFF"};
|
|
388
448
|
} catch {
|
|
389
449
|
await saveRun({...next, phase: "BLOCKED", reason: "ERROR_ACTION_CREATE_FAILED"});
|
|
@@ -633,7 +693,7 @@ async function onTurnFinished(message, sender) {
|
|
|
633
693
|
try {
|
|
634
694
|
const url = chrome.runtime.getURL(nextAction === "HANDOFF" ? "handoff.html" : "continue.html")
|
|
635
695
|
+ "?tabId=" + tabId + "&token=" + encodeURIComponent(token);
|
|
636
|
-
const actionTab = await
|
|
696
|
+
const actionTab = await createBoundedAction({url, active: false, windowId: sender.tab.windowId});
|
|
637
697
|
return {status: "QUEUED", actionTabId: actionTab.id};
|
|
638
698
|
} catch (error) {
|
|
639
699
|
await saveRun({...recorded, phase: "BLOCKED", reason: "ACTION_PAGE_CREATE_FAILED"});
|
|
@@ -666,7 +726,7 @@ async function releaseCountInterval() {
|
|
|
666
726
|
delete run.countReleaseAt;
|
|
667
727
|
await saveRun(run);
|
|
668
728
|
try {
|
|
669
|
-
await
|
|
729
|
+
await createBoundedAction({url:chrome.runtime.getURL(action === "HANDOFF"
|
|
670
730
|
? "handoff.html" : "continue.html") + "?tabId=" + old.tabId
|
|
671
731
|
+ "&token=" + encodeURIComponent(run.token), active:false, windowId:tab.windowId});
|
|
672
732
|
} catch {
|
|
@@ -1082,6 +1142,9 @@ async function onResult(message, sender) {
|
|
|
1082
1142
|
}
|
|
1083
1143
|
if (!confirmed) delete next.modelTurnToken;
|
|
1084
1144
|
await saveRun(next);
|
|
1145
|
+
if(confirmed && run.action==="HANDOFF" && Number.isInteger(message.newTabId))
|
|
1146
|
+
await resources.confirmChat(message.newTabId,next.sessionId||next.token,
|
|
1147
|
+
"https://chatgpt.com"+message.newPage).catch(()=>{});
|
|
1085
1148
|
return {status: confirmed ? "OK" : "BLOCKED"};
|
|
1086
1149
|
}
|
|
1087
1150
|
// Extension updates invalidate previous injected listeners. Reconnect only its own observers.
|
|
@@ -1144,7 +1207,7 @@ async function releaseWallClockHold(run) {
|
|
|
1144
1207
|
wallReleasedAt: Date.now(), reason: ""};
|
|
1145
1208
|
await saveRun(next); // reserve once, then Action page exclusively sends
|
|
1146
1209
|
try {
|
|
1147
|
-
const action = await
|
|
1210
|
+
const action = await createBoundedAction({
|
|
1148
1211
|
url: chrome.runtime.getURL("continue.html") + "?tabId=" + run.tabId
|
|
1149
1212
|
+ "&token=" + encodeURIComponent(token), active: false, windowId: tab.windowId
|
|
1150
1213
|
});
|
|
@@ -1162,6 +1225,9 @@ async function ensureAlarm() {
|
|
|
1162
1225
|
if (!existing) await chrome.alarms.create(ALARM_NAME, {periodInMinutes: 1});
|
|
1163
1226
|
}
|
|
1164
1227
|
async function watchActiveTurn() {
|
|
1228
|
+
// One bounded reconciliation on the existing 1-minute Worker alarm.
|
|
1229
|
+
await serial(async()=>{await expireStaleAction();
|
|
1230
|
+
await resources.reconcile(await readRun()).catch(()=>{});});
|
|
1165
1231
|
// Normal periodic wakeups also reconcile a delayed one-shot deadline.
|
|
1166
1232
|
// All state changes share the same serial queue as Turn/Action receipts.
|
|
1167
1233
|
await serial(async()=>{await expireStaleModelIntent();await prepareHandoffIfDue();});
|
|
@@ -1218,18 +1284,43 @@ chrome.alarms?.onAlarm?.addListener(alarm => {
|
|
|
1218
1284
|
});
|
|
1219
1285
|
chrome.runtime.onInstalled?.addListener(() => {
|
|
1220
1286
|
void ensureAlarm();
|
|
1287
|
+
void serial(async()=>{await expireStaleAction();
|
|
1288
|
+
await resources.reconcile(await readRun()).catch(()=>{});});
|
|
1221
1289
|
void reconnectOpenChats();
|
|
1222
1290
|
});
|
|
1223
1291
|
chrome.runtime.onStartup?.addListener(() => {
|
|
1224
1292
|
void ensureAlarm();
|
|
1293
|
+
void serial(async()=>{await expireStaleAction();
|
|
1294
|
+
await resources.reconcile(await readRun()).catch(()=>{});});
|
|
1225
1295
|
void reconnectOpenChats();
|
|
1226
1296
|
});
|
|
1227
1297
|
void ensureAlarm();
|
|
1228
1298
|
// Reconcile an elapsed deadline after MV3 Worker suspension/reload.
|
|
1229
|
-
void serial(
|
|
1299
|
+
void serial(async()=>{await expireStaleAction();
|
|
1300
|
+
await resources.reconcile(await readRun()).catch(()=>{});
|
|
1301
|
+
await prepareHandoffIfDue();});
|
|
1230
1302
|
// A manual extension Reload does not necessarily fire onInstalled/onStartup.
|
|
1231
1303
|
// Reattach the two idempotent read-only scripts to existing ChatGPT tabs.
|
|
1232
1304
|
void reconnectOpenChats();
|
|
1305
|
+
// User-clicked popup Clean must share the same Action quota as Worker actions.
|
|
1306
|
+
// The popup cannot create extra Chrome tabs directly.
|
|
1307
|
+
async function openPopupClean(message,sender) {
|
|
1308
|
+
if(sender?.url!==chrome.runtime.getURL('popup.html')
|
|
1309
|
+
||!Number.isSafeInteger(message?.tabId)||message.tabId<=0)
|
|
1310
|
+
return {status:'DENIED',code:'POPUP_SOURCE_INVALID'};
|
|
1311
|
+
const target=await chrome.tabs.get(message.tabId).catch(()=>null);
|
|
1312
|
+
let valid=false;
|
|
1313
|
+
try {valid=target?.active===true && Number.isSafeInteger(target.windowId)
|
|
1314
|
+
&& new URL(target.url).origin==='https://chatgpt.com';} catch {}
|
|
1315
|
+
if(!valid)return {status:'DENIED',code:'CLEAN_TARGET_UNVERIFIED'};
|
|
1316
|
+
try {
|
|
1317
|
+
const action=await createBoundedAction({url:chrome.runtime.getURL('clean.html')
|
|
1318
|
+
+'?tabId='+target.id,windowId:target.windowId,active:true});
|
|
1319
|
+
return {status:'OK',actionTabId:action.id};
|
|
1320
|
+
} catch(error) {
|
|
1321
|
+
return {status:'BLOCKED',code:error?.message||'CLEAN_ACTION_UNVERIFIED'};
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1233
1324
|
// Read-only target resolver for a CLI-created formal Eye Action.
|
|
1234
1325
|
// This never starts a Loop, changes Owner, or sends a chat message.
|
|
1235
1326
|
async function onExtensionCliTarget(message,sender) {
|
|
@@ -1264,8 +1355,11 @@ chrome.runtime.onMessage.addListener((message, sender, reply) => {
|
|
|
1264
1355
|
"CHAT_LOOP_ACTION_TEST_PREPARE_V1",
|
|
1265
1356
|
"CHAT_LOOP_SESSION_START_PREPARE_V1", "CHAT_LOOP_SESSION_START_TAKE_V1",
|
|
1266
1357
|
"CHAT_LOOP_SESSION_START_RESULT_V1", "CHAT_LOOP_SESSION_STATUS_V1",
|
|
1358
|
+
"CHAT_LOOP_RESOURCE_CHAT_V1",
|
|
1267
1359
|
"CHAT_LOOP_ACCEPTANCE_CLOCK_ADVANCE_V1"].includes(type)) {
|
|
1268
|
-
const work = serial(() => type === "
|
|
1360
|
+
const work = serial(() => type === "CHAT_LOOP_RESOURCE_CHAT_V1"
|
|
1361
|
+
? resourceChatGate(message,sender)
|
|
1362
|
+
: type === "CHAT_LOOP_SESSION_START_PREPARE_V1"
|
|
1269
1363
|
? sessionStartPrepare(message,sender) : type === "CHAT_LOOP_SESSION_START_TAKE_V1"
|
|
1270
1364
|
? sessionStartTake(message,sender) : type === "CHAT_LOOP_SESSION_START_RESULT_V1"
|
|
1271
1365
|
? sessionStartResult(message,sender) : type === "CHAT_LOOP_SESSION_STATUS_V1"
|
|
@@ -1282,6 +1376,11 @@ chrome.runtime.onMessage.addListener((message, sender, reply) => {
|
|
|
1282
1376
|
void work.then(reply, error => reply({status: "BLOCKED", code: error.message || "SCHEDULER_ERROR"}));
|
|
1283
1377
|
return true;
|
|
1284
1378
|
}
|
|
1379
|
+
if (type === 'CHAT_LOOP_POPUP_CLEAN_OPEN_V1') {
|
|
1380
|
+
void serial(()=>openPopupClean(message,sender)).then(reply,
|
|
1381
|
+
()=>reply({status:'BLOCKED',code:'CLEAN_ACTION_UNVERIFIED'}));
|
|
1382
|
+
return true;
|
|
1383
|
+
}
|
|
1285
1384
|
if (type === "CHAT_LOOP_EXTENSION_TARGET_V1") {
|
|
1286
1385
|
void onExtensionCliTarget(message,sender).then(reply,
|
|
1287
1386
|
()=>reply({status:"DENIED",code:"OWNER_UNVERIFIED"}));
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// One bounded inventory for the existing Extension Worker. It never sends,
|
|
2
|
+
// types into, or otherwise drives a ChatGPT page.
|
|
3
|
+
(() => {
|
|
4
|
+
const KEY = 'chat-loop.resources.v1';
|
|
5
|
+
const ACTION_LIMIT = 3; // Includes machine Eye, Start, manual and timed Actions.
|
|
6
|
+
const CHAT_LIMIT = 2; // One owner plus at most one Handoff successor.
|
|
7
|
+
const AUDIT_TTL = 60_000;
|
|
8
|
+
const READ_TTL = 180_000;
|
|
9
|
+
const TERMINAL_TTL = 30_000;
|
|
10
|
+
const READ_ONLY = new Set(['status','text','dom','screenshot','reload']);
|
|
11
|
+
const ACTIONS = new Set(['start','continue','handoff','status','text','dom',
|
|
12
|
+
'screenshot','reload','clean','clock']);
|
|
13
|
+
const validId = s => /^[a-f0-9]{32}$/.test(s || '');
|
|
14
|
+
const validTabId = s => /^[1-9][0-9]*$/.test(s || '');
|
|
15
|
+
const extensionBase = () => chrome.runtime.getURL('');
|
|
16
|
+
function classify(tab) {
|
|
17
|
+
if (!Number.isInteger(tab?.id) || !tab.url) return null;
|
|
18
|
+
let u;
|
|
19
|
+
try {u = new URL(tab.url);} catch {return null;}
|
|
20
|
+
if (u.protocol !== 'chrome-extension:' || !tab.url.startsWith(extensionBase())
|
|
21
|
+
|| u.host !== new URL(extensionBase()).host) return null;
|
|
22
|
+
if (u.pathname === '/popup.html' && u.search === '?audit=1')
|
|
23
|
+
return {kind:'audit'};
|
|
24
|
+
if (u.pathname === '/popup.html' && u.searchParams.size === 1) {
|
|
25
|
+
const name=[...u.searchParams.keys()][0],value=u.searchParams.get(name);
|
|
26
|
+
const noId=new Set(['newTest','cleanup','cleanupVerified','cleanupStaleMachine']);
|
|
27
|
+
const tabId=new Set(['cleanupTestHome','cleanupFailedStartHome']);
|
|
28
|
+
if ((noId.has(name)&&value==='1') || (tabId.has(name)&&validTabId(value)))
|
|
29
|
+
return {kind:'manual',name:'acceptance'};
|
|
30
|
+
}
|
|
31
|
+
const name = u.pathname.match(/^\/(start|continue|handoff|status|text|dom|screenshot|reload|clean|clock)\.html$/)?.[1];
|
|
32
|
+
if (!ACTIONS.has(name)) return null;
|
|
33
|
+
if (u.searchParams.size === 1 && validId(u.searchParams.get('requestId')))
|
|
34
|
+
return {kind:READ_ONLY.has(name) || ['continue','handoff'].includes(name)
|
|
35
|
+
? 'read-only' : 'machine', name, requestId:u.searchParams.get('requestId')};
|
|
36
|
+
if (['continue','handoff'].includes(name)
|
|
37
|
+
&& u.searchParams.size === 2 && validTabId(u.searchParams.get('tabId'))
|
|
38
|
+
&& /^[0-9a-f-]{36}$/i.test(u.searchParams.get('token') || ''))
|
|
39
|
+
return {kind:'send', name, token:u.searchParams.get('token'),
|
|
40
|
+
targetId:Number(u.searchParams.get('tabId'))};
|
|
41
|
+
// Manual pages count toward the quota but are never automatically closed.
|
|
42
|
+
return {kind:'manual', name};
|
|
43
|
+
}
|
|
44
|
+
const initial = () => ({created:0, observed:0, closed:0, denied:0,
|
|
45
|
+
peakActions:0, peakChats:0, lastDenial:null, entries:[]});
|
|
46
|
+
const load = async () => {
|
|
47
|
+
const value=(await chrome.storage.local.get(KEY))[KEY];
|
|
48
|
+
return value && Array.isArray(value.entries) ? value : initial();
|
|
49
|
+
};
|
|
50
|
+
const save = state => chrome.storage.local.set({[KEY]:state});
|
|
51
|
+
const cleanEntry = x => x && Number.isInteger(x.id) && typeof x.url === 'string';
|
|
52
|
+
const safeRemove = async (tab,entry,state,reason) => {
|
|
53
|
+
const live=await chrome.tabs.get(tab.id).catch(()=>null);
|
|
54
|
+
if (!live || live.url !== entry.url || live.active || live.pinned) return false;
|
|
55
|
+
await chrome.tabs.remove(tab.id);
|
|
56
|
+
state.closed++;
|
|
57
|
+
state.lastReaped={id:tab.id, reason, at:Date.now()};
|
|
58
|
+
state.entries=state.entries.filter(e=>e.id!==tab.id);
|
|
59
|
+
return true;
|
|
60
|
+
};
|
|
61
|
+
const noDraft = async tabId => {
|
|
62
|
+
try {
|
|
63
|
+
const [p]=await chrome.scripting.executeScript({target:{tabId},func:()=>{
|
|
64
|
+
if (location.origin !== 'https://chatgpt.com' || !/^\/c\/[0-9a-f-]{12,}$/i.test(location.pathname)) return false;
|
|
65
|
+
const editor=document.querySelector('[contenteditable="true"][data-composer-markdown]')
|
|
66
|
+
||document.querySelector('#prompt-textarea[contenteditable="true"]');
|
|
67
|
+
return !!editor && !(editor.innerText || editor.textContent || '').trim()
|
|
68
|
+
&& !document.querySelector('[data-testid*="attachment-preview"], [data-testid*="image-preview"], [data-testid="composer-attachment"]');
|
|
69
|
+
}});
|
|
70
|
+
return p?.result===true;
|
|
71
|
+
} catch {return false;}
|
|
72
|
+
};
|
|
73
|
+
async function reconcile(run, {reap=true}={}) {
|
|
74
|
+
const state=await load(),now=Date.now();
|
|
75
|
+
const all=await chrome.tabs.query({});
|
|
76
|
+
const live=new Map(all.filter(t=>Number.isInteger(t.id)).map(t=>[t.id,t]));
|
|
77
|
+
const kept=[];
|
|
78
|
+
for (const entry of state.entries.filter(cleanEntry)) {
|
|
79
|
+
const tab=live.get(entry.id);
|
|
80
|
+
if (!tab || tab.url!==entry.url && entry.kind!=='chat') {
|
|
81
|
+
if (!tab) state.closed++;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// A Chat URL may change from blank to canonical only via markConfirmed.
|
|
85
|
+
if (entry.kind==='chat' && tab.url!==entry.url) {
|
|
86
|
+
kept.push(entry);continue; // Preserve provenance; never delete on unknown navigation.
|
|
87
|
+
}
|
|
88
|
+
kept.push(entry);
|
|
89
|
+
}
|
|
90
|
+
state.entries=kept;
|
|
91
|
+
for (const tab of all) {
|
|
92
|
+
const info=classify(tab);
|
|
93
|
+
if (!info || state.entries.some(e=>e.id===tab.id)) continue;
|
|
94
|
+
state.entries.push({id:tab.id,url:tab.url,kind:'action',type:info.kind,
|
|
95
|
+
name:info.name||null,firstSeenAt:now,owned:false});
|
|
96
|
+
state.observed++;
|
|
97
|
+
}
|
|
98
|
+
// A crashed Action may have created a Chat before calling registerChat.
|
|
99
|
+
// Chrome's trusted openerTabId lets us recover that exact provenance,
|
|
100
|
+
// including after an MV3 Worker restart. Unknown other Chats are not owned.
|
|
101
|
+
for (const tab of all) {
|
|
102
|
+
if (!Number.isInteger(tab.id)||!Number.isInteger(tab.openerTabId)
|
|
103
|
+
|| !/^https:\/\/chatgpt\.com\/(?:|c\/[0-9a-f-]{12,})$/i.test(tab.url||'')
|
|
104
|
+
|| state.entries.some(e=>e.id===tab.id)) continue;
|
|
105
|
+
const opener=live.get(tab.openerTabId), proof=classify(opener);
|
|
106
|
+
const exactStart=proof?.name==='start' && proof.kind==='machine'
|
|
107
|
+
&& [run?.machineActionTabId,run?.lastStartActionTabId].includes(opener?.id);
|
|
108
|
+
const exactHandoff=proof?.name==='handoff' && proof.kind==='send'
|
|
109
|
+
&& proof.token===run?.token
|
|
110
|
+
&& [run?.tabId,run?.sourceTabId].includes(proof.targetId);
|
|
111
|
+
if ((!exactStart&&!exactHandoff)||!(run?.sessionId||run?.token)) continue;
|
|
112
|
+
state.entries.push({id:tab.id,url:tab.url,kind:'chat',
|
|
113
|
+
sessionId:run.sessionId||run.token,firstSeenAt:now,confirmed:false,
|
|
114
|
+
testOwned:/^cl-intent-e2e-[a-z0-9-]+$/i.test(run.projectId||'')});
|
|
115
|
+
state.observed++;state.created++;
|
|
116
|
+
}
|
|
117
|
+
if (reap) {
|
|
118
|
+
for (const entry of [...state.entries]) {
|
|
119
|
+
const tab=live.get(entry.id);
|
|
120
|
+
if (!tab || tab.active || tab.pinned) continue;
|
|
121
|
+
const age=now-(entry.firstSeenAt||now);
|
|
122
|
+
if (entry.kind==='action') {
|
|
123
|
+
const info=classify(tab);
|
|
124
|
+
if (!info || tab.url!==entry.url) continue;
|
|
125
|
+
if (info.kind==='audit' && age>=AUDIT_TTL)
|
|
126
|
+
await safeRemove(tab,entry,state,'STALE_AUDIT');
|
|
127
|
+
else if (info.kind==='read-only' && age>=READ_TTL)
|
|
128
|
+
await safeRemove(tab,entry,state,'STALE_READ_ONLY_NO_SEND');
|
|
129
|
+
else if (info.kind==='send' && age>=TERMINAL_TTL
|
|
130
|
+
&& ['DONE','BLOCKED'].includes(run?.phase)
|
|
131
|
+
&& run?.token===info.token
|
|
132
|
+
&& [run.tabId,run.sourceTabId].includes(info.targetId))
|
|
133
|
+
await safeRemove(tab,entry,state,'CONFIRMED_TERMINAL_ACTION');
|
|
134
|
+
else if (info.kind==='machine' && info.name==='start'
|
|
135
|
+
&& age>=TERMINAL_TTL && ['DONE','BLOCKED'].includes(run?.phase)
|
|
136
|
+
&& [run?.lastStartActionTabId,run?.machineActionTabId].includes(tab.id))
|
|
137
|
+
await safeRemove(tab,entry,state,'CONFIRMED_TERMINAL_START');
|
|
138
|
+
} else if (entry.kind==='chat' && entry.confirmed
|
|
139
|
+
&& entry.testOwned===true && entry.terminal==='DONE' && age>=TERMINAL_TTL
|
|
140
|
+
&& tab.url===entry.url && await noDraft(tab.id)) {
|
|
141
|
+
await safeRemove(tab,entry,state,'DONE_OWNED_CHAT');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Count every live matching page, even when the bounded evidence index
|
|
146
|
+
// cannot retain every item after an external flood of manual pages.
|
|
147
|
+
const after=await chrome.tabs.query({});
|
|
148
|
+
const actionCount=after.filter(t=>classify(t)).length;
|
|
149
|
+
const afterIds=new Set(after.map(t=>t.id));
|
|
150
|
+
const chatCount=state.entries.filter(e=>e.kind==='chat'
|
|
151
|
+
&& afterIds.has(e.id)).length;
|
|
152
|
+
const unownedHomes=after.filter(t=>t.url==='https://chatgpt.com/'
|
|
153
|
+
&& !state.entries.some(e=>e.kind==='chat'&&e.id===t.id)).length;
|
|
154
|
+
state.peakActions=Math.max(state.peakActions,actionCount);
|
|
155
|
+
state.peakChats=Math.max(state.peakChats,chatCount);
|
|
156
|
+
// Retain tracked Chats first: evicting their provenance would weaken the
|
|
157
|
+
// quota and could make an unknown send appear safe to replay.
|
|
158
|
+
const chatEntries=state.entries.filter(e=>e.kind==='chat');
|
|
159
|
+
const actionEntries=state.entries.filter(e=>e.kind==='action');
|
|
160
|
+
const slots=Math.max(0,32-chatEntries.length);
|
|
161
|
+
state.entries=[...chatEntries,...(slots?actionEntries.slice(-slots):[])];
|
|
162
|
+
// Native/manual pages remain counted directly from the browser snapshot.
|
|
163
|
+
state.current={actions:actionCount,chats:chatCount,
|
|
164
|
+
unownedHomes,totalTabs:after.length,at:now};
|
|
165
|
+
await save(state);
|
|
166
|
+
return {state,actionCount,chatCount,unownedHomes};
|
|
167
|
+
}
|
|
168
|
+
async function admit(run,{kind='action'}={}) {
|
|
169
|
+
const {state,actionCount,chatCount,unownedHomes}=await reconcile(run);
|
|
170
|
+
const blocked=actionCount+(kind==='action'?1:0)>ACTION_LIMIT
|
|
171
|
+
? 'RESOURCE_ACTION_LIMIT' : chatCount+unownedHomes+(kind==='chat'?1:0)>CHAT_LIMIT
|
|
172
|
+
? 'RESOURCE_CHAT_LIMIT' : null;
|
|
173
|
+
if (blocked) {
|
|
174
|
+
state.denied++;state.lastDenial={code:blocked,at:Date.now()};
|
|
175
|
+
await save(state);
|
|
176
|
+
}
|
|
177
|
+
return blocked?{ok:false,code:blocked,actionCount,chatCount}
|
|
178
|
+
:{ok:true,actionCount,chatCount};
|
|
179
|
+
}
|
|
180
|
+
async function registerAction(tab) {
|
|
181
|
+
if (!classify(tab)) return false;
|
|
182
|
+
const state=await load();
|
|
183
|
+
if (!state.entries.some(e=>e.id===tab.id)) {
|
|
184
|
+
state.entries.push({id:tab.id,url:tab.url,kind:'action',
|
|
185
|
+
type:classify(tab).kind,firstSeenAt:Date.now(),owned:true});
|
|
186
|
+
state.created++;
|
|
187
|
+
// Fast actions can finish before the next alarm/reconciliation. Count
|
|
188
|
+
// their observed creation in peak metrics even if no later scan sees them.
|
|
189
|
+
state.peakActions=Math.max(state.peakActions,
|
|
190
|
+
state.entries.filter(e=>e.kind==='action').length);
|
|
191
|
+
await save(state);
|
|
192
|
+
}
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
async function registerChat(tab,run) {
|
|
196
|
+
if (!Number.isInteger(tab?.id) || tab.url!=='https://chatgpt.com/'
|
|
197
|
+
|| !(run?.sessionId || run?.token) || run.phase!=='PENDING') return false;
|
|
198
|
+
const state=await load();
|
|
199
|
+
const existing=state.entries.find(e=>e.id===tab.id);
|
|
200
|
+
if(existing)return existing.kind==='chat'
|
|
201
|
+
&&existing.sessionId===(run.sessionId||run.token);
|
|
202
|
+
state.entries.push({id:tab.id,url:tab.url,kind:'chat',
|
|
203
|
+
sessionId:run.sessionId||run.token,firstSeenAt:Date.now(),confirmed:false,
|
|
204
|
+
testOwned:/^cl-intent-e2e-[a-z0-9-]+$/i.test(run.projectId||'')});
|
|
205
|
+
state.created++;
|
|
206
|
+
state.peakChats=Math.max(state.peakChats,
|
|
207
|
+
state.entries.filter(e=>e.kind==='chat').length);
|
|
208
|
+
await save(state);
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
async function confirmChat(id,sessionId,url) {
|
|
212
|
+
const state=await load();
|
|
213
|
+
const entry=state.entries.find(e=>e.id===id&&e.kind==='chat'
|
|
214
|
+
&&e.sessionId===sessionId);
|
|
215
|
+
if (!entry || !/^https:\/\/chatgpt\.com\/c\/[0-9a-f-]{12,}$/i.test(url)) return;
|
|
216
|
+
entry.url=url;entry.confirmed=true;
|
|
217
|
+
await save(state);
|
|
218
|
+
}
|
|
219
|
+
async function terminal(run) {
|
|
220
|
+
if (!(run?.sessionId||run?.token) || !['DONE','BLOCKED'].includes(run.phase)) return;
|
|
221
|
+
const state=await load();
|
|
222
|
+
for(const entry of state.entries) if(entry.kind==='chat'
|
|
223
|
+
&&entry.sessionId===(run.sessionId||run.token)) entry.terminal=run.phase;
|
|
224
|
+
await save(state);
|
|
225
|
+
}
|
|
226
|
+
globalThis.ChatLoopResourcesV1=Object.freeze({reconcile,admit,registerAction,
|
|
227
|
+
registerChat,confirmChat,terminal,KEY,ACTION_LIMIT,CHAT_LIMIT});
|
|
228
|
+
})();
|
package/extension/manifest.json
CHANGED
package/extension/popup.js
CHANGED
|
@@ -76,6 +76,8 @@ document.getElementById('clean-action').addEventListener('click',async()=>{
|
|
|
76
76
|
if(!selected?.id||!selected?.url?.startsWith('https://chatgpt.com/')){
|
|
77
77
|
show('CLEAN_NOT_AVAILABLE','请先选中要清理的 ChatGPT 标签页。');return;
|
|
78
78
|
}
|
|
79
|
-
await chrome.
|
|
80
|
-
|
|
79
|
+
const opened=await chrome.runtime.sendMessage({
|
|
80
|
+
type:'CHAT_LOOP_POPUP_CLEAN_OPEN_V1',tabId:selected.id}).catch(()=>null);
|
|
81
|
+
if(opened?.status!=='OK')
|
|
82
|
+
show('CLEAN_BLOCKED',opened?.code||'CLEAN_ACTION_UNVERIFIED');
|
|
81
83
|
});
|