@bitefoundry/retrieviewer 0.0.2 → 0.0.3

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.
@@ -1,4 +1,4 @@
1
- // Explicit Windows companion launcher. Never imports the pet on help/status/stop.
1
+ // Explicit native companion launcher. Never imports the pet on help/status/stop.
2
2
  import {spawn,execFile} from 'node:child_process';
3
3
  import {promisify} from 'node:util';
4
4
  import {readFile,mkdir,lstat,unlink,open,rename,readdir} from 'node:fs/promises';
@@ -7,6 +7,7 @@ import {join,dirname} from 'node:path';
7
7
  import {fileURLToPath} from 'node:url';
8
8
  import {createConnection} from 'node:net';
9
9
  import {getConfigPaths} from '../../retrieviewer-config.mjs';
10
+ import {desktopPlatform,validControlAddress} from '../desktop/platform.mjs';
10
11
  const exec=promisify(execFile);
11
12
  const SOURCE=new URL('./Overlay.cs',import.meta.url);
12
13
  export function desktopPaths(options={}){const root=join(getConfigPaths(options).root,'desktop');return {root,session:join(root,'session.json')};}
@@ -17,7 +18,7 @@ export async function prepareRoot(options={}){
17
18
  export async function readSession(options={}){
18
19
  const {root,session}=desktopPaths(options);await noLink(dirname(root));await noLink(root);await noLink(session);
19
20
  let data;try{data=JSON.parse(await readFile(session,'utf8'));}catch(e){if(e.code==='ENOENT')return null;throw e;}
20
- if(data?.owner!=='bitefoundry-retrieviewer-overlay-v1'||!Number.isInteger(data.pid)||data.pid<=0||!/^\\\\\.\\pipe\\retrieviewer-overlay-[a-f0-9-]{36}$/.test(data.pipe)||!/^[a-f0-9]{64}$/.test(data.token))throw Error('Unrecognized desktop session; not modifying it');
21
+ if(data?.owner!=='bitefoundry-retrieviewer-overlay-v1'||!Number.isInteger(data.pid)||data.pid<=0||!validControlAddress(data.pipe)||!/^[a-f0-9]{64}$/.test(data.token))throw Error('Unrecognized desktop session; not modifying it');
21
22
  return data;
22
23
  }
23
24
  export function requestSession(session,command){
@@ -30,21 +31,37 @@ export function requestSession(session,command){
30
31
  socket.on('error',e=>finish(e));socket.on('end',()=>{if(!done)finish(Error('Desktop control closed without a response'));});
31
32
  });
32
33
  }
34
+ async function processAlive(pid){
35
+ try{process.kill(pid,0);}catch(error){if(error.code==='ESRCH')return false;throw error;}
36
+ if(process.platform==='linux')try{
37
+ // A container PID1 without a reaper can retain a dead detached worker as Z.
38
+ // kill(pid,0) alone treats it as alive, falsely making a successful stop fail.
39
+ const stat=await readFile('/proc/'+pid+'/stat','utf8'),state=stat.slice(stat.lastIndexOf(')')+2).charAt(0);
40
+ if(state==='Z'||state==='X')return false;
41
+ }catch(error){if(error.code==='ENOENT')return false;throw error;}
42
+ return true;
43
+ }
33
44
  export async function desktopStatus(options={}){
34
45
  const session=await readSession(options);if(!session)return {running:false};
35
- try{return await requestSession(session,'status');}catch(error){let alive=true;try{process.kill(session.pid,0);}catch(e){if(e.code==='ESRCH')alive=false;}
46
+ try{return await requestSession(session,'status');}catch(error){const alive=await processAlive(session.pid);
36
47
  return {running:alive,reachable:false,stale:!alive,pid:session.pid,reason:error.code??error.message};}
37
48
  }
38
49
  export async function stopDesktop(options={}){
39
50
  const session=await readSession(options);if(!session)return {running:false,stopped:true};
40
51
  const result=await desktopStatus(options);
41
- if(result.stale){await unlink(desktopPaths(options).session);return {running:false,stopped:true,staleSessionRemoved:true};}
52
+ if(result.stale){
53
+ if(session.pipe.startsWith('/tmp/')){
54
+ await noLink(session.pipe);
55
+ try{const entry=await lstat(session.pipe);if(!entry.isSocket()||(process.getuid&&entry.uid!==process.getuid()))throw Error('Refusing unowned/non-socket desktop endpoint');await unlink(session.pipe);}catch(e){if(e.code!=='ENOENT')throw e;}
56
+ }
57
+ await unlink(desktopPaths(options).session);return {running:false,stopped:true,staleSessionRemoved:true};
58
+ }
42
59
  if(result.reachable===false)throw Error('Desktop process is not responding; close it from its tray icon. No PID was killed.');
43
60
  await requestSession(session,'stop');
44
61
  // The stop response acknowledges the request, not native process termination.
45
62
  // Wait before advertising success so a following cleanup cannot race an in-use exe.
46
63
  for(let i=0;i<60;i++){
47
- try{process.kill(session.pid,0);}catch(e){if(e.code==='ESRCH')return {running:false,stopped:true};throw e;}
64
+ if(!await processAlive(session.pid))return {running:false,stopped:true};
48
65
  await new Promise(resolve=>setTimeout(resolve,100));
49
66
  }
50
67
  throw Error('Desktop accepted stop but has not exited; cache preserved. Retry after it closes.');
@@ -53,7 +70,7 @@ export async function buildHelper(options={}){
53
70
  if(process.platform!=='win32')throw Error('The transparent companion currently supports Windows only');
54
71
  const {root}=await prepareRoot(options),source=await readFile(SOURCE),hash=createHash('sha256').update(source).digest('hex').slice(0,20);
55
72
  const executable=join(root,`overlay-${hash}.exe`);await noLink(executable);
56
- try{await lstat(executable);return executable;}catch(e){if(e.code!=='ENOENT')throw e;}
73
+ try{if(!(await lstat(executable)).isFile())throw Error('Desktop helper cache is not a regular file');return executable;}catch(e){if(e.code!=='ENOENT')throw e;}
57
74
  const compiler=join(process.env.SystemRoot??'C:\\Windows','Microsoft.NET','Framework64','v4.0.30319','csc.exe');
58
75
  const temporary=join(root,`build-${randomUUID()}.exe`);
59
76
  try{
@@ -62,12 +79,27 @@ export async function buildHelper(options={}){
62
79
  }catch(error){await unlink(temporary).catch(()=>{});throw Error('Windows overlay build failed: '+(error.stdout??error.message));}
63
80
  return executable;
64
81
  }
82
+ export async function desktopHelper(options={}){
83
+ const platform=desktopPlatform(options);
84
+ if(platform.helper==='windows')return {command:await buildHelper(options),args:[],mode:platform.id,fps:platform.fps};
85
+ if(platform.helper==='macos')return {command:'/usr/bin/osascript',args:['-l','JavaScript',fileURLToPath(new URL('../macos-overlay/overlay.jxa',import.meta.url))],mode:platform.id,fps:platform.fps};
86
+ // Use an existing system Python, including AlmaLinux8's3.6 platform interpreter.
87
+ // No Python package, compiled addon, native bundle, compiler, or package manager.
88
+ const candidates=[...(process.env.RETRIEVIEWER_PYTHON?[process.env.RETRIEVIEWER_PYTHON]:[]),'python3','/usr/libexec/platform-python'];
89
+ for(const command of candidates)try{
90
+ await exec(command,['-I','-c','import sys;sys.exit(0 if sys.version_info >= (3,6) else 1)'],{windowsHide:true,timeout:5000,maxBuffer:4096});
91
+ return {command,args:['-I',fileURLToPath(new URL('../linux-overlay/overlay.py',import.meta.url))],mode:platform.id,fps:platform.fps};
92
+ }catch{}
93
+ throw Error('Linux desktop needs system Python3.6+ (AlmaLinux8 platform-python is supported). No packages or system settings were installed.');
94
+ }
65
95
  export async function startDesktop(options={}){
96
+ // Fail before creating any cache/session on unsupported desktops (e.g. Wayland).
97
+ desktopPlatform(options);
66
98
  const status=await desktopStatus(options);if(status.running)return {...status,alreadyRunning:true};
67
99
  if(status.stale)await stopDesktop(options);
68
- const executable=await buildHelper(options);
100
+ const helper=await desktopHelper(options);
69
101
  return new Promise((resolve,reject)=>{
70
- const child=spawn(process.execPath,[fileURLToPath(new URL('./worker.mjs',import.meta.url)),executable],{detached:true,windowsHide:true,stdio:['ignore','ignore','ignore','ipc'],env:{...process.env,RETRIEVIEWER_HOME:getConfigPaths(options).root}});
102
+ const child=spawn(process.execPath,[fileURLToPath(new URL('./worker.mjs',import.meta.url)),'--helper-spec',JSON.stringify(helper)],{detached:true,windowsHide:true,stdio:['ignore','ignore','ignore','ipc'],env:{...process.env,RETRIEVIEWER_HOME:getConfigPaths(options).root}});
71
103
  let settled=false;const finish=(error,message)=>{if(settled)return;settled=true;clearTimeout(timer);if(child.connected)child.disconnect();child.unref();error?reject(error):resolve(message);};
72
104
  const timer=setTimeout(()=>{if(child.connected)child.send({command:'abort'});finish(Error('Desktop startup timed out; no agent settings changed'));},20000);
73
105
  child.on('error',error=>finish(error));child.on('message',message=>message.error?finish(Error(message.error)):finish(null,message));child.on('exit',code=>finish(Error('Desktop exited during startup ('+code+')')));
@@ -2,12 +2,15 @@
2
2
  import {spawn} from 'node:child_process';
3
3
  import {createServer} from 'node:net';
4
4
  import {createInterface} from 'node:readline';
5
- import {open,unlink,readFile} from 'node:fs/promises';
5
+ import {open,unlink,readFile,chmod} from 'node:fs/promises';
6
6
  import {randomBytes,randomUUID} from 'node:crypto';
7
7
  import {prepareRoot,noLink} from './desktop.mjs';
8
8
  import {createFramePump} from './protocol.mjs';
9
+ import {controlAddress,parseHelperSpec} from '../desktop/platform.mjs';
9
10
 
10
11
  let helper,pump,timer,server,session,paths,owned=false,stopping=false,frames=0,paintedFrames=0,visible=false,selected=false,pickerVisible=false,pendingViewport=null,lastInteraction=null;
12
+ let nativeError=null;
13
+ let nativeStderr='';
11
14
  const sendParent=message=>{if(process.connected)process.send(message);};
12
15
  async function stop(){
13
16
  if(stopping)return;stopping=true;clearInterval(timer);pump?.dispose();
@@ -20,8 +23,9 @@ async function stop(){
20
23
  }
21
24
  process.on('SIGTERM',stop);process.on('SIGINT',stop);process.on('message',m=>{if(m?.command==='abort')stop();});
22
25
  try{
26
+ const spec=parseHelperSpec(process.argv.slice(2));
23
27
  paths=await prepareRoot();await noLink(paths.session);
24
- session={owner:'bitefoundry-retrieviewer-overlay-v1',pid:process.pid,pipe:'\\\\.\\pipe\\retrieviewer-overlay-'+randomUUID(),token:randomBytes(32).toString('hex')};
28
+ session={owner:'bitefoundry-retrieviewer-overlay-v1',pid:process.pid,pipe:controlAddress({uuid:randomUUID()}),token:randomBytes(32).toString('hex')};
25
29
  const record=await open(paths.session,'wx',0o600);owned=true;try{await record.writeFile(JSON.stringify(session)+'\n');}finally{await record.close();}
26
30
  server=createServer(socket=>{
27
31
  socket.setTimeout(1500,()=>socket.destroy());let body='';
@@ -34,16 +38,18 @@ try{
34
38
  });
35
39
  });
36
40
  await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(session.pipe,resolve);});
41
+ if(process.platform!=='win32')await chmod(session.pipe,0o600);
37
42
  // GUI-subsystem exe: no console to hide. SW_HIDE here also hides its first UI.
38
- helper=spawn(process.argv[2],process.argv[3]==='--ipc-test'?['--ipc-test']:[],{windowsHide:false,stdio:['pipe','pipe','pipe']});
39
- helper.stdin.on('error',()=>stop());helper.stderr.resume();helper.once('error',error=>{sendParent({error:error.message});stop();});
40
- helper.once('exit',code=>{if(!stopping)sendParent({error:'Windows painter exited ('+code+')'});stop();});
43
+ helper=spawn(spec.command,spec.args,{windowsHide:false,stdio:['pipe','pipe','pipe']});
44
+ helper.stdin.on('error',()=>stop());helper.stderr.setEncoding('utf8');helper.stderr.on('data',s=>{nativeStderr=(nativeStderr+s).slice(-4096);});helper.once('error',error=>{sendParent({error:error.message});stop();});
45
+ helper.once('exit',code=>{if(!stopping)sendParent({error:nativeError??('Desktop painter exited ('+code+')'+(nativeStderr?' — '+nativeStderr.trim():''))});stop();});
41
46
  const write=message=>{if(!stopping&&!helper.stdin.destroyed)helper.stdin.write(JSON.stringify(message)+'\n');};
42
47
  const reader=createInterface({input:helper.stdout});
43
48
  reader.on('line',line=>{
44
49
  if(line.length>100000)return stop();let m;try{m=JSON.parse(line);}catch{return stop();}
45
50
  try{
46
- if(m.type==='ready'){pickerVisible=m.pickerVisible===true;sendParent({running:true,windowPicker:pickerVisible,mode:'windows-overlay',pid:process.pid});}
51
+ if(m.type==='ready'){pickerVisible=m.pickerVisible===true;sendParent({running:true,windowPicker:pickerVisible,mode:spec.mode,pid:process.pid});}
52
+ else if(m.type==='error'){nativeError=String(m.message??'Desktop painter failed');sendParent({error:nativeError});stop();}
47
53
  else if(m.type==='picker')pickerVisible=m.visible===true;
48
54
  else if(m.type==='viewport'){pendingViewport=m;visible=m.visible;selected=m.selected===true;pump?.viewport(m);}
49
55
  else if(m.type==='ack'){frames++;if(m.painted)paintedFrames++;pump?.ack();}
@@ -55,6 +61,6 @@ try{
55
61
  const {createPet}=await import('../../retrieviewer.mjs');const pet=await createPet();
56
62
  if(stopping)pet.dispose();else{
57
63
  pump=createFramePump({pet,send:write});if(pendingViewport)pump.viewport(pendingViewport);
58
- write({type:'loaded'});timer=setInterval(()=>{try{pump.tick();}catch(error){write({type:'error',message:error.message});stop();}},1000/30);
64
+ write({type:'loaded'});timer=setInterval(()=>{try{pump.tick();}catch(error){write({type:'error',message:error.message});stop();}},1000/spec.fps);
59
65
  }
60
66
  }catch(error){sendParent({error:error.message});await stop();}
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@bitefoundry/retrieviewer",
3
- "version": "0.0.2",
4
- "description": "A fluffy Braille desktop companion for Windows apps, plus an embeddable TUI pet. Give it a ball or a treat.",
3
+ "version": "0.0.3",
4
+ "description": "A fluffy Braille desktop pet for Windows and Linux X11, with experimental macOS support and an embeddable TUI engine.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://github.com/BiteFoundry",
7
- "keywords": ["tui", "braille", "pet", "retriever", "opencode", "desktop-pet", "windows", "overlay"],
7
+ "keywords": ["tui", "braille", "pet", "retriever", "opencode", "desktop-pet", "windows", "linux", "macos", "overlay"],
8
8
  "publishConfig": {
9
9
  "access": "public",
10
10
  "registry": "https://registry.npmjs.org/",
@@ -56,6 +56,12 @@
56
56
  "integrations/windows-overlay/desktop.mjs",
57
57
  "integrations/windows-overlay/worker.mjs",
58
58
  "integrations/windows-overlay/Overlay.cs",
59
+ "integrations/desktop/platform.mjs",
60
+ "integrations/linux-overlay/overlay.py",
61
+ "integrations/linux-overlay/README.md",
62
+ "integrations/macos-overlay/overlay.jxa",
63
+ "integrations/macos-overlay/README.md",
64
+ "DESKTOP-PLATFORMS.md",
59
65
  "WINDOWS-COMPANION.md",
60
66
  "INSTALLING.md",
61
67
  "PLUGIN.md",
@@ -23,8 +23,10 @@ Enable opens a first-run selection/confirmation wizard on a real terminal.
23
23
  Non-interactive changes require --agent and --yes. Unsupported native renderers
24
24
  are refused: MCP connectivity alone is never treated as in-screen rendering.
25
25
  No command changes credentials, shell profiles or agent executables.
26
- Desktop is a separate Windows overlay, NOT a Codex plugin. Choose a window once;
26
+ Desktop is a separate native overlay, NOT a Codex plugin. Choose a window once;
27
27
  use Codex normally. Exit from its tray icon or desktop-stop. No auto-start is added.
28
+ Windows is verified; macOS and Linux X11 adapters are experimental. Native Wayland
29
+ app tracking is not supported. Linux targets AlmaLinux8/GLIBC2.28, system Python3.6+.
28
30
  `;
29
31
  export function parseArguments(args){
30
32
  const [command='help',...rest]=args,options={};
@@ -88,7 +90,12 @@ export async function runCli(args,{input=process.stdin,output=process.stdout,err
88
90
  }
89
91
 
90
92
  // CLI is deliberately a separate module from the side-effect-free library.
91
- import {pathToFileURL} from 'node:url';
92
- if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href){
93
+ import {fileURLToPath} from 'node:url';
94
+ import {realpathSync} from 'node:fs';
95
+ // npm global bins are symlinks on Unix. import.meta resolves the real module,
96
+ // whereas argv[1] keeps the bin link; comparing those raw URLs silently did nothing.
97
+ let main=false;
98
+ try{main=!!process.argv[1]&&realpathSync(process.argv[1])===realpathSync(fileURLToPath(import.meta.url));}catch{}
99
+ if(main){
93
100
  try{process.exitCode=await runCli(process.argv.slice(2));}catch(e){process.stderr.write('Retrieviewer: '+e.message+'\n');process.exitCode=1;}
94
101
  }