← Files BetterContextARCHIVED FILE
scripts/wake_service.py
10.2 KB · Oct 3, 2026 · 06:37 UTC
"""Install/manage the selected edition's optional per-user relay wake helper."""
from contextlib import closing
import json
import os
from pathlib import Path
import plistlib
import shutil
import sqlite3
import subprocess
import sys
from host_adapter import EDITION, load_host
from wake_dispatcher import find_codex
import chat_tracking
def service_root():
if os.name == 'nt':
# Store service files outside packaged-app AppData virtualization so
# Task Scheduler can see the same files as the desktop plugin process.
return Path.home()/'.bettercontext'/EDITION/'wake'
elif sys.platform=='darwin':
base=Path.home()/'Library/Application Support'
else:
base=Path(os.environ.get('XDG_STATE_HOME',str(Path.home()/'.local/state')))
return base/('BetterContextPrivate' if EDITION=='private' else 'BetterContextPublic')/'WakeDispatcher'
def run(command, check=True):
result=subprocess.run(command,capture_output=True,text=True,encoding='utf-8',errors='replace',timeout=45,
creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0))
if check and result.returncode:
raise ValueError((result.stderr or result.stdout).strip() or f'Service command exited {result.returncode}')
return result
def service_action(action):
root=service_root()
name='BetterContext '+EDITION.title()+' Wake'
if os.name=='nt':
helper=Path(__file__).with_name('wake_windows.ps1')
result=run(['powershell.exe','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',str(helper),
'-Action',action,'-Edition',EDITION,'-InstallRoot',str(root)],check=action!='status')
if result.returncode:
return {'installed':None,'error':(result.stderr or result.stdout).strip()}
return json.loads(result.stdout)
if sys.platform.startswith('linux'):
unit='bettercontext-'+EDITION+'-wake.service'
unit_path=Path.home()/'.config/systemd/user'/unit
if action=='status':
state=run(['systemctl','--user','is-active',unit],check=False)
return {'installed':unit_path.exists(),'running':state.stdout.strip()=='active','state':state.stdout.strip()}
if action=='install':
settings=json.loads((root/'service.json').read_text(encoding='utf-8'))
def quote(value):return '"'+str(value).replace('\\','\\\\').replace('"','\\"').replace('%','%%').replace('$','$$')+'"'
command=' '.join(quote(s) for s in [settings['python'],str(root/'wake_dispatcher.py'),'--config',str(root/'wake-dispatcher.json')])
unit_path.parent.mkdir(parents=True,exist_ok=True)
unit_path.write_text('[Unit]\nDescription='+name+'\n[Service]\nExecStart='+command+'\nRestart=on-failure\nRestartSec=5\n[Install]\nWantedBy=default.target\n',encoding='utf-8')
run(['systemctl','--user','daemon-reload'])
run(['systemctl','--user','enable',unit])
run(['systemctl','--user','restart',unit])
elif action=='uninstall':
run(['systemctl','--user','disable','--now',unit])
if unit_path.exists():unit_path.unlink()
run(['systemctl','--user','daemon-reload'])
else:run(['systemctl','--user',action,unit])
return service_action('status')
if sys.platform=='darwin':
label='to.unkd.bettercontext.'+EDITION+'.wake'
plist=Path.home()/'Library/LaunchAgents'/(label+'.plist')
target=f'gui/{os.getuid()}/{label}'
if action=='status':
state=run(['launchctl','print',target],check=False)
return {'installed':plist.exists(),'running':state.returncode==0 and 'state = running' in state.stdout}
if action in {'stop','uninstall'}:run(['launchctl','bootout',target],check=False)
if action=='uninstall' and plist.exists():plist.unlink()
if action=='install':
settings=json.loads((root/'service.json').read_text(encoding='utf-8'))
plist.parent.mkdir(parents=True,exist_ok=True)
payload={'Label':label,'ProgramArguments':[settings['python'],str(root/'wake_dispatcher.py'),'--config',str(root/'wake-dispatcher.json')],
'RunAtLoad':True,'KeepAlive':True,'StandardErrorPath':str(root/'service-error.log')}
plist.write_bytes(plistlib.dumps(payload))
run(['launchctl','bootout',target],check=False)
if action in {'install','start'}:run(['launchctl','bootstrap',f'gui/{os.getuid()}',str(plist)])
return service_action('status')
raise ValueError('Automatic service setup supports Windows, Linux systemd, and macOS launchd')
def legacy_installations(with_status=False):
"""Report separately managed private bridges without changing them."""
if EDITION != 'private':
return []
candidates = []
if sys.platform.startswith('linux'):
base = Path(os.environ.get('XDG_CONFIG_HOME', str(Path.home()/'.config')))/'bettercontext'
candidates = sorted(base.glob('wake-dispatcher*.json'))
elif os.name == 'nt':
for base in (Path(os.environ.get('LOCALAPPDATA', str(Path.home()/'AppData/Local'))), Path.home()/'AppData/Local'):
path = base/'BetterContext/WakeDispatcher/wake-dispatcher.json'
if path.is_file() and path not in candidates:
candidates.append(path)
result = []
for path in candidates:
entry = {'config_path': str(path), 'managed_externally': True, 'running': None}
try:
config = json.loads(path.read_text(encoding='utf-8'))
entry['local_aliases'] = config.get('local_aliases', [])
except (OSError, ValueError):
entry['configuration_readable'] = False
if with_status and sys.platform.startswith('linux'):
suffix = path.stem.removeprefix('wake-dispatcher')
unit = 'bettercontext-wake'+suffix+'.service'
entry['service'] = unit
state = run(['systemctl', '--user', 'is-active', unit], check=False)
entry['state'] = state.stdout.strip()
entry['running'] = entry['state'] == 'active'
result.append(entry)
return result
def plan(aliases):
if legacy_installations():
raise ValueError('A legacy private wake installation exists. Inspect and migrate it before enabling this edition service; do not run both.')
settings,_=load_host()
database=settings['database_path']
if not database.is_file():raise ValueError('Initialize or connect this edition database before wake setup')
if not aliases:raise ValueError('Choose at least one registered local chat to wake')
targets={}
with closing(sqlite3.connect(chat_tracking.uri(database),uri=True)) as conn:
for alias in aliases:
row=conn.execute('SELECT short_id,uuid FROM chat_aliases WHERE short_id=? COLLATE NOCASE',(alias,)).fetchone()
if not row:raise ValueError('Alias is not registered: '+alias)
if not chat_tracking.UUID.fullmatch(row[1]):raise ValueError('Wake needs the real local task UUID for '+row[0])
if row[1] not in chat_tracking.find_rollouts({row[1]}):
raise ValueError('This host does not have a local task history for '+row[0]+'; run setup on its destination host')
targets[row[0].casefold()]=row[1]
command=find_codex(None)
run([*command,'queue','--help'])
return settings,{'edition':EDITION,'database_path':str(database),'bettercontext_root':str(service_root()/'runtime'),
**{key:str(settings[key]) for key in ('storage_registry_path','shared_root','instance_id') if key in settings},
'local_aliases':list(targets),'local_targets':targets,'codex_command':command,
'state_path':str(service_root()/'dispatches.db'),'log_path':str(service_root()/'wake.log'),
'poll_seconds':2.0}
def install(aliases,check=False):
settings,config=plan(aliases)
if check:return {'validated':True,'installed':False,'plan':config}
root=service_root()
root.mkdir(parents=True,exist_ok=True)
# Stop only this edition before updating the files of a running helper.
previous=service_action('status')
if previous.get('running'):service_action('stop')
runtime=root/'runtime'
runtime.mkdir(exist_ok=True)
for name in ('memory.py','database.py','chat_tracking.py','storage_registry.py'):
shutil.copy2(settings['bettercontext_root']/name,runtime/name)
shutil.copy2(Path(__file__).with_name('wake_dispatcher.py'),root/'wake_dispatcher.py')
shutil.copy2(Path(__file__).with_name('storage_registry.py'),root/'storage_registry.py')
(root/'wake-dispatcher.json').write_text(json.dumps(config,indent=2)+'\n',encoding='utf-8')
(root/'service.json').write_text(json.dumps({'python':sys.executable},indent=2)+'\n',encoding='utf-8')
state=service_action('install')
return {'edition':EDITION,'service':state,'config_path':str(root/'wake-dispatcher.json'),
'local_aliases':config['local_aliases'],'starts_at_login':True}
def status():
root=service_root()
legacy = legacy_installations(with_status=True)
if not (root/'wake-dispatcher.json').is_file():return {'edition':EDITION,'installed':False,'running':False,'legacy_bridges':legacy}
config=json.loads((root/'wake-dispatcher.json').read_text(encoding='utf-8'))
return {'edition':EDITION,'legacy_bridges':legacy,**service_action('status'),'local_aliases':config['local_aliases'],'config_path':str(root/'wake-dispatcher.json')}
if __name__=='__main__':
import argparse
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('action',choices=['install','start','stop','status','uninstall'])
parser.add_argument('--alias',action='append',default=[])
parser.add_argument('--check',action='store_true')
args=parser.parse_args()
try:
result=install(args.alias,args.check) if args.action=='install' else status() if args.action=='status' else service_action(args.action)
print(json.dumps(result,indent=2))
except (OSError,ValueError,sqlite3.Error,subprocess.SubprocessError) as error:
print(str(error),file=sys.stderr)
raise SystemExit(2)
SHA-256: e4bb2a5da6d24e24a83c3f0851349398e4fbb24f0e64859f331b056e0dc34a4d