@tomflow/chatloop-extension 0.3.37 → 0.3.44

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