@tomflow/chatloop-extension 0.3.32 → 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 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,12 @@ 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
+
25
27
  Clean 只清空输入框,不发送,不更改 Owner;必须指定精确标签页 ID 且验证目标。Chrome 已安装扩展不随 npm 包自动安装。本地源码与已发布 npm 版本可能不同,真实结果请看 [CURRENT](../../context/CURRENT.md)。
26
28
 
27
29
  详细接口和安全边界统一见 [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)});
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,14 @@ 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}).catch(()=>null);
105
+ if(saved?.status!=="OK"||saved.session?.sessionId!==next.sessionId)
106
+ throw Error(saved?.code||"SESSION_PERSISTENCE_UNVERIFIED");
107
+ }
108
+
100
109
  try {
101
110
  if (clockActive(next) && !next.handoffPending && deadlineAt > Date.now()) {
102
111
  await chrome.alarms?.create?.(DEADLINE_ALARM_NAME, {when: deadlineAt});
@@ -108,6 +117,104 @@ async function saveRun(run) {
108
117
  else await chrome.alarms?.clear?.(COUNT_ALARM_NAME);
109
118
  } catch { /* The existing periodic alarm is a conservative backup. */ }
110
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
+ }
111
218
  async function prepareHandoffIfDue() {
112
219
  const run = await readRun();
113
220
  const deadlineAt = deadlineOf(run);
@@ -415,7 +522,9 @@ async function onTurnFinished(message, sender) {
415
522
  const newlyDue = Date.now() >= startedAt + windowMsOf(previous);
416
523
  if (nextAction === "CONTINUE" && newlyDue) nextAction = "HANDOFF";
417
524
  const token = crypto.randomUUID();
418
- const run = {tabId, page: message.page, userKey: message.userKey,
525
+ const run = {...(previous?.sessionId ? {sessionId: previous.sessionId,
526
+ projectId: previous.projectId, generation: previous.generation || 0} : {}),
527
+ tabId, page: message.page, userKey: message.userKey,
419
528
  assistantKey: message.assistantKey, phase: "RESERVED", token,
420
529
  action: nextAction, startedAt, modelReceipts:receiptHistory(previous),
421
530
  ...(countMode ? {countMode:true, countValue:verifiedCount,
@@ -524,7 +633,8 @@ async function onManualPrepare(message, sender) {
524
633
  || message.tabId !== Number(rawTabId))))
525
634
  return {status: "DENIED", code: "INVALID_ACTION_URL"};
526
635
  const previous = await readRun();
527
- if (previous && !["DONE", "BLOCKED"].includes(previous.phase))
636
+ if (previous && (previous.sessionId&&previous.phase!=="DONE"
637
+ || !["DONE", "BLOCKED"].includes(previous.phase)))
528
638
  return {status: "DENIED", code: "LOOP_ALREADY_ACTIVE"};
529
639
  const candidates = await chrome.tabs.query({url: "https://chatgpt.com/*"});
530
640
  const all = candidates.filter(tab => Number.isInteger(tab.id) && pathOf(tab.url));
@@ -586,7 +696,8 @@ async function onTestPrepare(message, sender) {
586
696
  chrome.runtime.getURL("continue.html") + "?tabId=" + tabId + "&setup=1&discardDraft=1&timed=1&wall=10m&count=86"].includes(sender.url))
587
697
  return {status: "DENIED", code: "INVALID_TEST_ACTION"};
588
698
  const previous = await readRun();
589
- if (previous && !["DONE", "BLOCKED"].includes(previous.phase))
699
+ if (previous && (previous.sessionId&&previous.phase!=="DONE"
700
+ || !["DONE", "BLOCKED"].includes(previous.phase)))
590
701
  return {status: "DENIED", code: "LOOP_ALREADY_ACTIVE"};
591
702
  if (previous?.tabId === tabId)
592
703
  return {status: "DENIED", code: "TEST_TARGET_ALREADY_USED"};
@@ -867,6 +978,7 @@ async function onResult(message, sender) {
867
978
  // Keep source identities as the already-processed baseline; the new
868
979
  // user's first reply must not be suppressed by duplicate detection.
869
980
  newUserKey: message.newUserKey, phase: "WAITING_REPLY", reason: "",
981
+ ...(run.sessionId?{generation:(run.generation||0)+1}:{}),
870
982
  // The new Chat has no previous assistant message. Never authenticate
871
983
  // its first Retry/Error Handoff using the OLD Chat's assistant ID.
872
984
  userKey: null, assistantKey: null, startedAt: Date.now(),
@@ -1092,8 +1204,14 @@ chrome.runtime.onMessage.addListener((message, sender, reply) => {
1092
1204
  "CHAT_LOOP_ACTION_RESULT_V1", "CHAT_LOOP_ACTION_MANUAL_PREPARE_V1",
1093
1205
  "CHAT_LOOP_MODEL_INTENT_V1", "CHAT_LOOP_MODEL_INTENT_STATUS_V1",
1094
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",
1095
1209
  "CHAT_LOOP_ACCEPTANCE_CLOCK_ADVANCE_V1"].includes(type)) {
1096
- const work = serial(() => type === "CHAT_LOOP_MODEL_INTENT_V1"
1210
+ const work = serial(() => type === "CHAT_LOOP_SESSION_START_PREPARE_V1"
1211
+ ? sessionStartPrepare(message,sender) : type === "CHAT_LOOP_SESSION_START_TAKE_V1"
1212
+ ? sessionStartTake(message,sender) : type === "CHAT_LOOP_SESSION_START_RESULT_V1"
1213
+ ? sessionStartResult(message,sender) : type === "CHAT_LOOP_SESSION_STATUS_V1"
1214
+ ? sessionStatus(message,sender) : type === "CHAT_LOOP_MODEL_INTENT_V1"
1097
1215
  ? onModelIntent(message, sender) : type === "CHAT_LOOP_MODEL_INTENT_STATUS_V1"
1098
1216
  ? onModelIntentStatus(message,sender) : type === "CHAT_LOOP_TURN_FINISHED_V1"
1099
1217
  ? 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.33",
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,16 @@ 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'].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
+ }
27
37
  if(action==='clean'){
28
38
  if(!intent || typeof intent!=='object' || Array.isArray(intent)
29
39
  || Object.keys(intent).length!==1 || !Number.isSafeInteger(intent.tabId)
@@ -82,7 +92,7 @@ export function readRequest(id) {
82
92
  const r=JSON.parse(readFileSync(p,'utf8'));
83
93
  if(r.id!==id||!ACTIONS.includes(r.action)||Date.now()>r.expiresAt)
84
94
  throw Object.assign(Error('Request expired or changed'),{code:'REQUEST_EXPIRED'});
85
- if(['continue','handoff','clean'].includes(r.action))validateIntent(r.action,r.intent);
95
+ if(['continue','handoff','clean','start'].includes(r.action))validateIntent(r.action,r.intent);
86
96
  return r;
87
97
  }
88
98
  export function finish({id,nonce,action,status,payload='',code=null}) {
@@ -0,0 +1,74 @@
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){
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(old&&old.sessionId!==next.sessionId&&old.phase!=='DONE')
58
+ throw fail('SESSION_ALREADY_ACTIVE');
59
+ if(old&&old.sessionId===next.sessionId){
60
+ if(next.generation<old.generation)throw fail('SESSION_STALE_GENERATION');
61
+ if(old.phase==='DONE'&&next.phase!=='DONE'||old.phase==='BLOCKED'
62
+ && !['BLOCKED','DONE'].includes(next.phase))
63
+ throw fail('SESSION_TERMINAL_LOCKED');
64
+ if(old.activeChatId!==null&&next.generation===old.generation&&
65
+ next.activeChatId!==null&&old.activeChatId!==next.activeChatId)
66
+ throw fail('SESSION_CHAT_CHANGED_WITHOUT_HANDOFF');
67
+ }
68
+ const temp=join(dir,'.active-session-'+randomUUID()+'.tmp');
69
+ try{writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{flag:'wx',mode:0o600});
70
+ renameSync(temp,file);
71
+ }catch(e){try{unlinkSync(temp);}catch{}throw e;}
72
+ return next;
73
+ }finally{closeSync(fd);unlinkSync(lock);}
74
+ }
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.33",
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"