@tomflow/chatloop-extension 0.3.33 → 0.3.35
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 +8 -0
- package/bin/native-host.mjs +1 -1
- package/extension/background.js +52 -8
- package/extension/manifest.json +1 -1
- package/lib/bridge.mjs +7 -3
- package/lib/session-file.mjs +7 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,6 +24,14 @@ Run: chatloop-extension extension <command> --help
|
|
|
24
24
|
|
|
25
25
|
**Chrome 独立运行安装路径:**先用 `npm install --prefix "$HOME/.chat-loop/extension/npm" --save-exact --ignore-scripts @tomflow/chatloop-extension@latest` 下载,再在 `chrome://extensions` 加载该目录下的 `node_modules/@tomflow/chatloop-extension/extension`。改目录会改变扩展 ID,必须通过同一个 npm 包的 `host install <id>` 更新 Native Host。详情见 [Extension 主文档](../../docs/extension.md)。
|
|
26
26
|
|
|
27
|
+
`extension start` 正常只在 `status=IDLE`、用户确认后启动。上一 Session 若是 `BLOCKED`,可在用户**再次明确批准一个不同 projectId 的全新任务**时附上 `replaceBlockedSessionId`(精确 UUID)作为受控替换条件:扩展 Worker 与本机持久指针必须同时指向该 BLOCKED Session,才能允许新 Session;旧执行 Chat 保留审计,旧用户 Turn 永不自动重发。示例只用于说明 JSON 签名,不会自动调用:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{"projectId":"new-distinct-test","prompt":"新的独立任务","confirmed":true,"replaceBlockedSessionId":"11111111-1111-4111-a111-111111111111"}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`PAGE_ERROR` 且无原生 Retry 时,Worker 仅观察最多四次;只有原用户消息 ID 未变且连续两次证明错误消失才继续观察;若回查时已经识别到同一 Turn 的可靠完成事件或已发出完成事件,则让已排队的完成回执自然结项,避免 4 秒回查窗口错误覆盖 DONE。其余情况依旧 BLOCKED。**不能把当前网页错误消失当成原消息未发送的证据**。
|
|
34
|
+
|
|
27
35
|
Clean 只清空输入框,不发送,不更改 Owner;必须指定精确标签页 ID 且验证目标。Chrome 已安装扩展不随 npm 包自动安装。本地源码与已发布 npm 版本可能不同,真实结果请看 [CURRENT](../../context/CURRENT.md)。
|
|
28
36
|
|
|
29
37
|
详细接口和安全边界统一见 [Extension 产品文档](../../docs/extension.md)。
|
package/bin/native-host.mjs
CHANGED
|
@@ -28,7 +28,7 @@ process.stdin.on('data',chunk=>{
|
|
|
28
28
|
}else if(m.op==='session-read'){
|
|
29
29
|
write({status:'OK',session:readSession()});
|
|
30
30
|
}else if(m.op==='session-sync'){
|
|
31
|
-
write({status:'OK',session:writeSession(m.session)});
|
|
31
|
+
write({status:'OK',session:writeSession(m.session,m.replaceBlockedSessionId)});
|
|
32
32
|
}else if(m.op==='stale-failure'){
|
|
33
33
|
const confirmed=staleFailureConfirmed(m.id,m.action);
|
|
34
34
|
write({status:confirmed?'STALE_FAILURE_CONFIRMED':'NOT_ELIGIBLE',
|
package/extension/background.js
CHANGED
|
@@ -101,7 +101,8 @@ async function saveRun(run) {
|
|
|
101
101
|
if(next.sessionId){
|
|
102
102
|
const record = sessionProjection(next);
|
|
103
103
|
const saved = await chrome.runtime.sendNativeMessage(SESSION_HOST,
|
|
104
|
-
{op:"session-sync",session:record
|
|
104
|
+
{op:"session-sync",session:record,
|
|
105
|
+
...(next.replacedBlockedSessionId?{replaceBlockedSessionId:next.replacedBlockedSessionId}:{})}).catch(()=>null);
|
|
105
106
|
if(saved?.status!=="OK"||saved.session?.sessionId!==next.sessionId)
|
|
106
107
|
throw Error(saved?.code||"SESSION_PERSISTENCE_UNVERIFIED");
|
|
107
108
|
}
|
|
@@ -135,24 +136,39 @@ async function sessionStartPrepare(message,sender){
|
|
|
135
136
|
if(!sessionPage(sender,'start',message.requestId))
|
|
136
137
|
return {status:'DENIED',code:'INVALID_START_SOURCE'};
|
|
137
138
|
const prior=await readRun();
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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'};
|
|
139
|
+
// Read the single authenticated intent BEFORE deciding whether a previously
|
|
140
|
+
// BLOCKED session can be explicitly superseded. Never infer consent from
|
|
141
|
+
// a failed page or an old session pointer alone.
|
|
144
142
|
const proof=await chrome.runtime.sendNativeMessage(SESSION_HOST,
|
|
145
143
|
{op:'acquire',id:message.requestId,action:'start'}).catch(()=>null);
|
|
146
144
|
const intent=proof?.intent;
|
|
147
145
|
if(proof?.status!=='OK'||intent?.confirmed!==true
|
|
148
146
|
||typeof intent.projectId!=='string'
|
|
149
147
|
||!/^[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
|
|
148
|
+
||typeof intent.prompt!=='string'||!intent.prompt.trim()||intent.prompt.length>4000
|
|
149
|
+
||(intent.replaceBlockedSessionId!==undefined
|
|
150
|
+
&& !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(intent.replaceBlockedSessionId)))
|
|
151
151
|
return {status:'DENIED',code:'START_INTENT_UNVERIFIED'};
|
|
152
|
+
const replace=intent.replaceBlockedSessionId||null;
|
|
153
|
+
const matchingBlocked=prior?.phase==='BLOCKED' && prior.sessionId===replace
|
|
154
|
+
&& prior.projectId!==intent.projectId;
|
|
155
|
+
if(replace&&!matchingBlocked)return {status:'DENIED',code:'BLOCKED_REPLACEMENT_MISMATCH'};
|
|
156
|
+
if(prior&&prior.phase!=='DONE'&&!matchingBlocked)
|
|
157
|
+
return {status:'DENIED',code:'LOOP_ALREADY_ACTIVE'};
|
|
158
|
+
const disk=await chrome.runtime.sendNativeMessage(SESSION_HOST,{op:'session-read'}).catch(()=>null);
|
|
159
|
+
if(disk?.status!=='OK')return {status:'DENIED',code:'SESSION_STATE_UNVERIFIED'};
|
|
160
|
+
if(matchingBlocked&&(!disk.session||disk.session.phase!=='BLOCKED'
|
|
161
|
+
||disk.session.sessionId!==replace||disk.session.projectId!==prior.projectId))
|
|
162
|
+
return {status:'DENIED',code:'SESSION_REPLACEMENT_MISMATCH'};
|
|
163
|
+
if(disk.session&&disk.session.phase!=='DONE'&&!matchingBlocked)
|
|
164
|
+
return {status:'DENIED',code:'SESSION_ALREADY_ACTIVE'};
|
|
165
|
+
// The same blocked task may never be replayed implicitly: a replacement
|
|
166
|
+
// requires a new project ID and leaves the old Chat intact for audit.
|
|
152
167
|
const next={sessionId:crypto.randomUUID(),projectId:intent.projectId,
|
|
153
168
|
generation:0,phase:'STARTING',action:'START',token:crypto.randomUUID(),
|
|
154
169
|
page:'/',tabId:null,startedAt:Date.now(),
|
|
155
170
|
machineRequestId:message.requestId,machineActionTabId:sender.tab.id,
|
|
171
|
+
...(matchingBlocked?{replacedBlockedSessionId:replace}:{}),
|
|
156
172
|
startPrompt:intent.prompt};
|
|
157
173
|
await saveRun(next); // Reserve BEFORE any new Chrome tab or send.
|
|
158
174
|
return {status:'PREPARED',token:next.token,sessionId:next.sessionId};
|
|
@@ -303,6 +319,34 @@ async function onTurnBlocked(message, sender) {
|
|
|
303
319
|
const second = await verify();
|
|
304
320
|
if (!second || first.retryAvailable !== second.retryAvailable)
|
|
305
321
|
return {status: "IGNORED", code: "ERROR_NOT_STABLE"};
|
|
322
|
+
// A network interruption may show a transient page error without a native
|
|
323
|
+
// Retry button. Only *observe* for a bounded grace period: never resend an
|
|
324
|
+
// uncertain user Turn. Resume normal observation only if the EXACT submitted
|
|
325
|
+
// user identity stays present and two consecutive polls show no page fault.
|
|
326
|
+
// If the optimistic user message disappears, this is NOT proof of no send.
|
|
327
|
+
if (reason === "PAGE_ERROR" && second.retryAvailable !== true) {
|
|
328
|
+
let recovered = 0;
|
|
329
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
330
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
331
|
+
const proof = await chrome.tabs.sendMessage(previous.tabId,
|
|
332
|
+
{type: "CHAT_LOOP_TURN_OBSERVE_V1", poll: true}).catch(() => null);
|
|
333
|
+
const sameTurn = proof?.contract === "chat-loop.turn-observer.v1"
|
|
334
|
+
&& proof.page === previous.page
|
|
335
|
+
&& proof.userKey === previous.newUserKey;
|
|
336
|
+
// The Observer may emit its verified completion while this error handler
|
|
337
|
+
// owns the serialized Worker queue. That completion message is waiting
|
|
338
|
+
// behind us: fail-closing here would discard a real, finished Turn.
|
|
339
|
+
// TURN_FINISHED or the exact already-emitted state is sufficient proof
|
|
340
|
+
// that the SAME user Turn recovered; do not invent/send another message.
|
|
341
|
+
if (sameTurn && (proof.status === "TURN_FINISHED"
|
|
342
|
+
|| proof.status === "IDLE" && proof.reason === "TURN_ALREADY_EMITTED"))
|
|
343
|
+
return {status: "IGNORED", code: "PAGE_ERROR_COMPLETION_QUEUED"};
|
|
344
|
+
if (sameTurn && ["RUNNING", "UNKNOWN"].includes(proof.status)) {
|
|
345
|
+
recovered++;
|
|
346
|
+
if (recovered === 2) return {status: "IGNORED", code: "PAGE_ERROR_CLEARED_SAME_TURN"};
|
|
347
|
+
} else recovered = 0;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
306
350
|
// A security challenge is an explicit stop condition, not a prompt to retry
|
|
307
351
|
// or evade site access controls. Keep the original Chat and user Turn intact.
|
|
308
352
|
if (reason === "SECURITY_CHALLENGE") {
|
package/extension/manifest.json
CHANGED
package/lib/bridge.mjs
CHANGED
|
@@ -26,13 +26,17 @@ export function requestDir(id) {
|
|
|
26
26
|
export function validateIntent(action, intent) {
|
|
27
27
|
if(action==='start'){
|
|
28
28
|
if(!intent||typeof intent!=='object'||Array.isArray(intent)
|
|
29
|
-
||Object.keys(intent).some(k=>!['projectId','prompt','confirmed'].includes(k))
|
|
29
|
+
||Object.keys(intent).some(k=>!['projectId','prompt','confirmed','replaceBlockedSessionId'].includes(k))
|
|
30
30
|
||!(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/).test(intent.projectId||'')
|
|
31
|
-
||intent.confirmed!==true
|
|
31
|
+
||intent.confirmed!==true
|
|
32
|
+
|| (intent.replaceBlockedSessionId!==undefined
|
|
33
|
+
&& !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(intent.replaceBlockedSessionId))
|
|
34
|
+
||typeof intent.prompt!=='string'
|
|
32
35
|
||!intent.prompt.trim()||intent.prompt.length>4000
|
|
33
36
|
||/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(intent.prompt))
|
|
34
37
|
throw Object.assign(Error('Explicit confirmed task and project required'),{code:'INVALID_START_INTENT'});
|
|
35
|
-
return {projectId:intent.projectId,prompt:intent.prompt,confirmed:true
|
|
38
|
+
return {projectId:intent.projectId,prompt:intent.prompt,confirmed:true,
|
|
39
|
+
...(intent.replaceBlockedSessionId?{replaceBlockedSessionId:intent.replaceBlockedSessionId}:{})};
|
|
36
40
|
}
|
|
37
41
|
if(action==='clean'){
|
|
38
42
|
if(!intent || typeof intent!=='object' || Array.isArray(intent)
|
package/lib/session-file.mjs
CHANGED
|
@@ -47,14 +47,19 @@ export function readSession(){
|
|
|
47
47
|
try{return validate(JSON.parse(readFileSync(file,'utf8')));}
|
|
48
48
|
catch(e){if(e.code)throw e;throw fail('SESSION_FILE_CORRUPT');}
|
|
49
49
|
}
|
|
50
|
-
export function writeSession(input){
|
|
50
|
+
export function writeSession(input,replaceBlockedSessionId=null){
|
|
51
51
|
const next=validate(input),file=sessionPath(),dir=dirname(file),lock=file+'.lock';
|
|
52
52
|
ensurePrivate(dir);
|
|
53
53
|
let fd;
|
|
54
54
|
try{fd=openSync(lock,'wx',0o600);}catch{throw fail('SESSION_CONCURRENT_UPDATE');}
|
|
55
55
|
try{
|
|
56
56
|
const old=readSession();
|
|
57
|
-
if(
|
|
57
|
+
if(replaceBlockedSessionId && (!old||old.sessionId!==next.sessionId
|
|
58
|
+
&& (old.phase!=='BLOCKED'||old.sessionId!==replaceBlockedSessionId)))
|
|
59
|
+
throw fail('SESSION_REPLACEMENT_MISMATCH');
|
|
60
|
+
if(old&&old.sessionId!==next.sessionId&&old.phase!=='DONE'
|
|
61
|
+
&& !(old.phase==='BLOCKED' && next.phase==='STARTING'
|
|
62
|
+
&& replaceBlockedSessionId===old.sessionId && next.projectId!==old.projectId))
|
|
58
63
|
throw fail('SESSION_ALREADY_ACTIVE');
|
|
59
64
|
if(old&&old.sessionId===next.sessionId){
|
|
60
65
|
if(next.generation<old.generation)throw fail('SESSION_STALE_GENERATION');
|