@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 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'?105000:27000);
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){
@@ -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 < 120; 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<85;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
- if(found?.result===false&&i>=15)throw Error('NEW_CHAT_NOT_BLANK_OR_READY');
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,windowId:own.windowId});
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}).catch(()=>{});
140
- await chrome.runtime.sendNativeMessage(host,{op:'complete',id,nonce:proof.nonce,
141
- action:'start',status:'FAILED',code:String(e.message||'START_UNVERIFIED').slice(0,100)}).catch(()=>{});
142
- throw e; // Unknown target tab is kept for inspection, never auto-retried.
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'));
@@ -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 next = {...run, ...(deadlineAt ? {deadlineAt} : {})};
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 chrome.tabs.create({url, active: false, windowId: sender.tab.windowId});
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 chrome.tabs.create({url, active: false, windowId: sender.tab.windowId});
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 chrome.tabs.create({url:chrome.runtime.getURL(action === "HANDOFF"
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 chrome.tabs.create({
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(prepareHandoffIfDue);
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 === "CHAT_LOOP_SESSION_START_PREPARE_V1"
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
+ })();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Chat Loop Extension",
4
- "version": "0.3.38",
4
+ "version": "0.3.45",
5
5
  "description": "Chat Loop browser companion: Eye actions, Turn observer, Continue and Handoff Actions",
6
6
  "permissions": [
7
7
  "activeTab",
@@ -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.tabs.create({url:chrome.runtime.getURL('clean.html?tabId='+selected.id),
80
- windowId:selected.windowId,active:true});
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomflow/chatloop-extension",
3
- "version": "0.3.38",
3
+ "version": "0.3.45",
4
4
  "type": "module",
5
5
  "description": "Chat Loop Extension package: nine formal Action pages and its own native CLI bridge",
6
6
  "bin": {