@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 +12 -2
- package/bin/cli.mjs +11 -6
- package/bin/native-host.mjs +5 -0
- package/extension/acceptance.js +1 -0
- package/extension/actions/start.js +145 -0
- package/extension/actions/status.js +22 -0
- package/extension/background.js +158 -4
- package/extension/manifest.json +1 -1
- package/extension/popup.js +1 -0
- package/extension/start.html +6 -0
- package/extension/status.html +6 -0
- package/lib/bridge.mjs +16 -2
- package/lib/session-file.mjs +79 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @tomflow/chatloop-extension
|
|
2
2
|
|
|
3
|
-
Chrome 扩展(
|
|
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(
|
|
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';
|
package/bin/native-host.mjs
CHANGED
|
@@ -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',
|
package/extension/acceptance.js
CHANGED
|
@@ -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
|
+
});
|
package/extension/background.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Only extension-owned Action pages execute Continue or cross-Chat Handoff.
|
|
3
3
|
importScripts("loop/core.js");
|
|
4
4
|
const RUN_KEY = "chat-loop.action.run.v1";
|
|
5
|
+
const SESSION_HOST = "com.tomflow.chatloop.extension";
|
|
5
6
|
const HOUR_MS = 60 * 60 * 1000; // Acceptance wall-clock hours are independent of the MVP timeout.
|
|
6
7
|
const RUN_BUDGET_MS = 10 * 60 * 1000; // Ten minutes per Chat; never interrupt a running Turn.
|
|
7
8
|
const ALARM_NAME = "chat-loop.turn-watch.v1";
|
|
@@ -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 = {
|
|
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 &&
|
|
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 &&
|
|
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 === "
|
|
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"
|
package/extension/manifest.json
CHANGED
package/extension/popup.js
CHANGED
|
@@ -56,6 +56,7 @@ async function showActionState() {
|
|
|
56
56
|
const run = (await chrome.storage.local.get(key))[key];
|
|
57
57
|
const allowed = ["phase", "action", "reason", "tabId", "page", "sourceTabId", "sourcePage",
|
|
58
58
|
"userKey", "assistantKey", "newUserKey", "testSetup", "startedAt",
|
|
59
|
+
"sessionId", "projectId", "generation",
|
|
59
60
|
"deadlineAt", "handoffPending", "handoffPendingAt", "attemptedNewTabId"];
|
|
60
61
|
const summary = {version: chrome.runtime.getManifest().version};
|
|
61
62
|
if (run) for (const key of allowed) if (run[key] !== undefined) summary[key] = run[key];
|
|
@@ -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.
|
|
3
|
+
"version": "0.3.34",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Chat Loop Extension package:
|
|
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"
|