@petercjl/infinite-canvas-cli 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,105 @@
1
+ import fs from 'node:fs/promises';
2
+ import {constants} from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import net from 'node:net';
6
+ import {spawn} from 'node:child_process';
7
+ import {execute,Fault,need,lock,sleep} from './core.mjs';
8
+
9
+ export const CDP='http://127.0.0.1:9223';
10
+ export const PROFILE_NAME='.dianshang-chrome-profile';
11
+ const escapeRE=s=>s.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
12
+ const powershell=env=>path.win32.join(env.SystemRoot||env.WINDIR||'C:\\Windows','System32','WindowsPowerShell','v1.0','powershell.exe');
13
+ const psPrefix='$ErrorActionPreference="Stop"; [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); ';
14
+ export function windowsListeners(output){
15
+ return String(output).split(/\r?\n/).map(line=>line.trim().split(/\s+/)).filter(row=>row.length===5&&/^TCP$/i.test(row[0])&&row[1].endsWith(':9223')&&/^\d+$/.test(row[4])&&['0.0.0.0:0','[::]:0'].includes(row[2])).map(row=>({pid:Number(row[4]),address:row[1].slice(0,-5).replace(/^\[|\]$/g,'')}));
16
+ }
17
+
18
+ export function chromeCandidates({platform=process.platform,env=process.env,home=os.homedir()}={}){
19
+ const p=platform==='win32'?path.win32:path.posix;
20
+ if(env.INFINITE_CANVAS_CHROME_BIN)return [env.INFINITE_CANVAS_CHROME_BIN];
21
+ if(platform==='darwin')return ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',p.join(home,'Applications/Google Chrome.app/Contents/MacOS/Google Chrome')];
22
+ if(platform==='win32')return [...new Set([env.LOCALAPPDATA||p.join(home,'AppData','Local'),env.ProgramFiles||env.PROGRAMFILES||'C:\\Program Files',env['ProgramFiles(x86)']||env['PROGRAMFILES(X86)']||'C:\\Program Files (x86)'].map(root=>p.join(root,'Google','Chrome','Application','chrome.exe')))];
23
+ return [];
24
+ }
25
+ export function dedicatedCommand(command,profile,platform=process.platform){
26
+ let c=String(command||''),expected=profile;
27
+ if(platform==='win32'){c=c.replaceAll('\\','/').toLowerCase();expected=expected.replaceAll('\\','/').toLowerCase();}
28
+ const flag=(name,value)=>new RegExp('(?:^|[\\s"])--'+name+'(?:=|\\s+)"?'+escapeRE(value)+'"?(?=$|[\\s"])').test(c);
29
+ return flag('remote-debugging-port','9223')&&flag('user-data-dir',expected)&&!/(?:^|[\s"])--no-sandbox(?:[\s"]|$)/.test(c)&&(!c.includes('--remote-debugging-address')||flag('remote-debugging-address','127.0.0.1'));
30
+ }
31
+ export function chromeArgs(profile,{headless=false,startupUrl='https://sealseek.cn/board/'}={}){
32
+ return ['--remote-debugging-address=127.0.0.1','--remote-debugging-port=9223','--user-data-dir='+profile,'--no-first-run','--no-default-browser-check',...(headless?['--headless=new']:[]),startupUrl];
33
+ }
34
+ async function listening(){return new Promise(resolve=>{const socket=net.createConnection({host:'127.0.0.1',port:9223});let done=false;const finish=v=>{if(done)return;done=true;socket.destroy();resolve(v);};socket.setTimeout(800,()=>finish(false));socket.once('connect',()=>finish(true));socket.once('error',()=>finish(false));});}
35
+ async function launch(executable,args){
36
+ const child=spawn(executable,args,{detached:true,stdio:'ignore',windowsHide:false});
37
+ await new Promise((resolve,reject)=>{child.once('spawn',resolve);child.once('error',reject);});child.unref();return child.pid;
38
+ }
39
+
40
+ export class ChromeRuntime {
41
+ constructor({platform=process.platform,env=process.env,home=os.homedir(),access=async file=>{const stat=await fs.stat(file);if(!stat.isFile())return false;await fs.access(file,platform==='win32'?constants.F_OK:constants.X_OK);return true;},exec=execute,probe=listening,fetcher=fetch,launchProcess=launch,mkdir=dir=>fs.mkdir(dir,{recursive:true,mode:0o700}),pause=sleep,headless=false,startupUrl}={}){
42
+ Object.assign(this,{platform,env,home,access,exec,probe,fetcher,launchProcess,mkdir,pause,headless,startupUrl});
43
+ this.paths=platform==='win32'?path.win32:path.posix;this.profile=this.paths.join(home,PROFILE_NAME);
44
+ }
45
+ async discover(){
46
+ for(const file of chromeCandidates(this)){try{if(await this.access(file))return {installed:true,executable:file,source:this.env.INFINITE_CANVAS_CHROME_BIN?'configured':'standard-location'};}catch{}}
47
+ if(this.env.INFINITE_CANVAS_CHROME_BIN)return {installed:false,executable:this.env.INFINITE_CANVAS_CHROME_BIN,source:'configured',error:{code:'CHROME_NOT_FOUND',message:'Configured Chrome executable is unavailable.'}};
48
+ if(this.platform==='win32'){
49
+ for(const key of ['HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe','HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe']){
50
+ try{const {stdout}=await this.exec('reg.exe',['query',key,'/ve'],{timeout:3000,windowsHide:true});let file=stdout.match(/REG_(?:EXPAND_)?SZ\s+(.+)/)?.[1]?.trim().replace(/^"|"$/g,'');if(file){file=file.replace(/%([^%]+)%/g,(_,name)=>Object.entries(this.env).find(([k])=>k.toLowerCase()===name.toLowerCase())?.[1]||'%'+name+'%');if(await this.access(file))return {installed:true,executable:file,source:'windows-app-paths'};}}catch{}
51
+ }
52
+ }
53
+ return {installed:false,source:'system-discovery',error:{code:['darwin','win32'].includes(this.platform)?'CHROME_NOT_FOUND':'UNSUPPORTED_PLATFORM',message:'Install Google Chrome, or provide its executable with INFINITE_CANVAS_CHROME_BIN.'}};
54
+ }
55
+ async owners(){
56
+ if(this.platform==='win32'){
57
+ const network=await this.exec(path.win32.join(this.env.SystemRoot||this.env.WINDIR||'C:\\Windows','System32','netstat.exe'),['-ano','-p','tcp'],{timeout:5000,maxBuffer:2*1024*1024,windowsHide:true});
58
+ const connections=windowsListeners(network.stdout),pids=[...new Set(connections.map(p=>p.pid))];if(!pids.length)return [];
59
+ const script=psPrefix+'$result=@(foreach($id in @('+pids.join(',')+')){ $p=Get-CimInstance Win32_Process -Filter ("ProcessId = "+$id); [pscustomobject]@{pid=$p.ProcessId;name=$p.Name;command=$p.CommandLine} }); ConvertTo-Json -InputObject $result -Compress';
60
+ const {stdout}=await this.exec(powershell(this.env),['-NoProfile','-NonInteractive','-Command',script],{timeout:30000,maxBuffer:1024*1024,windowsHide:true});const data=JSON.parse(stdout.replace(/^\uFEFF/,''));return (Array.isArray(data)?data:[data]).map(p=>({...p,addresses:connections.filter(c=>c.pid===p.pid).map(c=>c.address)}));
61
+ }
62
+ if(this.platform==='darwin'){
63
+ const {stdout}=await this.exec('/usr/sbin/lsof',['-b','-nP','-t','-iTCP:9223','-sTCP:LISTEN'],{timeout:5000});const pids=[...new Set(stdout.trim().split(/\s+/).filter(v=>/^\d+$/.test(v)))];
64
+ return Promise.all(pids.map(async pid=>({pid:Number(pid),command:(await this.exec('/bin/ps',['-p',pid,'-o','command='],{timeout:3000})).stdout.trim()})));
65
+ }
66
+ throw new Fault('UNSUPPORTED_PLATFORM','Built-in Chrome startup currently supports macOS and Windows.');
67
+ }
68
+ async inspect(chrome){
69
+ if(!await this.probe())return {state:'stopped',running:false,ready:false};
70
+ let owners;
71
+ try{owners=await this.owners();}catch{return {state:'conflict',running:true,ready:false,error:{code:'BROWSER_OWNERSHIP_UNVERIFIED',message:'Port 9223 is occupied, but its process/profile could not be verified.'}};}
72
+ const matches=owners.length>0&&owners.every(p=>dedicatedCommand(p.command,this.profile,this.platform)&&(this.platform==='win32'?p.name?.toLowerCase()==='chrome.exe'&&p.addresses?.length&&p.addresses.every(a=>['127.0.0.1','::1'].includes(a)):p.command.startsWith(chrome.executable+' ')||p.command.startsWith('"'+chrome.executable+'" ')));
73
+ if(!matches)return {state:'conflict',running:true,ready:false,error:{code:'BROWSER_PORT_CONFLICT',message:'Port 9223 belongs to another process, profile, or non-loopback listener. Preserve it and resolve the conflict.'}};
74
+ try{
75
+ const response=await this.fetcher(CDP+'/json/version',{signal:AbortSignal.timeout(1500),redirect:'error'});const data=await response.json(),ws=new URL(data.webSocketDebuggerUrl);
76
+ need(response.ok&&/^(?:Headless)?Chrome\//.test(data.Browser)&&ws.protocol==='ws:'&&ws.hostname==='127.0.0.1'&&ws.port==='9223','BROWSER_UNAVAILABLE','Chrome CDP response is invalid.');
77
+ return {state:'ready',running:true,ready:true,pids:owners.map(p=>p.pid),browser:data.Browser};
78
+ }catch{return {state:'starting',running:true,ready:false,pids:owners.map(p=>p.pid)};}
79
+ }
80
+ async status(){
81
+ const chrome=await this.discover();const runtime=await this.inspect(chrome);
82
+ return {ok:(chrome.installed||runtime.ready)&&!runtime.error,available:chrome.installed||runtime.ready,chrome,...runtime,cdp:CDP,profile:this.profile,source:'built-in',...(chrome.error&&!runtime.ready?{error:chrome.error}:{}),next_action:runtime.ready?'open-or-reuse-board':chrome.installed&&!runtime.error?'browser open':runtime.error?'resolve-port-conflict':'install-google-chrome'};
83
+ }
84
+ async configuredLauncher(action){
85
+ const file=this.env.INFINITE_CANVAS_BROWSER_BIN;if(!file)return null;
86
+ let value;try{const r=await this.exec(file,[action,'--json'],{timeout:30000});value=JSON.parse(r.stdout);}catch(e){try{value=JSON.parse(e.stdout);}catch{throw new Fault('BROWSER_UNAVAILABLE','Configured browser launcher is unavailable.');}}
87
+ need(value.cdp===CDP&&this.paths.resolve(value.profile||'')===this.profile,'PROFILE_MISMATCH','Configured launcher must use port 9223 and the current-user dedicated profile.');
88
+ const own=await this.status();need(!own.error,own.error?.code||'BROWSER_UNAVAILABLE',own.error?.message||'Browser verification failed.');return {...own,source:'configured-launcher'};
89
+ }
90
+ async open({timeoutMs=60000}={}){
91
+ if(this.env.INFINITE_CANVAS_BROWSER_BIN){await this.configuredLauncher('open');const result=await this.status();need(result.ready,'BROWSER_UNAVAILABLE','Configured launcher did not make Chrome ready.');return result;}
92
+ const end=Date.now()+timeoutMs;
93
+ while(Date.now()<end){
94
+ try{return await lock('browser-start:'+this.profile,async()=>{
95
+ let state=await this.status();if(state.ready)return {...state,reused:true,started:false};
96
+ need(!state.error,state.error?.code||'BROWSER_UNAVAILABLE',state.error?.message||'Browser is unavailable.');
97
+ need(state.chrome.installed,'CHROME_NOT_FOUND','Google Chrome is unavailable.');
98
+ if(!state.running){await this.mkdir(this.profile);await this.launchProcess(state.chrome.executable,chromeArgs(this.profile,{headless:this.headless,startupUrl:this.startupUrl}));}
99
+ const started=!state.running;while(Date.now()<end){await this.pause(300);state=await this.status();need(!state.error,state.error?.code||'BROWSER_UNAVAILABLE',state.error?.message||'Browser start conflict.');if(state.ready)return {...state,started,reused:!started};}
100
+ throw new Fault('BROWSER_START_TIMEOUT','Chrome did not become ready. Check whether this dedicated profile is already open without debugging; preserve its files and existing browser processes.');
101
+ });}catch(e){if(e.code!=='BUSY')throw e;await this.pause(300);}
102
+ }
103
+ throw new Fault('BUSY','Another process is preparing the dedicated Chrome browser.');
104
+ }
105
+ }
@@ -0,0 +1,130 @@
1
+ import path from 'node:path';
2
+ import crypto from 'node:crypto';
3
+ import {chromium} from 'playwright-core';
4
+ import {need,Fault,sleep,newJSON,lock} from './core.mjs';
5
+ import {sceneSignature,verifyGrouping} from './frame.mjs';
6
+ import {ChromeRuntime} from './browser-runtime.mjs';
7
+
8
+ export const LOCATORS={newChat:'button:has(svg.lucide-square-plus)',upload:'input[type=file][accept="image/*,video/mp4,video/webm,video/*"]',editor:'[contenteditable=true][role=textbox]',send:'button[aria-label="发送消息"]'};
9
+ export async function handleBrowserDialog(dialog){try{if(dialog.type()==='beforeunload'||dialog.type()==='alert')await dialog.accept();else await dialog.dismiss();return true;}catch{return false;}}
10
+ export function composerImageState(expected){let node=document.querySelector('[contenteditable=true][role=textbox]');while(node&&!node.querySelector('input[type=file]'))node=node.parentElement;const imgs=[...(node?.querySelectorAll('[data-horizontal-media-strip] img')||[])];if(typeof expected!=='number')return imgs.length;return imgs.length===expected&&imgs.every(e=>e.src.startsWith('https://')&&e.complete&&e.naturalWidth>0)&&!node.innerText.includes('上传中');}
11
+ export function allowedGateway(route,method='GET'){
12
+ if(method==='POST')return route==='/canvas/save';
13
+ return method==='GET'&&(/^\/canvas\/detail\/[A-Za-z0-9_-]+$/.test(route)||route==='/chat/v3/history'||/^\/threads\/[A-Za-z0-9_-]+\/runs(?:\/[A-Za-z0-9_-]+)?$/.test(route));
14
+ }
15
+ export function nativeSubmission(body,expectedPrompt,files){
16
+ need(body?.config?.configurable?.chat_mode==='agent','AGENT_MODE_REQUIRED','The webpage did not submit in Agent mode.');
17
+ need(body.input?.messages?.length===1,'CONTEXT_NOT_CLEAN','Expected one message in a new conversation.');
18
+ const content=body.input.messages[0].content,text=typeof content==='string'?content:content?.filter(c=>c.type==='text').map(c=>c.text).join('');
19
+ need(text===expectedPrompt,'UI_CHANGED','The webpage submitted a different prompt.');
20
+ const refs=Array.isArray(content)?content.filter(c=>c.type==='image_url'):[];
21
+ need(refs.length===files.length&&refs.every((r,i)=>r.name===path.basename(files[i])),'REFERENCE_ORDER_CHANGED','The submitted reference order differs from the input.');
22
+ return {sessionId:body.metadata?.session_id,referenceUrls:refs.map(r=>r.image_url.url),videoSettings:body.config.configurable.video_params,traceId:body.config.configurable.trace_id};
23
+ }
24
+
25
+ export class BrowserDriver {
26
+ async connect(canvasId,{open=true}={}){
27
+ this.canvasId=canvasId;
28
+ const runtime=new ChromeRuntime();
29
+ const status=open?await runtime.open():await runtime.configuredLauncher('status')||await runtime.status();
30
+ need(status.state==='ready'&&status.cdp==='http://127.0.0.1:9223','BROWSER_UNAVAILABLE','The fixed ecommerce CDP browser is unavailable.');
31
+ this.status=status;this.browser=await chromium.connectOverCDP(status.cdp,{timeout:15000});
32
+ for(const context of this.browser.contexts()){for(const page of context.pages())page.on('dialog',handleBrowserDialog);context.on('page',page=>page.on('dialog',handleBrowserDialog));}
33
+ await this.composerLock(()=>this.selectBoardTab({open}));
34
+ const resumeEditor=this.page.getByRole('button',{name:'在此继续编辑',exact:true});if(await resumeEditor.count()===1)await resumeEditor.click();
35
+ try{await this.page.locator('.excalidraw').waitFor({state:'attached',timeout:25000});await this.page.locator(LOCATORS.editor).waitFor({state:'attached',timeout:25000});await this.unique(LOCATORS.editor);}catch{throw new Fault('AUTH_OR_BOARD_REQUIRED','Sign in and open an editable board in the ecommerce browser.');}
36
+ this.baseline=sceneSignature(await this.liveElements());return this;
37
+ }
38
+ async selectBoardTab({open=true}={}){
39
+ const canvasId=this.canvasId,pages=this.browser.contexts().flatMap(c=>c.pages()),matching=pages.filter(p=>this.boardId(p.url())===canvasId);
40
+ need(matching.length<=1,'BOARD_SELECTION_REQUIRED','The same board is open in multiple tabs. Keep one target tab.');this.page=matching[0];
41
+ if(!this.page){need(open,'BOARD_NOT_OPEN','Open the requested board in the ecommerce browser.');const context=this.browser.contexts()[0];need(context,'BROWSER_UNAVAILABLE','Browser context unavailable.');this.page=await context.newPage();await this.page.goto('https://sealseek.cn/board/#/canvas/'+encodeURIComponent(canvasId),{waitUntil:'domcontentloaded'});}
42
+ return this.page;
43
+ }
44
+ boardId(url){try{const u=new URL(url);return u.protocol==='https:'&&u.hostname==='sealseek.cn'?u.hash.match(/^#\/canvas\/([^?]+)/)?.[1]:null;}catch{return null;}}
45
+ assertBoard(){need(this.boardId(this.page.url())===this.canvasId,'BOARD_CHANGED','The browser navigated to another board.');}
46
+ async unique(selector){const l=this.page.locator(selector);need(await l.count()===1,'UI_LOCATOR_CHANGED','Expected one matching webpage control.',{selector});return l;}
47
+ async idle(){need(await this.page.locator('button[aria-label="停止生成"],button[aria-label="视频生成中,任务提交后暂不可停止"]').count()===0,'UI_BUSY','A conversation is still generating in this page.');}
48
+ async gateway(route,{method='GET',params,data}={}){
49
+ this.assertBoard();need(allowedGateway(route,method),'FORBIDDEN_OPERATION','This driver only permits status, history and canvas operations.');
50
+ const request=this.page.evaluate(async({route,method,params,data})=>{
51
+ const urls=performance.getEntriesByType('resource').filter(r=>r.initiatorType==='script'&&/\/board\/static\/index-[^/]+\.js$/.test(r.name)).map(r=>r.name);
52
+ let client;for(const u of urls.slice(0,8)){const m=await import(u);const candidates=[...new Set(Object.values(m).filter(v=>typeof v==='function'&&v.interceptors?.request&&String(v.defaults?.baseURL||'').includes('/api/sealseek-infinitecanvas')))];if(candidates.length===1){client=candidates[0];break;}}
53
+ if(!client)throw Error('WEB_CLIENT_UNAVAILABLE: webpage client export changed');
54
+ const origin=new URL(client.defaults.baseURL).origin;
55
+ const target=route.startsWith('/threads/')?origin+'/api/canvas/v2'+route:route;
56
+ const r=await client(target,{method,params,data,timeout:20000});
57
+ if(r?.code!==undefined&&r.code!==200)throw Error('PROVIDER_FAILURE: '+String(r.msg||r.code));
58
+ return r?.code===200?r.data:r;
59
+ },{route,method,params,data});
60
+ let timer;try{return await Promise.race([request,new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Fault('WEB_REQUEST_TIMEOUT','Browser API request did not complete; inspect saved state before retrying.')),25000);})]);}finally{clearTimeout(timer);}
61
+ }
62
+ getBoard(){return this.gateway('/canvas/detail/'+this.canvasId,{params:{_ic_read:crypto.randomUUID()}});}
63
+ history(sessionId){return this.gateway('/chat/v3/history',{params:{sessionId,limit:100}});}
64
+ run(sessionId,runId){return this.gateway(`/threads/${sessionId}/runs/${runId}`);}
65
+ runs(sessionId){return this.gateway(`/threads/${sessionId}/runs`);}
66
+ async liveElements(){
67
+ await this.page.locator('.excalidraw').waitFor({state:'attached',timeout:25000});
68
+ return this.page.evaluate(()=>{
69
+ const el=document.querySelector('.excalidraw');if(!el)throw Error('EDITOR_UNAVAILABLE');const k=Object.keys(el).find(k=>k.startsWith('__reactFiber$'));let f=k&&el[k];while(f?.return)f=f.return;
70
+ const seen=new Set();let result;function walk(n){if(!n||seen.has(n)||result)return;seen.add(n);if(seen.size>30000)return;const a=n.stateNode;if(a&&typeof a.getSceneElements==='function'){result=JSON.parse(JSON.stringify(a.getSceneElements()));return;}walk(n.child);walk(n.sibling);}walk(f);if(!result)throw Error('EDITOR_ADAPTER_UNSUPPORTED');return result;
71
+ });
72
+ }
73
+ async beforeWrite(){
74
+ this.assertBoard();const end=Date.now()+12000;
75
+ while(Date.now()<end){const before=await this.getBoard(),live=await this.liveElements();if(sceneSignature(live)===sceneSignature(JSON.parse(before.content).elements))return {before,signature:sceneSignature(live)};if(sceneSignature(live)===this.baseline){await this.syncLive(before.content);this.baseline=sceneSignature(await this.liveElements());}await sleep(500);}
76
+ throw new Fault('CANVAS_UNSAVED','Webpage and saved canvas differ. Finish manual edits and save before retrying.');
77
+ }
78
+ async afterWrite(token){
79
+ need(sceneSignature(await this.liveElements())===token.signature,'CANVAS_SYNC_PENDING','Canvas was updated remotely, but the user changed the page. Save/reload must be resolved before continuing.');
80
+ await this.syncLive((await this.getBoard()).content);
81
+ const end=Date.now()+15000;while(Date.now()<end){const saved=await this.getBoard();if(sceneSignature(await this.liveElements())===sceneSignature(JSON.parse(saved.content).elements)){this.baseline=sceneSignature(await this.liveElements());return;}await sleep(500);}
82
+ throw new Fault('CANVAS_SYNC_PENDING','Canvas saved, but webpage synchronization was not verified.');
83
+ }
84
+ async syncLive(content){
85
+ await this.page.evaluate(async content=>{const data=JSON.parse(content||'{}'),el=document.querySelector('.excalidraw');if(!el)throw Error('EDITOR_UNAVAILABLE');const key=Object.keys(el).find(k=>k.startsWith('__reactFiber$'));let root=el[key];while(root?.return)root=root.return;const seen=new Set();let api;function walk(n){if(!n||seen.has(n)||api)return;seen.add(n);if(typeof n.stateNode?.getSceneElements==='function'){api=n.stateNode;return;}walk(n.child);walk(n.sibling);}walk(root);if(typeof api?.updateScene!=='function'||typeof api.addFiles!=='function')throw Error('EDITOR_ADAPTER_UNSUPPORTED');const files=Object.entries(data.imageUrlMap||{}).map(([id,url])=>({id,dataURL:url,mimeType:data.elements?.find(e=>e.fileId===id)?.customData?.fileMimeType||'image/png',created:Date.now(),lastRetrieved:Date.now()}));if(files.length)await api.addFiles(files);await api.updateScene({elements:data.elements||[],captureUpdate:'NEVER'});},content);
86
+ }
87
+ async writeLock(action){for(let i=0;i<120;i++){try{return await lock('board-write:'+this.canvasId,action);}catch(e){if(e.code!=='BUSY')throw e;await sleep(1000);}}throw new Fault('BUSY','Canvas write queue did not become available.');}
88
+ async composerLock(action){for(let i=0;i<300;i++){try{return await lock('board-ui:'+this.canvasId,action);}catch(e){if(e.code!=='BUSY')throw e;await sleep(1000);}}throw new Fault('BUSY','Canvas conversation queue did not become available.');}
89
+ async helperMutation(action){return this.writeLock(async()=>{const token=await this.beforeWrite();const out=await action();await this.afterWrite(token);return out;});}
90
+ async newConversation(){
91
+ this.assertBoard();const editor=await this.unique(LOCATORS.editor);
92
+ need(!(await editor.innerText()).replaceAll('\u200b','').trim(),'CHAT_DRAFT_PRESENT','Preserve the existing unsent chat draft.');
93
+ need(await this.page.evaluate(composerImageState)===0,'CHAT_DRAFT_PRESENT','Preserve the existing unsent attachments.');
94
+ const created=this.page.waitForResponse(r=>r.request().method()==='POST'&&r.url().includes('/chat/createSession'),{timeout:20000});created.catch(()=>{});
95
+ await (await this.unique(LOCATORS.newChat)).click();const response=await created;need(response.ok(),'SESSION_CREATE_FAILED','Native conversation creation failed.');await response.finished();
96
+ await this.page.waitForTimeout(300);
97
+ await this.page.getByText('SealSeek AI 助手',{exact:true}).waitFor({timeout:15000});
98
+ need(await this.page.locator('button:has(svg.lucide-bot)').count()===1,'AGENT_MODE_REQUIRED','Select Agent mode in the webpage composer.');
99
+ }
100
+ async attach(files){
101
+ await (await this.unique(LOCATORS.upload)).setInputFiles(files);
102
+ await this.page.waitForFunction(composerImageState,files.length,{timeout:60000});
103
+ }
104
+ async send(prompt,files,onCaptured){
105
+ this.assertBoard();await (await this.unique(LOCATORS.editor)).fill(prompt);
106
+ const requestWait=this.page.waitForRequest(r=>r.method()==='POST'&&/\/api\/canvas\/v2\/threads\/[^/]+\/runs\/stream$/.test(r.url()),{timeout:25000});
107
+ requestWait.catch(()=>{});
108
+ await (await this.unique(LOCATORS.send)).click();
109
+ const req=await requestWait,body=req.postDataJSON();
110
+ const captured={sessionId:body.metadata?.session_id,requestObserved:true,referenceUrls:(body.input?.messages?.[0]?.content||[]).filter?.(c=>c.type==='image_url').map(c=>c.image_url.url)||[],videoSettings:body.config?.configurable?.video_params};
111
+ await onCaptured(captured);
112
+ const context=nativeSubmission(body,prompt,files);
113
+ need(context.sessionId,'SUBMISSION_UNCERTAIN','Native request has no session ID.');await onCaptured({...context,requestObserved:true});
114
+ const response=await req.response();need(response&&response.ok(),'SUBMISSION_UNCERTAIN','Native submission response was not verified.');
115
+ const headers=response.headers(),runId=headers['x-run-id']||headers['content-location']?.match(/\/runs\/([^/]+)$/)?.[1];
116
+ need(runId,'SUBMISSION_UNCERTAIN','Native submission did not expose a run ID.');await onCaptured({...context,runId});return {...context,runId};
117
+ }
118
+ async saveFrame(change,directory){
119
+ return this.composerLock(()=>this.writeLock(()=>this.saveFrameLocked(change,directory)));
120
+ }
121
+ async saveFrameLocked(change,directory){
122
+ const token=await this.beforeWrite(),next=change(token.before.content);
123
+ if(!next.changed){verifyGrouping(token.before.content,token.before.content,next);return {...next,changed:false};}
124
+ await newJSON(path.join(directory,`canvas-before-frame-${crypto.randomUUID()}.json`),{content:token.before.content,id:this.canvasId,createdAt:new Date().toISOString()});
125
+ const fresh=await this.getBoard();need(fresh.content===token.before.content,'CANVAS_CHANGED','Canvas changed before grouping.');
126
+ await this.gateway('/canvas/save',{method:'POST',data:{id:this.canvasId,content:next.content,thumbnail:token.before.thumbnail,triggerType:'auto'}});
127
+ const after=await this.getBoard();verifyGrouping(token.before.content,after.content,next);await this.afterWrite(token);return next;
128
+ }
129
+ async close(){await this.browser?.close();}
130
+ }
@@ -0,0 +1,60 @@
1
+ import {META} from './core.mjs';
2
+ const s={type:'string'},b={type:'boolean'};
3
+ export const commandContracts={
4
+ 'video generate':{options:{'--canvas':s,'--prompt':s,'--prompt-file':s,'--reference':{...s,repeatable:true},'--output':s,'--request-id':s,'--new':b,'--submit':b,'--dry-run':b,'--json':b},sideEffects:'local-write',externalMutation:'With --submit: import references, send native Agent conversation, import a missing video, group selected elements in a Frame.',paid:'Only native webpage sending may charge credits.'},
5
+ 'task get|wait|resume|sync':{options:{'--timeout':s,'--json':b},sideEffects:'local-write',externalMutation:'resume continues original task; sync reapplies existing video archival and grouping; never a second generation.'},
6
+ 'frame group':{options:{'--canvas':s,'--elements':s,'--frame-id':s,'--name':s,'--submit':b,'--json':b},sideEffects:'local-write',externalMutation:'With --submit: reparent existing nodes and preserve their identities.'},
7
+ 'browser status':{options:{'--json':b},sideEffects:'none',output:'Chrome availability and separate CDP runtime readiness'},
8
+ 'browser open':{options:{'--json':b},sideEffects:'local-write',externalMutation:'Launch or reuse system Chrome on loopback 9223 with the current-user dedicated profile; no generation.'},
9
+ 'doctor':{options:{'--canvas':s,'--live':b,'--json':b},sideEffects:'none'},
10
+ 'skill source':{options:{'--json':b},sideEffects:'none'},
11
+ 'skill status|install|update':{options:{'--agent':s,'--path':s,'--json':b},sideEffects:'local-write'},
12
+ 'update check|install':{options:{'--yes':b,'--agent':s,'--path':s,'--json':b},sideEffects:'package-and-skill-update'},
13
+ 'update auto':{options:{'--json':b},sideEffects:'local-write'}
14
+ };
15
+ export const manifest={schema:'infinite-canvas-capabilities',schema_version:'1.0.0',package:META.name,version:META.version,
16
+ input:{required:['canvas_id','prompt','references'],references:'ordered PNG/JPEG/WebP local files or HTTPS URLs'},
17
+ output:{terminal:'local MP4 + saved Frame containing original reference nodes and one video node',fields:['task_id','status','canvas_id','session_id','run_id','frame_id','reference_element_ids','video_element_id','artifacts']},
18
+ operations:['browser.status','browser.open','board.inspect','video.generate.native-agent','task.inspect','task.wait','task.resume','task.sync','frame.group-existing'],
19
+ generationTransport:{kind:'native-browser-ui',newConversationPerLogicalVideo:true,singleTabPerCanvas:true,concurrency:'separate conversations in the same tab; queued UI submissions and canvas writes; parallel remote runs',submission:'click native Send',directGenerationAPI:false,recovery:'saved sessionId/runId; read-only run and history APIs',model:'inherit webpage settings'},
20
+ dependencies:{node:'>=22',browser:'Installed Google Chrome; built-in macOS/Windows discovery and startup on loopback 9223 with current-user dedicated profile',canvasAPI:'sealseek-media >=0.6.7, public CLI commands',libraries:['playwright-core','image-size'],optional:['ffprobe']},
21
+ platforms:{'codex:macos':'tested: six native Agent video tasks across two boards; one tab per board; downloads, deduplication and Frame readback verified','generic:macos':'spec-compatible, runtime untested','sealseek:macos':'spec-compatible, runtime untested','generic:windows':'built-in Chrome discovery/startup implemented; actual host video end-to-end verification pending'},
22
+ commandContracts,
23
+ automaticUpdates:{enabledByDefault:true,defaultMode:'notify',intervalHours:24,timeoutMs:1500,trigger:'ordinary video/frame/doctor/capabilities entry; excludes help/version/skill/task polling',configuration:'private update-policy.json; update auto on|off|status',disable:'INFINITE_CANVAS_AUTO_UPDATE=0',recovery:'offline backoff preserves installation; source checkout updates through Git; explicit install keeps a package backup',installation:'explicit --yes only; no automatic installation'},
24
+ limitations:['No service-side atomic canvas revision check; stop on detected user edits.','Frame grouping first version supports unrotated image/video nodes.','Do not submit another generation after unknown submission.']
25
+ };
26
+ export const HELP=`infinite-canvas ${META.version}
27
+
28
+ video generate --canvas ID (--prompt TEXT | --prompt-file FILE)
29
+ --reference IMAGE ... [--output DIR] [--request-id KEY | --new]
30
+ [--dry-run | --submit] --json
31
+ Default: validate only. --submit starts one background native Agent task.
32
+ Each logical video uses a fresh conversation. The prompt is sent unchanged.
33
+ Final output: MP4 and a Frame containing the input nodes and resulting video.
34
+
35
+ task get TASK_ID --json
36
+ task wait TASK_ID [--timeout 30] --json (1–60 seconds per wait)
37
+ task resume TASK_ID --json (same session/run, no resubmission)
38
+ task sync TASK_ID --json (reapply existing output to canvas, no generation)
39
+ frame group --canvas ID --elements IDS.json --frame-id ID [--name TEXT]
40
+ [--submit] --json (preview by default)
41
+ browser status --json (read-only installation and runtime state)
42
+ browser open --json (built-in system Chrome startup/reuse)
43
+ board inspect --canvas ID --json
44
+ doctor [--live --canvas ID] --json
45
+ capabilities --json
46
+ version [--json]
47
+ skill source --json
48
+ skill status|install|update [--agent codex|generic|AGENT_NAME] [--path DIR] --json
49
+ update check --json
50
+ update install --yes [--agent AGENT_NAME --path DIR] --json
51
+ update auto on|off|status --json
52
+
53
+ Runtime: Node >=22; installed Google Chrome on macOS/Windows; sealseek-media.
54
+ Chrome startup is bundled. doctor checks availability; doctor --live verifies a running editable board.
55
+ Configuration: INFINITE_CANVAS_STATE_DIR, INFINITE_CANVAS_CHROME_BIN,
56
+ INFINITE_CANVAS_BROWSER_BIN (optional legacy launcher override),
57
+ INFINITE_CANVAS_MEDIA_BIN. Credentials remain with their existing providers.
58
+ Package update detection is throttled and can be disabled using
59
+ INFINITE_CANVAS_AUTO_UPDATE=0. No generation is submitted by installation.
60
+ `;
package/src/core.mjs ADDED
@@ -0,0 +1,52 @@
1
+ import fs from 'node:fs/promises';
2
+ import {constants} from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import crypto from 'node:crypto';
6
+ import {fileURLToPath} from 'node:url';
7
+ import {execFile} from 'node:child_process';
8
+ import {promisify} from 'node:util';
9
+
10
+ export const ROOT=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
11
+ export const META=JSON.parse(await fs.readFile(path.join(ROOT,'package.json'),'utf8'));
12
+ export const execute=promisify(execFile);
13
+ export class Fault extends Error {constructor(code,message,details={}){super(message);this.code=code;this.details=details;}}
14
+ export function need(value,code,message,details){if(!value)throw new Fault(code,message,details);return value;}
15
+ export const canonical=v=>JSON.stringify(sort(v));
16
+ function sort(v){return Array.isArray(v)?v.map(sort):v&&typeof v==='object'?Object.fromEntries(Object.keys(v).sort().map(k=>[k,sort(v[k])])):v;}
17
+ export const hash=v=>crypto.createHash('sha256').update(Buffer.isBuffer(v)?v:typeof v==='string'?v:canonical(v)).digest('hex');
18
+ export const stateRoot=()=>path.resolve(process.env.INFINITE_CANVAS_STATE_DIR||path.join(os.homedir(),'.local','share','infinite-canvas'));
19
+ export const exists=async p=>!!await fs.lstat(p).catch(e=>{if(e.code==='ENOENT')return null;throw e;});
20
+ export const readJSON=async p=>JSON.parse(await fs.readFile(p,'utf8'));
21
+ export async function mkdir(p){await fs.mkdir(p,{recursive:true,mode:0o700});}
22
+ export async function newFile(p,data){need(!await exists(p),'OUTPUT_EXISTS','Preserve the existing file.',{path:p});await mkdir(path.dirname(p));await fs.writeFile(p,data,{flag:'wx',mode:0o600});return p;}
23
+ export const newJSON=(p,data)=>newFile(p,JSON.stringify(data,null,2)+'\n');
24
+ export async function replaceOwned(p,data){
25
+ if(!await exists(p))return newJSON(p,data);
26
+ const stat=await fs.lstat(p);need(stat.isFile()&&!stat.isSymbolicLink(),'UNMANAGED_STATE','State must be a regular file.');
27
+ const old=await readJSON(p);need(old.owner===META.name,'UNMANAGED_STATE','Preserve user-owned state.',{path:p});
28
+ const backup=p+'.'+crypto.randomUUID()+'.backup';await fs.copyFile(p,backup,constants.COPYFILE_EXCL);
29
+ need((await fs.stat(backup)).size===stat.size,'BACKUP_FAILED','State backup could not be verified.');
30
+ const tmp=p+'.'+crypto.randomUUID()+'.tmp';await newJSON(tmp,data);await fs.rename(tmp,p);await fs.chmod(p,0o600);return p;
31
+ }
32
+ export const sleep=ms=>new Promise(r=>setTimeout(r,ms));
33
+ export function alive(pid){try{process.kill(pid,0);return true;}catch{return false;}}
34
+ export async function lock(key,fn,{root=stateRoot()}={}){
35
+ const p=path.join(root,'locks',hash(key)+'.json');await mkdir(path.dirname(p));
36
+ if(await exists(p)){let old;try{old=await readJSON(p);}catch(e){if(e.code==='ENOENT')return lock(key,fn,{root});if(e instanceof SyntaxError)throw new Fault('BUSY','Another process is committing its lock.');throw e;}need(old.owner===META.name,'UNMANAGED_STATE','Unrecognized lock.');need(!alive(old.pid),'BUSY','Another process owns this operation.',{pid:old.pid});await fs.rename(p,p+'.'+crypto.randomUUID()+'.abandoned');}
37
+ try{await newJSON(p,{owner:META.name,pid:process.pid,key,createdAt:new Date().toISOString()});}catch(e){if(['EEXIST','OUTPUT_EXISTS'].includes(e.code))throw new Fault('BUSY','Operation is already locked.');throw e;}
38
+ try{return await fn();}finally{await fs.unlink(p);}
39
+ }
40
+ export function safeId(v,label='ID'){need(typeof v==='string'&&/^[A-Za-z0-9_-]{1,100}$/.test(v),'INVALID_INPUT','Invalid '+label+'.');return v;}
41
+ export function httpsUrl(v){const u=new URL(v);need(u.protocol==='https:'&&!u.username&&!u.password,'INVALID_INPUT','Use an HTTPS media URL.');return u;}
42
+ export function redact(e){return {code:e.code||'OPERATION_FAILED',message:String(e.message||e).replace(/Bearer\s+\S+/g,'Bearer [redacted]').replace(/\beyJ[\w-]+\.[\w-]+\.[\w-]+/g,'[redacted]'),details:e.details||{}};}
43
+ export function parse(argv,{values=[],booleans=[],repeat=[]}={}){
44
+ const o={},args=[];for(let i=0;i<argv.length;i++){
45
+ const token=argv[i];if(!token.startsWith('--')){args.push(token);continue;}
46
+ const k=token.slice(2);need([...values,...booleans,...repeat].includes(k),'INVALID_ARGUMENT','Unknown option: '+token);
47
+ need(!(k in o)||repeat.includes(k),'INVALID_ARGUMENT','Duplicate option: '+token);
48
+ if(booleans.includes(k)){o[k]=true;continue;}
49
+ const v=argv[++i];need(v!==undefined&&!v.startsWith('--'),'INVALID_ARGUMENT','Missing value for '+token);
50
+ if(repeat.includes(k))(o[k]??=[]).push(v);else o[k]=v;
51
+ }return {options:o,args};
52
+ }
package/src/frame.mjs ADDED
@@ -0,0 +1,35 @@
1
+ import {need,hash,canonical} from './core.mjs';
2
+
3
+ export function mediaUrl(e){return e.customData?.videoUrl||e.customData?.fileUrl||e.src||null;}
4
+ export function comparableUrl(s){try{const u=new URL(s);return u.origin+u.pathname;}catch{return s;}}
5
+ export function findVideo(elements,url){return elements.filter(e=>!e.isDeleted&&e.customData?.isVideoThumbnail&&comparableUrl(mediaUrl(e))===comparableUrl(url));}
6
+ export function sceneSignature(elements=[]){return hash(elements.filter(e=>!e.isDeleted&&!(e.type==='rectangle'&&e.customData?.type==='frame-background'&&e.customData.ownerFrameId===e.frameId&&e.id===e.frameId+'__bg')).map(e=>{const data={...e.customData};if(data.originalUrl===data.fileUrl||data.originalUrl===data.videoUrl)delete data.originalUrl;if(e.type==='frame'&&Array.isArray(data.fills))data.fills=data.fills.map(({id,...fill})=>fill);return Object.fromEntries(['id','type','x','y','width','height','angle','frameId','fileId','text','name','customData'].filter(k=>e[k]!==undefined).map(k=>[k,k==='customData'?data:e[k]]));}));}
7
+ export function groupIntoFrame(content,{ids,frameId,name='Video references and result',padding=32,gap=24,width=320,retireIds=[]}){
8
+ need(Array.isArray(ids)&&ids.length>0&&ids.length<=30&&new Set(ids).size===ids.length,'INVALID_INPUT','Provide unique element IDs.');
9
+ need(/^[A-Za-z0-9_-]{1,100}$/.test(frameId)&&typeof name==='string'&&name.trim()&&name.length<=200,'INVALID_INPUT','Invalid Frame ID or name.');
10
+ need([padding,gap,width].every(n=>Number.isFinite(n)&&n>=0)&&width>0&&width<=2000,'INVALID_INPUT','Invalid layout values.');
11
+ const v=JSON.parse(content),before=canonical(v);need(Array.isArray(v.elements),'OUTPUT_CONTRACT_FAILED','Canvas elements are unavailable.');
12
+ const selected=ids.map(id=>need(v.elements.find(e=>e.id===id&&!e.isDeleted),'ELEMENT_NOT_FOUND','Selected element is missing.',{id}));
13
+ for(const id of retireIds){const e=v.elements.find(e=>e.id===id);if(!e)continue;need(!ids.includes(id)&&e.customData?.cliTaskId?.startsWith('ic-video-')&&selected.some(s=>s.customData?.isVideoThumbnail&&comparableUrl(mediaUrl(s))===comparableUrl(mediaUrl(e))),'FEATURE_UNSUPPORTED','Only a CLI-owned duplicate of the selected video can be retired.');if(!e.isDeleted){e.isDeleted=true;e.version=(e.version||1)+1;e.updated=Date.now();}}
14
+ need(selected.every(e=>e.type==='image'&&e.width>0&&e.height>0&&(!e.angle||e.angle===0)),'FEATURE_UNSUPPORTED','Frame grouping supports unrotated image/video nodes.');
15
+ const old=v.elements.find(e=>e.id===frameId);need(!old||(!old.isDeleted&&old.type==='frame'),'IDEMPOTENCY_CONFLICT','Frame ID conflicts with another element.');
16
+ if(old)need(!v.elements.some(e=>!e.isDeleted&&e.frameId===frameId&&!ids.includes(e.id)&&!(e.id===frameId+'__bg'&&e.type==='rectangle'&&e.customData?.type==='frame-background'&&e.customData.ownerFrameId===frameId)),'FRAME_CONFLICT','Frame contains unrelated elements.');
17
+ const others=v.elements.filter(e=>!e.isDeleted&&!ids.includes(e.id)&&e.id!==frameId);
18
+ const x=old?.x??0,y=old?.y??(others.length?Math.max(0,...others.map(e=>e.y+e.height))+80:0);
19
+ let cursor=x+padding,maxHeight=0;
20
+ for(const e of selected){const scale=Math.min(width/e.width,600/e.height),w=e.width*scale,h=e.height*scale;const next={x:cursor,y:y+padding,width:w,height:h,frameId};if(Object.entries(next).some(([k,val])=>e[k]!==val)){Object.assign(e,next);e.version=(e.version||1)+1;e.versionNonce=Number.parseInt(hash({id:e.id,next}).slice(0,7),16);e.updated=Date.now();}cursor+=w+gap;maxHeight=Math.max(maxHeight,h);}
21
+ const bounds={x,y,width:cursor-x-gap+padding,height:maxHeight+2*padding};
22
+ const frame=old||{id:frameId,type:'frame',angle:0,strokeColor:'#bbb',backgroundColor:'transparent',fillStyle:'solid',strokeWidth:1,strokeStyle:'solid',roughness:0,opacity:100,groupIds:[],frameId:null,index:null,roundness:null,seed:1,version:1,versionNonce:1,isDeleted:false,boundElements:null,link:null,locked:false};
23
+ frame.customData??={fills:[{id:'fill-'+frameId,type:'solid',color:'#FFFFFF',opacity:100,visible:true,blendMode:'normal'}],cornerRadius:8,schemaVersion:1,__sealseekFrameTreeParentId:null};
24
+ if(!old||frame.name!==name||Object.entries(bounds).some(([k,val])=>frame[k]!==val)){Object.assign(frame,bounds,{name,updated:Date.now(),version:(old?.version||0)+1});}
25
+ if(!old)v.elements.push(frame);
26
+ return {content:JSON.stringify(v),changed:canonical(v)!==before,frameId,elementIds:ids,bounds,retiredElementIds:retireIds.filter(id=>v.elements.some(e=>e.id===id))};
27
+ }
28
+ export function verifyGrouping(before,after,{frameId,elementIds,retiredElementIds=[]}){
29
+ const a=JSON.parse(after),b=JSON.parse(before),frame=a.elements.find(e=>e.id===frameId&&!e.isDeleted);
30
+ need(frame?.type==='frame','CANVAS_SAVE_UNCERTAIN','Frame was not saved.');
31
+ for(const id of elementIds){const e=a.elements.find(e=>e.id===id&&!e.isDeleted),old=b.elements.find(e=>e.id===id);need(e?.frameId===frameId&&e.x>=frame.x&&e.y>=frame.y&&e.x+e.width<=frame.x+frame.width+1e-6&&e.y+e.height<=frame.y+frame.height+1e-6,'CANVAS_SAVE_UNCERTAIN','Frame membership or bounds were not saved.');need(e.fileId===old.fileId&&canonical(e.customData)===canonical(old.customData),'CANVAS_SAVE_UNCERTAIN','Media identity changed during grouping.');}
32
+ for(const id of retiredElementIds){const old=b.elements.find(e=>e.id===id),e=a.elements.find(e=>e.id===id);need(old?.customData?.cliTaskId?.startsWith('ic-video-')&&e?.isDeleted&&e.fileId===old.fileId&&canonical(e.customData)===canonical(old.customData),'CANVAS_SAVE_UNCERTAIN','CLI duplicate retirement was not verified.');}
33
+ for(const e of b.elements.filter(e=>!elementIds.includes(e.id)&&!retiredElementIds.includes(e.id)&&e.id!==frameId))need(canonical(a.elements.find(n=>n.id===e.id))===canonical(e),'CANVAS_SAVE_UNCERTAIN','Unrelated canvas content changed.');
34
+ return true;
35
+ }
@@ -0,0 +1,80 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import crypto from 'node:crypto';
5
+ import {ROOT,META,need,exists,readJSON,newJSON,newFile,replaceOwned,stateRoot,hash,mkdir,lock,execute,Fault} from './core.mjs';
6
+
7
+ export const SKILL=path.join(ROOT,'skills','infinite-canvas-video');
8
+ export async function treeFiles(root,prefix=''){
9
+ const out=[];for(const e of await fs.readdir(path.join(root,prefix),{withFileTypes:true})){need(!e.isSymbolicLink(),'UNMANAGED_STATE','Skill resources must not be symbolic links.');const rel=path.posix.join(prefix,e.name);if(e.isDirectory())out.push(...await treeFiles(root,rel));else out.push(rel);}return out.sort();
10
+ }
11
+ const marker='.infinite-canvas-managed.json';
12
+ const hashes=async root=>Object.fromEntries(await Promise.all((await treeFiles(root)).filter(n=>n!==marker).map(async n=>[n,hash(await fs.readFile(path.join(root,n)))])));
13
+ export function skillTarget(agent='codex',destination){
14
+ need(/^[a-z][a-z0-9-]{0,63}$/.test(agent),'INVALID_ARGUMENT','Malformed Agent name.');
15
+ const dest=destination?path.resolve(destination):agent==='codex'?path.join(os.homedir(),'.codex','skills','infinite-canvas-video'):null;
16
+ need(dest,'INVALID_ARGUMENT','This Agent requires an explicit --path for its own Skill directory.');
17
+ return {agent,adapter:agent==='codex'?'codex':'generic',destination:dest};
18
+ }
19
+ export async function skillStatus(agent,destination){
20
+ const target=skillTarget(agent,destination),current=await hashes(SKILL);if(!await exists(target.destination))return {ok:true,...target,installed:false,current:false,version:META.version};
21
+ const s=await fs.lstat(target.destination);need(s.isDirectory()&&!s.isSymbolicLink(),'UNMANAGED_TARGET','Preserve the existing Skill target.');
22
+ const p=path.join(target.destination,marker);need(await exists(p),'UNMANAGED_TARGET','Existing Skill is not managed by this package.');const old=await readJSON(p);need(old.owner===META.name,'UNMANAGED_TARGET','Skill belongs to a different package.');
23
+ const actual=await hashes(target.destination),edited=Object.entries(old.hashes).filter(([k,v])=>k!=='agents/openai.yaml'&&actual[k]!==v).map(([k])=>k);
24
+ return {ok:true,...target,installed:true,current:old.version===META.version&&Object.entries(current).every(([k,v])=>k==='agents/openai.yaml'||actual[k]===v),edited,version:old.version,source_version:META.version,hashes:actual};
25
+ }
26
+ export async function installSkill(agent,destination,{failAfterCopy=false}={}){
27
+ const status=await skillStatus(agent,destination);need(!status.edited?.length,'LOCAL_EDITS','Managed Skill was edited; preserve it.',{files:status.edited});
28
+ if(status.installed&&status.current)return {...status,changed:false};
29
+ return lock('skill:'+status.destination,async()=>{
30
+ const fresh=await skillStatus(agent,destination);need(!fresh.edited?.length,'LOCAL_EDITS','Skill changed before synchronization.');
31
+ let backup;if(fresh.installed){backup=path.join(stateRoot(),'skill-backups',crypto.randomUUID());await mkdir(path.dirname(backup));await fs.cp(fresh.destination,backup,{recursive:true,errorOnExist:true,force:false});need(JSON.stringify(await hashes(backup))===JSON.stringify(fresh.hashes),'BACKUP_FAILED','Skill backup verification failed.');}
32
+ const files=await treeFiles(SKILL);const previous=new Set(fresh.installed?await treeFiles(fresh.destination):[]);
33
+ const previousRecord=fresh.installed?await readJSON(path.join(fresh.destination,marker)):null;
34
+ if(previousRecord)for(const rel of files)need(!previous.has(rel)||Object.hasOwn(previousRecord.hashes,rel),'UNMANAGED_TARGET','A new managed resource conflicts with a user-owned file.',{file:rel});
35
+ const customUI=fresh.installed&&fresh.hashes['agents/openai.yaml']!==(previousRecord.canonicalHashes||previousRecord.hashes)['agents/openai.yaml'];
36
+ await mkdir(fresh.destination);
37
+ try{
38
+ for(const rel of files){if(rel==='agents/openai.yaml'&&customUI)continue;const p=path.join(fresh.destination,rel),data=await fs.readFile(path.join(SKILL,rel));await mkdir(path.dirname(p));if(await exists(p)){const stat=await fs.lstat(p);need(stat.isFile()&&!stat.isSymbolicLink(),'UNMANAGED_TARGET','Managed file type changed.');await fs.writeFile(p,data);}else await newFile(p,data);}
39
+ if(failAfterCopy)throw new Fault('TEST_SYNC_FAILURE','Injected synchronization failure.');
40
+ const actual=await hashes(fresh.destination),canonicalHashes=await hashes(SKILL);
41
+ const record={owner:META.name,version:META.version,hashes:Object.fromEntries(files.map(f=>[f,actual[f]])),canonicalHashes,source:SKILL};const p=path.join(fresh.destination,marker);await replaceOwned(p,record);
42
+ const verified=await skillStatus(agent,destination);need(verified.current,'SKILL_SYNC_FAILED','Installed bytes differ from canonical source.');return {...verified,changed:true,backup,metadata_preserved:!!customUI};
43
+ }catch(e){
44
+ if(backup){for(const rel of await treeFiles(backup)){const p=path.join(fresh.destination,rel);await mkdir(path.dirname(p));await fs.copyFile(path.join(backup,rel),p);}for(const rel of files.filter(f=>!previous.has(f)))await fs.unlink(path.join(fresh.destination,rel)).catch(()=>{});}
45
+ else {for(const rel of files)await fs.unlink(path.join(fresh.destination,rel)).catch(()=>{});const dirs=[...new Set(files.map(f=>path.dirname(f)).filter(d=>d!=='.'))].sort((a,b)=>b.length-a.length);for(const rel of dirs)await fs.rmdir(path.join(fresh.destination,rel)).catch(()=>{});await fs.rmdir(fresh.destination).catch(()=>{});}
46
+ throw new Fault('SKILL_SYNC_FAILED','Skill synchronization failed; existing content was restored.',{backup,cause:e.code||e.message});
47
+ }
48
+ });
49
+ }
50
+ const cacheFile=()=>path.join(stateRoot(),'update-cache.json');
51
+ const policyFile=()=>path.join(stateRoot(),'update-policy.json');
52
+ export async function updatePolicy(mode){
53
+ let policy={owner:META.name,enabled:true};if(await exists(policyFile())){policy=await readJSON(policyFile());need(policy.owner===META.name&&typeof policy.enabled==='boolean','UNMANAGED_STATE','Preserve malformed update policy.');}
54
+ if(mode&&mode!=='status'){policy.enabled=mode==='on';await replaceOwned(policyFile(),policy);}return {ok:true,enabled:policy.enabled,automatic_installation:false};
55
+ }
56
+ export async function checkUpdate({force=false,fetcher=fetch,now=Date.now()}={}){
57
+ if(process.env.INFINITE_CANVAS_AUTO_UPDATE==='0'&&!force)return {enabled:false};
58
+ if(!force&&!(await updatePolicy()).enabled)return {enabled:false};
59
+ let old;if(await exists(cacheFile())){old=await readJSON(cacheFile());need(old.owner===META.name&&Number.isFinite(old.checkedAt),'UNMANAGED_STATE','Preserve malformed update cache.');if(!force&&now-old.checkedAt<(old.available===false?3600000:86400000))return {...old,cached:true};}
60
+ return lock('update-check',async()=>{
61
+ let record={owner:META.name,checkedAt:now,current:META.version,available:false};
62
+ try{const r=await fetcher('https://registry.npmjs.org/'+encodeURIComponent(META.name)+'/latest',{signal:AbortSignal.timeout(1500)});if(r.status===404)record={...record,published:false};else{need(r.ok,'REGISTRY_UNAVAILABLE','Registry check failed.');const d=await r.json();need(typeof d.version==='string'&&/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(d.version),'REGISTRY_UNAVAILABLE','Invalid registry version.');record={...record,available:true,published:true,latest:d.version,update_available:d.version!==META.version};}}
63
+ catch{record={...record,reason:'offline-or-timeout'};}
64
+ await replaceOwned(cacheFile(),record);return record;
65
+ });
66
+ }
67
+ export async function automaticCheck(){try{const r=await checkUpdate();if(r.update_available&&!r.cached)process.stderr.write(JSON.stringify({update_available:true,current:META.version,latest:r.latest,installation:'explicit update install --yes required'})+'\n');}catch(e){process.stderr.write(JSON.stringify({update_check:'unavailable',code:e.code||'UPDATE_BUSY'})+'\n');}}
68
+ export async function installUpdate({yes,agent,destination}={}){
69
+ need(yes,'PERMISSION_REQUIRED','Package update requires --yes.');
70
+ need(!await exists(path.join(ROOT,'.git')),'SOURCE_CHECKOUT','This is a Git-owned source checkout; update it through its repository.');
71
+ const version=await checkUpdate({force:true});need(version.published&&version.available,'REGISTRY_UNAVAILABLE','A published version is unavailable.');
72
+ if(version.latest===META.version){const skill=agent?await installSkill(agent,destination):null;return {ok:true,changed:!!skill?.changed,version:META.version,skill};}
73
+ const backup=path.join(stateRoot(),'package-backups',crypto.randomUUID());await mkdir(path.dirname(backup));await fs.cp(ROOT,backup,{recursive:true,errorOnExist:true,force:false});
74
+ try{
75
+ await execute(process.platform==='win32'?'npm.cmd':'npm',['install','--global',META.name+'@'+version.latest],{timeout:180000,maxBuffer:1024*1024});
76
+ const entry=process.platform==='win32'?'infinite-canvas.cmd':'infinite-canvas';const r=JSON.parse((await execute(entry,['version','--json'],{timeout:15000})).stdout);need(r.version===version.latest,'UPDATE_VERIFY_FAILED','Installed CLI version differs.');
77
+ let skill;if(agent){const args=['skill','update','--agent',agent,'--json',...(destination?['--path',destination]:[])];skill=JSON.parse((await execute(entry,args,{timeout:30000})).stdout);need(skill.ok,'SKILL_SYNC_FAILED','Package upgraded but selected Skill did not synchronize.');}
78
+ return {ok:true,version:r.version,changed:true,backup,skill};
79
+ }catch(e){throw new Fault('UPDATE_FAILED','Update did not verify. The complete previous package is backed up.',{backup,cause:e.code||e.message});}
80
+ }
@@ -0,0 +1,13 @@
1
+ import path from 'node:path';
2
+ import {execute,newJSON,readJSON,exists,canonical,need,Fault} from './core.mjs';
3
+
4
+ export class MediaCLI {
5
+ constructor(){this.bin=process.env.INFINITE_CANVAS_MEDIA_BIN||'sealseek-media';}
6
+ async call(args){
7
+ try{const {stdout}=await execute(this.bin,[...args,'--json'],{timeout:90000,maxBuffer:32*1024*1024,env:{...process.env,SEALSEEK_MEDIA_AUTO_UPDATE:'0'}});const r=JSON.parse(stdout);need(r.ok!==false,r.error?.code||'MEDIA_API_FAILED',r.error?.message||'Canvas operation failed.');return r;}
8
+ catch(e){if(e instanceof Fault)throw e;let r;try{r=JSON.parse(e.stdout);}catch{}throw new Fault(r?.error?.code||'MEDIA_CLI_FAILED',r?.error?.message||'sealseek-media could not complete this operation.',{command:args.slice(0,2),cause:e.code});}
9
+ }
10
+ get(canvas){return this.call(['canvas','get',canvas]);}
11
+ upload(file){return this.call(['image','upload',file,'--submit']);}
12
+ async add(canvas,media,directory){const file=path.join(directory,`import-${media.id}.json`);if(await exists(file))need(canonical(await readJSON(file))===canonical(media),'IDEMPOTENCY_CONFLICT','Import record differs.');else await newJSON(file,media);return this.call(['canvas','add',canvas,'--media',file,'--submit']);}
13
+ }
package/src/state.mjs ADDED
@@ -0,0 +1,47 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import {imageSize} from 'image-size';
5
+ import {META,stateRoot,newFile,newJSON,readJSON,replaceOwned,exists,need,hash,safeId,httpsUrl,mkdir} from './core.mjs';
6
+
7
+ export const jobDir=id=>path.join(stateRoot(),'jobs',safeId(id));
8
+ export const jobFile=id=>path.join(jobDir(id),'job.json');
9
+ export async function getJob(id){const j=await readJSON(jobFile(id));need(j.owner===META.name&&j.id===id,'UNMANAGED_STATE','Unrecognized task.');return j;}
10
+ export async function saveJob(j){j.updatedAt=new Date().toISOString();await replaceOwned(jobFile(j.id),j);return j;}
11
+ export async function prepareInput({canvas,prompt,references,output}){
12
+ safeId(canvas,'canvas ID');need(typeof prompt==='string'&&prompt.trim()&&prompt.length<=20000,'INVALID_INPUT','Provide a non-empty prompt of at most 20000 characters.');
13
+ need(Array.isArray(references)&&references.length>0&&references.length<=20,'INVALID_INPUT','Provide 1–20 ordered reference images.');
14
+ const refs=[];for(const value of references){
15
+ need(typeof value==='string'&&value,'INVALID_INPUT','Reference must be a path or HTTPS URL.');
16
+ if(/^https:/i.test(value)){httpsUrl(value);refs.push({source:value,url:value});continue;}
17
+ const file=path.resolve(value),stat=await fs.stat(file);need(stat.isFile()&&stat.size>0&&stat.size<=30*1024*1024,'INVALID_REFERENCE','Image is missing, empty or larger than 30 MiB.');
18
+ const b=await fs.readFile(file),dim=imageSize(b);need(['png','jpg','webp'].includes(dim.type),'INVALID_REFERENCE','Use PNG, JPEG or WebP reference images.');
19
+ refs.push({source:file,file,sha256:hash(b),bytes:b.length,width:dim.width,height:dim.height,extension:dim.type==='jpg'?'jpg':dim.type});
20
+ }
21
+ return {canvasId:canvas,prompt,references:refs,output:output?path.resolve(output):null};
22
+ }
23
+ export async function createJob(input,{requestId,newRequest=false}={}){
24
+ need(!requestId||!newRequest,'INVALID_INPUT','Use either request-id or new, not both.');
25
+ const digest=hash({...input,output:undefined,references:input.references.map(r=>({sha256:r.sha256,url:r.url}))});
26
+ requestId=requestId||(newRequest?crypto.randomUUID():digest);safeId(requestId,'request ID');
27
+ const index=path.join(stateRoot(),'requests',hash(requestId)+'.json');
28
+ if(await exists(index)){const old=await readJSON(index);need(old.owner===META.name,'UNMANAGED_STATE','Unrecognized request record.');need(old.digest===digest,'IDEMPOTENCY_CONFLICT','Request ID is associated with different inputs.');const job=await getJob(old.id);need(job.input.output===input.output,'IDEMPOTENCY_CONFLICT','Existing request uses a different output directory; its original task is preserved.');return {job,reused:true};}
29
+ const id=crypto.randomUUID();await mkdir(jobDir(id));
30
+ const job={owner:META.name,id,requestId,digest,createdAt:new Date().toISOString(),status:'queued',stage:'created',input,referenceElements:[],references:[],warnings:[]};
31
+ await newJSON(jobFile(id),job);
32
+ try{await newJSON(index,{owner:META.name,id,digest});}catch(e){if(['EEXIST','OUTPUT_EXISTS'].includes(e.code)){const old=await readJSON(index);need(old.owner===META.name&&old.digest===digest,'IDEMPOTENCY_CONFLICT','Concurrent request conflict.');const existing=await getJob(old.id);need(existing.input.output===input.output,'IDEMPOTENCY_CONFLICT','Concurrent request uses another output directory.');return {job:existing,reused:true};}throw e;}
33
+ return {job,reused:false};
34
+ }
35
+ export async function cacheReferences(job){
36
+ for(let i=0;i<job.input.references.length;i++){
37
+ if(job.references[i]?.file)continue;
38
+ const ref=job.input.references[i];let bytes;
39
+ if(ref.file){bytes=await fs.readFile(ref.file);need(hash(bytes)===ref.sha256,'REFERENCE_CHANGED','A reference changed after preparation.');}
40
+ else{const r=await fetch(httpsUrl(ref.url),{signal:AbortSignal.timeout(30000),redirect:'error'});need(r.ok,'REFERENCE_DOWNLOAD_FAILED','Could not download the reference.');const max=30*1024*1024;need(Number(r.headers.get('content-length')||0)<=max,'INVALID_REFERENCE','Reference exceeds 30 MiB.');const chunks=[];let size=0;for await(const c of r.body){size+=c.length;need(size<=max,'INVALID_REFERENCE','Reference exceeds 30 MiB.');chunks.push(c);}bytes=Buffer.concat(chunks);}
41
+ const dim=imageSize(bytes);need(['png','jpg','webp'].includes(dim.type),'INVALID_REFERENCE','Unsupported reference format.');
42
+ const file=path.join(jobDir(job.id),'references',`${String(i+1).padStart(2,'0')}-reference.${dim.type==='jpg'?'jpg':dim.type}`);
43
+ if(await exists(file))need(hash(await fs.readFile(file))===hash(bytes),'REFERENCE_CHANGED','Cached reference differs.');else await newFile(file,bytes);
44
+ job.references[i]={...ref,file,sha256:hash(bytes),width:dim.width,height:dim.height,bytes:bytes.length};await saveJob(job);
45
+ }
46
+ }
47
+ export function summary(j){return {ok:j.status!=='failed'&&j.status!=='uncertain',task_id:j.id,status:j.status,stage:j.stage,canvas_id:j.input.canvasId,session_id:j.sessionId||null,run_id:j.runId||null,frame_id:j.frameId||null,reference_element_ids:j.referenceElements||[],video_element_id:j.videoElementId||null,artifacts:j.artifacts||[],result:j.result?{reference_count:j.result.referenceCount,billing:j.result.billing}:null,error:j.error||null,warnings:j.warnings||[],created_at:j.createdAt,updated_at:j.updatedAt};}
@@ -0,0 +1,13 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import {ROOT} from './core.mjs';
5
+ import {manifest} from './contracts.mjs';
6
+ const target=path.join(ROOT,'capabilities.json');
7
+ const stat=await fs.lstat(target);
8
+ if(!stat.isFile()||stat.isSymbolicLink())throw Error('Unmanaged capability manifest');
9
+ const old=await fs.readFile(target);
10
+ const backup=target+'.'+crypto.randomUUID()+'.backup';
11
+ await fs.copyFile(target,backup,fs.constants.COPYFILE_EXCL);
12
+ if(!(await fs.readFile(backup)).equals(old))throw Error('Manifest backup failed');
13
+ await fs.writeFile(target,JSON.stringify(manifest,null,2)+'\n');