@tomflow/chatloop-extension 0.3.32 → 0.3.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @tomflow/chatloop-extension
2
2
 
3
- Chrome 扩展(7 个 Action)及其独立 CLI / Native Messaging Host。执行前先看 Help:
3
+ Chrome 扩展(9 个独立 Action,含 Status / Start)及其独立 CLI / Native Messaging Host。执行前先看 Help:
4
4
 
5
5
  ```sh
6
6
  npx --yes --package=@tomflow/chatloop-extension@latest chatloop-extension --help
@@ -18,10 +18,20 @@ npx --yes --package=@tomflow/chatloop-extension@latest chatloop-extension extens
18
18
 
19
19
  ```text
20
20
  Usage: chatloop-extension extension <command>
21
- Commands: chat text | chat dom | chat screenshot | reload | clean <tab-id> | continue | handoff
21
+ Commands: chat text | chat dom | chat screenshot | reload | clean <tab-id> | continue | handoff | status | start
22
22
  Run: chatloop-extension extension <command> --help
23
23
  ```
24
24
 
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
+
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 未变且连续两次证明错误消失才继续观察,其余情况依旧 BLOCKED。**不能把当前网页错误消失当成原消息未发送的证据**。
34
+
25
35
  Clean 只清空输入框,不发送,不更改 Owner;必须指定精确标签页 ID 且验证目标。Chrome 已安装扩展不随 npm 包自动安装。本地源码与已发布 npm 版本可能不同,真实结果请看 [CURRENT](../../context/CURRENT.md)。
26
36
 
27
37
  详细接口和安全边界统一见 [Extension 产品文档](../../docs/extension.md)。
package/bin/cli.mjs CHANGED
@@ -10,10 +10,12 @@ const contract='chat-loop.extension-cli.v1';
10
10
  const HELP={
11
11
  '': 'Usage: chatloop-extension <command> [options]\nCommands: capabilities, host, extension\nRun: chatloop-extension <command> --help',
12
12
  host: 'Usage: chatloop-extension host install <extension-id>\nRegisters the local Chrome Native Messaging Host (macOS).',
13
- extension: 'Usage: chatloop-extension extension <command>\nCommands: chat text | chat dom | chat screenshot | reload | clean <tab-id> | continue | handoff\nRun: chatloop-extension extension <command> --help',
13
+ extension: 'Usage: chatloop-extension extension <command>\nCommands: chat text | chat dom | chat screenshot | reload | clean <tab-id> | continue | handoff | status | start\nRun: chatloop-extension extension <command> --help',
14
14
  'extension clean':'Usage: chatloop-extension extension clean <tab-id>\nClear only the verified ChatGPT composer. Never send or close a Chat.\nRequires exact tab id of a registered test page or the sole completed Chat Loop Owner.',
15
15
  'extension continue':'Usage: chatloop-extension extension continue (JSON intent on stdin)\nSubmit next-turn text or query receipt; does not send directly.',
16
- 'extension handoff':'Usage: chatloop-extension extension handoff (JSON intent on stdin)\nRequest early handoff or query receipt; does not transfer directly.'
16
+ 'extension handoff':'Usage: chatloop-extension extension handoff (JSON intent on stdin)\nRequest early handoff or query receipt; does not transfer directly.',
17
+ 'extension status':'Usage: chatloop-extension extension status\nRead-only, verified active Web Chat status.',
18
+ 'extension start':'Usage: chatloop-extension extension start (JSON on stdin)\nStart exactly one confirmed task in the Mac Chrome Web Chat; refuses duplicate owner.'
17
19
  };
18
20
  function helpFor(args){
19
21
  if(args.length===1&&['-h','--help','help'].includes(args[0]))return HELP[''];
@@ -62,18 +64,18 @@ function openAction(url) {
62
64
  async function executeAction(action,intent) {
63
65
  if(!ACTIONS.includes(action))throw Object.assign(Error('Unknown action'),{code:'UNKNOWN_ACTION'});
64
66
  const config=configuration(),req=prepare(action,intent);
65
- const page=action==='reload'?'reload.html': ['continue','handoff','clean'].includes(action)
67
+ const page=action==='reload'?'reload.html': ['continue','handoff','clean','start','status'].includes(action)
66
68
  ? action+'.html':action.slice(5)+'.html';
67
69
  const url='chrome-extension://'+config.extensionId+'/'+page+'?requestId='+req.id;
68
70
  openAction(url);
69
- const until=Date.now()+27000;
71
+ const until=Date.now()+(action==='start'?105000:27000);
70
72
  while(Date.now()<until){
71
73
  const result=readResult(req.id);
72
74
  if(result){
73
75
  if(result.status!=='OK')throw Object.assign(Error(result.code||'ACTION_FAILED'),{code:result.code||'ACTION_FAILED'});
74
76
  if(!result.verified) return {...result,status:'ACCEPTED_UNVERIFIED'};
75
77
  // The temp file is the canonical artifact. Returning small text is a CLI convenience.
76
- if(action==='chat_text'||action==='chat_dom'||action==='continue'||action==='handoff'||action==='clean'){
78
+ if(['chat_text','chat_dom','continue','handoff','clean','start','status'].includes(action)){
77
79
  const contents=readFileSync(result.path,'utf8');
78
80
  return {...result,content:contents.length<=500000?contents:undefined,
79
81
  truncated:contents.length>500000};
@@ -102,7 +104,8 @@ async function execute(argv) {
102
104
  return {implemented:['capabilities','host install <extension-id>','extension chat text',
103
105
  'extension chat dom','extension chat screenshot','extension reload',
104
106
  'extension clean <tab-id>',
105
- 'extension continue (JSON on stdin)','extension handoff (JSON on stdin)'],
107
+ 'extension continue (JSON on stdin)','extension handoff (JSON on stdin)',
108
+ 'extension status','extension start (JSON on stdin)'],
106
109
  available:existsSync(configFile()),hostConfigured:existsSync(configFile())};
107
110
  }
108
111
  if(argv[0]==='host'&&argv[1]==='install'&&argv.length===3)return installHost(argv[2]);
@@ -115,6 +118,8 @@ async function execute(argv) {
115
118
  return executeAction('clean',{tabId:Number(argv[2])});
116
119
  if(cmd==='extension continue')return executeAction('continue',await readIntent());
117
120
  if(cmd==='extension handoff')return executeAction('handoff',await readIntent());
121
+ if(cmd==='extension status')return executeAction('status');
122
+ if(cmd==='extension start')return executeAction('start',await readIntent());
118
123
  throw Object.assign(Error('Unknown command'),{code:'INVALID_ARGUMENT'});
119
124
  }
120
125
  const args=process.argv.slice(2),command=args.join(' ')||'capabilities';
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // Chrome Native Messaging: one request per process, no persistent server.
3
3
  import {readRequest, finish, staleFailureConfirmed} from '../lib/bridge.mjs';
4
+ import {readSession,writeSession} from '../lib/session-file.mjs';
4
5
  const MAX_INPUT=48*1024*1024;
5
6
  const write=obj=>{
6
7
  const b=Buffer.from(JSON.stringify(obj)),len=Buffer.alloc(4);
@@ -24,6 +25,10 @@ process.stdin.on('data',chunk=>{
24
25
  }else if(m.op==='complete'){
25
26
  const r=finish(m);
26
27
  write({status:'SAVED',id:r.id,action:r.action,bytes:r.bytes});
28
+ }else if(m.op==='session-read'){
29
+ write({status:'OK',session:readSession()});
30
+ }else if(m.op==='session-sync'){
31
+ write({status:'OK',session:writeSession(m.session,m.replaceBlockedSessionId)});
27
32
  }else if(m.op==='stale-failure'){
28
33
  const confirmed=staleFailureConfirmed(m.id,m.action);
29
34
  write({status:confirmed?'STALE_FAILURE_CONFIRMED':'NOT_ELIGIBLE',
@@ -49,6 +49,7 @@
49
49
  messageEvidence: {}, actionEvidence: {},
50
50
  run: run ? Object.fromEntries(["phase", "action", "reason", "tabId", "page",
51
51
  "sourceTabId", "sourcePage", "attemptedNewTabId", "newUserKey",
52
+ "sessionId", "projectId", "generation",
52
53
  "userKey", "assistantKey", "startedAt", "deadlineAt", "handoffPending",
53
54
  "handoffPendingAt", "handoffMode", "testSetup", "timedTest", "testTurnIndex",
54
55
  "clockAdvancedAt", "clockAdvanceMinutes", "clockRunningProof",
@@ -0,0 +1,145 @@
1
+ // Dedicated first-Turn Action. Only this page creates and sends the initial Web Chat.
2
+ // All subsequent turns are owned by the existing Observer / Continue / Handoff flow.
3
+ const info=document.getElementById('details'),label=document.getElementById('status');
4
+ const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms));
5
+ const show=(s,d)=>{label.textContent=s;info.textContent=d;document.body.dataset.result=s==='SUCCESS'?'success':'error';};
6
+ const host='com.tomflow.chatloop.extension';
7
+ async function newChatReady(id){
8
+ for(let i=0;i<85;i++){
9
+ const tab=await chrome.tabs.get(id).catch(()=>null);
10
+ if(tab?.url==='https://chatgpt.com/'){
11
+ const [found]=await chrome.scripting.executeScript({target:{tabId:id},func:()=>{
12
+ const visible=e=>Boolean(e?.getClientRects?.().length);
13
+ const mains=[...document.querySelectorAll('main')].filter(visible);
14
+ const editor=document.querySelector('[contenteditable="true"][data-composer-markdown]')
15
+ ||document.querySelector('#prompt-textarea[contenteditable="true"]');
16
+ return location.origin==='https://chatgpt.com'&&location.pathname==='/'&&mains.length===1
17
+ &&visible(editor)&&!(editor.innerText||editor.textContent||'').trim()
18
+ &&!mains[0].querySelector('[data-chatgpt-search-message-ids],[data-message-author-role]');
19
+ }}).catch(()=>[]);
20
+ if(found?.result===true)return;
21
+ if(found?.result===false&&i>=15)throw Error('NEW_CHAT_NOT_BLANK_OR_READY');
22
+ }
23
+ await pause(420);
24
+ }
25
+ throw Error('NEW_CHAT_NOT_READY');
26
+ }
27
+ async function observerReady(id){
28
+ let proof=await chrome.tabs.sendMessage(id,{type:'CHAT_LOOP_TURN_OBSERVE_V1'}).catch(()=>null);
29
+ if(proof?.contract!=='chat-loop.turn-observer.v1'){
30
+ await chrome.scripting.executeScript({target:{tabId:id},files:['content.js','loop/observer.js']});
31
+ proof=await chrome.tabs.sendMessage(id,{type:'CHAT_LOOP_TURN_OBSERVE_V1'}).catch(()=>null);
32
+ }
33
+ if(proof?.contract!=='chat-loop.turn-observer.v1')throw Error('FIRST_TURN_OBSERVER_UNAVAILABLE');
34
+ }
35
+ async function sendFirst(id,text){
36
+ const [clicked]=await chrome.scripting.executeScript({target:{tabId:id},
37
+ func:async instruction=>{
38
+ try{
39
+ const visible=e=>Boolean(e?.getClientRects?.().length);
40
+ if(location.origin!=='https://chatgpt.com'||location.pathname!=='/')throw Error('START_PAGE_CHANGED');
41
+ const mains=[...document.querySelectorAll('main')].filter(visible);
42
+ const editor=document.querySelector('[contenteditable="true"][data-composer-markdown]')
43
+ ||document.querySelector('#prompt-textarea[contenteditable="true"]');
44
+ if(mains.length!==1||!visible(editor)||!(editor.isContentEditable)||!document.contains(editor)
45
+ ||(editor.innerText||editor.textContent||'').trim()
46
+ ||mains[0].querySelector('[data-chatgpt-search-message-ids],[data-message-author-role]'))
47
+ throw Error('START_COMPOSER_UNVERIFIED');
48
+ const canonical=v=>String(v||'').replace(/\s+/g,'');
49
+ editor.focus();
50
+ if(!document.execCommand('insertText',false,instruction))throw Error('START_INPUT_REJECTED');
51
+ let verified=false;
52
+ for(let i=0;i<25;i++){
53
+ if(location.pathname!=='/')throw Error('START_PAGE_CHANGED_DURING_INPUT');
54
+ if(canonical(editor.innerText)===canonical(instruction)
55
+ ||canonical(editor.textContent)===canonical(instruction)){verified=true;break;}
56
+ await new Promise(r=>setTimeout(r,120));
57
+ }
58
+ if(!verified)throw Error('START_INPUT_UNVERIFIED');
59
+ let send;
60
+ for(let i=0;i<16;i++){
61
+ send=document.querySelector('button[data-testid="send-button"]')
62
+ ||document.querySelector('button[data-testid="composer-send-button"]')
63
+ ||document.querySelector('button[type="submit"][aria-label="发送"]')
64
+ ||document.querySelector('button[aria-label="Send message"]');
65
+ if(send&&!send.disabled&&visible(send))break;
66
+ await new Promise(r=>setTimeout(r,120));
67
+ }
68
+ if(!send||send.disabled||!visible(send))throw Error('START_SEND_BUTTON_UNAVAILABLE');
69
+ if(location.pathname!=='/'||(canonical(editor.innerText)!==canonical(instruction)
70
+ &&canonical(editor.textContent)!==canonical(instruction)))
71
+ throw Error('START_INPUT_CHANGED');
72
+ send.click(); // Once only. Worker persisted PENDING before this point.
73
+ return {clickedOnce:true};
74
+ }catch(e){return {clickedOnce:false,reason:e.message||'START_INPUT_UNKNOWN'};}
75
+ },args:[text]});
76
+ if(clicked?.result?.clickedOnce!==true)
77
+ throw Error(clicked?.result?.reason||'START_CLICK_UNVERIFIED');
78
+ // Once clicked: read-only receipt, NEVER retry typing or clicking.
79
+ for(let i=0;i<90;i++){
80
+ const tab=await chrome.tabs.get(id).catch(()=>null);
81
+ const page=tab?.url&&new URL(tab.url).pathname;
82
+ if(/^\/c\/[0-9a-f-]{12,}$/i.test(page||'')){
83
+ const [r]=await chrome.scripting.executeScript({target:{tabId:id},func:expected=>{
84
+ const visible=e=>Boolean(e?.getClientRects?.().length);
85
+ const mains=[...document.querySelectorAll('main')].filter(visible);
86
+ if(mains.length!==1)return null;
87
+ const modern=[...mains[0].querySelectorAll('[data-chatgpt-search-unit-key][data-chatgpt-search-message-ids]')];
88
+ const rows=modern.length?modern:[...mains[0].querySelectorAll('[data-message-author-role]')];
89
+ const role=n=>n.getAttribute('data-message-author-role')
90
+ ||n.getAttribute('data-chatgpt-search-unit-key')?.split(':').at(-1);
91
+ const user=rows.filter(n=>role(n)==='user').at(-1);
92
+ const id=user?.getAttribute('data-message-id')
93
+ ||user?.getAttribute('data-chatgpt-search-message-ids')?.trim().split(/\s+/)[0];
94
+ const bubble=user?.querySelector('[data-search-result-target]')
95
+ ||user?.querySelector('[data-user-message-bubble="true"]')||user;
96
+ const canonical=v=>String(v||'').replace(/\s+/g,'');
97
+ return id&&canonical(bubble?.innerText||bubble?.textContent)===canonical(expected)
98
+ ?{userKey:'user:'+id}:null;
99
+ },args:[text]}).catch(()=>[]);
100
+ if(r?.result?.userKey)return {status:'SENT_CONFIRMED',newUserKey:r.result.userKey,
101
+ newPage:page,newTabId:id};
102
+ }
103
+ await pause(420);
104
+ }
105
+ throw Error('FIRST_SEND_RECEIPT_UNKNOWN');
106
+ }
107
+ async function run(){
108
+ const own=await chrome.tabs.getCurrent();
109
+ const q=new URLSearchParams(location.search),id=q.get('requestId');
110
+ if(!own?.id||own.url!==location.href||[...q.keys()].length!==1
111
+ ||!/^[a-f0-9]{32}$/.test(id||''))throw Error('START_ACTION_SOURCE_INVALID');
112
+ const proof=await chrome.runtime.sendNativeMessage(host,{op:'acquire',id,action:'start'});
113
+ if(proof?.status!=='OK'||proof.action!=='start'||proof.id!==id
114
+ ||typeof proof.nonce!=='string')throw Error(proof?.code||'START_NATIVE_INVALID');
115
+ let prepared,taken,newTab,committed;
116
+ try{
117
+ prepared=await chrome.runtime.sendMessage({type:'CHAT_LOOP_SESSION_START_PREPARE_V1',requestId:id});
118
+ if(prepared?.status!=='PREPARED')throw Error(prepared?.code||'START_PREPARE_DENIED');
119
+ taken=await chrome.runtime.sendMessage({type:'CHAT_LOOP_SESSION_START_TAKE_V1',
120
+ requestId:id,token:prepared.token});
121
+ if(taken?.status!=='OK'||taken.sessionId!==prepared.sessionId
122
+ ||typeof taken.prompt!=='string')throw Error(taken?.code||'START_TAKE_DENIED');
123
+ show('RUNNING','已独占会话,正在创建唯一网页 Chat 并确认首轮发送。');
124
+ newTab=await chrome.tabs.create({url:'https://chatgpt.com/',active:true,windowId:own.windowId});
125
+ await newChatReady(newTab.id);
126
+ await observerReady(newTab.id);
127
+ const receipt=await sendFirst(newTab.id,taken.prompt);
128
+ committed=await chrome.runtime.sendMessage({type:'CHAT_LOOP_SESSION_START_RESULT_V1',
129
+ requestId:id,token:prepared.token,...receipt,result:receipt.status});
130
+ if(committed?.status!=='OK')throw Error(committed?.code||'START_COMMIT_UNVERIFIED');
131
+ const stored=await chrome.runtime.sendNativeMessage(host,{op:'complete',
132
+ id,nonce:proof.nonce,action:'start',status:'OK',payload:JSON.stringify(committed)});
133
+ if(stored?.status!=='SAVED')throw Error(stored?.code||'START_RECEIPT_NOT_STORED');
134
+ show('SUCCESS','唯一网页 Chat 已确认;执行由 Observer 和 Continue/Handoff 接管。');
135
+ await chrome.tabs.remove(own.id);
136
+ }catch(e){
137
+ if(taken&&committed?.status!=='OK')
138
+ await chrome.runtime.sendMessage({type:'CHAT_LOOP_SESSION_START_RESULT_V1',
139
+ requestId:id,token:prepared.token,result:'UNVERIFIED',newTabId:newTab?.id}).catch(()=>{});
140
+ await chrome.runtime.sendNativeMessage(host,{op:'complete',id,nonce:proof.nonce,
141
+ action:'start',status:'FAILED',code:String(e.message||'START_UNVERIFIED').slice(0,100)}).catch(()=>{});
142
+ throw e; // Unknown target tab is kept for inspection, never auto-retried.
143
+ }
144
+ }
145
+ void run().catch(e=>show('BLOCKED',e.message||'START_UNVERIFIED'));
@@ -0,0 +1,22 @@
1
+ // Read-only machine Status. Does not create a Chat or modify the browser Owner.
2
+ const host='com.tomflow.chatloop.extension';
3
+ async function run(){
4
+ const own=await chrome.tabs.getCurrent(),q=new URLSearchParams(location.search);
5
+ const id=q.get('requestId');
6
+ if(!own?.id||own.url!==location.href||[...q.keys()].length!==1
7
+ ||!/^[a-f0-9]{32}$/.test(id||''))throw Error('INVALID_STATUS_ACTION');
8
+ const proof=await chrome.runtime.sendNativeMessage(host,{op:'acquire',id,action:'status'});
9
+ if(proof?.status!=='OK'||proof.action!=='status'||proof.id!==id)
10
+ throw Error(proof?.code||'STATUS_NATIVE_INVALID');
11
+ const state=await chrome.runtime.sendMessage({type:'CHAT_LOOP_SESSION_STATUS_V1',requestId:id});
12
+ const receipt=await chrome.runtime.sendNativeMessage(host,{op:'complete',id,
13
+ nonce:proof.nonce,action:'status',status:'OK',payload:JSON.stringify(state)});
14
+ if(receipt?.status!=='SAVED')throw Error(receipt?.code||'STATUS_NOT_STORED');
15
+ document.getElementById('status').textContent='SUCCESS';
16
+ document.getElementById('details').textContent='只读状态已返回本机请求;未修改执行 Chat。';
17
+ await chrome.tabs.remove(own.id);
18
+ }
19
+ void run().catch(e=>{
20
+ document.getElementById('status').textContent='UNVERIFIED';
21
+ document.getElementById('details').textContent=e.message||'STATUS_UNKNOWN';
22
+ });
@@ -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";
@@ -97,6 +98,15 @@ async function saveRun(run) {
97
98
  next.handoffPendingAt = now;
98
99
  }
99
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,
105
+ ...(next.replacedBlockedSessionId?{replaceBlockedSessionId:next.replacedBlockedSessionId}:{})}).catch(()=>null);
106
+ if(saved?.status!=="OK"||saved.session?.sessionId!==next.sessionId)
107
+ throw Error(saved?.code||"SESSION_PERSISTENCE_UNVERIFIED");
108
+ }
109
+
100
110
  try {
101
111
  if (clockActive(next) && !next.handoffPending && deadlineAt > Date.now()) {
102
112
  await chrome.alarms?.create?.(DEADLINE_ALARM_NAME, {when: deadlineAt});
@@ -108,6 +118,119 @@ async function saveRun(run) {
108
118
  else await chrome.alarms?.clear?.(COUNT_ALARM_NAME);
109
119
  } catch { /* The existing periodic alarm is a conservative backup. */ }
110
120
  }
121
+ // Browser Owner remains the only send authority. Native Host mirrors a minimal
122
+ // session pointer so later Chats can find the current Web Chat without a task engine.
123
+ function sessionProjection(run) {
124
+ const page=typeof run.page==='string'?run.page:'';
125
+ const match=page.match(/\/c\/([0-9a-f-]{12,})$/i);
126
+ const activeChatId=match?.[1]||null;
127
+ return {sessionId:run.sessionId,projectId:run.projectId,
128
+ phase:run.phase,generation:run.generation||0,activeChatId,
129
+ activeChatUrl:activeChatId?"https://chatgpt.com"+page:null,
130
+ updatedAt:Date.now()};
131
+ }
132
+ const sessionPage=(sender,name,id)=> Number.isInteger(sender.tab?.id)
133
+ && typeof id==='string'&&/^[a-f0-9]{32}$/.test(id)
134
+ && sender.url===chrome.runtime.getURL(name+'.html')+'?requestId='+id;
135
+ async function sessionStartPrepare(message,sender){
136
+ if(!sessionPage(sender,'start',message.requestId))
137
+ return {status:'DENIED',code:'INVALID_START_SOURCE'};
138
+ const prior=await readRun();
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.
142
+ const proof=await chrome.runtime.sendNativeMessage(SESSION_HOST,
143
+ {op:'acquire',id:message.requestId,action:'start'}).catch(()=>null);
144
+ const intent=proof?.intent;
145
+ if(proof?.status!=='OK'||intent?.confirmed!==true
146
+ ||typeof intent.projectId!=='string'
147
+ ||!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(intent.projectId)
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
+ 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.
167
+ const next={sessionId:crypto.randomUUID(),projectId:intent.projectId,
168
+ generation:0,phase:'STARTING',action:'START',token:crypto.randomUUID(),
169
+ page:'/',tabId:null,startedAt:Date.now(),
170
+ machineRequestId:message.requestId,machineActionTabId:sender.tab.id,
171
+ ...(matchingBlocked?{replacedBlockedSessionId:replace}:{}),
172
+ startPrompt:intent.prompt};
173
+ await saveRun(next); // Reserve BEFORE any new Chrome tab or send.
174
+ return {status:'PREPARED',token:next.token,sessionId:next.sessionId};
175
+ }
176
+ async function sessionStartTake(message,sender){
177
+ const run=await readRun();
178
+ if(!sessionPage(sender,'start',message.requestId)||run?.machineRequestId!==message.requestId
179
+ ||run.machineActionTabId!==sender.tab.id||run.token!==message.token
180
+ ||run.phase!=='STARTING')return {status:'DENIED',code:'START_OWNER_CHANGED'};
181
+ await saveRun({...run,phase:'PENDING'});
182
+ return {status:'OK',sessionId:run.sessionId,prompt:run.startPrompt};
183
+ }
184
+ async function sessionStartResult(message,sender){
185
+ const run=await readRun();
186
+ if(!sessionPage(sender,'start',message.requestId)||run?.machineRequestId!==message.requestId
187
+ ||run.machineActionTabId!==sender.tab.id||run.token!==message.token
188
+ ||run.phase!=='PENDING')return {status:'DENIED',code:'START_OWNER_CHANGED'};
189
+ let confirmed=message.result==='SENT_CONFIRMED'
190
+ &&Number.isInteger(message.newTabId)
191
+ &&typeof message.newPage==='string'&&pathOf('https://chatgpt.com'+message.newPage)===message.newPage
192
+ &&typeof message.newUserKey==='string'&&/^user:[^\s]{8,}$/.test(message.newUserKey);
193
+ if(confirmed){
194
+ const tab=await chrome.tabs.get(message.newTabId).catch(()=>null);
195
+ confirmed=!!tab&&pathOf(tab.url)===message.newPage;
196
+ }
197
+ const next={...run,phase:confirmed?'WAITING_REPLY':'BLOCKED',
198
+ reason:confirmed?'':'START_SEND_UNVERIFIED',
199
+ ...(confirmed?{tabId:message.newTabId,page:message.newPage,
200
+ newUserKey:message.newUserKey}:{attemptedNewTabId:Number.isInteger(message.newTabId)?message.newTabId:null})};
201
+ delete next.startPrompt;
202
+ delete next.machineRequestId;
203
+ delete next.machineActionTabId;
204
+ await saveRun(next);
205
+ return confirmed?{status:'OK',sessionId:next.sessionId,
206
+ activeChatId:sessionProjection(next).activeChatId,
207
+ activeChatUrl:sessionProjection(next).activeChatUrl}
208
+ :{status:'BLOCKED',code:'START_SEND_UNVERIFIED'};
209
+ }
210
+ async function sessionStatus(message,sender){
211
+ if(!sessionPage(sender,'status',message.requestId))
212
+ return {status:'DENIED',code:'INVALID_STATUS_SOURCE'};
213
+ const run=await readRun();
214
+ const disk=await chrome.runtime.sendNativeMessage(SESSION_HOST,{op:'session-read'}).catch(()=>null);
215
+ if(disk?.status!=='OK')return {status:'UNVERIFIED',code:'SESSION_STATE_UNVERIFIED'};
216
+ if(run?.sessionId&&(!disk.session||run.sessionId!==disk.session.sessionId
217
+ ||run.phase!==disk.session.phase
218
+ ||(run.generation||0)!==disk.session.generation))
219
+ return {status:'UNVERIFIED',code:'SESSION_FILE_MISMATCH'};
220
+ if(!run?.sessionId&&disk.session&&disk.session.phase!=='DONE')
221
+ return {status:'UNVERIFIED',code:'SESSION_OWNER_MISMATCH'};
222
+ if(run&&run.phase!=='DONE'&&!run.sessionId)
223
+ return {status:'OK',state:'LEGACY_OWNER',phase:run.phase};
224
+ if(!run||run.phase==='DONE')return {status:'OK',state:'IDLE',
225
+ ...(disk.session?{lastSession:disk.session}:{} )};
226
+ const record=sessionProjection(run);
227
+ if(record.activeChatId&&run.phase==='WAITING_REPLY'){
228
+ const tab=await chrome.tabs.get(run.tabId).catch(()=>null);
229
+ if(!tab||pathOf(tab.url)!==run.page)
230
+ return {status:'UNVERIFIED',code:'OWNER_TAB_MISSING',session:record};
231
+ }
232
+ return {status:'OK',state:run.phase==='BLOCKED'?'BLOCKED':'ACTIVE',session:record};
233
+ }
111
234
  async function prepareHandoffIfDue() {
112
235
  const run = await readRun();
113
236
  const deadlineAt = deadlineOf(run);
@@ -196,6 +319,26 @@ async function onTurnBlocked(message, sender) {
196
319
  const second = await verify();
197
320
  if (!second || first.retryAvailable !== second.retryAvailable)
198
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
+ if (sameTurn && ["RUNNING", "UNKNOWN", "IDLE"].includes(proof.status)) {
337
+ recovered++;
338
+ if (recovered === 2) return {status: "IGNORED", code: "PAGE_ERROR_CLEARED_SAME_TURN"};
339
+ } else recovered = 0;
340
+ }
341
+ }
199
342
  // A security challenge is an explicit stop condition, not a prompt to retry
200
343
  // or evade site access controls. Keep the original Chat and user Turn intact.
201
344
  if (reason === "SECURITY_CHALLENGE") {
@@ -415,7 +558,9 @@ async function onTurnFinished(message, sender) {
415
558
  const newlyDue = Date.now() >= startedAt + windowMsOf(previous);
416
559
  if (nextAction === "CONTINUE" && newlyDue) nextAction = "HANDOFF";
417
560
  const token = crypto.randomUUID();
418
- const run = {tabId, page: message.page, userKey: message.userKey,
561
+ const run = {...(previous?.sessionId ? {sessionId: previous.sessionId,
562
+ projectId: previous.projectId, generation: previous.generation || 0} : {}),
563
+ tabId, page: message.page, userKey: message.userKey,
419
564
  assistantKey: message.assistantKey, phase: "RESERVED", token,
420
565
  action: nextAction, startedAt, modelReceipts:receiptHistory(previous),
421
566
  ...(countMode ? {countMode:true, countValue:verifiedCount,
@@ -524,7 +669,8 @@ async function onManualPrepare(message, sender) {
524
669
  || message.tabId !== Number(rawTabId))))
525
670
  return {status: "DENIED", code: "INVALID_ACTION_URL"};
526
671
  const previous = await readRun();
527
- if (previous && !["DONE", "BLOCKED"].includes(previous.phase))
672
+ if (previous && (previous.sessionId&&previous.phase!=="DONE"
673
+ || !["DONE", "BLOCKED"].includes(previous.phase)))
528
674
  return {status: "DENIED", code: "LOOP_ALREADY_ACTIVE"};
529
675
  const candidates = await chrome.tabs.query({url: "https://chatgpt.com/*"});
530
676
  const all = candidates.filter(tab => Number.isInteger(tab.id) && pathOf(tab.url));
@@ -586,7 +732,8 @@ async function onTestPrepare(message, sender) {
586
732
  chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&discardDraft=1&timed=1&wall=10m&count=86"].includes(sender.url))
587
733
  return {status: "DENIED", code: "INVALID_TEST_ACTION"};
588
734
  const previous = await readRun();
589
- if (previous && !["DONE", "BLOCKED"].includes(previous.phase))
735
+ if (previous && (previous.sessionId&&previous.phase!=="DONE"
736
+ || !["DONE", "BLOCKED"].includes(previous.phase)))
590
737
  return {status: "DENIED", code: "LOOP_ALREADY_ACTIVE"};
591
738
  if (previous?.tabId === tabId)
592
739
  return {status: "DENIED", code: "TEST_TARGET_ALREADY_USED"};
@@ -867,6 +1014,7 @@ async function onResult(message, sender) {
867
1014
  // Keep source identities as the already-processed baseline; the new
868
1015
  // user's first reply must not be suppressed by duplicate detection.
869
1016
  newUserKey: message.newUserKey, phase: "WAITING_REPLY", reason: "",
1017
+ ...(run.sessionId?{generation:(run.generation||0)+1}:{}),
870
1018
  // The new Chat has no previous assistant message. Never authenticate
871
1019
  // its first Retry/Error Handoff using the OLD Chat's assistant ID.
872
1020
  userKey: null, assistantKey: null, startedAt: Date.now(),
@@ -1092,8 +1240,14 @@ chrome.runtime.onMessage.addListener((message, sender, reply) => {
1092
1240
  "CHAT_LOOP_ACTION_RESULT_V1", "CHAT_LOOP_ACTION_MANUAL_PREPARE_V1",
1093
1241
  "CHAT_LOOP_MODEL_INTENT_V1", "CHAT_LOOP_MODEL_INTENT_STATUS_V1",
1094
1242
  "CHAT_LOOP_ACTION_TEST_PREPARE_V1",
1243
+ "CHAT_LOOP_SESSION_START_PREPARE_V1", "CHAT_LOOP_SESSION_START_TAKE_V1",
1244
+ "CHAT_LOOP_SESSION_START_RESULT_V1", "CHAT_LOOP_SESSION_STATUS_V1",
1095
1245
  "CHAT_LOOP_ACCEPTANCE_CLOCK_ADVANCE_V1"].includes(type)) {
1096
- const work = serial(() => type === "CHAT_LOOP_MODEL_INTENT_V1"
1246
+ const work = serial(() => type === "CHAT_LOOP_SESSION_START_PREPARE_V1"
1247
+ ? sessionStartPrepare(message,sender) : type === "CHAT_LOOP_SESSION_START_TAKE_V1"
1248
+ ? sessionStartTake(message,sender) : type === "CHAT_LOOP_SESSION_START_RESULT_V1"
1249
+ ? sessionStartResult(message,sender) : type === "CHAT_LOOP_SESSION_STATUS_V1"
1250
+ ? sessionStatus(message,sender) : type === "CHAT_LOOP_MODEL_INTENT_V1"
1097
1251
  ? onModelIntent(message, sender) : type === "CHAT_LOOP_MODEL_INTENT_STATUS_V1"
1098
1252
  ? onModelIntentStatus(message,sender) : type === "CHAT_LOOP_TURN_FINISHED_V1"
1099
1253
  ? onTurnFinished(message, sender) : type === "CHAT_LOOP_TURN_BLOCKED_V1"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Chat Loop Extension",
4
- "version": "0.3.32",
4
+ "version": "0.3.34",
5
5
  "description": "Chat Loop browser companion: Eye actions, Turn observer, Continue and Handoff Actions",
6
6
  "permissions": [
7
7
  "activeTab",
@@ -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];
@@ -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', 'continue', 'handoff', 'clean'];
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;
@@ -24,6 +24,20 @@ export function requestDir(id) {
24
24
  return join(tempRoot(),id);
25
25
  }
26
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','replaceBlockedSessionId'].includes(k))
30
+ ||!(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/).test(intent.projectId||'')
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'
35
+ ||!intent.prompt.trim()||intent.prompt.length>4000
36
+ ||/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(intent.prompt))
37
+ throw Object.assign(Error('Explicit confirmed task and project required'),{code:'INVALID_START_INTENT'});
38
+ return {projectId:intent.projectId,prompt:intent.prompt,confirmed:true,
39
+ ...(intent.replaceBlockedSessionId?{replaceBlockedSessionId:intent.replaceBlockedSessionId}:{})};
40
+ }
27
41
  if(action==='clean'){
28
42
  if(!intent || typeof intent!=='object' || Array.isArray(intent)
29
43
  || Object.keys(intent).length!==1 || !Number.isSafeInteger(intent.tabId)
@@ -82,7 +96,7 @@ export function readRequest(id) {
82
96
  const r=JSON.parse(readFileSync(p,'utf8'));
83
97
  if(r.id!==id||!ACTIONS.includes(r.action)||Date.now()>r.expiresAt)
84
98
  throw Object.assign(Error('Request expired or changed'),{code:'REQUEST_EXPIRED'});
85
- if(['continue','handoff','clean'].includes(r.action))validateIntent(r.action,r.intent);
99
+ if(['continue','handoff','clean','start'].includes(r.action))validateIntent(r.action,r.intent);
86
100
  return r;
87
101
  }
88
102
  export function finish({id,nonce,action,status,payload='',code=null}) {
@@ -0,0 +1,79 @@
1
+ // Single-Mac durable session pointer. Extension Worker is the only admission/sending authority.
2
+ // This file is a checked, minimal projection, not a second executor or task engine.
3
+ import {openSync,closeSync,readFileSync,writeFileSync,renameSync,unlinkSync,lstatSync,existsSync,mkdirSync} from 'node:fs';
4
+ import {homedir} from 'node:os';
5
+ import {join,dirname} from 'node:path';
6
+ import {randomUUID} from 'node:crypto';
7
+
8
+ const ID=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+ const CHAT=/^[0-9a-f-]{12,}$/i;
10
+ const PROJECT=/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
11
+ const PHASE=new Set(['STARTING','RESERVED','PENDING','WAITING_REPLY','WAITING_INTERVAL','DONE','BLOCKED','WALL_CLOCK_HOLD']);
12
+ const fail=code=>Object.assign(Error(code),{code});
13
+ export const sessionPath=()=>join(homedir(),'.chat-loop','session','active-session.json');
14
+ const ensurePrivate=dir=>{
15
+ if(!existsSync(dir))mkdirSync(dir,{recursive:true,mode:0o700});
16
+ const st=lstatSync(dir);
17
+ if(!st.isDirectory()||st.isSymbolicLink()||(process.getuid&&st.uid!==process.getuid())||
18
+ (process.platform!=='win32'&&(st.mode&0o077)))throw fail('SESSION_DIRECTORY_UNTRUSTED');
19
+ };
20
+ const validate=r=>{
21
+ if(!r||typeof r!=='object'||Array.isArray(r)||
22
+ !ID.test(r.sessionId||'')||!PROJECT.test(r.projectId||'')||
23
+ !PHASE.has(r.phase)||!Number.isSafeInteger(r.generation)||r.generation<0||
24
+ !Number.isSafeInteger(r.updatedAt)||r.updatedAt<=0)
25
+ throw fail('SESSION_INVALID');
26
+ if(r.activeChatId!==null&&(typeof r.activeChatId!=='string'||!CHAT.test(r.activeChatId)))
27
+ throw fail('SESSION_CHAT_INVALID');
28
+ if(r.activeChatUrl!==null&&(typeof r.activeChatUrl!=='string'
29
+ || !/^https:\/\/chatgpt\.com\/(?:c\/[0-9a-f-]{12,}|g\/[^/]+\/c\/[0-9a-f-]{12,})$/i.test(r.activeChatUrl)))
30
+ throw fail('SESSION_CHAT_INVALID');
31
+ if((r.activeChatId===null)!==(r.activeChatUrl===null))
32
+ throw fail('SESSION_CHAT_INVALID');
33
+ if(r.activeChatUrl!==null&&!r.activeChatUrl.endsWith('/'+r.activeChatId))
34
+ throw fail('SESSION_CHAT_INVALID');
35
+ if(r.activeChatId===null&&r.phase==='WAITING_REPLY')throw fail('SESSION_CHAT_INVALID');
36
+ const keys=['sessionId','projectId','phase','generation','activeChatId','activeChatUrl','updatedAt'];
37
+ if(Object.keys(r).some(k=>!keys.includes(k)))throw fail('SESSION_EXTRA_FIELDS');
38
+ return Object.fromEntries(keys.map(k=>[k,r[k]]));
39
+ };
40
+ export function readSession(){
41
+ const file=sessionPath();
42
+ if(!existsSync(file))return null;
43
+ ensurePrivate(dirname(file));
44
+ const st=lstatSync(file);
45
+ if(!st.isFile()||st.isSymbolicLink()||(process.getuid&&st.uid!==process.getuid())
46
+ ||(process.platform!=='win32'&&(st.mode&0o077)))throw fail('SESSION_FILE_UNTRUSTED');
47
+ try{return validate(JSON.parse(readFileSync(file,'utf8')));}
48
+ catch(e){if(e.code)throw e;throw fail('SESSION_FILE_CORRUPT');}
49
+ }
50
+ export function writeSession(input,replaceBlockedSessionId=null){
51
+ const next=validate(input),file=sessionPath(),dir=dirname(file),lock=file+'.lock';
52
+ ensurePrivate(dir);
53
+ let fd;
54
+ try{fd=openSync(lock,'wx',0o600);}catch{throw fail('SESSION_CONCURRENT_UPDATE');}
55
+ try{
56
+ const old=readSession();
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))
63
+ throw fail('SESSION_ALREADY_ACTIVE');
64
+ if(old&&old.sessionId===next.sessionId){
65
+ if(next.generation<old.generation)throw fail('SESSION_STALE_GENERATION');
66
+ if(old.phase==='DONE'&&next.phase!=='DONE'||old.phase==='BLOCKED'
67
+ && !['BLOCKED','DONE'].includes(next.phase))
68
+ throw fail('SESSION_TERMINAL_LOCKED');
69
+ if(old.activeChatId!==null&&next.generation===old.generation&&
70
+ next.activeChatId!==null&&old.activeChatId!==next.activeChatId)
71
+ throw fail('SESSION_CHAT_CHANGED_WITHOUT_HANDOFF');
72
+ }
73
+ const temp=join(dir,'.active-session-'+randomUUID()+'.tmp');
74
+ try{writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{flag:'wx',mode:0o600});
75
+ renameSync(temp,file);
76
+ }catch(e){try{unlinkSync(temp);}catch{}throw e;}
77
+ return next;
78
+ }finally{closeSync(fd);unlinkSync(lock);}
79
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@tomflow/chatloop-extension",
3
- "version": "0.3.32",
3
+ "version": "0.3.34",
4
4
  "type": "module",
5
- "description": "Chat Loop Extension package: seven formal Action pages and its own native CLI bridge",
5
+ "description": "Chat Loop Extension package: nine formal Action pages and its own native CLI bridge",
6
6
  "bin": {
7
7
  "chatloop-extension": "bin/cli.mjs",
8
8
  "chat-loop-browser": "bin/cli.mjs"