@tomflow/chatloop-extension 0.3.38 → 0.3.45
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 +12 -0
- package/bin/cli.mjs +1 -1
- package/extension/acceptance.js +80 -3
- package/extension/actions/handoff.js +20 -2
- package/extension/actions/start.js +35 -8
- package/extension/background.js +108 -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.45)**:首轮发送前若新 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
|
|
@@ -35,3 +45,5 @@ Run: chatloop-extension extension <command> --help
|
|
|
35
45
|
Clean 只清空输入框,不发送,不更改 Owner;必须指定精确标签页 ID 且验证目标。Chrome 已安装扩展不随 npm 包自动安装。本地源码与已发布 npm 版本可能不同,真实结果请看 [CURRENT](../../context/CURRENT.md)。
|
|
36
46
|
|
|
37
47
|
详细接口和安全边界统一见 [Extension 产品文档](../../docs/extension.md)。
|
|
48
|
+
|
|
49
|
+
**新页准备超时策略(0.3.45)**:Start 新 Chat 编辑器只读等待约 218 秒,Handoff 新 Chat 只读等待约 234 秒,均保持一次新建/一次发送且不跳过 ChatGPT 网页验证。Start CLI 最多等待 310 秒,MCP Tools 转发最多 330 秒;Worker 对 Start/Handoff 的未确认资源等待 330 秒,其它 Action 维持 180 秒。窗口到期仍无确定回执时保留 BLOCKED,禁止自动重发。
|
package/bin/cli.mjs
CHANGED
|
@@ -68,7 +68,7 @@ async function executeAction(action,intent) {
|
|
|
68
68
|
? action+'.html':action.slice(5)+'.html';
|
|
69
69
|
const url='chrome-extension://'+config.extensionId+'/'+page+'?requestId='+req.id;
|
|
70
70
|
openAction(url);
|
|
71
|
-
const until=Date.now()+(action==='start'?
|
|
71
|
+
const until=Date.now()+(action==='start'?310000:27000);
|
|
72
72
|
while(Date.now()<until){
|
|
73
73
|
const result=readResult(req.id);
|
|
74
74
|
if(result){
|
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.
|
|
@@ -90,7 +90,7 @@ async function verifyOldChat(target, summary, mode = "EXPLICIT") {
|
|
|
90
90
|
return execution.result.snapshot || summary;
|
|
91
91
|
}
|
|
92
92
|
async function waitForNewChat(tabId, allowKnownTestDraftCleanup = false) {
|
|
93
|
-
for (let attempt = 0; attempt <
|
|
93
|
+
for (let attempt = 0; attempt < 520; attempt++) {
|
|
94
94
|
const tab = await chrome.tabs.get(tabId);
|
|
95
95
|
if (tab.url?.startsWith("https://chatgpt.com/")) {
|
|
96
96
|
try {
|
|
@@ -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,8 +4,16 @@ 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
|
-
for(let i=0;i<
|
|
16
|
+
for(let i=0;i<520;i++){
|
|
9
17
|
const tab=await chrome.tabs.get(id).catch(()=>null);
|
|
10
18
|
if(tab?.url==='https://chatgpt.com/'){
|
|
11
19
|
const [found]=await chrome.scripting.executeScript({target:{tabId:id},func:()=>{
|
|
@@ -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
|
+
// Security interstitials may outlast 2 minutes; wait up to ~218 seconds 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,15 @@ 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<((run.action==='START'||run.action==='HANDOFF')
|
|
291
|
+
?330000:180000)) return;
|
|
292
|
+
// No action may be recreated or re-sent on a missing receipt. Keep the Chat.
|
|
293
|
+
await saveRun({...run,phase:'BLOCKED',reason:'ACTION_RESULT_TIMEOUT_UNVERIFIED'});
|
|
294
|
+
}
|
|
234
295
|
async function prepareHandoffIfDue() {
|
|
235
296
|
const run = await readRun();
|
|
236
297
|
const deadlineAt = deadlineOf(run);
|
|
@@ -383,7 +444,7 @@ async function onTurnBlocked(message, sender) {
|
|
|
383
444
|
try {
|
|
384
445
|
const url = chrome.runtime.getURL(retry ? "continue.html" : "handoff.html")
|
|
385
446
|
+ "?tabId=" + previous.tabId + "&token=" + encodeURIComponent(token);
|
|
386
|
-
const actionTab = await
|
|
447
|
+
const actionTab = await createBoundedAction({url, active: false, windowId: sender.tab.windowId});
|
|
387
448
|
return {status: "QUEUED", actionTabId: actionTab.id, code: retry ? "NATIVE_RETRY_ONCE" : "CONTEXT_HANDOFF"};
|
|
388
449
|
} catch {
|
|
389
450
|
await saveRun({...next, phase: "BLOCKED", reason: "ERROR_ACTION_CREATE_FAILED"});
|
|
@@ -633,7 +694,7 @@ async function onTurnFinished(message, sender) {
|
|
|
633
694
|
try {
|
|
634
695
|
const url = chrome.runtime.getURL(nextAction === "HANDOFF" ? "handoff.html" : "continue.html")
|
|
635
696
|
+ "?tabId=" + tabId + "&token=" + encodeURIComponent(token);
|
|
636
|
-
const actionTab = await
|
|
697
|
+
const actionTab = await createBoundedAction({url, active: false, windowId: sender.tab.windowId});
|
|
637
698
|
return {status: "QUEUED", actionTabId: actionTab.id};
|
|
638
699
|
} catch (error) {
|
|
639
700
|
await saveRun({...recorded, phase: "BLOCKED", reason: "ACTION_PAGE_CREATE_FAILED"});
|
|
@@ -666,7 +727,7 @@ async function releaseCountInterval() {
|
|
|
666
727
|
delete run.countReleaseAt;
|
|
667
728
|
await saveRun(run);
|
|
668
729
|
try {
|
|
669
|
-
await
|
|
730
|
+
await createBoundedAction({url:chrome.runtime.getURL(action === "HANDOFF"
|
|
670
731
|
? "handoff.html" : "continue.html") + "?tabId=" + old.tabId
|
|
671
732
|
+ "&token=" + encodeURIComponent(run.token), active:false, windowId:tab.windowId});
|
|
672
733
|
} catch {
|
|
@@ -1082,6 +1143,9 @@ async function onResult(message, sender) {
|
|
|
1082
1143
|
}
|
|
1083
1144
|
if (!confirmed) delete next.modelTurnToken;
|
|
1084
1145
|
await saveRun(next);
|
|
1146
|
+
if(confirmed && run.action==="HANDOFF" && Number.isInteger(message.newTabId))
|
|
1147
|
+
await resources.confirmChat(message.newTabId,next.sessionId||next.token,
|
|
1148
|
+
"https://chatgpt.com"+message.newPage).catch(()=>{});
|
|
1085
1149
|
return {status: confirmed ? "OK" : "BLOCKED"};
|
|
1086
1150
|
}
|
|
1087
1151
|
// Extension updates invalidate previous injected listeners. Reconnect only its own observers.
|
|
@@ -1144,7 +1208,7 @@ async function releaseWallClockHold(run) {
|
|
|
1144
1208
|
wallReleasedAt: Date.now(), reason: ""};
|
|
1145
1209
|
await saveRun(next); // reserve once, then Action page exclusively sends
|
|
1146
1210
|
try {
|
|
1147
|
-
const action = await
|
|
1211
|
+
const action = await createBoundedAction({
|
|
1148
1212
|
url: chrome.runtime.getURL("continue.html") + "?tabId=" + run.tabId
|
|
1149
1213
|
+ "&token=" + encodeURIComponent(token), active: false, windowId: tab.windowId
|
|
1150
1214
|
});
|
|
@@ -1162,6 +1226,9 @@ async function ensureAlarm() {
|
|
|
1162
1226
|
if (!existing) await chrome.alarms.create(ALARM_NAME, {periodInMinutes: 1});
|
|
1163
1227
|
}
|
|
1164
1228
|
async function watchActiveTurn() {
|
|
1229
|
+
// One bounded reconciliation on the existing 1-minute Worker alarm.
|
|
1230
|
+
await serial(async()=>{await expireStaleAction();
|
|
1231
|
+
await resources.reconcile(await readRun()).catch(()=>{});});
|
|
1165
1232
|
// Normal periodic wakeups also reconcile a delayed one-shot deadline.
|
|
1166
1233
|
// All state changes share the same serial queue as Turn/Action receipts.
|
|
1167
1234
|
await serial(async()=>{await expireStaleModelIntent();await prepareHandoffIfDue();});
|
|
@@ -1218,18 +1285,43 @@ chrome.alarms?.onAlarm?.addListener(alarm => {
|
|
|
1218
1285
|
});
|
|
1219
1286
|
chrome.runtime.onInstalled?.addListener(() => {
|
|
1220
1287
|
void ensureAlarm();
|
|
1288
|
+
void serial(async()=>{await expireStaleAction();
|
|
1289
|
+
await resources.reconcile(await readRun()).catch(()=>{});});
|
|
1221
1290
|
void reconnectOpenChats();
|
|
1222
1291
|
});
|
|
1223
1292
|
chrome.runtime.onStartup?.addListener(() => {
|
|
1224
1293
|
void ensureAlarm();
|
|
1294
|
+
void serial(async()=>{await expireStaleAction();
|
|
1295
|
+
await resources.reconcile(await readRun()).catch(()=>{});});
|
|
1225
1296
|
void reconnectOpenChats();
|
|
1226
1297
|
});
|
|
1227
1298
|
void ensureAlarm();
|
|
1228
1299
|
// Reconcile an elapsed deadline after MV3 Worker suspension/reload.
|
|
1229
|
-
void serial(
|
|
1300
|
+
void serial(async()=>{await expireStaleAction();
|
|
1301
|
+
await resources.reconcile(await readRun()).catch(()=>{});
|
|
1302
|
+
await prepareHandoffIfDue();});
|
|
1230
1303
|
// A manual extension Reload does not necessarily fire onInstalled/onStartup.
|
|
1231
1304
|
// Reattach the two idempotent read-only scripts to existing ChatGPT tabs.
|
|
1232
1305
|
void reconnectOpenChats();
|
|
1306
|
+
// User-clicked popup Clean must share the same Action quota as Worker actions.
|
|
1307
|
+
// The popup cannot create extra Chrome tabs directly.
|
|
1308
|
+
async function openPopupClean(message,sender) {
|
|
1309
|
+
if(sender?.url!==chrome.runtime.getURL('popup.html')
|
|
1310
|
+
||!Number.isSafeInteger(message?.tabId)||message.tabId<=0)
|
|
1311
|
+
return {status:'DENIED',code:'POPUP_SOURCE_INVALID'};
|
|
1312
|
+
const target=await chrome.tabs.get(message.tabId).catch(()=>null);
|
|
1313
|
+
let valid=false;
|
|
1314
|
+
try {valid=target?.active===true && Number.isSafeInteger(target.windowId)
|
|
1315
|
+
&& new URL(target.url).origin==='https://chatgpt.com';} catch {}
|
|
1316
|
+
if(!valid)return {status:'DENIED',code:'CLEAN_TARGET_UNVERIFIED'};
|
|
1317
|
+
try {
|
|
1318
|
+
const action=await createBoundedAction({url:chrome.runtime.getURL('clean.html')
|
|
1319
|
+
+'?tabId='+target.id,windowId:target.windowId,active:true});
|
|
1320
|
+
return {status:'OK',actionTabId:action.id};
|
|
1321
|
+
} catch(error) {
|
|
1322
|
+
return {status:'BLOCKED',code:error?.message||'CLEAN_ACTION_UNVERIFIED'};
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1233
1325
|
// Read-only target resolver for a CLI-created formal Eye Action.
|
|
1234
1326
|
// This never starts a Loop, changes Owner, or sends a chat message.
|
|
1235
1327
|
async function onExtensionCliTarget(message,sender) {
|
|
@@ -1264,8 +1356,11 @@ chrome.runtime.onMessage.addListener((message, sender, reply) => {
|
|
|
1264
1356
|
"CHAT_LOOP_ACTION_TEST_PREPARE_V1",
|
|
1265
1357
|
"CHAT_LOOP_SESSION_START_PREPARE_V1", "CHAT_LOOP_SESSION_START_TAKE_V1",
|
|
1266
1358
|
"CHAT_LOOP_SESSION_START_RESULT_V1", "CHAT_LOOP_SESSION_STATUS_V1",
|
|
1359
|
+
"CHAT_LOOP_RESOURCE_CHAT_V1",
|
|
1267
1360
|
"CHAT_LOOP_ACCEPTANCE_CLOCK_ADVANCE_V1"].includes(type)) {
|
|
1268
|
-
const work = serial(() => type === "
|
|
1361
|
+
const work = serial(() => type === "CHAT_LOOP_RESOURCE_CHAT_V1"
|
|
1362
|
+
? resourceChatGate(message,sender)
|
|
1363
|
+
: type === "CHAT_LOOP_SESSION_START_PREPARE_V1"
|
|
1269
1364
|
? sessionStartPrepare(message,sender) : type === "CHAT_LOOP_SESSION_START_TAKE_V1"
|
|
1270
1365
|
? sessionStartTake(message,sender) : type === "CHAT_LOOP_SESSION_START_RESULT_V1"
|
|
1271
1366
|
? sessionStartResult(message,sender) : type === "CHAT_LOOP_SESSION_STATUS_V1"
|
|
@@ -1282,6 +1377,11 @@ chrome.runtime.onMessage.addListener((message, sender, reply) => {
|
|
|
1282
1377
|
void work.then(reply, error => reply({status: "BLOCKED", code: error.message || "SCHEDULER_ERROR"}));
|
|
1283
1378
|
return true;
|
|
1284
1379
|
}
|
|
1380
|
+
if (type === 'CHAT_LOOP_POPUP_CLEAN_OPEN_V1') {
|
|
1381
|
+
void serial(()=>openPopupClean(message,sender)).then(reply,
|
|
1382
|
+
()=>reply({status:'BLOCKED',code:'CLEAN_ACTION_UNVERIFIED'}));
|
|
1383
|
+
return true;
|
|
1384
|
+
}
|
|
1285
1385
|
if (type === "CHAT_LOOP_EXTENSION_TARGET_V1") {
|
|
1286
1386
|
void onExtensionCliTarget(message,sender).then(reply,
|
|
1287
1387
|
()=>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
|
});
|