@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.
- package/README.md +20 -164
- package/bin/cli.mjs +53 -6
- package/bin/native-host.mjs +11 -2
- package/extension/acceptance.js +105 -3
- package/extension/actions/clean.js +120 -0
- package/extension/actions/common.js +13 -4
- package/extension/actions/continue.js +3 -1
- package/extension/actions/handoff.js +16 -3
- package/extension/actions/model-intent.js +53 -0
- package/extension/actions/start.js +145 -0
- package/extension/actions/status.js +22 -0
- package/extension/background.js +310 -13
- package/extension/clean.html +5 -0
- package/extension/manifest.json +1 -1
- package/extension/popup.html +1 -1
- package/extension/popup.js +11 -0
- package/extension/start.html +6 -0
- package/extension/status.html +6 -0
- package/lib/bridge.mjs +62 -3
- package/lib/session-file.mjs +74 -0
- package/package.json +2 -2
package/extension/background.js
CHANGED
|
@@ -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
|
-
|
|
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 = {
|
|
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(
|
|
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({...
|
|
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 &&
|
|
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 &&
|
|
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
|
-
|
|
643
|
-
|
|
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 === "
|
|
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>
|
package/extension/manifest.json
CHANGED
package/extension/popup.html
CHANGED
|
@@ -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>
|
package/extension/popup.js
CHANGED
|
@@ -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
|
|
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;
|