@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 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)。
@@ -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',
@@ -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}).catch(()=>null);
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
- 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'};
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") {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Chat Loop Extension",
4
- "version": "0.3.33",
4
+ "version": "0.3.35",
5
5
  "description": "Chat Loop browser companion: Eye actions, Turn observer, Continue and Handoff Actions",
6
6
  "permissions": [
7
7
  "activeTab",
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||typeof intent.prompt!=='string'
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)
@@ -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(old&&old.sessionId!==next.sessionId&&old.phase!=='DONE')
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');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomflow/chatloop-extension",
3
- "version": "0.3.33",
3
+ "version": "0.3.35",
4
4
  "type": "module",
5
5
  "description": "Chat Loop Extension package: nine formal Action pages and its own native CLI bridge",
6
6
  "bin": {