@tomflow/chatloop-extension 0.3.29 → 0.3.33

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.
@@ -2,6 +2,7 @@
2
2
  // Only extension-owned Action pages execute Continue or cross-Chat Handoff.
3
3
  importScripts("loop/core.js");
4
4
  const RUN_KEY = "chat-loop.action.run.v1";
5
+ const SESSION_HOST = "com.tomflow.chatloop.extension";
5
6
  const HOUR_MS = 60 * 60 * 1000; // Acceptance wall-clock hours are independent of the MVP timeout.
6
7
  const RUN_BUDGET_MS = 10 * 60 * 1000; // Ten minutes per Chat; never interrupt a running Turn.
7
8
  const ALARM_NAME = "chat-loop.turn-watch.v1";
@@ -10,6 +11,10 @@ const DEADLINE_ALARM_NAME = "chat-loop.handoff-deadline.v1";
10
11
  const COUNT_ALARM_NAME = "chat-loop.count-wait.v1";
11
12
  const COUNT_INTERVAL_MS = 60000;
12
13
  const CONTINUE_TEXT = "继续当前任务,完成本轮后只在末行写一个状态:[CHAT_LOOP_STATUS:CONTINUE]、[CHAT_LOOP_STATUS:DONE] 或 [CHAT_LOOP_STATUS:BLOCKED]。";
14
+ // The model cannot authenticate its originating Chat through MCP alone.
15
+ // This per-Turn capability is sent only to the newly confirmed loop-owned Chat.
16
+ // It is never a browser-send authorization; it only permits staging one intent.
17
+ const turnToolNote = token => "\n\n【Chat Loop 本轮工具凭证】只有本轮需要自定下一轮内容或提前交接时,才能调用 chat_loop_extension__continue / chat_loop_extension__handoff;参数 turnToken 填写 " + token + "。工具只登记意图,不等待本轮结束,不直接发送;请照常在本轮结束时输出 CONTINUE、DONE 或 BLOCKED 标记。不要在助手正文重复该凭证。";
13
18
  // Explicit acceptance setup only, dispatched by the same formal Continue page.
14
19
  // Fixed harmless prompt; no arbitrary text or external write interface.
15
20
  const ACTION_TEST_TEXT = "这是 Chat Loop 短轮回归专用测试,不调用工具、不改文件。每次仅回复一句短话,最后一行单独输出一个协议状态标记。\n第1条消息:OK1,然后末行 [CHAT_LOOP_STATUS:CONTINUE]。\n第2条消息:OK2,然后末行 [CHAT_LOOP_STATUS:CONTINUE]。\n第3条消息:OK3,然后末行 [CHAT_LOOP_STATUS:DONE]。\n不主动交接,也不追加解释。校验串:LOOP-V037-FAST-3C1H。";
@@ -52,7 +57,37 @@ const deadlineOf = run => Number.isSafeInteger(run?.startedAt) && run.startedAt
52
57
  ? run.startedAt + windowMsOf(run) : null;
53
58
  const clockActive = run => run && ["RESERVED", "PENDING", "WAITING_REPLY", "WAITING_INTERVAL"].includes(run.phase)
54
59
  && !run.testSetup && !run.wallTestHours;
60
+ // Durable, bounded metadata for model intent receipts. The prompt text remains only
61
+ // in the active run while its Action is pending; history never stores the prompt.
62
+ const MODEL_RECEIPT_TTL_MS = 24 * 60 * 60 * 1000;
63
+ const receiptHistory = run => Array.isArray(run?.modelReceipts)
64
+ ? run.modelReceipts.filter(x => x && typeof x.id === "string"
65
+ && Number.isSafeInteger(x.updatedAt)
66
+ && Date.now() - x.updatedAt < MODEL_RECEIPT_TTL_MS).slice(-16) : [];
67
+ const updateModelReceipt = (run, id, change) => {
68
+ if (!id) return run;
69
+ const records = receiptHistory(run);
70
+ const index = records.findIndex(x => x.id === id);
71
+ const existing = index >= 0 ? records.splice(index, 1)[0] : {id};
72
+ return {...run, modelReceipts: [...records,
73
+ {...existing, ...change, id, updatedAt:Date.now()}].slice(-16)};
74
+ };
75
+ const settleModelIntent = (run, status, reason) => {
76
+ const pending = run?.modelIntent;
77
+ const finished = pending
78
+ ? updateModelReceipt(run, pending.id, {status,
79
+ reason:reason||run.reason||null,
80
+ actualAction:run.action||pending.action,
81
+ checkpointForwarded:false})
82
+ : run;
83
+ const next = {...finished};
84
+ delete next.modelIntent;
85
+ delete next.modelTurnToken;
86
+ return next;
87
+ };
55
88
  async function saveRun(run) {
89
+ if (run && ["DONE","BLOCKED"].includes(run.phase))
90
+ run = settleModelIntent(run, run.phase==="DONE"?"CANCELLED_DONE":"BLOCKED", run.reason);
56
91
  const now = Date.now(), deadlineAt = deadlineOf(run);
57
92
  // Persist the intent even when a delayed alarm arrives after an Action
58
93
  // receipt. This never revokes a Continue already reserved before deadline.
@@ -63,6 +98,14 @@ async function saveRun(run) {
63
98
  next.handoffPendingAt = now;
64
99
  }
65
100
  await chrome.storage.local.set({[RUN_KEY]: next});
101
+ if(next.sessionId){
102
+ const record = sessionProjection(next);
103
+ const saved = await chrome.runtime.sendNativeMessage(SESSION_HOST,
104
+ {op:"session-sync",session:record}).catch(()=>null);
105
+ if(saved?.status!=="OK"||saved.session?.sessionId!==next.sessionId)
106
+ throw Error(saved?.code||"SESSION_PERSISTENCE_UNVERIFIED");
107
+ }
108
+
66
109
  try {
67
110
  if (clockActive(next) && !next.handoffPending && deadlineAt > Date.now()) {
68
111
  await chrome.alarms?.create?.(DEADLINE_ALARM_NAME, {when: deadlineAt});
@@ -74,6 +117,104 @@ async function saveRun(run) {
74
117
  else await chrome.alarms?.clear?.(COUNT_ALARM_NAME);
75
118
  } catch { /* The existing periodic alarm is a conservative backup. */ }
76
119
  }
120
+ // Browser Owner remains the only send authority. Native Host mirrors a minimal
121
+ // session pointer so later Chats can find the current Web Chat without a task engine.
122
+ function sessionProjection(run) {
123
+ const page=typeof run.page==='string'?run.page:'';
124
+ const match=page.match(/\/c\/([0-9a-f-]{12,})$/i);
125
+ const activeChatId=match?.[1]||null;
126
+ return {sessionId:run.sessionId,projectId:run.projectId,
127
+ phase:run.phase,generation:run.generation||0,activeChatId,
128
+ activeChatUrl:activeChatId?"https://chatgpt.com"+page:null,
129
+ updatedAt:Date.now()};
130
+ }
131
+ const sessionPage=(sender,name,id)=> Number.isInteger(sender.tab?.id)
132
+ && typeof id==='string'&&/^[a-f0-9]{32}$/.test(id)
133
+ && sender.url===chrome.runtime.getURL(name+'.html')+'?requestId='+id;
134
+ async function sessionStartPrepare(message,sender){
135
+ if(!sessionPage(sender,'start',message.requestId))
136
+ return {status:'DENIED',code:'INVALID_START_SOURCE'};
137
+ const prior=await readRun();
138
+ if(prior&&prior.phase!=='DONE')
139
+ return {status:'DENIED',code:'LOOP_ALREADY_ACTIVE'};
140
+ const disk=await chrome.runtime.sendNativeMessage(SESSION_HOST,{op:'session-read'}).catch(()=>null);
141
+ if(disk?.status!=='OK')return {status:'DENIED',code:'SESSION_STATE_UNVERIFIED'};
142
+ if(disk.session&&disk.session.phase!=='DONE')
143
+ return {status:'DENIED',code:'SESSION_ALREADY_ACTIVE'};
144
+ const proof=await chrome.runtime.sendNativeMessage(SESSION_HOST,
145
+ {op:'acquire',id:message.requestId,action:'start'}).catch(()=>null);
146
+ const intent=proof?.intent;
147
+ if(proof?.status!=='OK'||intent?.confirmed!==true
148
+ ||typeof intent.projectId!=='string'
149
+ ||!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(intent.projectId)
150
+ ||typeof intent.prompt!=='string'||!intent.prompt.trim()||intent.prompt.length>4000)
151
+ return {status:'DENIED',code:'START_INTENT_UNVERIFIED'};
152
+ const next={sessionId:crypto.randomUUID(),projectId:intent.projectId,
153
+ generation:0,phase:'STARTING',action:'START',token:crypto.randomUUID(),
154
+ page:'/',tabId:null,startedAt:Date.now(),
155
+ machineRequestId:message.requestId,machineActionTabId:sender.tab.id,
156
+ startPrompt:intent.prompt};
157
+ await saveRun(next); // Reserve BEFORE any new Chrome tab or send.
158
+ return {status:'PREPARED',token:next.token,sessionId:next.sessionId};
159
+ }
160
+ async function sessionStartTake(message,sender){
161
+ const run=await readRun();
162
+ if(!sessionPage(sender,'start',message.requestId)||run?.machineRequestId!==message.requestId
163
+ ||run.machineActionTabId!==sender.tab.id||run.token!==message.token
164
+ ||run.phase!=='STARTING')return {status:'DENIED',code:'START_OWNER_CHANGED'};
165
+ await saveRun({...run,phase:'PENDING'});
166
+ return {status:'OK',sessionId:run.sessionId,prompt:run.startPrompt};
167
+ }
168
+ async function sessionStartResult(message,sender){
169
+ const run=await readRun();
170
+ if(!sessionPage(sender,'start',message.requestId)||run?.machineRequestId!==message.requestId
171
+ ||run.machineActionTabId!==sender.tab.id||run.token!==message.token
172
+ ||run.phase!=='PENDING')return {status:'DENIED',code:'START_OWNER_CHANGED'};
173
+ let confirmed=message.result==='SENT_CONFIRMED'
174
+ &&Number.isInteger(message.newTabId)
175
+ &&typeof message.newPage==='string'&&pathOf('https://chatgpt.com'+message.newPage)===message.newPage
176
+ &&typeof message.newUserKey==='string'&&/^user:[^\s]{8,}$/.test(message.newUserKey);
177
+ if(confirmed){
178
+ const tab=await chrome.tabs.get(message.newTabId).catch(()=>null);
179
+ confirmed=!!tab&&pathOf(tab.url)===message.newPage;
180
+ }
181
+ const next={...run,phase:confirmed?'WAITING_REPLY':'BLOCKED',
182
+ reason:confirmed?'':'START_SEND_UNVERIFIED',
183
+ ...(confirmed?{tabId:message.newTabId,page:message.newPage,
184
+ newUserKey:message.newUserKey}:{attemptedNewTabId:Number.isInteger(message.newTabId)?message.newTabId:null})};
185
+ delete next.startPrompt;
186
+ delete next.machineRequestId;
187
+ delete next.machineActionTabId;
188
+ await saveRun(next);
189
+ return confirmed?{status:'OK',sessionId:next.sessionId,
190
+ activeChatId:sessionProjection(next).activeChatId,
191
+ activeChatUrl:sessionProjection(next).activeChatUrl}
192
+ :{status:'BLOCKED',code:'START_SEND_UNVERIFIED'};
193
+ }
194
+ async function sessionStatus(message,sender){
195
+ if(!sessionPage(sender,'status',message.requestId))
196
+ return {status:'DENIED',code:'INVALID_STATUS_SOURCE'};
197
+ const run=await readRun();
198
+ const disk=await chrome.runtime.sendNativeMessage(SESSION_HOST,{op:'session-read'}).catch(()=>null);
199
+ if(disk?.status!=='OK')return {status:'UNVERIFIED',code:'SESSION_STATE_UNVERIFIED'};
200
+ if(run?.sessionId&&(!disk.session||run.sessionId!==disk.session.sessionId
201
+ ||run.phase!==disk.session.phase
202
+ ||(run.generation||0)!==disk.session.generation))
203
+ return {status:'UNVERIFIED',code:'SESSION_FILE_MISMATCH'};
204
+ if(!run?.sessionId&&disk.session&&disk.session.phase!=='DONE')
205
+ return {status:'UNVERIFIED',code:'SESSION_OWNER_MISMATCH'};
206
+ if(run&&run.phase!=='DONE'&&!run.sessionId)
207
+ return {status:'OK',state:'LEGACY_OWNER',phase:run.phase};
208
+ if(!run||run.phase==='DONE')return {status:'OK',state:'IDLE',
209
+ ...(disk.session?{lastSession:disk.session}:{} )};
210
+ const record=sessionProjection(run);
211
+ if(record.activeChatId&&run.phase==='WAITING_REPLY'){
212
+ const tab=await chrome.tabs.get(run.tabId).catch(()=>null);
213
+ if(!tab||pathOf(tab.url)!==run.page)
214
+ return {status:'UNVERIFIED',code:'OWNER_TAB_MISSING',session:record};
215
+ }
216
+ return {status:'OK',state:run.phase==='BLOCKED'?'BLOCKED':'ACTIVE',session:record};
217
+ }
77
218
  async function prepareHandoffIfDue() {
78
219
  const run = await readRun();
79
220
  const deadlineAt = deadlineOf(run);
@@ -126,6 +267,18 @@ async function recheckUnknownTurn(message, previous, tabId) {
126
267
  }
127
268
  return null;
128
269
  }
270
+ // A suspended MV3 Worker may lose the Action receipt. Never retry a send
271
+ // from a stale RESERVED/PENDING phase; scrub the queued task text and block.
272
+ async function expireStaleModelIntent() {
273
+ const run=await readRun();
274
+ if(!run?.modelIntent || !Number.isSafeInteger(run.modelIntent.queuedAt)
275
+ ||Date.now()-run.modelIntent.queuedAt<=MODEL_RECEIPT_TTL_MS)return;
276
+ const pendingSend=["RESERVED","PENDING"].includes(run.phase);
277
+ const next=settleModelIntent(run,"STALE_UNVERIFIED","INTENT_TTL_EXPIRED");
278
+ await saveRun(pendingSend
279
+ ?{...next,phase:"BLOCKED",reason:"STALE_INTENT_UNKNOWN_SEND"}
280
+ :next);
281
+ }
129
282
  async function onTurnBlocked(message, sender) {
130
283
  const reason = message.reason;
131
284
  if (!["PAGE_ERROR", "CONTEXT_LIMIT", "SECURITY_CHALLENGE"].includes(reason)
@@ -164,9 +317,14 @@ async function onTurnBlocked(message, sender) {
164
317
  || reason === "PAGE_ERROR" && previous.retryCount !== 1 && second.retryAvailable === true) {
165
318
  const retry = reason === "PAGE_ERROR";
166
319
  const token = crypto.randomUUID();
167
- const next = {...previous, phase: "RESERVED", action: retry ? "CONTINUE" : "HANDOFF",
320
+ let next = {...previous, phase: "RESERVED", action: retry ? "CONTINUE" : "HANDOFF",
168
321
  token, reason: "", ...(retry ? {retryMode: "NATIVE", retryCount: 1}
169
322
  : {handoffMode: "ERROR", errorReason: reason})};
323
+ if (previous.modelIntent) {
324
+ next=updateModelReceipt(next,previous.modelIntent.id,{
325
+ status:"OVERRIDDEN_BY_ERROR",reason,actualAction:next.action});
326
+ delete next.modelIntent;
327
+ }
170
328
  await saveRun(next); // Durable before opening the only authorized Action.
171
329
  try {
172
330
  const url = chrome.runtime.getURL(retry ? "continue.html" : "handoff.html")
@@ -183,6 +341,76 @@ async function onTurnBlocked(message, sender) {
183
341
  : "CONTEXT_HANDOFF_REPEATED", errorUserKey: message.userKey, errorAt: Date.now()});
184
342
  return {status: "BLOCKED", code: reason === "PAGE_ERROR" ? "PAGE_ERROR" : "CONTEXT_HANDOFF_REPEATED"};
185
343
  }
344
+ // A model-driven request can only be queued against a currently RUNNING,
345
+ // already-owned user Turn. The MCP call returns immediately: waiting for this
346
+ // assistant to finish inside the tool call would deadlock the Turn.
347
+ async function onModelIntent(message,sender) {
348
+ const action=message?.action;
349
+ const file=action==='HANDOFF'?'handoff.html':action==='CONTINUE'?'continue.html':null;
350
+ const id=message?.id;
351
+ if(!file||!Number.isInteger(sender.tab?.id)||!/^[a-f0-9]{32}$/.test(id||'')
352
+ ||sender.url!==chrome.runtime.getURL(file)+'?requestId='+id)
353
+ return {status:'DENIED',code:'INVALID_MODEL_INTENT_SOURCE'};
354
+ const intent=message.intent;
355
+ if(!intent||typeof intent!=='object'||Array.isArray(intent)
356
+ ||Object.keys(intent).some(k=>k!=='turnToken' && (action==='CONTINUE'?k!=='nextPrompt':k!=='reason'))
357
+ ||typeof intent.turnToken!=='string'
358
+ ||!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(intent.turnToken))
359
+ return {status:'DENIED',code:'INVALID_MODEL_INTENT'};
360
+ if(action==='CONTINUE' && (typeof intent.nextPrompt!=='string'
361
+ ||!intent.nextPrompt.trim()||intent.nextPrompt.length>4000
362
+ ||/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(intent.nextPrompt))
363
+ ||action==='HANDOFF' && intent.reason!==undefined
364
+ &&(typeof intent.reason!=='string'||intent.reason.length>400
365
+ ||/[\x00-\x1f]/.test(intent.reason)))
366
+ return {status:'DENIED',code:'INVALID_MODEL_INTENT'};
367
+ const run=await readRun();
368
+ if(!run||run.phase!=='WAITING_REPLY'||run.testSetup||run.countMode
369
+ ||run.timedTest||run.wallTestHours||run.modelTurnToken!==intent.turnToken)
370
+ return {status:'DENIED',code:'MODEL_INTENT_OWNER_NOT_RUNNING'};
371
+ if(run.modelIntent)
372
+ return {status:'DENIED',code:'MODEL_INTENT_ALREADY_QUEUED'};
373
+ const tab=await chrome.tabs.get(run.tabId).catch(()=>null);
374
+ if(!tab||pathOf(tab.url)!==run.page)
375
+ return {status:'DENIED',code:'MODEL_INTENT_OWNER_CHANGED'};
376
+ const proof=await chrome.tabs.sendMessage(run.tabId,{
377
+ type:'CHAT_LOOP_TURN_OBSERVE_V1',poll:true}).catch(()=>null);
378
+ if(proof?.contract!=='chat-loop.turn-observer.v1'||proof.status!=='RUNNING'
379
+ ||proof.page!==run.page||proof.userKey!==run.newUserKey)
380
+ return {status:'DENIED',code:'MODEL_INTENT_TURN_NOT_RUNNING'};
381
+ const next = {...run,modelIntent:{
382
+ id, action, page:run.page, userKey:run.newUserKey,
383
+ ...(action==='CONTINUE'?{nextPrompt:intent.nextPrompt}
384
+ :{reason:intent.reason||''}), queuedAt:Date.now()
385
+ }};
386
+ await saveRun(updateModelReceipt(next,id,{action,turnToken:intent.turnToken,
387
+ status:'QUEUED',reason:null,actualAction:null,checkpointForwarded:false}));
388
+ return {status:'QUEUED',action,intentId:id};
389
+ }
390
+ async function onModelIntentStatus(message,sender) {
391
+ const action=message?.action;
392
+ const file=action==='HANDOFF'?'handoff.html':action==='CONTINUE'?'continue.html':null;
393
+ const id=message?.id, intent=message?.intent;
394
+ if(!file||!Number.isInteger(sender.tab?.id)||!/^[a-f0-9]{32}$/.test(id||'')
395
+ ||sender.url!==chrome.runtime.getURL(file)+'?requestId='+id
396
+ ||!intent||typeof intent!=='object'||Array.isArray(intent)
397
+ ||typeof intent.turnToken!=='string'
398
+ ||!(intent.lookup===true||typeof intent.intentId==='string')
399
+ ||Object.keys(intent).some(k=>k!=='turnToken'&&
400
+ k!==(intent.lookup===true?'lookup':'intentId'))
401
+ ||intent.lookup!==true&& !/^[a-f0-9]{32}$/.test(intent.intentId||''))
402
+ return {status:'DENIED',code:'INTENT_QUERY_UNAUTHORIZED'};
403
+ const run=await readRun();
404
+ const receipts=receiptHistory(run).filter(x=>x.turnToken===intent.turnToken
405
+ &&x.action===action);
406
+ const receipt=intent.lookup===true?receipts.at(-1)
407
+ :receipts.find(x=>x.id===intent.intentId);
408
+ if(!receipt)return {status:'DENIED',code:'INTENT_RECEIPT_NOT_FOUND'};
409
+ return {status:'STATUS',action,intentId:receipt.id,phase:receipt.status,
410
+ actualAction:receipt.actualAction||null,reason:receipt.reason||null,
411
+ checkpointForwarded:receipt.checkpointForwarded===true,
412
+ updatedAt:receipt.updatedAt};
413
+ }
186
414
  async function onTurnFinished(message, sender) {
187
415
  if (!await eligible(sender, message.page)
188
416
  || typeof message.userKey !== "string" || !/^user:[^\s]{8,}$/.test(message.userKey)
@@ -248,9 +476,14 @@ async function onTurnFinished(message, sender) {
248
476
  const timeExpired = now >= startedAt + windowMsOf(previous);
249
477
  // Deadline intent can be persisted by the timer even while a Turn RUNS.
250
478
  // The decision below still waits for the observer's complete Turn proof.
479
+ const queued = previous?.modelIntent?.page===message.page
480
+ && previous.modelIntent.userKey===message.userKey ? previous.modelIntent : null;
481
+ // Existing DONE/BLOCKED, deadline and page-error policies still win.
482
+ // A queued model request only changes a still-eligible CONTINUE Turn.
251
483
  let nextAction = status === "HANDOFF" ? "UNSUPPORTED_MODEL_HANDOFF"
252
484
  : (previous?.handoffPending || timeExpired) && status === "CONTINUE"
253
- ? "HANDOFF" : status;
485
+ ? "HANDOFF" : status === "CONTINUE" && queued
486
+ ? queued.action : status;
254
487
  if (nextAction !== "CONTINUE" && nextAction !== "HANDOFF") {
255
488
  if (previous?.phase === "WAITING_REPLY" && previous.tabId === tabId) {
256
489
  await saveRun({...previous, phase: status === "DONE" ? "DONE" : "BLOCKED",
@@ -289,9 +522,11 @@ async function onTurnFinished(message, sender) {
289
522
  const newlyDue = Date.now() >= startedAt + windowMsOf(previous);
290
523
  if (nextAction === "CONTINUE" && newlyDue) nextAction = "HANDOFF";
291
524
  const token = crypto.randomUUID();
292
- const run = {tabId, page: message.page, userKey: message.userKey,
525
+ const run = {...(previous?.sessionId ? {sessionId: previous.sessionId,
526
+ projectId: previous.projectId, generation: previous.generation || 0} : {}),
527
+ tabId, page: message.page, userKey: message.userKey,
293
528
  assistantKey: message.assistantKey, phase: "RESERVED", token,
294
- action: nextAction, startedAt,
529
+ action: nextAction, startedAt, modelReceipts:receiptHistory(previous),
295
530
  ...(countMode ? {countMode:true, countValue:verifiedCount,
296
531
  countLastSendAt:previous.countLastSendAt,
297
532
  countHandoffs:previous.countHandoffs || 0} : {}),
@@ -322,16 +557,28 @@ async function onTurnFinished(message, sender) {
322
557
  wallOriginalReleaseAt: previous.wallOriginalReleaseAt} : {})} : {})} : {}),
323
558
  // A timed Handoff uses an Action-local read-only snapshot. Do not keep
324
559
  // the entire conversation in storage or embed it in the Action URL.
325
- handoffMode: nextAction === "HANDOFF" ? "TIMED" : null};
560
+ handoffMode: nextAction === "HANDOFF" ? "TIMED" : null,
561
+ ...(status==="CONTINUE" && queued
562
+ && (queued.action===nextAction
563
+ ||queued.action==="CONTINUE"&&nextAction==="HANDOFF")
564
+ ? {modelIntent:queued} : {})};
565
+ // Deadline wins, but the queued continuation is retained as a *proposal*
566
+ // in the bounded Handoff packet instead of silently losing task progress.
567
+ const overridden=queued?.action==="CONTINUE"&&nextAction==="HANDOFF";
568
+ const recorded=queued?updateModelReceipt(run,queued.id,{
569
+ status:overridden?"OVERRIDDEN_BY_DEADLINE":"RESERVED",
570
+ actualAction:nextAction,
571
+ reason:overridden?"OVERRIDDEN_BY_DEADLINE":null,
572
+ checkpointForwarded:false}):run;
326
573
  // Durable before opening an Action; an uncertain outcome must never be retried.
327
- await saveRun(run);
574
+ await saveRun(recorded);
328
575
  try {
329
576
  const url = chrome.runtime.getURL(nextAction === "HANDOFF" ? "handoff.html" : "continue.html")
330
577
  + "?tabId=" + tabId + "&token=" + encodeURIComponent(token);
331
578
  const actionTab = await chrome.tabs.create({url, active: false, windowId: sender.tab.windowId});
332
579
  return {status: "QUEUED", actionTabId: actionTab.id};
333
580
  } catch (error) {
334
- await saveRun({...run, phase: "BLOCKED", reason: "ACTION_PAGE_CREATE_FAILED"});
581
+ await saveRun({...recorded, phase: "BLOCKED", reason: "ACTION_PAGE_CREATE_FAILED"});
335
582
  return {status: "BLOCKED", code: error.message || "ACTION_PAGE_CREATE_FAILED"};
336
583
  }
337
584
  }
@@ -386,7 +633,8 @@ async function onManualPrepare(message, sender) {
386
633
  || message.tabId !== Number(rawTabId))))
387
634
  return {status: "DENIED", code: "INVALID_ACTION_URL"};
388
635
  const previous = await readRun();
389
- if (previous && !["DONE", "BLOCKED"].includes(previous.phase))
636
+ if (previous && (previous.sessionId&&previous.phase!=="DONE"
637
+ || !["DONE", "BLOCKED"].includes(previous.phase)))
390
638
  return {status: "DENIED", code: "LOOP_ALREADY_ACTIVE"};
391
639
  const candidates = await chrome.tabs.query({url: "https://chatgpt.com/*"});
392
640
  const all = candidates.filter(tab => Number.isInteger(tab.id) && pathOf(tab.url));
@@ -448,7 +696,8 @@ async function onTestPrepare(message, sender) {
448
696
  chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&discardDraft=1&timed=1&wall=10m&count=86"].includes(sender.url))
449
697
  return {status: "DENIED", code: "INVALID_TEST_ACTION"};
450
698
  const previous = await readRun();
451
- if (previous && !["DONE", "BLOCKED"].includes(previous.phase))
699
+ if (previous && (previous.sessionId&&previous.phase!=="DONE"
700
+ || !["DONE", "BLOCKED"].includes(previous.phase)))
452
701
  return {status: "DENIED", code: "LOOP_ALREADY_ACTIVE"};
453
702
  if (previous?.tabId === tabId)
454
703
  return {status: "DENIED", code: "TEST_TARGET_ALREADY_USED"};
@@ -639,8 +888,18 @@ async function onTake(message, sender) {
639
888
  await saveRun({...run, phase: "BLOCKED", reason: "CHAT_TARGET_CHANGED"});
640
889
  return {status: "DENIED", code: "CHAT_TARGET_CHANGED"};
641
890
  }
642
- await saveRun({...run, phase: "PENDING"});
643
- return {status: "OK", action: run.action || "CONTINUE",
891
+ // Bind a model-control capability to the next *user* Turn, not to a tab or to another Chat.
892
+ // Acceptance fixtures retain their existing prompts; native Retry keeps its original Turn token.
893
+ const modelTurnToken = run.retryMode === "NATIVE" ? run.modelTurnToken || null
894
+ : run.testSetup || run.countMode || run.timedTest || run.wallTestHours
895
+ ? null : crypto.randomUUID();
896
+ const pending={...run, phase: "PENDING", modelTurnToken};
897
+ await saveRun(run.modelIntent ? updateModelReceipt(pending,run.modelIntent.id,{
898
+ status:run.action==="HANDOFF"&&run.modelIntent.action==="CONTINUE"
899
+ ?"OVERRIDDEN_BY_DEADLINE":"PENDING"}) : pending);
900
+ const permit = {status: "OK", action: run.action || "CONTINUE",
901
+ modelTurnToken,
902
+ modelTurnNote: modelTurnToken ? turnToolNote(modelTurnToken) : "",
644
903
  handoffMode: run.handoffMode || null, retryMode: run.retryMode || null,
645
904
  countMode: run.countMode === true,
646
905
  countValue: run.countMode ? run.countValue : null,
@@ -649,13 +908,27 @@ async function onTake(message, sender) {
649
908
  userKey: run.retryMode === "NATIVE" || run.handoffMode === "ERROR"
650
909
  ? run.newUserKey : run.userKey,
651
910
  testSetup: run.testSetup === true, discardDraft: run.discardDraft === true},
911
+ modelIntent: run.action==="HANDOFF" && run.modelIntent?.action==="HANDOFF"
912
+ ? {id:run.modelIntent.id,reason:run.modelIntent.reason||""} : null,
913
+ pendingPlan: run.action==="HANDOFF" && run.modelIntent?.action==="CONTINUE"
914
+ ? {id:run.modelIntent.id,nextPrompt:run.modelIntent.nextPrompt} : null,
652
915
  text: run.testSetup ? (run.countMode ? COUNT_SETUP_TEXT
653
916
  : run.wallTestHours || run.wallTestMinutes === 10 ? ACTION_WALL_TEST_TEXT
654
917
  : run.timedTest ? ACTION_TIMED_TEST_TEXT : ACTION_TEST_TEXT)
655
918
  : run.action === "HANDOFF" ? run.handoffText || null
919
+ : run.modelIntent?.action==="CONTINUE" ? (
920
+ "【Chat Loop 续接来源】上一轮由模型登记的 Continue 请求 " + run.modelIntent.id
921
+ + " 已被扩展唯一 Action 领取。\n"
922
+ + "【待核实的模型下一步计划(并非用户新增授权)】\n"
923
+ + run.modelIntent.nextPrompt + "\n\n"
924
+ + "执行前核对当前项目状态、既有用户授权和已完成副作用,不重做不明操作。"
925
+ + "本轮完成后,末行只输出一个状态标记:[CHAT_LOOP_STATUS:CONTINUE]、[CHAT_LOOP_STATUS:DONE] 或 [CHAT_LOOP_STATUS:BLOCKED]。")
656
926
  : run.countMode ? countText(run.countValue)
657
927
  : run.timedTest && run.wallTestMinutes === 10
658
928
  ? wallArithmeticText(run.testTurnIndex || 2) : CONTINUE_TEXT};
929
+ if (modelTurnToken && typeof permit.text === "string")
930
+ permit.text += permit.modelTurnNote;
931
+ return permit;
659
932
  }
660
933
  async function onResult(message, sender) {
661
934
  const run = await readRun();
@@ -705,6 +978,7 @@ async function onResult(message, sender) {
705
978
  // Keep source identities as the already-processed baseline; the new
706
979
  // user's first reply must not be suppressed by duplicate detection.
707
980
  newUserKey: message.newUserKey, phase: "WAITING_REPLY", reason: "",
981
+ ...(run.sessionId?{generation:(run.generation||0)+1}:{}),
708
982
  // The new Chat has no previous assistant message. Never authenticate
709
983
  // its first Retry/Error Handoff using the OLD Chat's assistant ID.
710
984
  userKey: null, assistantKey: null, startedAt: Date.now(),
@@ -735,6 +1009,20 @@ async function onResult(message, sender) {
735
1009
  newUserKey: message.newUserKey,
736
1010
  ...(run.countMode ? {countLastSendAt:Date.now()} : {})};
737
1011
  }
1012
+ // A model prompt is one-shot. Keep only a short receipt, not the prompt
1013
+ // body, after the Action has reached a confirmed or blocked terminal state.
1014
+ if(run.modelIntent){
1015
+ const override=run.action==="HANDOFF"&&run.modelIntent.action==="CONTINUE";
1016
+ const finalStatus=confirmed?"SENT_CONFIRMED":"BLOCKED";
1017
+ next=updateModelReceipt(next,run.modelIntent.id,{
1018
+ status:finalStatus,actualAction:run.action,
1019
+ reason:override?"OVERRIDDEN_BY_DEADLINE":confirmed?null:failure,
1020
+ checkpointForwarded:confirmed&&override});
1021
+ next.lastModelIntent={id:run.modelIntent.id,action:run.modelIntent.action,
1022
+ result:finalStatus,at:Date.now()};
1023
+ delete next.modelIntent;
1024
+ }
1025
+ if (!confirmed) delete next.modelTurnToken;
738
1026
  await saveRun(next);
739
1027
  return {status: confirmed ? "OK" : "BLOCKED"};
740
1028
  }
@@ -818,7 +1106,7 @@ async function ensureAlarm() {
818
1106
  async function watchActiveTurn() {
819
1107
  // Normal periodic wakeups also reconcile a delayed one-shot deadline.
820
1108
  // All state changes share the same serial queue as Turn/Action receipts.
821
- await serial(prepareHandoffIfDue);
1109
+ await serial(async()=>{await expireStaleModelIntent();await prepareHandoffIfDue();});
822
1110
  const run = await readRun();
823
1111
  // While an Action is pending, never dispatch competing operations.
824
1112
  if (run?.phase === "WALL_CLOCK_HOLD") {
@@ -914,9 +1202,18 @@ chrome.runtime.onMessage.addListener((message, sender, reply) => {
914
1202
  if (["CHAT_LOOP_TURN_FINISHED_V1", "CHAT_LOOP_TURN_BLOCKED_V1",
915
1203
  "CHAT_LOOP_ACTION_TAKE_V1",
916
1204
  "CHAT_LOOP_ACTION_RESULT_V1", "CHAT_LOOP_ACTION_MANUAL_PREPARE_V1",
1205
+ "CHAT_LOOP_MODEL_INTENT_V1", "CHAT_LOOP_MODEL_INTENT_STATUS_V1",
917
1206
  "CHAT_LOOP_ACTION_TEST_PREPARE_V1",
1207
+ "CHAT_LOOP_SESSION_START_PREPARE_V1", "CHAT_LOOP_SESSION_START_TAKE_V1",
1208
+ "CHAT_LOOP_SESSION_START_RESULT_V1", "CHAT_LOOP_SESSION_STATUS_V1",
918
1209
  "CHAT_LOOP_ACCEPTANCE_CLOCK_ADVANCE_V1"].includes(type)) {
919
- const work = serial(() => type === "CHAT_LOOP_TURN_FINISHED_V1"
1210
+ const work = serial(() => type === "CHAT_LOOP_SESSION_START_PREPARE_V1"
1211
+ ? sessionStartPrepare(message,sender) : type === "CHAT_LOOP_SESSION_START_TAKE_V1"
1212
+ ? sessionStartTake(message,sender) : type === "CHAT_LOOP_SESSION_START_RESULT_V1"
1213
+ ? sessionStartResult(message,sender) : type === "CHAT_LOOP_SESSION_STATUS_V1"
1214
+ ? sessionStatus(message,sender) : type === "CHAT_LOOP_MODEL_INTENT_V1"
1215
+ ? onModelIntent(message, sender) : type === "CHAT_LOOP_MODEL_INTENT_STATUS_V1"
1216
+ ? onModelIntentStatus(message,sender) : type === "CHAT_LOOP_TURN_FINISHED_V1"
920
1217
  ? onTurnFinished(message, sender) : type === "CHAT_LOOP_TURN_BLOCKED_V1"
921
1218
  ? onTurnBlocked(message, sender) : type === "CHAT_LOOP_ACTION_TAKE_V1"
922
1219
  ? onTake(message, sender) : type === "CHAT_LOOP_ACTION_MANUAL_PREPARE_V1"
@@ -0,0 +1,5 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN"><head><meta charset="utf-8"><title>Chat Loop · Clean</title>
3
+ <style>body{font:14px system-ui;padding:22px;color:#242424}#status{font-weight:bold}#details{color:#666}</style></head>
4
+ <body><h1>Clean · 清理输入框</h1><p id="status">VERIFYING</p><p id="details">正在核实目标 Chat,不发送消息。</p>
5
+ <script src="actions/clean.js"></script></body></html>
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Chat Loop Extension",
4
- "version": "0.3.29",
4
+ "version": "0.3.33",
5
5
  "description": "Chat Loop browser companion: Eye actions, Turn observer, Continue and Handoff Actions",
6
6
  "permissions": [
7
7
  "activeTab",
@@ -17,7 +17,7 @@
17
17
  <p id="status" role="status">正在检测…</p>
18
18
  <p id="detail">仅检查当前页面与浏览器类型,不读取聊天内容。</p>
19
19
  <p>打开动作页即执行,成功后自动关闭。多 Chat 标签页时请在动作页 URL 指定 <code>?tabId=...</code>。</p>
20
- <p><a href="screenshot.html" target="_blank">Screenshot</a> · <a href="dom.html" target="_blank">DOM</a> · <a href="text.html" target="_blank">Text</a> · <a href="reload.html" target="_blank">Reload</a></p>
20
+ <p><a href="screenshot.html" target="_blank">Screenshot</a> · <a href="dom.html" target="_blank">DOM</a> · <a href="text.html" target="_blank">Text</a> · <a href="reload.html" target="_blank">Reload</a> · <button id="clean-action" type="button">Clean 输入框</button></p>
21
21
  <p><a href="https://chatgpt.com/" target="_blank" rel="noopener noreferrer">打开 ChatGPT 网页版</a></p>
22
22
  <p id="action-state" role="status">最近发送动作:读取中…</p>
23
23
  <script src="popup.js"></script>
@@ -56,6 +56,7 @@ async function showActionState() {
56
56
  const run = (await chrome.storage.local.get(key))[key];
57
57
  const allowed = ["phase", "action", "reason", "tabId", "page", "sourceTabId", "sourcePage",
58
58
  "userKey", "assistantKey", "newUserKey", "testSetup", "startedAt",
59
+ "sessionId", "projectId", "generation",
59
60
  "deadlineAt", "handoffPending", "handoffPendingAt", "attemptedNewTabId"];
60
61
  const summary = {version: chrome.runtime.getManifest().version};
61
62
  if (run) for (const key of allowed) if (run[key] !== undefined) summary[key] = run[key];
@@ -68,3 +69,13 @@ void showActionState().catch(() => {
68
69
  document.getElementById("action-state").textContent = "最近发送动作:状态无法确认,不要重复发送";
69
70
  });
70
71
  chrome.storage.onChanged.addListener(() => {void showActionState();});
72
+
73
+ // User-clicked menu action only; bind to the single active ChatGPT tab.
74
+ document.getElementById('clean-action').addEventListener('click',async()=>{
75
+ const selected=(await chrome.tabs.query({active:true,currentWindow:true}))[0];
76
+ if(!selected?.id||!selected?.url?.startsWith('https://chatgpt.com/')){
77
+ show('CLEAN_NOT_AVAILABLE','请先选中要清理的 ChatGPT 标签页。');return;
78
+ }
79
+ await chrome.tabs.create({url:chrome.runtime.getURL('clean.html?tabId='+selected.id),
80
+ windowId:selected.windowId,active:true});
81
+ });
@@ -0,0 +1,6 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN"><head><meta charset="utf-8"><title>Chat Loop · start</title>
3
+ <style>body{font:15px system-ui,sans-serif;padding:24px;max-width:620px;color:#222}#status{font-weight:bold}#details{white-space:pre-wrap;overflow-wrap:anywhere}body[data-result="error"] #status{color:#b00020}</style>
4
+ </head><body><h1>Chat Loop · start</h1><p id="status" role="status">STARTING</p>
5
+ <p id="details">只有已确认意图且成功取得全局执行权才会发送首条消息。</p>
6
+ <script src="actions/start.js"></script></body></html>
@@ -0,0 +1,6 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN"><head><meta charset="utf-8"><title>Chat Loop · status</title>
3
+ <style>body{font:15px system-ui,sans-serif;padding:24px;max-width:620px;color:#222}#status{font-weight:bold}#details{white-space:pre-wrap;overflow-wrap:anywhere}body[data-result="error"] #status{color:#b00020}</style>
4
+ </head><body><h1>Chat Loop · status</h1><p id="status" role="status">STARTING</p>
5
+ <p id="details">只读查询唯一网页执行会话,不执行网页副作用。</p>
6
+ <script src="actions/status.js"></script></body></html>
package/lib/bridge.mjs CHANGED
@@ -4,7 +4,7 @@ import {homedir, tmpdir} from 'node:os';
4
4
  import {join} from 'node:path';
5
5
 
6
6
  export const HOST_NAME = 'com.tomflow.chatloop.extension';
7
- export const ACTIONS = ['chat_text', 'chat_dom', 'chat_screenshot', 'reload'];
7
+ export const ACTIONS = ['chat_text', 'chat_dom', 'chat_screenshot', 'reload', 'continue', 'handoff', 'clean', 'status', 'start'];
8
8
  const ID = /^[a-f0-9]{32}$/;
9
9
  const MAX_TEXT = 8 * 1024 * 1024;
10
10
  const MAX_PNG = 24 * 1024 * 1024;
@@ -23,13 +23,62 @@ export function requestDir(id) {
23
23
  if(typeof id!=='string'||!ID.test(id))throw Object.assign(Error('Invalid request ID'),{code:'INVALID_REQUEST_ID'});
24
24
  return join(tempRoot(),id);
25
25
  }
26
- export function prepare(action) {
26
+ export function validateIntent(action, intent) {
27
+ if(action==='start'){
28
+ if(!intent||typeof intent!=='object'||Array.isArray(intent)
29
+ ||Object.keys(intent).some(k=>!['projectId','prompt','confirmed'].includes(k))
30
+ ||!(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/).test(intent.projectId||'')
31
+ ||intent.confirmed!==true||typeof intent.prompt!=='string'
32
+ ||!intent.prompt.trim()||intent.prompt.length>4000
33
+ ||/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(intent.prompt))
34
+ throw Object.assign(Error('Explicit confirmed task and project required'),{code:'INVALID_START_INTENT'});
35
+ return {projectId:intent.projectId,prompt:intent.prompt,confirmed:true};
36
+ }
37
+ if(action==='clean'){
38
+ if(!intent || typeof intent!=='object' || Array.isArray(intent)
39
+ || Object.keys(intent).length!==1 || !Number.isSafeInteger(intent.tabId)
40
+ || intent.tabId<1)
41
+ throw Object.assign(Error('Clean requires an exact tabId'),{code:'INVALID_CLEAN_TARGET'});
42
+ return {tabId:intent.tabId};
43
+ }
44
+ if(!['continue','handoff'].includes(action)) {
45
+ if(intent!==undefined)throw Object.assign(Error('Unexpected intent'),{code:'INVALID_INTENT'});
46
+ return null;
47
+ }
48
+ if(!intent||typeof intent!=='object'||Array.isArray(intent)
49
+ || typeof intent.turnToken!=='string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(intent.turnToken))
50
+ throw Object.assign(Error('Invalid model intent'),{code:'INVALID_INTENT'});
51
+ // Query is read-only and uses the same formal Action page; no seventh Action.
52
+ if(Object.hasOwn(intent,'intentId')||intent.lookup===true){
53
+ const latest=intent.lookup===true;
54
+ if(Object.keys(intent).some(k=>k!=='turnToken'&&k!==(latest?'lookup':'intentId'))
55
+ ||latest&&Object.hasOwn(intent,'intentId')
56
+ ||!latest&&(typeof intent.intentId!=='string'||!/^[a-f0-9]{32}$/.test(intent.intentId)))
57
+ throw Object.assign(Error('Invalid intent query'),{code:'INVALID_INTENT'});
58
+ return latest?{turnToken:intent.turnToken,lookup:true}
59
+ :{turnToken:intent.turnToken,intentId:intent.intentId};
60
+ }
61
+ if(Object.keys(intent).some(k=>k!=='turnToken'&&(action==='continue'?k!=='nextPrompt':k!=='reason')))
62
+ throw Object.assign(Error('Invalid model intent'),{code:'INVALID_INTENT'});
63
+ if(action==='continue' && (typeof intent.nextPrompt!=='string'
64
+ || !intent.nextPrompt.trim() || intent.nextPrompt.length>4000
65
+ || /[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(intent.nextPrompt)))
66
+ throw Object.assign(Error('Invalid continuation text'),{code:'INVALID_INTENT'});
67
+ if(action==='handoff' && intent.reason!==undefined
68
+ && (typeof intent.reason!=='string'||intent.reason.length>400
69
+ || /[\x00-\x1f]/.test(intent.reason)))
70
+ throw Object.assign(Error('Invalid handoff reason'),{code:'INVALID_INTENT'});
71
+ return action==='continue' ? {turnToken:intent.turnToken,nextPrompt:intent.nextPrompt}
72
+ : {turnToken:intent.turnToken,reason:intent.reason||''};
73
+ }
74
+ export function prepare(action,intent) {
27
75
  if(!ACTIONS.includes(action))throw Object.assign(Error('Unknown extension action'),{code:'UNKNOWN_ACTION'});
76
+ const validated=validateIntent(action,intent);
28
77
  privateDir(tempRoot());
29
78
  cleanup();
30
79
  const id=randomUUID().replaceAll('-',''), nonce=randomUUID().replaceAll('-',''), at=Date.now();
31
80
  const dir=privateDir(requestDir(id));
32
- writeFileSync(join(dir,'request.json'), JSON.stringify({id,nonce,action,at,expiresAt:at+TTL_MS}),
81
+ writeFileSync(join(dir,'request.json'), JSON.stringify({id,nonce,action,at,expiresAt:at+TTL_MS,...(validated ? {intent:validated} : {})}),
33
82
  {flag:'wx',mode:0o600});
34
83
  return {id,nonce,action,dir};
35
84
  }
@@ -43,6 +92,7 @@ export function readRequest(id) {
43
92
  const r=JSON.parse(readFileSync(p,'utf8'));
44
93
  if(r.id!==id||!ACTIONS.includes(r.action)||Date.now()>r.expiresAt)
45
94
  throw Object.assign(Error('Request expired or changed'),{code:'REQUEST_EXPIRED'});
95
+ if(['continue','handoff','clean','start'].includes(r.action))validateIntent(r.action,r.intent);
46
96
  return r;
47
97
  }
48
98
  export function finish({id,nonce,action,status,payload='',code=null}) {
@@ -84,6 +134,15 @@ export function readResult(id) {
84
134
  privateDir(requestDir(id));
85
135
  return JSON.parse(readFileSync(p,'utf8'));
86
136
  }
137
+ // Read-only cleanup proof. Only an aged, durably FAILED machine request
138
+ // can qualify; never expose the nonce, original intent, or result contents.
139
+ export function staleFailureConfirmed(id,action) {
140
+ if(!ACTIONS.includes(action))return false;
141
+ const result=readResult(id);
142
+ return !!(result && result.id===id && result.action===action
143
+ && result.status==='FAILED' && Number.isSafeInteger(result.at)
144
+ && result.at<=Date.now()-TTL_MS);
145
+ }
87
146
  export function cleanup(){
88
147
  const root=tempRoot();
89
148
  if(!existsSync(root))return;