webhelm 1.0.12 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,6 +11,10 @@
11
11
 
12
12
  The npm package **does not include** the Skill or site Actions. The Skill's executable uses `npm exec --yes --package=webhelm@latest -- webhelm` to avoid inherited npm lifecycle variable conflicts; its contents may update without a Runtime release.
13
13
 
14
+ The agent-facing `webhelm` CLI uses only the `WebHelm` Workspace. It intentionally does not accept `--workspace` or expose Workspace management helpers in `session exec`. `session stop --all` only stops WebHelm Sessions. Application services such as Relay provision their own named Workspaces through the daemon IPC `workspace-create`, `workspace-resize`, and `workspace-delete`; they may start Sessions via the daemon `start` operation for those pre-provisioned names. Leasing a missing Workspace fails rather than creating it.
15
+
16
+ Neo Master development uses the Git checkout directly via local symlinks for the CLI, Skill, and Extension; production nodes use published npm Runtime installations. Set `WEBHELM_SOURCE_MODE=1` when running the checkout directly; this loads the Extension from the repository and creates a LaunchAgent that references the repository's CLI without staging an npm Runtime copy.
17
+
14
18
  ## Install and use
15
19
 
16
20
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webhelm",
3
- "version": "1.0.12",
3
+ "version": "1.0.14",
4
4
  "description": "Agent browser automation in a visible managed Chrome with persistent JavaScript sessions",
5
5
  "type": "module",
6
6
  "bin": {
package/runtime/cli.mjs CHANGED
@@ -4,13 +4,14 @@ import path from 'node:path';
4
4
  import os from 'node:os';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { randomUUID } from 'node:crypto';
7
- import { ensureDaemon, request, handle, SOCKET } from './node-daemon.mjs';
7
+ import { ensureDaemon, request, SOCKET } from './node-daemon.mjs';
8
8
  import { PLATFORMS, listPlatforms, platformProfile, actionPath, validateConfig } from './platforms.mjs';
9
9
  import { ACTIONS_DIR, ensureActions, syncActions, publishActions } from './actions-repo.mjs';
10
10
  import { migrateConfig, migrateChromeProfile } from './migrate.mjs';
11
11
  import { installLoginGuard, stageRuntimeForLoginGuard } from './login-guard.mjs';
12
12
 
13
13
  const ROOT = path.dirname(fileURLToPath(import.meta.url));
14
+ const CLI_WORKSPACE = 'WebHelm';
14
15
  const emit = value => console.log(JSON.stringify(value));
15
16
  const arg = (items, name) => {
16
17
  const i = items.indexOf(name);
@@ -26,8 +27,8 @@ Usage:
26
27
  webhelm setup
27
28
  webhelm status
28
29
  webhelm ensure # verify/recover managed Chrome and WebHelm group, not just daemon ping
29
- webhelm capacity [--workspace NAME]
30
- webhelm session start [--workspace NAME] [--url URL]
30
+ webhelm capacity # WebHelm workspace only
31
+ webhelm session start [--url URL] # WebHelm workspace only
31
32
  webhelm session list
32
33
  webhelm session heartbeat SESSION_ID # keep a working Session alive
33
34
  webhelm session exec SESSION_ID [--code-file FILE] # JavaScript
@@ -93,9 +94,26 @@ async function runPlatform(args) {
93
94
  };
94
95
  }
95
96
  if (mode === 'sync' && (!sid || auto)) throw Error('sync new-turn requires a caller-owned --session-id');
97
+ if (sid) await requireCliSession(sid);
96
98
  if (!sid && !auto) throw Error('platform run requires --session-id; use --auto-session only for one-shot actions');
97
99
  return request({op:'platform-run', platform, action, config: {...config, ...(mode ? {mode} : {})},
98
- session_id:sid, auto_session:auto, url:arg(args, '--url')});
100
+ session_id:sid, auto_session:auto, url:arg(args, '--url'), workspace:CLI_WORKSPACE});
101
+ }
102
+
103
+ // The agent CLI is not a Workspace provisioning interface. Other workspaces
104
+ // belong to application code using the explicit daemon IPC API.
105
+ function rejectWorkspaceOverride(args) {
106
+ if (has(args, '--workspace') || args.some(a=>a.startsWith('--workspace=')))
107
+ throw Error('CLI is restricted to the WebHelm workspace; applications must provision custom workspaces through the daemon API');
108
+ }
109
+ function onlyCliWorkspace(sessions) {
110
+ return (sessions || []).filter(item=>item.workspace === CLI_WORKSPACE);
111
+ }
112
+ async function requireCliSession(sessionId) {
113
+ if (!sessionId) throw Error('session id is required');
114
+ const listed = await request({op:'list'});
115
+ if (!onlyCliWorkspace(listed.sessions).some(item=>item.session_id===sessionId))
116
+ throw Error(`Session ${sessionId} is not owned by the WebHelm CLI workspace`);
99
117
  }
100
118
 
101
119
  async function main(args = process.argv.slice(2)) {
@@ -110,8 +128,11 @@ async function main(args = process.argv.slice(2)) {
110
128
  if (group === 'setup') {
111
129
  const repo=await ensureActions();
112
130
  const chrome=await ensureDaemon();
113
- const persisted=stageRuntimeForLoginGuard();
114
- const login_guard=installLoginGuard({cliPath:path.join(persisted.path,'bin/webhelm.mjs'),nodePath:process.execPath});
131
+ const sourceMode=process.env.WEBHELM_SOURCE_MODE==='1';
132
+ const cliPath=sourceMode
133
+ ? fileURLToPath(new URL('../bin/webhelm.mjs',import.meta.url))
134
+ : path.join(stageRuntimeForLoginGuard().path,'bin/webhelm.mjs');
135
+ const login_guard=installLoginGuard({cliPath,nodePath:process.execPath,sourceMode});
115
136
  return emit({status:'ready',...repo,daemon_pid:chrome.pid,managed_chrome:chrome.managed_chrome,login_guard});
116
137
  }
117
138
  if (group === 'ensure') return emit(await request({op:'guard-check'}));
@@ -124,26 +145,31 @@ async function main(args = process.argv.slice(2)) {
124
145
  daemon:{running:true, pid:daemon.pid, session_count:daemon.session_count, queue_paused:daemon.queue_paused, socket:SOCKET}});
125
146
  }
126
147
  if (group === 'capacity') {
127
- const payload={op:'capacity',workspace:arg(args,'--workspace')};
128
- try { return emit(await request(payload)); }
129
- catch(error) {
130
- // Rolling upgrade: query extension metadata directly until a busy old
131
- // Node daemon can drain its Sessions and load the new capacity operation.
132
- if (!String(error.message).includes('unknown op: capacity')) throw error;
133
- emit(await handle(payload));
134
- return process.exit(0); // close this CLI's one-off CDP websocket
135
- }
148
+ rejectWorkspaceOverride(args);
149
+ const payload={op:'capacity',workspace:CLI_WORKSPACE};
150
+ // Never run a second in-process browser controller as a fallback: it may
151
+ // reload an older daemon's Extension while its Sessions are still active.
152
+ return emit(await request(payload));
136
153
  }
137
154
  if (group === 'session') {
138
- if (command === 'start') return emit(await request({op:'start', workspace:arg(args,'--workspace'), url:arg(args,'--url')}));
139
- if (command === 'list') return emit(await request({op:'list'}));
155
+ rejectWorkspaceOverride(args);
156
+ if (command === 'start') return emit(await request({op:'start', workspace:CLI_WORKSPACE, url:arg(args,'--url')}));
157
+ if (command === 'list') {
158
+ const result=await request({op:'list'});
159
+ const sessions=onlyCliWorkspace(result.sessions);
160
+ return emit({session_count:sessions.length,sessions});
161
+ }
140
162
  if (command === 'heartbeat') {
141
- if (!args[2]) throw Error('session heartbeat requires SESSION_ID');
163
+ await requireCliSession(args[2]);
142
164
  return emit(await request({op:'heartbeat',session_id:args[2]}));
143
165
  }
144
- if (command === 'stop') return emit(await request(has(args,'--all') ? {op:'stop-all'} : {op:'stop',session_id:args[2]}));
166
+ if (command === 'stop') {
167
+ if (has(args,'--all')) return emit(await request({op:'stop-workspace',workspace:CLI_WORKSPACE}));
168
+ await requireCliSession(args[2]);
169
+ return emit(await request({op:'stop',session_id:args[2]}));
170
+ }
145
171
  if (command === 'exec') {
146
- if (!args[2]) throw Error('session exec requires SESSION_ID');
172
+ await requireCliSession(args[2]);
147
173
  const file = arg(args, '--code-file');
148
174
  const code = file ? fs.readFileSync(file, 'utf8') : fs.readFileSync(0, 'utf8');
149
175
  const result = await request({op:'exec', session_id:args[2], code});
@@ -153,6 +179,7 @@ async function main(args = process.argv.slice(2)) {
153
179
  }
154
180
  }
155
181
  if (group === 'platform') {
182
+ rejectWorkspaceOverride(args);
156
183
  if (command === 'sync') return emit(await syncActions());
157
184
  if (command === 'publish') return emit(await publishActions(arg(args, '--message')));
158
185
  await ensureActions();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "WebHelm",
4
- "version": "0.4.5",
4
+ "version": "0.4.6",
5
5
  "description": "Own named Chrome tab-group workspaces and lazy reusable tab pools for browser agents.",
6
6
  "permissions": [
7
7
  "storage",
@@ -96,7 +96,18 @@ globalThis.browserWorkspaceManagerRpc = async (request) => {
96
96
  }
97
97
  };
98
98
 
99
+ // Only the daemon's direct Service Worker RPC may provision, resize, delete,
100
+ // or restore Workspace identities. A browser Session on an extension page must
101
+ // not gain management rights through chrome.runtime.sendMessage().
102
+ const APPLICATION_ONLY_METHODS = new Set([
103
+ "workspace.create", "workspace.resize", "workspace.delete",
104
+ "workspace.ensure", "workspace.migrateDefault",
105
+ ]);
99
106
  chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
107
+ if (APPLICATION_ONLY_METHODS.has(request?.method)) {
108
+ sendResponse({ok:false,error:{message:"Workspace management requires an application daemon API"}});
109
+ return false;
110
+ }
100
111
  if (request?.method?.startsWith("workspace.control.") &&
101
112
  (sender?.url !== chrome.runtime.getURL("popup.html") ||
102
113
  !["workspace.control.requestStop", "workspace.control.status"].includes(request.method))) {
@@ -550,7 +550,11 @@ export class WorkspaceManager {
550
550
 
551
551
  async acquireIdentity(name) {
552
552
  const normalizedName = normalizeWorkspaceName(name);
553
- const ready = await this.ensureWorkspace(normalizedName);
553
+ // Leasing may allocate a Tab inside an existing Workspace, but must never
554
+ // provision a new Workspace as a side effect. Only app-owned creation can.
555
+ const ready = await this.status(normalizedName);
556
+ if (!ready.initialized)
557
+ throw new Error(`Workspace ${normalizedName} does not exist; an application must provision it first`);
554
558
  const tabs = await this.groupTabs(ready.groupId);
555
559
  const { idle, leased } = this.classify(normalizedName, tabs);
556
560
  if (!idle.length && leased.length >= ready.poolSize) {
@@ -591,7 +595,11 @@ export class WorkspaceManager {
591
595
  throw new Error("Workspace acquire requires an http(s) or chrome-extension URL");
592
596
  }
593
597
 
594
- const ready = await this.ensureWorkspace(normalizedName);
598
+ // Leasing may allocate a Tab inside an existing Workspace, but must never
599
+ // provision a new Workspace as a side effect. Only app-owned creation can.
600
+ const ready = await this.status(normalizedName);
601
+ if (!ready.initialized)
602
+ throw new Error(`Workspace ${normalizedName} does not exist; an application must provision it first`);
595
603
  const tabs = await this.groupTabs(ready.groupId);
596
604
  const { idle, leased } = this.classify(normalizedName, tabs);
597
605
  if (!idle.length && leased.length >= ready.poolSize) {
@@ -6,8 +6,10 @@ import {fileURLToPath} from 'node:url';
6
6
  import {LOCAL_ROOT} from './config-paths.mjs';
7
7
 
8
8
  export const EXTENSION_ID='kkkpgmhpoeceojalkpfdidffdfnlgdog';
9
- export const EXTENSION_HOME=path.resolve(process.env.WEBHELM_EXTENSION_HOME || path.join(LOCAL_ROOT,'extension'));
10
9
  const SOURCE=fileURLToPath(new URL('./extension/',import.meta.url));
10
+ const SOURCE_MODE=process.env.WEBHELM_SOURCE_MODE==='1';
11
+ export const EXTENSION_HOME=path.resolve(process.env.WEBHELM_EXTENSION_HOME ||
12
+ (SOURCE_MODE ? SOURCE : path.join(LOCAL_ROOT,'extension')));
11
13
  const FILES=[
12
14
  'manifest.json','service-worker.mjs','workspace-manager.mjs','workspace.html','helm.html','workspace-page.mjs',
13
15
  'control-channel.mjs','workspace-summary.mjs','session-badge.mjs','popup.html','popup.mjs',
@@ -50,6 +52,10 @@ function excludeFromActionsGit(dir){
50
52
  export function stageExtension(dir=EXTENSION_HOME){
51
53
  const target=path.resolve(dir);
52
54
  const bundled=manifest(SOURCE);
55
+ // Neo Master works from its Git checkout. Never stage or overwrite source
56
+ // files, and never accept arbitrary symlinks as a deployment directory.
57
+ if(SOURCE_MODE && canonicalPath(target)===canonicalPath(SOURCE))
58
+ return {path:SOURCE,version:bundled.version,updated:false,sourceMode:true};
53
59
  if(fs.existsSync(target)&&fs.lstatSync(target).isSymbolicLink())throw Error('Refusing to replace extension symlink');
54
60
  if(fs.existsSync(target)&&identical(target))return {path:target,version:bundled.version,updated:false};
55
61
  if(fs.existsSync(target))manifest(target); // Never replace unrelated content.
@@ -36,23 +36,23 @@ export function stageRuntimeForLoginGuard({home=os.homedir(),source=fileURLToPat
36
36
  }
37
37
 
38
38
  const escapeXml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[c]));
39
- export function loginGuardDefinition(home=os.homedir(),{cliPath=path.join(home,'.local/bin/webhelm'),nodePath=null}={}) {
39
+ export function loginGuardDefinition(home=os.homedir(),{cliPath=path.join(home,'.local/bin/webhelm'),nodePath=null,sourceMode=false}={}) {
40
40
  const cli=cliPath;
41
41
  const program=nodePath||cli;
42
42
  const args=nodePath?`<string>${escapeXml(cli)}</string><string>ensure</string>`:'<string>ensure</string>';
43
43
  const environment=[path.join(home,'.local/bin'),path.join(home,'.local/opt/node22/bin'),
44
44
  '/opt/homebrew/bin','/usr/local/bin','/usr/bin','/bin'].join(':');
45
- // Use the cached, tested npm Runtime directly to avoid registry/network
46
- // dependency at login or when Chrome has just auto-updated. This must call
45
+ // Use a pinned local Runtime (or the source checkout on Neo Master), not
46
+ // npm/network at login or after Chrome updates. This must call
47
47
  // 'ensure', not 'status': a live daemon does not imply a live Chrome.
48
- return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n<key>Label</key><string>${LABEL}</string>\n<key>ProgramArguments</key><array><string>${escapeXml(program)}</string>${args}</array>\n<key>EnvironmentVariables</key><dict><key>PATH</key><string>${escapeXml(environment)}</string><key>HOME</key><string>${escapeXml(home)}</string></dict>\n<key>RunAtLoad</key><true/>\n<key>StartInterval</key><integer>30</integer>\n<key>StandardOutPath</key><string>/dev/null</string>\n<key>StandardErrorPath</key><string>/dev/null</string>\n</dict></plist>\n`;
48
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n<key>Label</key><string>${LABEL}</string>\n<key>ProgramArguments</key><array><string>${escapeXml(program)}</string>${args}</array>\n<key>EnvironmentVariables</key><dict><key>PATH</key><string>${escapeXml(environment)}</string><key>HOME</key><string>${escapeXml(home)}</string>${sourceMode?'<key>WEBHELM_SOURCE_MODE</key><string>1</string>':''}</dict>\n<key>RunAtLoad</key><true/>\n<key>StartInterval</key><integer>30</integer>\n<key>StandardOutPath</key><string>/dev/null</string>\n<key>StandardErrorPath</key><string>/dev/null</string>\n</dict></plist>\n`;
49
49
  }
50
- export function installLoginGuard({home=os.homedir(), platform=process.platform, apply=true,cliPath,nodePath}={}){
50
+ export function installLoginGuard({home=os.homedir(), platform=process.platform, apply=true,cliPath,nodePath,sourceMode=false}={}){
51
51
  if(platform!=='darwin')return {status:'unsupported',label:LABEL};
52
52
  const cli=cliPath||path.join(home,'.local/bin/webhelm');
53
53
  if(!fs.existsSync(cli))return {status:'skipped',reason:'WebHelm runtime launcher missing',label:LABEL};
54
54
  const dest=path.join(home,'Library/LaunchAgents',`${LABEL}.plist`);
55
- const content=loginGuardDefinition(home,{cliPath,nodePath});
55
+ const content=loginGuardDefinition(home,{cliPath,nodePath,sourceMode});
56
56
  fs.mkdirSync(path.dirname(dest),{recursive:true});
57
57
  if(fs.existsSync(dest)){
58
58
  const old=fs.readFileSync(dest,'utf8');
@@ -232,9 +232,6 @@ function makeHelpers(s){
232
232
  async workspace_status(){return extensionCall(s,'workspace.status',{name:s.workspace})},
233
233
  async workspace_capacity(){const r=await h.workspace_status();const idle=Array.isArray(r.idleTabIds)?r.idleTabIds.length:null,leased=Array.isArray(r.leasedTabIds)?r.leasedTabIds.length:null;return {name:r.name,poolSize:r.poolSize??null,maxCapacity:r.maxCapacity??r.poolSize??null,physicalTabs:r.physicalTabs??(idle!==null&&leased!==null?idle+leased:null),idle,leased,available:Number.isInteger(r.maxCapacity??r.poolSize)&&leased!==null?Math.max(0,(r.maxCapacity??r.poolSize)-leased):null}} ,
234
234
  async workspace_list(){return extensionCall(s,'workspace.list')},
235
- async workspace_create(name,poolSize=5){return extensionCall(s,'workspace.create',{name,poolSize})},
236
- async workspace_resize(name,poolSize){return extensionCall(s,'workspace.resize',{name,poolSize})},
237
- async workspace_delete(name,force=false){return extensionCall(s,'workspace.delete',{name,force})},
238
235
  async wait(seconds){return delay(seconds*1000)},
239
236
  async sleep(milliseconds){return delay(milliseconds)},
240
237
  async type_text(text){return cdp(s,'Input.insertText',{text:String(text)})},
@@ -507,7 +504,18 @@ function heartbeatSession(sid){
507
504
  return {session_id:sid,alive:true,last_used_at:s.last_used_at};
508
505
  }
509
506
 
510
- async function workspaceOp(req){const h=makeHelpers({workspace:req.name||await defaultWorkspaceName(),targetId:'',targets:new Set(),attachments:new Map()});if(req.op==='workspace-create')return h.workspace_create(req.name,req.pool_size);if(req.op==='workspace-delete')return h.workspace_delete(req.name,req.force);throw Error(`unknown op: ${req.op}`)}
507
+ // Applications (Relay, Neo, etc.) provision Workspaces through the daemon IPC,
508
+ // never through the agent-facing CLI or its session JavaScript namespace.
509
+ async function workspaceOp(req) {
510
+ if (typeof req.name !== 'string' || !req.name.trim()) throw Error('workspace name required');
511
+ if (req.op === 'workspace-create')
512
+ return extensionCall(null,'workspace.create',{name:req.name,poolSize:req.pool_size ?? 8});
513
+ if (req.op === 'workspace-resize')
514
+ return extensionCall(null,'workspace.resize',{name:req.name,poolSize:req.pool_size});
515
+ if (req.op === 'workspace-delete')
516
+ return extensionCall(null,'workspace.delete',{name:req.name,force:Boolean(req.force)});
517
+ throw Error(`unknown workspace operation: ${req.op}`);
518
+ }
511
519
  function getChatGPTQueue() {
512
520
  if (chatgptQueue) return chatgptQueue;
513
521
  chatgptQueue = new ChatGPTQueue({
@@ -605,7 +613,7 @@ export function actionOutput(logs, value) {
605
613
  async function runPlatform(req) {
606
614
  const platform=req.platform, action=req.action, config=req.config || {};
607
615
  const auto=Boolean(req.auto_session);
608
- const started=auto ? await createSession({url:req.url}) : {session_id:req.session_id};
616
+ const started=auto ? await createSession({url:req.url,workspace:req.workspace}) : {session_id:req.session_id};
609
617
  const sid=started.session_id;
610
618
  if (!sid) throw Error('platform run requires a session_id or auto_session');
611
619
  const session=sessions.get(sid);
@@ -646,7 +654,7 @@ async function runPlatform(req) {
646
654
  self_heal_capable:!auto,result};
647
655
  }
648
656
 
649
- export async function handle(req){switch(req.op){case'ping':return {ok:true,pid:process.pid,version:PACKAGE_VERSION,queue_paused:process.env.WEBHELM_CHATGPT_QUEUE_PAUSED==='1',session_count:sessions.size,managed_chrome:managedChrome||{profile_dir:PROFILE_DIR, endpoint:process.env.WEBHELM_CDP_URL||'http://127.0.0.1:9222',reused:true},chrome_guard:managedChromeGuard.status()};case'guard-check':{let status=await managedChromeGuard.tick();for(let retry=0;retry<6 && status.status!=='healthy' && !status.skipped;retry++){await delay(800);status=await managedChromeGuard.tick();}if(status.status!=='healthy' && !status.skipped)throw Error(`WebHelm Chrome health degraded: ${status.error||status.status}`);return {ok:true,chrome_guard:status};}case'heartbeat':return heartbeatSession(req.session_id);case'list':return {session_count:sessions.size,sessions:[...sessions.values()].map(s=>({session_id:s.session_id,workspace:s.workspace,target_id:s.targetId,last_used_at:s.last_used_at}))};case'start':return createSession(req);case'exec':return execute(req);case'stop':return stopSession(req.session_id);case'stop-all':return stopAllSessions();case'capacity':return workspaceCapacity(req);case'workspace-create':case'workspace-delete':return workspaceOp(req);case'chatgpt-new-turn-enqueue':return getChatGPTQueue().enqueue(req.config);case'platform-run':return runPlatform(req);default:throw Error(`unknown op: ${req.op}`)}}
657
+ export async function handle(req){switch(req.op){case'ping':return {ok:true,pid:process.pid,version:PACKAGE_VERSION,queue_paused:process.env.WEBHELM_CHATGPT_QUEUE_PAUSED==='1',session_count:sessions.size,managed_chrome:managedChrome||{profile_dir:PROFILE_DIR, endpoint:process.env.WEBHELM_CDP_URL||'http://127.0.0.1:9222',reused:true},chrome_guard:managedChromeGuard.status()};case'guard-check':{let status=await managedChromeGuard.tick();for(let retry=0;retry<6 && status.status!=='healthy' && !status.skipped;retry++){await delay(800);status=await managedChromeGuard.tick();}if(status.status!=='healthy' && !status.skipped)throw Error(`WebHelm Chrome health degraded: ${status.error||status.status}`);return {ok:true,chrome_guard:status};}case'heartbeat':return heartbeatSession(req.session_id);case'list':return {session_count:sessions.size,sessions:[...sessions.values()].map(s=>({session_id:s.session_id,workspace:s.workspace,target_id:s.targetId,last_used_at:s.last_used_at}))};case'start':return createSession(req);case'exec':return execute(req);case'stop':return stopSession(req.session_id);case'stop-all':return stopAllSessions();case'stop-workspace':{if(typeof req.workspace!=='string'||!req.workspace.trim())throw Error('workspace required');return stopWorkspaceSessions(req.workspace)}case'capacity':return workspaceCapacity(req);case'workspace-create':case'workspace-resize':case'workspace-delete':return workspaceOp(req);case'chatgpt-new-turn-enqueue':return getChatGPTQueue().enqueue(req.config);case'platform-run':return runPlatform(req);default:throw Error(`unknown op: ${req.op}`)}}
650
658
  function serve(conn) {
651
659
  let buffer = '';
652
660
  conn.setEncoding('utf8');