diff options
Diffstat (limited to 'modules')
| -rw-r--r-- | modules/core/module-agent-registry.js | 79 | ||||
| -rw-r--r-- | modules/core/module-aiassist.js | 74 | ||||
| -rw-r--r-- | modules/core/module-chatstudio.js | 32 | ||||
| -rw-r--r-- | modules/core/module-fs.js | 34 | ||||
| -rw-r--r-- | modules/core/module-leases.js | 102 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 7 | ||||
| -rw-r--r-- | modules/core/module-orchestrator.js | 151 | ||||
| -rw-r--r-- | modules/core/module-system.js | 20 | ||||
| -rw-r--r-- | modules/core/terminal.js | 7 | ||||
| -rw-r--r-- | modules/ext/skill-pack-core.js | 284 | ||||
| -rw-r--r-- | modules/ext/webdav.js | 515 |
11 files changed, 1131 insertions, 174 deletions
diff --git a/modules/core/module-agent-registry.js b/modules/core/module-agent-registry.js new file mode 100644 index 0000000..367f8b1 --- /dev/null +++ b/modules/core/module-agent-registry.js @@ -0,0 +1,79 @@ +// module-agent-registry.js โ VFS-backed agent directory +// Command: agent-list, agent-register, agent-unregister, agent-status +class AgentRegistryModule { + constructor() { + this.commands = {}; + this.terminal = null; + } + registerCommands() { + this.commands['agent-list'] = () => this.listAgents(); + this.commands['agent-status'] = (args) => this.showAgentStatus(args[0]); + this.commands['agent-register'] = (args) => this.registerAgent(args); + this.commands['agent-unregister'] = (args) => this.unregisterAgent(args[0]); + } + init(terminal) { + this.terminal = terminal; + if (!this.terminal.fs.readFile('/agents/')) { + this.terminal.fs.mkdir('agents'); + } + this.terminal.print('๐ Agent Registry loaded', 'info'); + } + listAgents() { + const agents = this.terminal.fs.ls('/agents/'); + if (!agents || agents.length === 0) { + return this.terminal.print('No agents registered', 'info'); + } + this.terminal.print(`๐ Registered Agents (${agents.length}):`, 'header'); + agents.forEach(a => { + if (a.type === 'dir' && a.name !== 'configured') { + const data = this.terminal.fs.readFile(`/agents/${a.name}/status.json`); + const s = data ? JSON.parse(data) : { status: 'unknown', capabilities: [] }; + this.terminal.print(` ${a.name} โ ${s.status} (${(s.capabilities || []).length} capabilities)`, 'info'); + } + }); + } + registerAgent(args) { + const id = args[1] || `agent-${Date.now()}`; + const capabilities = args.slice(2).length ? args.slice(2) : ['list_files', 'read_file']; + const agentDir = `agents/${id}`; + if (!this.terminal.fs.readFile('/' + agentDir + '/')) this.terminal.fs.mkdir(agentDir); + this.terminal.fs.writeFile(`${agentDir}/status.json`, JSON.stringify({ + id, + status: 'idle', + capabilities, + registered: new Date().toISOString(), + lastHeartbeat: new Date().toISOString() + })); + this.terminal.print(`โ
Agent '${id}' registered with ${capabilities.length} capabilities`, 'success'); + } + unregisterAgent(id) { + const ok = this.terminal.fs.deleteFile(`agents/${id}`); + if (ok) { + this.terminal.print(`โ
Agent '${id}' unregistered`, 'success'); + } else { + this.terminal.print(`โ Agent '${id}' not found`, 'error'); + } + } + showAgentStatus(id) { + if (!id) return this.terminal.print('Usage: agent-status <agent-id>', 'error'); + const data = this.terminal.fs.readFile(`/agents/${id}/status.json`); + if (!data) return this.terminal.print(`โ Agent '${id}' not found`, 'error'); + const s = JSON.parse(data); + this.terminal.print(`Agent: ${s.id}`, 'header'); + this.terminal.print(` Status: ${s.status}`, 'info'); + this.terminal.print(` Capabilities: ${(s.capabilities || []).join(', ')}`, 'info'); + this.terminal.print(` Registered: ${s.registered}`, 'info'); + this.terminal.print(` Last Heartbeat: ${s.lastHeartbeat}`, 'info'); + } + getAgent(id) { + const data = this.terminal.fs.readFile(`/agents/${id}/status.json`); + return data ? JSON.parse(data) : null; + } + updateHeartbeat(id) { + const agent = this.getAgent(id); + if (!agent) return false; + agent.lastHeartbeat = new Date().toISOString(); + this.terminal.fs.writeFile(`agents/${id}/status.json`, JSON.stringify(agent)); + return true; + } +}
\ No newline at end of file diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js index edc5225..eb41af9 100644 --- a/modules/core/module-aiassist.js +++ b/modules/core/module-aiassist.js @@ -384,6 +384,40 @@ RULES: description: 'Session aggregate stats', parameters: { type: 'object', properties: {}, required: [] } } + }, { + type: 'function', + function: { + name: 'list_agents', + description: 'List registered agent IDs, status, and capabilities', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'delegate_task', + description: '(DESTRUCTIVE) Delegate a task to a worker agent. Auto-spawns if needed.', + parameters: { + type: 'object', + properties: { + task: { type: 'string', description: 'The task description to delegate' } + }, + required: ['task'] + } + } + }, { + type: 'function', + function: { + name: 'spawn_agent', + description: '(DESTRUCTIVE) Spawn a new worker agent with a role and capabilities', + parameters: { + type: 'object', + properties: { + role: { type: 'string', description: 'Agent role (e.g. researcher, writer)' }, + capabilities: { type: 'string', description: 'Comma-separated capabilities (optional)' } + }, + required: ['role'] + } + } }]; } getHelpInfo() { @@ -568,7 +602,14 @@ RULES: (entry.error ? ' <span class="debug-err">โ ๏ธ</span>' : '') + '</div>'; } - panel.innerHTML = '<div class="debug-header" style="display:flex;justify-content:space-between;align-items:center;padding:6px 10px;border-bottom:1px solid #333;font-weight:bold;color:var(--success-color);font-size:0.9em;">๐ง Tool Log <span class="debug-count" style="color:var(--text-muted);font-weight:normal;font-size:0.85em;">' + this._toolCallLog.length + ' calls</span><span style="cursor:pointer;color:var(--text-muted);font-size:1.1em;touch-action:manipulation;" onclick="var p=document.getElementById(\'debug-panel\');if(p)p.style.display=\'none\';var a=window.webx11&&webx11.moduleManager&&webx11.moduleManager.modules&&webx11.moduleManager.modules.WebX11AIAssistantModule;if(a)a._debugPanelEnabled=false;">โ</span></div>' + html; + panel.innerHTML = '<div class="debug-header" style="display:flex;justify-content:space-between;align-items:center;padding:6px 10px;border-bottom:1px solid #333;font-weight:bold;color:var(--success-color);font-size:0.9em;flex-wrap:wrap;gap:4px;">' + + '<span>๐ง Tool Log <span class="debug-count" style="color:var(--text-muted);font-weight:normal;font-size:0.85em;">' + this._toolCallLog.length + ' calls</span></span>' + + '<span style="display:flex;gap:4px;">' + + '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var t=m._toolCallLog.map(function(e){return\'#\'+e.turn+\' \'+e.name+\' (\'+e.duration+\'s)\'+(e.error?\' โ ๏ธ\':\'\')+\'\\n\'+JSON.stringify(e.args).substring(0,80)}).join(\'\\n\\n\');navigator.clipboard.writeText(t).then(function(){m.terminal&&m.terminal.print(\'๐ Debug log copied to clipboard\',\'success\')}).catch(function(){prompt(\'Copy debug log:\',t)})}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">๐</button>' + + '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var json=JSON.stringify(m._toolCallLog,null,2);var b=new Blob([json],{type:\'application/json\'});var u=URL.createObjectURL(b);var a=document.createElement(\'a\');a.href=u;a.download=\'debug-log-\'+new Date().toISOString().slice(0,19)+\'.json\';document.body.appendChild(a);a.click();setTimeout(function(){document.body.removeChild(a);URL.revokeObjectURL(u)},2000);m.terminal&&m.terminal.print(\'๐ฅ Debug log exported\',\'success\')}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">๐ฅ</button>' + + '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var existing=[];var raw=m.terminal.fs.readFile(\'/debug.log\');if(raw){try{existing=JSON.parse(raw);}catch(e){}}var log=m._toolCallLog;var seen={};existing.forEach(function(e){seen[e.turn]=true;});log.forEach(function(e){if(!seen[e.turn]){existing.push(e);seen[e.turn]=true;}});var json=JSON.stringify(existing,null,2);m.terminal.fs.writeFile(\'/debug.log\',json);m.terminal.print(\'๐พ Debug log appended โ \'+existing.length+\' entries total\',\'success\')}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">๐พ</button>' + + '<span style="cursor:pointer;color:var(--text-muted);font-size:1.1em;touch-action:manipulation;" onclick="var p=document.getElementById(\'debug-panel\');if(p)p.style.display=\'none\';var a=window.webx11&&webx11.moduleManager&&webx11.moduleManager.modules&&webx11.moduleManager.modules.WebX11AIAssistantModule;if(a)a._debugPanelEnabled=false;">โ</span>' + + '</span></div>' + html; } _isToolError(result) { if (!result || typeof result !== 'string') return null; @@ -1194,6 +1235,37 @@ RULES: for (const [, meta] of entries) totalTasks += meta.tasks || 0; return JSON.stringify({ totalSessions: entries.length, totalTasks, dates: entries.map(([d]) => d) }); } + case 'list_agents': { + const registry = this.terminal.moduleManager.modules['AgentRegistryModule']; + if (!registry) return JSON.stringify({ error: 'Agent registry not available' }); + const agents = this.terminal.fs.ls('/agents/'); + const results = []; + if (agents) { + agents.forEach(a => { + if (a.type === 'dir' && a.name !== 'configured') { + const data = this.terminal.fs.readFile(`/agents/${a.name}/status.json`); + if (data) results.push(JSON.parse(data)); + } + }); + } + return JSON.stringify({ agents: results, count: results.length }); + } + case 'delegate_task': { + const task = a.task || ''; + if (!task) return JSON.stringify({ error: 'No task provided' }); + const orch = this.terminal.moduleManager.modules['OrchestratorModule']; + if (!orch) return JSON.stringify({ error: 'Orchestrator not available' }); + orch.delegateTask(task); + return JSON.stringify({ message: `Task delegated: ${task.substring(0, 80)}` }); + } + case 'spawn_agent': { + const role = a.role || 'worker'; + const caps = a.capabilities ? a.capabilities.split(',').map(s => s.trim()) : ['list_files', 'read_file']; + const orch = this.terminal.moduleManager.modules['OrchestratorModule']; + if (!orch) return JSON.stringify({ error: 'Orchestrator not available' }); + const agent = orch.spawnAgent(role, caps); + return JSON.stringify({ message: `Agent spawned: ${agent.id}`, agent }); + } case 'load_skills': { const query = (a && a.query || '').toLowerCase(); const fs = this.terminal.fs; diff --git a/modules/core/module-chatstudio.js b/modules/core/module-chatstudio.js index 762eae3..9643fff 100644 --- a/modules/core/module-chatstudio.js +++ b/modules/core/module-chatstudio.js @@ -17,32 +17,12 @@ class ChatStudioModule { getHelpInfo() { return { - 'chat': 'Toggle the ChatStudio overlay', - 'chat-setapi': 'Set the API base URL for ChatStudio', - 'chat-setmodel': 'Set the default model for ChatStudio', - 'chat-ask': 'Ask a question directly via ChatStudio' + 'chat': 'Toggle the ChatStudio overlay (inside: /setapi, /setmodel, /ask, /help)', }; } registerCommands() { this.commands['chat'] = () => this.toggleChatOverlay(); - this.commands['chat-setapi'] = (args) => { - if (!args[0]) { - const base = window.API_BASE || '/api'; - this.terminal.print('Current API base: ' + base, 'info'); - this.terminal.print('Usage: chat-setapi <base-url>', 'info'); - this.terminal.print('Example: chat-setapi /api', 'info'); - return; - } - const url = args[0].replace(/\/+$/, ''); - window.API_BASE = url; - try { - var settings = JSON.parse(localStorage.getItem('webx11_chat_settings') || '{}'); - settings.apiBase = url; - localStorage.setItem('webx11_chat_settings', JSON.stringify(settings)); - } catch (e) {} - this.terminal.print('API base set to: ' + url + ' (reopen chat to apply)', 'success'); - }; } toggleChatOverlay() { @@ -59,7 +39,7 @@ class ChatStudioModule { if (!this._cssLoaded) { const link = document.createElement('link'); link.rel = 'stylesheet'; - link.href = 'chat/chat-ui.css?v=4'; + link.href = 'chat/chat-ui.css?v=5'; link.onload = () => { this._cssLoaded = true; }; link.onerror = () => reject(new Error('Failed to load chat/chat-ui.css')); document.head.appendChild(link); @@ -120,7 +100,7 @@ class ChatStudioModule { resolve(); return; } - fetch('chat/chat-ui.html?v=4') + fetch('chat/chat-ui.html?v=5') .then(r => r.text()) .then(html => { this._overlayHtml = html; // cache for re-open @@ -134,7 +114,7 @@ class ChatStudioModule { const loadScript = (src) => { return new Promise((res, rej) => { const s = document.createElement('script'); - s.src = src; + s.src = src + '?v=5'; s.onload = () => res(); s.onerror = () => rej(new Error('Failed to load ' + src)); document.body.appendChild(s); @@ -142,8 +122,8 @@ class ChatStudioModule { }; Promise.all([ - loadScript('chat/chat-commands.js?v=4'), - loadScript('chat/chat-modules.js?v=4') + loadScript('chat/chat-commands.js'), + loadScript('chat/chat-modules.js') ]).then(() => { resolve(); }); diff --git a/modules/core/module-fs.js b/modules/core/module-fs.js index da2e740..b119343 100644 --- a/modules/core/module-fs.js +++ b/modules/core/module-fs.js @@ -279,18 +279,44 @@ class FileSystemModule { this.commands['upload'] = () => { document.getElementById('file-upload-input').click(); }; this.commands['download'] = (args) => { if(!args[0]) return this.terminal.print('Usage: download <filename>', 'warning'); - const content = this.terminal.fs.readFile(args[0]); + const fname = args[0]; + const content = this.terminal.fs.readFile(fname); if(content !== null && content !== undefined) { + const size = content.length; + const sizeStr = size > 1024 ? (size / 1024).toFixed(1) + ' KB' : size + ' B'; + // Desktop: programmatic click (works on desktop Chrome/Firefox) const blob = new Blob([content], {type: 'application/octet-stream'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); - a.href = url; a.download = args[0]; + a.href = url; a.download = fname; a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 2000); - this.terminal.print('Downloaded: ' + args[0], 'success'); - } else { this.terminal.print('download: ' + args[0] + ': No such file', 'error'); } + // Mobile fallback: build a DOM button with real user gesture + const line = document.createElement('div'); + line.className = 'output-line'; + const msg = document.createElement('span'); + msg.textContent = 'โฌ ' + fname + ' (' + sizeStr + ') '; + const btn = document.createElement('button'); + btn.textContent = 'โฌ Download'; + btn.className = 'btn'; + btn.style.cssText = 'background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--success-color);padding:4px 12px;border-radius:4px;cursor:pointer;font-size:0.85em;min-height:36px;'; + btn.onclick = () => { + const b = new Blob([this.terminal.fs.readFile(fname)], {type: 'application/octet-stream'}); + const u = URL.createObjectURL(b); + const el = document.createElement('a'); + el.href = u; el.download = fname; + el.style.display = 'none'; + document.body.appendChild(el); + el.click(); + setTimeout(() => { document.body.removeChild(el); URL.revokeObjectURL(u); }, 2000); + }; + line.appendChild(msg); + line.appendChild(btn); + this.terminal.outputContainer.appendChild(line); + this.terminal.scrollToBottom(); + } else { this.terminal.print('download: ' + fname + ': No such file', 'error'); } }; this.commands['vfs-export'] = () => { const data = this.terminal.fs.root; diff --git a/modules/core/module-leases.js b/modules/core/module-leases.js new file mode 100644 index 0000000..b79870c --- /dev/null +++ b/modules/core/module-leases.js @@ -0,0 +1,102 @@ +// module-leases.js โ Lease system for agent task delegation +// Commands: lease-create, lease-list, lease-revoke, lease-renew +class LeaseModule { + constructor() { + this.commands = {}; + this.terminal = null; + this._leases = {}; + this._watchdogTimer = null; + } + registerCommands() { + this.commands['lease-create'] = (args) => this.createLease(args); + this.commands['lease-list'] = () => this.listLeases(); + this.commands['lease-revoke'] = (args) => this.revokeLease(args[0]); + this.commands['lease-renew'] = (args) => this.renewLease(args[0], parseInt(args[1]) || 30); + } + init(terminal) { + this.terminal = terminal; + if (!this.terminal.fs.readFile('/leases/')) { + this.terminal.fs.mkdir('leases'); + } + // Restore persisted leases + const files = this.terminal.fs.ls('/leases/'); + if (files) { + files.forEach(f => { + if (f.name.endsWith('.json')) { + const data = this.terminal.fs.readFile(`/leases/${f.name}`); + if (data) { + try { + const lease = JSON.parse(data); + this._leases[lease.id] = lease; + } catch (e) {} + } + } + }); + } + // Start watchdog every 5 seconds + this._watchdogTimer = setInterval(() => this._checkExpired(), 5000); + this.terminal.print('๐ Lease system loaded', 'info'); + } + createLease(args) { + const workerId = args[1]; + const duration = parseInt(args[2]) || 30; + const task = args.slice(3).join(' ') || 'generic task'; + if (!workerId) return this.terminal.print('Usage: lease-create <agent-id> [duration_sec] [task]', 'error'); + + const lease = { + id: `lease-${Date.now()}`, + workerId, + orchestratorId: 'orchestrator-0', + task, + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + duration * 1000).toISOString(), + status: 'active', + duration + }; + this._leases[lease.id] = lease; + this.terminal.fs.writeFile(`leases/${lease.id}.json`, JSON.stringify(lease)); + this.terminal.print(`๐ Lease ${lease.id} created for ${workerId} (${duration}s, task: ${task.substring(0, 40)})`, 'success'); + return lease; + } + listLeases() { + const all = Object.values(this._leases); + const active = all.filter(l => l.status === 'active'); + const expired = all.filter(l => l.status === 'expired'); + const revoked = all.filter(l => l.status === 'revoked'); + this.terminal.print(`๐ Leases: ${active.length} active, ${expired.length} expired, ${revoked.length} revoked`, 'header'); + active.forEach(l => { + const remaining = Math.max(0, Math.round((new Date(l.expiresAt) - Date.now()) / 1000)); + this.terminal.print(` ${l.id} โ ${l.workerId} (${remaining}s remain) โ ${l.task.substring(0, 40)}`, 'info'); + }); + } + revokeLease(id) { + if (!this._leases[id]) return this.terminal.print(`โ Lease ${id} not found`, 'error'); + this._leases[id].status = 'revoked'; + this._leases[id].revokedAt = new Date().toISOString(); + this.terminal.fs.writeFile(`leases/${id}.json`, JSON.stringify(this._leases[id])); + this.terminal.print(`โ
Lease ${id} revoked`, 'warning'); + } + renewLease(id, additionalSeconds) { + if (!this._leases[id] || this._leases[id].status !== 'active') { + return this.terminal.print(`โ Lease ${id} not found or not active`, 'error'); + } + const lease = this._leases[id]; + const newExpiry = new Date(Date.now() + additionalSeconds * 1000); + lease.expiresAt = newExpiry.toISOString(); + lease.duration += additionalSeconds; + lease.renewedAt = new Date().toISOString(); + this.terminal.fs.writeFile(`leases/${id}.json`, JSON.stringify(lease)); + this.terminal.print(`โ
Lease ${id} renewed (+${additionalSeconds}s)`, 'success'); + } + _checkExpired() { + const now = Date.now(); + Object.values(this._leases).forEach(lease => { + if (lease.status === 'active' && new Date(lease.expiresAt) < now) { + lease.status = 'expired'; + lease.expiredAt = new Date().toISOString(); + this.terminal.fs.writeFile(`leases/${lease.id}.json`, JSON.stringify(lease)); + this.terminal.log('INFO', 'Lease', `Lease ${lease.id} for ${lease.workerId} expired`); + } + }); + } +}
\ No newline at end of file diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index 7187fec..2ffdb17 100644 --- a/modules/core/module-loader.js +++ b/modules/core/module-loader.js @@ -30,7 +30,10 @@ 'module-fetcher.js', 'module-skills.js', 'secret-vault.js', - 'module-chatstudio.js' + 'module-chatstudio.js', + 'module-agent-registry.js', + 'module-leases.js', + 'module-orchestrator.js' ]; function getSelectedModules() { @@ -99,7 +102,7 @@ window.__moduleLoaderReload = reloadWithSelection; -const LATEST_VER = '2026-06-02-fontfix3'; +const LATEST_VER = '2026-06-02-webdav2'; const CACHE = '?v=' + LATEST_VER; const stored = localStorage.getItem('webx11_cache_ver'); if (stored !== LATEST_VER) { diff --git a/modules/core/module-orchestrator.js b/modules/core/module-orchestrator.js new file mode 100644 index 0000000..4fab599 --- /dev/null +++ b/modules/core/module-orchestrator.js @@ -0,0 +1,151 @@ +// module-orchestrator.js โ Multi-agent orchestration system +// Commands: orchestrate, orchestrator-status, orchestrator-spawn, orchestrator-kill, orchestrator-purge +class OrchestratorModule { + constructor() { + this.commands = {}; + this.terminal = null; + this._agents = {}; + this._SPAWN_LIMIT = 3; + this._heartbeatInterval = null; + } + registerCommands() { + this.commands['orchestrate'] = (args) => this.delegateTask(args.join(' ')); + this.commands['orchestrator-status'] = () => this.showStatus(); + this.commands['orchestrator-spawn'] = (args) => this.spawnAgent(args[0], args.slice(1)); + this.commands['orchestrator-kill'] = (args) => this.killAgent(args[0]); + this.commands['orchestrator-purge'] = () => this.purgeDeadAgents(); + } + init(terminal) { + this.terminal = terminal; + // Register orchestrator in the agent registry + const registry = this.terminal.moduleManager.modules['AgentRegistryModule']; + if (registry) { + registry.registerAgent(['register', 'orchestrator-0', 'delegate', 'spawn', 'monitor', 'purge']); + } + // Heartbeat watchdog every 3 seconds + this._heartbeatInterval = setInterval(() => this._checkHeartbeats(), 3000); + this.terminal.print('๐ง Orchestrator loaded โ ready to delegate', 'success'); + } + spawnAgent(role, capabilities) { + const id = `agent-${Object.keys(this._agents).length + 1}-${role || 'worker'}`; + if (Object.keys(this._agents).length >= this._SPAWN_LIMIT) { + this.terminal.print(`โ Agent spawn limit (${this._SPAWN_LIMIT}) reached`, 'error'); + return null; + } + const agent = { + id, + role: role || 'worker', + capabilities: capabilities.length ? capabilities : ['list_files', 'read_file', 'web_search'], + createdAt: new Date().toISOString(), + lastHeartbeat: Date.now(), + status: 'idle', + currentTask: null + }; + this._agents[id] = agent; + // Register in agent registry + const registry = this.terminal.moduleManager.modules['AgentRegistryModule']; + if (registry) registry.registerAgent(['register', id, ...agent.capabilities]); + this.terminal.print(`๐ค Agent '${id}' spawned (role: ${role || 'worker'}, ${agent.capabilities.length} caps)`, 'success'); + return agent; + } + killAgent(id) { + if (!this._agents[id]) return this.terminal.print(`โ Agent '${id}' not found`, 'error'); + // Revoke any active lease + const leaseModule = this.terminal.moduleManager.modules['LeaseModule']; + if (leaseModule) { + Object.values(leaseModule._leases).forEach(l => { + if (l.workerId === id && l.status === 'active') leaseModule.revokeLease(l.id); + }); + } + // Unregister from registry + const registry = this.terminal.moduleManager.modules['AgentRegistryModule']; + if (registry) registry.unregisterAgent(id); + delete this._agents[id]; + this.terminal.print(`๐ Agent '${id}' killed`, 'warning'); + } + async delegateTask(task) { + if (!task) return this.terminal.print('Usage: orchestrate <task description>', 'error'); + // Find an idle agent or auto-spawn + let agent = Object.values(this._agents).find(a => a.status === 'idle'); + if (!agent) { + if (Object.keys(this._agents).length >= this._SPAWN_LIMIT) { + return this.terminal.print('โ All agents busy and spawn limit reached', 'error'); + } + this.spawnAgent('worker', ['list_files', 'read_file', 'write_file', 'web_search']); + agent = Object.values(this._agents).find(a => a.status === 'idle'); + } + if (!agent) return this.terminal.print('โ No available agents', 'error'); + // Create a lease + const leaseModule = this.terminal.moduleManager.modules['LeaseModule']; + const lease = leaseModule ? leaseModule.createLease(['lease-create', agent.id, '60', task]) : null; + // Mark agent as busy + agent.status = 'busy'; + agent.currentTask = task; + agent.leaseId = lease?.id; + // Write task to agent's inbox + if (!this.terminal.fs.readFile(`/agents/${agent.id}/`)) this.terminal.fs.mkdir(`agents/${agent.id}`); + this.terminal.fs.writeFile(`agents/${agent.id}/inbox.json`, JSON.stringify({ + task, + from: 'orchestrator-0', + leaseId: lease?.id, + timestamp: new Date().toISOString(), + ttl_seconds: 60 + })); + this.terminal.print(`๐ฏ Delegated "${task.substring(0, 60)}" to ${agent.id} (lease: ${lease?.id || 'none'})`, 'info'); + // Execute via AI assistant + try { + const aiModule = this.terminal.moduleManager.modules['WebX11AIAssistantModule']; + if (aiModule && typeof aiModule.chat === 'function') { + const result = await aiModule.chat([`[DELEGATED] ${task}`]); + agent.status = 'completed'; + this.terminal.fs.writeFile(`agents/${agent.id}/outbox.json`, JSON.stringify({ + result: result || 'completed', + status: 'completed', + timestamp: new Date().toISOString() + })); + this.terminal.print(`โ
${agent.id} completed task`, 'success'); + } else { + this.terminal.print(`โน๏ธ AI module not available โ task queued for ${agent.id}`, 'info'); + } + } catch (e) { + agent.status = 'failed'; + this.terminal.print(`โ ${agent.id} failed: ${e.message}`, 'error'); + } + // Return to idle pool + agent.status = 'idle'; + agent.currentTask = null; + agent.lastHeartbeat = Date.now(); + } + _checkHeartbeats() { + const now = Date.now(); + Object.entries(this._agents).forEach(([id, agent]) => { + if (agent.status === 'busy' && now - agent.lastHeartbeat > 15000) { + this.terminal.print(`โ ๏ธ Agent '${id}' unresponsive (${Math.round((now - agent.lastHeartbeat) / 1000)}s since heartbeat)`, 'warning'); + } + }); + } + purgeDeadAgents() { + const now = Date.now(); + let count = 0; + Object.entries(this._agents).forEach(([id, agent]) => { + if (now - agent.lastHeartbeat > 60000) { + this.killAgent(id); + count++; + } + }); + this.terminal.print(`๐งน Purged ${count} dead agents`, count > 0 ? 'warning' : 'info'); + } + showStatus() { + const total = Object.keys(this._agents).length; + const busy = Object.values(this._agents).filter(a => a.status === 'busy').length; + const idle = Object.values(this._agents).filter(a => a.status === 'idle').length; + const failed = Object.values(this._agents).filter(a => a.status === 'failed').length; + this.terminal.print('๐ง Orchestrator Status', 'header'); + this.terminal.print(` Agents: ${total} (${busy} busy, ${idle} idle, ${failed} failed)`, 'info'); + this.terminal.print(` Spawn limit: ${this._SPAWN_LIMIT}`, 'info'); + Object.values(this._agents).forEach(a => { + const task = a.currentTask ? ` (task: ${a.currentTask.substring(0, 40)})` : ''; + this.terminal.print(` ${a.id} โ ${a.status}${task}`, 'info'); + }); + } +}
\ No newline at end of file diff --git a/modules/core/module-system.js b/modules/core/module-system.js index e54a5fe..c0dbe14 100644 --- a/modules/core/module-system.js +++ b/modules/core/module-system.js @@ -36,6 +36,13 @@ this.terminal.print(`Memory Usage: ${mem} MB`, 'info'); document.getElementById('header-status').innerText = `Mem: ${mem}MB`; }; + // Refresh memory display on boot + setTimeout(() => { + if (performance.memory) { + const mem = (performance.memory.usedJSHeapSize / 1048576).toFixed(1); + document.getElementById('header-status').innerText = `Mem: ${mem}MB`; + } + }, 500); this.commands['status'] = () => { this.terminal.print(`System: WebX11 v4.2`); this.terminal.print(`Network: ${navigator.onLine ? 'Online' : 'Offline'}`); @@ -223,12 +230,12 @@ } // Categorize all commands const cats = { - files: ['ls','ll','cat','put','rm','mv','cp','mkdir','rmdir','cd','pwd','find','grep','head','tail','sort','wc','touch','tree','du','upload','download','vfs-export','vfs-import','edit','diff'], - ai: ['ai','ask','ai-clear','ai-system','ai-context','ai-allow','ai-deny','ai-ignore','ai-stop','ai-model'], + files: ['ls','ll','cat','put','rm','mv','cp','mkdir','rmdir','cd','pwd','find','grep','head','tail','sort','wc','touch','tree','du','upload','download','vfs-export','vfs-import','edit','diff','trash','trash-dir','ls-trash','empty-trash'], + ai: ['ai','ask','ai-clear','ai-system','ai-context','ai-allow','ai-deny','ai-ignore','ai-stop','ai-model','ai-compress','debug'], search: ['search','s','ddg','duck','fetch','web','extract'], tools: ['calc','cron-every','cron-list','cron-stop','cron-clear','wpm','mindmap','theme','voice','alias','unalias', - 'which','env','date','cal','yes','seq','base64','printf','cut','uniq','hostname','whoami','basename','dirname','sleep','expand','tutorial'], - system: ['help','clear','cls','echo','mem','status','font-size','setfont','smart-prompt','logs','preview','factory-reset','reset-vfs','export','unset','script','set','test'], + 'which','env','date','cal','yes','seq','base64','printf','cut','uniq','hostname','whoami','basename','dirname','sleep','expand','tutorial','gzip','gunzip','encrypt','decrypt','files','session-save','session-load','session-list','session-delete','session-export','session-import'], + system: ['help','clear','cls','echo','mem','status','font-size','setfont','smart-prompt','logs','preview','factory-reset','reset-vfs','export','unset','script','set','test','logrotate','health-check','memory-map-export','commands','check','checkpoint','checkpoint-list','rollback','checkpoint-del'], net: ['server','servers','server-check','server-remove','monitor'], mods: ['modules','listmods','loadmod','removemod','load-local','loadlocal','startup-modules'], ollama: ['ollama','ollamaconnect','ollamadisconnect','ollamamodels','ollamasetmodel','ollamasetproxy','models','model-info','model-pull','model-delete','model-run'], @@ -236,14 +243,15 @@ config: ['config','config-set','config-list','config-delete','config-export','config-reset'], webx: ['webxai','wx','webx','context','contexts','context-new','context-delete','smart-ask','prompt','prompt-save','prompt-list','prompt-delete','prompt-var','prompt-vars'], data: ['my','my-set','my-get','my-list','my-export','my-import','my-clear','export-mods','import-mods','backup','restore','share-mod'], - ext: ['chat','chat-setapi','chat-setmodel','chat-ask','webdav-config','webdav-status','webdav-ls','webdav-push','webdav-pull','webdav-backup','webdav-restore','webdav-backup-list'], + ext: ['chat','chat-setapi','chat-setmodel','chat-ask','webdav-config','webdav-status','webdav-ls','webdav-push','webdav-pull','webdav-backup','webdav-restore','webdav-backup-list','webdav-explorer','webdav-browser'], gui: ['gui','gui-button','gui-input','gui-text','gui-clear','gui-run'], + vault: ['secret-set','secret-get','secret-list','secret-del','secret-lock','secret-unlock','secret-setpin','secret-status'], }; const labels = { files:'๐ Filesystem', ai:'๐ค AI', search:'๐ Search & Web', tools:'๐ง Tools', system:'โ๏ธ System', net:'๐ก Network', mods:'๐งฉ Modules', ollama:'๐ฆ Ollama', skills:'๐ Skills', config:'โ๏ธ Config', webx:'๐ WebX', data:'๐พ Data', - ext:'๐งฐ Extras', gui:'๐ฅ๏ธ GUI' + ext:'๐งฐ Extras', gui:'๐ฅ๏ธ GUI', vault:'๐ Vault' }; let result = 'โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\nโ ๐ท WebX11 v4.2 โ All Commands โ\nโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n\n'; const total = Object.keys(helpMap).length; diff --git a/modules/core/terminal.js b/modules/core/terminal.js index f0ef036..4111f5f 100644 --- a/modules/core/terminal.js +++ b/modules/core/terminal.js @@ -868,10 +868,10 @@ const lowerInput = input.toLowerCase(); // Expanded command lists for autocomplete - const fileArgCommands = ['cat', 'preview', 'rm', 'rmdir', 'edit', 'view', 'cp', 'mv', 'touch', 'put', 'run', 'chmod', 'gzip', 'gunzip', 'encrypt', 'decrypt', 'webdav-backup', 'webdav-restore', 'webdav-upload', 'webdav-download', 'webdav-list']; + const fileArgCommands = ['cat', 'preview', 'rm', 'rmdir', 'edit', 'view', 'cp', 'mv', 'touch', 'put', 'run', 'chmod', 'gzip', 'gunzip', 'encrypt', 'decrypt', 'download', 'webdav-backup', 'webdav-restore', 'webdav-upload', 'webdav-download', 'webdav-list']; const dirArgCommands = ['ls', 'cd', 'mkdir', 'rmdir']; const sessionArgCommands = ['session-load', 'session-delete', 'session-export', 'session-save', 'session-list', 'session-import']; - const pathArgCommands = ['cat', 'preview', 'rm', 'edit', 'view', 'cp', 'mv', 'touch', 'put', 'run', 'chmod', 'gzip', 'gunzip', 'encrypt', 'decrypt', 'webdav-backup', 'webdav-restore', 'webdav-upload', 'webdav-download', 'webdav-list', 'ls', 'cd', 'mkdir', 'rmdir', 'cat', 'preview']; + const pathArgCommands = ['cat', 'preview', 'rm', 'edit', 'view', 'cp', 'mv', 'touch', 'put', 'run', 'chmod', 'gzip', 'gunzip', 'encrypt', 'decrypt', 'download', 'webdav-backup', 'webdav-restore', 'webdav-upload', 'webdav-download', 'webdav-list', 'ls', 'cd', 'mkdir', 'rmdir', 'cat', 'preview']; // Collect commands that take arguments from ext module metadata const extFileCmds = new Set(); @@ -1024,6 +1024,9 @@ this._safeRegister('SecretVault', 'secret vault'); this.reloadPersistedModules(); this._safeRegister('ChatStudioModule', 'chat studio'); + this._safeRegister('AgentRegistryModule', 'agent registry'); + this._safeRegister('LeaseModule', 'lease system'); + this._safeRegister('OrchestratorModule', 'orchestrator'); } _safeRegister(className, label) { let cls; diff --git a/modules/ext/skill-pack-core.js b/modules/ext/skill-pack-core.js index cc8ae88..f731594 100644 --- a/modules/ext/skill-pack-core.js +++ b/modules/ext/skill-pack-core.js @@ -20,37 +20,51 @@ class SkillPackCoreModule { getHelpInfo() { return { - 'skill-pack-core': 'Seed and manage built-in core skills (navigation, memory, search, modules, backup, mindmap, etc.)', + 'skill-pack-core': 'Seed and manage built-in core skills (navigation, memory, search, modules, backup, agents, webdav, security, etc.)', }; } _buildSkills() { const s = (name, desc, tags, cat, steps) => ({ name, description: desc, tags, category: cat, - content: `---\nname: ${name}\ndescription: ${desc}\ntags: [${tags.join(', ')}]\ncategory: ${cat}\n---\n## Steps\n${steps}` + content: '---\nname: ' + name + '\ndescription: ' + desc + '\ntags: [' + tags.join(', ') + ']\ncategory: ' + cat + '\n---\n## Steps\n' + steps + }); + + const sk = (name, desc, tags, cat, content) => ({ + name, description: desc, tags, category: cat, + content: '---\nname: ' + name + '\ndescription: ' + desc + '\ntags: [' + tags.join(', ') + ']\ncategory: ' + cat + '\n---\n' + content }); return [ s('navigating-the-terminal', 'How to explore the WebX11 terminal, find commands, check status, and navigate the filesystem', ['terminal', 'navigation', 'help', 'filesystem'], 'system', `1. **Get an overview of available commands:** - - Call get_help() to get a full command list sorted by category - - Call get_status() to see filesystem state, loaded modules, Ollama status - - For a specific command, use help <command> or check the help map + - Type "help" for a full command list sorted by category + - Type "status" to see filesystem state, loaded modules, Ollama status + - For a specific command, use help <command> + - Type "commands" for a compact list with module source info + - Type "check" for system health including module requirements 2. **Explore the filesystem:** - - Use list_files(path?) to browse directories (omit path for current dir) - - Use read_file(path) to view file contents - - Use get_current_dir to see your working directory - - Use run_command("ls") or run_command("tree") for visual output + - Use ls, ll, tree to browse directories + - Use cd <dir> to navigate, pwd to show current path + - Use cat or edit to view file contents + - Use find or grep to search files + - Use du to see disk usage 3. **Understand system state:** - - run_command("mem") โ memory usage - - run_command("status") โ network, files, modules, Ollama - - run_command("modules") โ module manager GUI - - get_module_status() โ loaded modules, URLs, local edits - -4. **When user asks a vague question:** - - First call get_status() and get_help() to understand the current state + - mem โ memory usage (auto-shown in header on boot) + - status โ network, files, modules, Ollama + - modules โ module manager GUI + - health-check โ VFS size, modules, logs, trash, skills, tool failures + - checkpoint-list โ VFS checkpoint snapshots + +4. **Agent / Orchestration state:** + - agent-list โ registered agents (type, capabilities, status) + - orchestrate โ list active orchestrated agents + - lease-list โ show active leases with TTL + +5. **When user asks a vague question:** + - First run status and help to understand the current state - Then ask clarifying questions based on what you found`), s('memory-and-profile', 'How to use the user profile system, session memory, and pattern tracking for persistent context', ['memory', 'profile', 'session', 'persistence'], 'system', @@ -67,26 +81,36 @@ class SkillPackCoreModule { - session_recent(count) โ show last N session summaries - session_stats โ aggregate totals across all sessions - Recent sessions auto-load into context on first message + - systemd-style journal: use logs and logrotate commands for persistent logging 3. **Conversation Patterns:** - Pattern tracking runs automatically โ frequent topics surface as hints - my-set, my-get, my-list store additional personal data - AI conversation context persists within a session (use ai-clear to reset) + - debug command toggles real-time debug panel with Copy/Export/VFS buttons -4. **When to use:** +4. **Checkpoints (VFS snapshots):** + - checkpoint [name] โ snapshot current VFS state + - checkpoint-list โ list all checkpoints + - rollback <name> โ restore from checkpoint + - checkpoint-del <name> โ delete a checkpoint + +5. **When to use:** - At start: call session_recent(3) to see what was worked on before - After identifying user preferences: call user_set to store them - - After complex tasks: call session_save with a brief summary`), + - After complex tasks: call session_save with a brief summary + - Before risky edits: call checkpoint to create a restore point`), s('web-search-and-fetch', 'How to search the web and fetch URL content using the WebX11 proxy API', ['search', 'fetch', 'web', 'proxy', 'api'], 'research', `1. **Search the web:** - - Call web_search(query) โ uses /api/search which queries SearXNG + DDG fallback + - Use search <query> or web_search(query) โ uses /api/search which queries SearXNG + DDG fallback - Results include: title, url, content snippet, source engine - Always available (same-origin, no CORS issues) - 5-minute server-side cache for repeated queries + - Alternative: use ddg or duck <query> for DuckDuckGo direct, or s <query> as alias 2. **Fetch URL content:** - - Call web_fetch(url) โ uses /api/extract which strips HTML, extracts clean text + - Use fetch <url> or web_fetch(url) โ uses /api/extract which strips HTML, extracts clean text - Returns: title, content (up to 4000 chars), word_count, extraction method - SSRF protection blocks internal network addresses - 15-minute server-side cache @@ -96,19 +120,21 @@ class SkillPackCoreModule { - search <query> --fetch โ fetches content snippets for top 3 results 4. **When to use:** - - User asks about current events, facts, research โ call web_search - - User says "read this article" or provides a URL โ call web_fetch + - User asks about current events, facts, research โ use search <query> + - User says "read this article" or provides a URL โ use fetch <url> - For in-depth analysis: search first, then fetch top results`), s('module-management', 'How to view, edit, test, reload modules and manage startup configuration', ['modules', 'editing', 'code', 'self-improvement'], 'dev', `1. **View module source:** - view_module(name) โ fetches original source or reads from _mods/ if edited - Supports all loaded modules: SystemModule, FileSystemModule, SearXNGModule, etc. + - Or use modules command for GUI module manager 2. **Edit a module:** - edit_module(name) โ copies source to _mods/<name>.js and opens code editor - Editor shows the full source in a textarea - Click "Save & Test" to save and auto-load the edited version + - Checkpoint before editing: checkpoint mod-edit-<name> 3. **Test and reload:** - test_module(name) โ loads edited module from _mods/ for live testing @@ -121,8 +147,15 @@ class SkillPackCoreModule { 5. **Manage startup:** - startup-modules โ opens the Startup Modules UI to select which modules load - load-local <path> โ load a module from VFS or URL without editing + - loadmod <url> โ alternative: load module from URL + - removemod <name> โ remove a loaded module -6. **When to use:** +6. **Agent modules (Phase 2):** + - module-agent-registry.js โ Agent registry with agent-list, agent-register, agent-unregister, agent-status + - module-leases.js โ Time-bound contracts with 5s watchdog, auto-expiry + - module-orchestrator.js โ Spawn, delegate, kill, purge with heartbeat monitoring + +7. **When to use:** - User reports a bug in a module โ view_module, edit_module, test_module - User asks "can you add X feature?" โ same workflow - Always save edits before testing. If test fails, the original module stays loaded.`), @@ -142,42 +175,68 @@ class SkillPackCoreModule { - config-export โ dump all config as JSON - config-reset โ restore defaults (use before factory-reset if you want to save data) -4. **Health checks (run automatically after complex tasks):** +4. **Security level:** + - security-level <0-3> โ set security level (0=Permissive, 1=Standard, 2=Paranoid, 3=Audit) + - security โ show current security level, blocked count, and stats + - cmd-allow <cmd> โ whitelist a command from security checks + - cmd-deny <cmd> โ blacklist a command + +5. **Health checks (run automatically after complex tasks):** - Check module loading status: loaded vs total - Check for pending module edits - Check memory usage (high if >100MB) - Check skill count (0 skills after 2+ tool calls โ suggest create_skill) + - health-check โ full audit: VFS size, modules, logs, trash, skills, tool failures -5. **Font size and display:** +6. **Font size and display:** - font-size <8-32> โ set terminal font size, persists across sessions - setfont โ alias for font-size -6. **When to use:** - - System is slow or modules fail to load โ check check_updates, memory +7. **Checkpoints:** + - checkpoint [name] โ snapshot current VFS state + - checkpoint-list โ list all checkpoints + - rollback <name> โ restore from checkpoint + +8. **When to use:** + - System is slow or modules fail to load โ check check_updates, mem - User says "reset everything" โ factory-reset --confirm - - User says "clear my files but keep settings" โ reset-vfs --confirm`), + - User says "clear my files but keep settings" โ reset-vfs --confirm + - User reports security concerns โ security-level or cmd-allow/cmd-deny`), s('backup-and-restore', 'How to backup, restore, and export WebX11 data', ['backup', 'restore', 'export', 'import', 'persistence'], 'system', `1. **Backup VFS:** - vfs-export โ downloads the entire virtual filesystem as a dated JSON file - backup โ creates a full backup including VFS, modules, config (saves to _backups/) -2. **Export/Import personal data:** +2. **WebDAV cloud backup:** + - webdav-backup โ push VFS snapshot to WebDAV server (auto-archive) + - webdav-backup-list โ list remote WebDAV backups + - webdav-restore <file> โ restore VFS from remote WebDAV snapshot + - webdav-push <local> <remote> โ push file to WebDAV space + - webdav-pull <remote> <local> โ pull file from WebDAV space + - webdav-ls [path] โ list remote WebDAV directory contents + - webdav-status โ check WebDAV connection and space usage + - webdav-explorer โ GUI file browser for WebDAV + - webdav-browser โ simpler file listing for WebDAV + - webdav-config โ configure WebDAV endpoint and credentials + +3. **Export/Import personal data:** - my-export โ prints all my-set data as formatted JSON - my-import <json> โ bulk-import personal data from JSON string -3. **Export/Import modules:** +4. **Export/Import modules:** - export-mods โ exports module registry as JSON - import-mods <json> โ imports modules from JSON backup -4. **Restore:** +5. **Restore:** - restore <json> โ restores terminal state from a backup JSON - Session logs are saved to /memory/ automatically on page unload -5. **When to use:** +6. **When to use:** - Before factory-reset: call my-export, vfs-export, export-mods to save data - When switching browsers or devices: export all, then import - - User says "backup my data" or "save my files"`), + - User says "backup my data" or "save my files" + - For cloud backup: use webdav-backup (requires WebDAV server configured)`), s('mindmap-workflow', 'How to create, view, edit, and manage brainstorming mindmaps', ['mindmap', 'brainstorming', 'planning', 'organization'], 'productivity', `1. **Create a new mindmap:** @@ -215,18 +274,19 @@ class SkillPackCoreModule { s('skills-system-guide', 'How to use the skills system: load, create, patch, list, and audit skills for persistent procedural memory', ['skills', 'system', 'meta'], 'system', `1. **Always load skills at task start:** - - At the START of every task, call load_skills(query) to check for stored procedures - - Example: load_skills("search the web") returns matching skill content - - Example: load_skills("edit module") returns the module editing workflow + - At the START of every task, type or call "skill-view <name>" or "skill-list" + - Example: skill-view "module-management" โ returns matching skill content + - Example: skill-list system โ browse skills by category 2. **Create skills after complex tasks:** - - After any task with 3+ tool calls, call create_skill to save the approach + - After any task with 3+ tool calls, call "skill-create" - Required fields: name (kebab-case), description, content (markdown with YAML frontmatter) - Optional: tags (comma-separated), category (e.g. research, system, dev) - Skills persist in /skills/ as .md files with /skills/index.json for fast lookup + - Skill pack auto-seeds 8 core skills on first boot 3. **Patch skills when they're outdated:** - - If a stored procedure has wrong steps, call patch_skill(name, newContent) + - If a stored procedure has wrong steps, call "skill-patch <name>" - This replaces the entire skill content and updates the index - Do NOT create a duplicate โ always patch the existing skill @@ -235,13 +295,139 @@ class SkillPackCoreModule { - Named tool-<name>-workaround (e.g. tool-write_file-workaround) 5. **Audit and maintenance:** - - list_skills(category?) โ browse available skills, optionally filter + - skill-list <category?> โ browse available skills, optionally filter + - skill-export <name> โ download a skill as markdown + - skill-import <json> โ bulk import skills + - skill-sync โ re-index skills from /skills/ directory - Skills are audited every 20 messages for staleness (empty content, >30 days old) 6. **YAML frontmatter format:** - Each skill file must start with: ---\\nname: <name>\\ndescription: <text>\\ntags: [...]\\ncategory: <cat>\\n---\\n - Steps follow under ## Steps heading - Use numbered lists for sequential steps`), + + sk('agent-orchestration', 'How to use the agent registry, leases, and orchestrator for multi-agent workflows', ['agents', 'orchestration', 'delegation', 'leases'], 'system', +`## Steps +1. **Agent Registry:** + - agent-list โ list all registered agents (ID, type, capabilities, status) + - agent-register <type> <capabilities> โ register a new agent (capabilities: comma-separated like "code,research") + - agent-unregister <id> โ remove an agent + - agent-status <id> โ detailed status including uptime, task history + +2. **Leases (time-bound contracts):** + - lease-create <agent-id> <ttl-seconds> โ create a lease with watchdog timer + - lease-list โ show active leases with remaining TTL + - lease-revoke <lease-id> โ terminate a lease early + - lease-renew <lease-id> <ttl-seconds> โ extend a lease + +3. **Orchestrator (spawn, delegate, kill):** + - orchestrator-spawn <type> <capabilities> โ spawn a new agent automatically (registers + creates lease) + - orchestrator-kill <agent-id> โ kill an agent (revokes lease + unregisters) + - orchestrate โ list all active agents with their orchestrator state + - orchestrator-purge โ kill all idle/expired agents + - orchestrator-status โ full system status: active, idle, dead agents + +4. **AI tool integration:** + - AI tools list_agents, delegate_task, spawn_agent are available in the AI system prompt + - The AI can auto-spawn worker agents for sub-tasks + - Each agent gets its own VFS workspace at /agents/<agent-id>/ + +5. **When to use:** + - User needs parallel task execution โ orchestrator-spawn coder + researcher + - Time-bound automation โ lease-create with short TTL + - System cleanup โ orchestrator-purge to kill stale agents`), + + sk('debug-panel-and-logs', 'How to use the debug panel, session logging, and terminal diagnostics', ['debug', 'logging', 'diagnostics', 'troubleshooting'], 'system', +`## Steps +1. **Toggle debug panel:** + - debug โ opens real-time debug panel showing last AI tool call, response time, and context + - Debug panel includes Copy, Export, and Write to VFS buttons + - Export: saves tool call log as timestamped JSON to downloads + - Write to VFS: appends debug data to /debug.log (dedup by turn ID) + +2. **Session logging:** + - logs [n] [LEVEL] โ view last n log entries (optionally filtered by INFO/WARN/ERROR) + - logrotate โ rotate current.log to dated archive (auto-cleans old logs beyond 10) + - Logs stored in /logs/ directory: current.log + dated archives + - Health check runs automatically after complex tasks + +3. **Diagnostic commands:** + - health-check โ full audit: VFS size, modules, logs, trash, skills + - check โ quick system health including module requirement status + - mem โ real-time memory usage (also in header) + - status โ network, files, modules, Ollama connectivity + +4. **System journals:** + - debug.log โ appended by debug panel VFS button (dedup by turn ID) + - current.log โ system activity log with rotation + - Auto-save: terminal state auto-saves every 60s + +5. **When to use:** + - AI is behaving unexpectedly โ debug to see tool calls + - User reports errors โ logs 50 ERROR to see error entries + - Memory concerns โ mem to check, health-check for full audit`), + + sk('webdav-operations', 'How to use WebDAV cloud storage for backup, file sync, and remote file access', ['webdav', 'cloud', 'backup', 'sync', 'storage'], 'system', +`## Steps +1. **Configuration:** + - webdav-config โ configure WebDAV endpoint URL and credentials + - Connection auto-tested on config; webdav-status shows connection health + - Uses per-user token stored in .token file on the server side + +2. **File operations:** + - webdav-ls [path] โ list remote directory contents + - webdav-push <local-path> <remote-path> โ upload file to WebDAV + - webdav-pull <remote-path> [local-path] โ download file from WebDAV + - webdav-explorer โ GUI file browser for WebDAV + - webdav-browser โ CLI file browser for WebDAV + +3. **Backup & Restore:** + - webdav-backup โ push full VFS snapshot to WebDAV (creates /backups/newos4-<date>.json) + - webdav-backup-list โ list all remote backup snapshots + - webdav-restore <filename> โ restore VFS from a remote backup snapshot + +4. **Status & Diagnostics:** + - webdav-status โ connection status, last sync, space information + - If uploads fail: check webdav-config, ensure .token file is valid + - WebDAV runs on the same server (nginx with WebDAV module), no external dependency + +5. **When to use:** + - Before factory-reset: webdav-backup to store VFS remotely + - Cross-browser: webdav-push/pull to move files between instances + - User says "cloud backup" or "remote save" โ webdav-backup`), + + sk('security-and-safety', 'How to use WebX11 security levels, command blocking, and safety features', ['security', 'safety', 'blocking', 'permissions'], 'system', +`## Steps +1. **Security levels:** + - security-level <0-3> โ set security level: + 0 = Permissive (no blocks, no confirms โ anything runs) + 1 = Standard (unknown cmds โ AI route, destructive โ confirm, system โ block) + 2 = Paranoid (unknown cmds blocked entirely, destructive blocked) + 3 = Audit (everything allowed, everything logged) + - Default: level 1 + - security โ show current level, blocked count, and stats + +2. **Command control:** + - cmd-allow <cmd> โ whitelist a command from security checks + - cmd-deny <cmd> โ blacklist/block a command entirely + - whitelist built-in: help, clear, ls, cat, pwd, whoami, df, free, top, ps, echo, date, env, history + +3. **What gets blocked at each level:** + - Unknown commands: Level 1 โ AI routed, Level 2+ โ blocked + - Destructive commands (delete, write, save, format, etc.): Level 1 โ confirmation, Level 2+ โ blocked + - System commands (reboot, shutdown, format, kill): Level 1+ โ blocked + - Suspicious flags (--execute, --eval, --shell): Level 1+ โ blocked + +4. **Safety checklist:** + - Check security: security shows your current level + - Before running risky commands: create a checkpoint first + - For temporary relax: security-level 0, then restore to 1 + - For total lockdown before leaving: security-level 2 + +5. **When to use:** + - User wants to lock down the terminal โ security-level 2 + - User wants to allow a blocked command โ cmd-allow <cmd> + - Debugging strange behavior โ verify security-level (user may have set it higher)`) ]; } registerCommands() { @@ -264,8 +450,9 @@ class SkillPackCoreModule { console.log('[SkillPackCore] existing index keys:', Object.keys(index)); let seeded = 0; for (const skill of this._skills) { - if (!index[skill.name]) { - fs.writeFile(`/skills/${skill.name}.md`, skill.content); + const existing = index[skill.name]; + if (!existing) { + fs.writeFile('/skills/' + skill.name + '.md', skill.content); index[skill.name] = { name: skill.name, description: skill.description, @@ -275,14 +462,25 @@ class SkillPackCoreModule { updated: new Date().toISOString() }; seeded++; + } else { + // Update existing skill content on fresh load (cache-bust version bump triggers this) + fs.writeFile('/skills/' + skill.name + '.md', skill.content); + index[skill.name] = { + ...existing, + description: skill.description, + tags: skill.tags, + category: skill.category, + updated: new Date().toISOString() + }; } } if (seeded > 0) { - fs.fsWriteJSON('/skills/index.json', index); + this.terminal.print('๐ Seeded ' + seeded + ' new skill(s)', 'success'); } + fs.fsWriteJSON('/skills/index.json', index); this._seeded = true; } } if (typeof window.webx11 !== 'undefined' && window.webx11) { webx11.moduleManager.registerModule(new SkillPackCoreModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/skill-pack-core.js'); -} +}
\ No newline at end of file diff --git a/modules/ext/webdav.js b/modules/ext/webdav.js index b4dfa6f..eea513b 100644 --- a/modules/ext/webdav.js +++ b/modules/ext/webdav.js @@ -46,8 +46,11 @@ class WebDAVModule { 'webdav-backup': 'Backup entire VFS to WebDAV as a dated JSON snapshot', 'webdav-restore': 'Restore VFS from a WebDAV backup file', 'webdav-backup-list': 'List available backup files on WebDAV', - 'webdav-explorer': 'webdav-explorer [path] โ Interactive remote file browser', - 'webdav-browser': 'webdav-browser [path] โ Visual WebDAV file explorer modal', + 'webdav-browser': 'webdav-browser [path] โ Modern WebDAV file-tree browser with bulk operations', + 'webdav-rm': 'webdav-rm <path> โ Delete a remote file or directory', + 'webdav-mkdir': 'webdav-mkdir <path> โ Create a remote directory', + 'webdav-rename': 'webdav-rename <old> <new> โ Rename or move a remote file/dir', + 'webdav-move': 'webdav-move <old> <new> โ Alias for webdav-rename', }; } @@ -127,7 +130,11 @@ class WebDAVModule { } async _listDir(path) { - // Try PROPFIND first (works with WebDAV servers), fallback to nginx autoindex + // For WebX11 spaces, skip PROPFIND (nginx returns 302) and use __list__ directly + if (this._spaceName) { + return this._listDirViaAutoIndex(path); + } + // Non-nginx: try PROPFIND first, fallback to autoindex try { const response = await this._webdavRequest('PROPFIND', path, { headers: { 'Depth': '1' } @@ -137,15 +144,24 @@ class WebDAVModule { return this._parsePropfind(text); } } catch (e) { /* PROPFIND failed, try autoindex */ } - // Fallback: use nginx __list__ endpoint return this._listDirViaAutoIndex(path); } async _listDirViaAutoIndex(path) { - const baseUrl = this.config.url.replace(/\/+$/, '') + '/'; - const urlPath = path ? (path.startsWith('/') ? path : '/' + path) : '/'; - // Use __list__ for directory listing - const listUrl = baseUrl.replace(/\/spaces\/([^/]+)\//, '/spaces/$1/__list__/') + urlPath.replace(/^\//, ''); + if (!this._spaceName) { + throw new Error('WebDAV: cannot use __list__ without space name'); + } + // Derive host from configured URL to support different environments + let host = 'https://webx11.duckdns.org'; + if (this.config && this.config.url) { + try { + const u = new URL(this.config.url); + host = u.origin; + } catch (e) {} + } + const cleanPath = path ? (path.startsWith('/') ? path : '/' + path) : '/'; + // Avoid double slash when path starts with / + const listUrl = host + '/spaces/' + this._spaceName + '/__list__' + (cleanPath === '/' ? '' : cleanPath.replace(/\/{2,}/g, '/')); try { const resp = await fetch(listUrl, { method: 'GET' }); if (!resp.ok) throw new Error('Autoindex failed: HTTP ' + resp.status); @@ -158,15 +174,14 @@ class WebDAVModule { _parseAutoindex(html) { const entries = []; - // Parse nginx autoindex HTML format - const linkRegex = /<a href="([^"]+)">([^<]+)<\/a>\s+(\d{2}-[A-Za-z]{3}-\d{4}\s+\d{2}:\d{2})\s+([\d\w.-]+)/g; + // Parse nginx autoindex HTML format โ flexible regex that doesn't depend on date locale + const linkRegex = /<a href="([^"]+)">([^<]+)<\/a>\s+([\w]{2}-[\w]{3}-\d{4}\s+\d{2}:\d{2})\s+([\d\w.-]+)/g; let match; while ((match = linkRegex.exec(html)) !== null) { const href = match[1]; const name = match[2].replace(/\/$/, ''); const sizeStr = match[4]; - if (name === '..' || name === '../') continue; - if (name === '__list__') continue; + if (name === '..' || name === '../' || name === '__list__') continue; const isDir = href.endsWith('/'); let size = 0; if (!isDir && sizeStr !== '-') { @@ -181,22 +196,40 @@ class WebDAVModule { } // Fallback: if regex failed, try simpler parse if (entries.length === 0) { - const simpleRegex = /<a href="([^"]+)">/g; + // Try broadest: extract all hrefs + const broadRegex = /<a href="([^"]+)"[^>]*>/g; const names = []; - while ((match = simpleRegex.exec(html)) !== null) { + while ((match = broadRegex.exec(html)) !== null) { const href = match[1]; if (href === '../' || href === '/' || href === './') continue; if (href.startsWith('?')) continue; names.push(href); } - for (const href of names) { - const isDir = href.endsWith('/'); - entries.push({ - name: decodeURIComponent(href.replace(/\/$/, '').split('/').pop() || href), - type: isDir ? 'dir' : 'file', - size: 0, - href: href - }); + if (names.length > 0) { + for (const href of names) { + const isDir = href.endsWith('/'); + entries.push({ + name: decodeURIComponent(href.replace(/\/$/, '').split('/').pop() || href), + type: isDir ? 'dir' : 'file', + size: 0, + href: href + }); + } + } else { + // Last resort: match any <a href="..."> + const lastRegex = /<a href="([^"]+)"/g; + while ((match = lastRegex.exec(html)) !== null) { + const href = match[1]; + if (href === '../' || href === '/' || href === './') continue; + if (href.startsWith('?')) continue; + const isDir = href.endsWith('/'); + entries.push({ + name: decodeURIComponent(href.replace(/\/$/, '').split('/').pop() || href), + type: isDir ? 'dir' : 'file', + size: 0, + href: href + }); + } } } return entries; @@ -529,16 +562,27 @@ class WebDAVModule { try { await self._ensureDir('backups'); // Build structured export: root + currentPath + aliases + moduleRegistry - const exportData = { - type: 'webx11-backup', - version: 2, - savedAt: new Date().toISOString(), - root: self.terminal.fs.root, - currentPath: self.terminal.fs.currentPath, - aliases: self.terminal.moduleManager.aliases || {}, - moduleRegistry: self.terminal.moduleManager.moduleRegistry || [] - }; + let exportData; + try { + exportData = { + type: 'webx11-backup', + version: 2, + savedAt: new Date().toISOString(), + root: self.terminal.fs.root, + currentPath: self.terminal.fs.currentPath, + aliases: self.terminal.moduleManager.aliases || {}, + moduleRegistry: self.terminal.moduleManager.moduleRegistry || [] + }; + } catch (e) { + self.terminal.print('Failed to read VFS: ' + e.message, 'error'); + return; + } const serialized = JSON.stringify(exportData, null, 2); + // Size check: abort if >5MB + if (serialized.length > 5 * 1024 * 1024) { + self.terminal.print('Backup too large: ' + self._formatSize(serialized.length) + ' (>5MB limit). Try reducing VFS size first.', 'error'); + return; + } // Gzip payload let payload = serialized; let isGzipped = false; @@ -550,7 +594,9 @@ class WebDAVModule { const buf = await resp.arrayBuffer(); payload = new Uint8Array(buf); isGzipped = true; - } catch (e) { /* fallback to plain */ } + } catch (e) { self.terminal.print('Gzip unavailable, falling back to plain JSON', 'info'); } + } else { + self.terminal.print('CompressionStream not supported, saving as plain JSON', 'info'); } // Encrypt if requested if (encryptPass) { @@ -671,37 +717,74 @@ class WebDAVModule { } }; - // --- webdav-browser: visual modal file browser --- + // --- webdav-browser: modern visual file browser --- this.commands['webdav-browser'] = async (args) => { const startPath = args[0] || self._browsingPath || '/'; await self.openWebdavBrowser(startPath); }; - // --- webdav-explorer: interactive file browser via WebDAV --- - this.commands['webdav-explorer'] = async (args) => { - const startPath = args[0] || '/'; + // --- webdav-rm: delete remote file/dir --- + this.commands['webdav-rm'] = async (args) => { + if (!args[0]) { + self.terminal.print('Usage: webdav-rm <remote-path>', 'warning'); + return; + } try { - const entries = await self._listDir(startPath); - self.terminal.print('๐ WebDAV: ' + startPath, 'info'); - if (entries.length === 0) { - self.terminal.print('(empty)', 'info'); - return; + const ok = await self._deleteFile(args[0]); + if (ok) { + self.terminal.print('Deleted: ' + args[0], 'success'); + } else { + self.terminal.print('Delete failed: resource may not exist', 'error'); } - entries.forEach(e => { - const icon = e.type === 'dir' ? '๐' : '๐'; - const size = e.type === 'file' ? ' (' + self._formatSize(e.size) + ')' : '/'; - self.terminal.print(icon + ' ' + e.name + (e.type === 'dir' ? '/' : '') + size); + } catch (e) { + self.terminal.print('Delete error: ' + e.message, 'error'); + } + }; + + // --- webdav-mkdir: create remote directory --- + this.commands['webdav-mkdir'] = async (args) => { + if (!args[0]) { + self.terminal.print('Usage: webdav-mkdir <remote-path>', 'warning'); + return; + } + try { + const ok = await self._ensureDir(args[0]); + if (ok) { + self.terminal.print('Created directory: ' + args[0], 'success'); + } else { + self.terminal.print('Failed to create directory', 'error'); + } + } catch (e) { + self.terminal.print('Mkdir error: ' + e.message, 'error'); + } + }; + + // --- webdav-rename: rename/move remote file or dir (MOVE method) --- + this.commands['webdav-rename'] = async (args) => { + if (!args[0] || !args[1]) { + self.terminal.print('Usage: webdav-rename <old-path> <new-path>', 'warning'); + self.terminal.print('Renames or moves a file/directory using WebDAV MOVE', 'info'); + return; + } + try { + const resp = await self._webdavRequest('MOVE', args[0], { + headers: { 'Destination': args[1] } }); - self.terminal.print('---'); - self.terminal.print('โณ Navigate: webdav-explorer <dir> | Pull: webdav-pull <file>', 'text-muted'); - self.terminal.print(entries.length + ' item(s)', 'text-muted'); + if (resp.ok || resp.status === 201 || resp.status === 204) { + self.terminal.print('Renamed: ' + args[0] + ' โ ' + args[1], 'success'); + } else { + self.terminal.print('Rename failed: HTTP ' + resp.status, 'error'); + } } catch (e) { - self.terminal.print('WebDAV error: ' + e.message, 'error'); + self.terminal.print('Rename error: ' + e.message, 'error'); } }; + + // --- webdav-move: alias for webdav-rename --- + this.commands['webdav-move'] = this.commands['webdav-rename']; } - /* โโ Visual WebDAV Browser Modal โโ */ + /* โโ Modern WebDAV File-Tree Browser with Bulk Ops โโ */ async openWebdavBrowser(path) { if (!this.config || !this.config.url) { @@ -710,6 +793,7 @@ class WebDAVModule { } this._browsingPath = path; const self = this; + this._selected = {}; // Remove existing modal if any const existing = document.getElementById('webdav-browser-modal'); @@ -724,6 +808,12 @@ class WebDAVModule { errorMsg = e.message; } + // Sort: dirs first, then files, alphabetically + entries.sort(function(a, b) { + if (a.type !== b.type) return a.type === 'dir' ? -1 : 1; + return a.name.localeCompare(b.name); + }); + // Build modal const overlay = document.createElement('div'); overlay.id = 'webdav-browser-modal'; @@ -733,73 +823,237 @@ class WebDAVModule { const content = document.createElement('div'); content.className = 'modal-content'; - content.style.cssText = 'max-width:650px; max-height:80vh; width:92%;'; + content.style.cssText = 'max-width:750px; max-height:85vh; width:94%;'; // Header const header = document.createElement('div'); header.className = 'modal-header'; - header.innerHTML = '<span>๐ WebDAV Browser</span><span style="cursor:pointer" onclick="var m=document.getElementById(\'webdav-browser-modal\');if(m)m.remove();var t=window.webx11;if(t&&t.inputField)t.inputField.focus();">โ</span>'; + header.innerHTML = '<span>๐ WebDAV File Tree</span>' + + '<span style="display:flex;gap:6px;align-items:center">' + + '<span id="sel-count" style="font-size:0.8em;color:var(--text-muted)"></span>' + + '<span style="cursor:pointer" onclick="var m=document.getElementById(\'webdav-browser-modal\');if(m)m.remove();var t=window.webx11;if(t&&t.inputField)t.inputField.focus();">โ</span></span>'; content.appendChild(header); // Body const body = document.createElement('div'); body.className = 'modal-body'; - body.style.cssText = 'padding:8px; overflow-y:auto; flex-grow:1;'; + body.style.cssText = 'padding:0; overflow-y:auto; flex-grow:1;'; if (errorMsg) { const errDiv = document.createElement('div'); errDiv.style.cssText = 'color:var(--error-color); padding:16px; text-align:center;'; errDiv.textContent = 'โ ' + errorMsg; body.appendChild(errDiv); - } else if (entries.length === 0) { - const emptyDiv = document.createElement('div'); - emptyDiv.className = 'text-center'; - emptyDiv.style.cssText = 'padding:20px; color:var(--text-muted);'; - emptyDiv.textContent = '(empty) โ upload files using the button below'; - body.appendChild(emptyDiv); } else { - // Sort: dirs first, then files, alphabetically - entries.sort(function(a, b) { - if (a.type !== b.type) return a.type === 'dir' ? -1 : 1; - return a.name.localeCompare(b.name); - }); + // Select-all row + const selectAllRow = document.createElement('div'); + selectAllRow.style.cssText = 'display:flex;align-items:center;padding:6px 10px;border-bottom:1px solid #222;font-size:0.8em;color:var(--text-muted);gap:8px;'; + const selAllCheck = document.createElement('input'); + selAllCheck.type = 'checkbox'; + selAllCheck.style.cssText = 'accent-color:var(--success-color);width:16px;height:16px;cursor:pointer;'; + selAllCheck.onchange = function() { + const checked = this.checked; + entries.forEach(function(e) { self._selected[e.name] = checked; }); + document.querySelectorAll('.wdav-checkbox').forEach(function(c) { c.checked = checked; }); + self._updateSelectionCount(); + }; + selectAllRow.appendChild(selAllCheck); + selectAllRow.appendChild(document.createTextNode('Select All (' + entries.length + ' items)')); + body.appendChild(selectAllRow); - var frag = document.createDocumentFragment(); + // File rows + const listContainer = document.createElement('div'); + let rowIndex = 0; entries.forEach(function(entry) { - var item = document.createElement('div'); - item.className = entry.type === 'dir' ? 'file-item dir' : 'file-item'; - item.style.cssText = 'display:flex; justify-content:space-between; align-items:center; padding:8px; border-bottom:1px solid #111; cursor:pointer; min-height:36px;'; - item.onmouseover = function() { this.style.background = 'var(--bg-tertiary)'; }; - item.onmouseout = function() { this.style.background = ''; }; + var row = document.createElement('div'); + row.className = 'wdav-row'; + row.style.cssText = 'display:flex;align-items:center;padding:7px 10px;border-bottom:1px solid #1a1a1a;cursor:default;min-height:38px;gap:8px;'; + row.onmouseover = function() { this.style.background = 'var(--bg-tertiary)'; }; + row.onmouseout = function() { this.style.background = ''; }; + row.dataset.index = rowIndex++; + + // Checkbox + var cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.className = 'wdav-checkbox'; + cb.style.cssText = 'accent-color:var(--success-color);width:15px;height:15px;cursor:pointer;flex-shrink:0;'; + cb.onchange = function(e) { + self._selected[entry.name] = this.checked; + self._updateSelectionCount(); + e.stopPropagation(); + }; + row.appendChild(cb); + // Icon + var iconSpan = document.createElement('span'); + iconSpan.textContent = entry.type === 'dir' ? '๐' : '๐'; + iconSpan.style.cssText = 'flex-shrink:0;font-size:1.1em;'; + + // Filename (editable on dblclick) var nameSpan = document.createElement('span'); - var icon = entry.type === 'dir' ? '๐' : '๐'; - nameSpan.textContent = icon + ' ' + entry.name + (entry.type === 'dir' ? '/' : ''); + nameSpan.textContent = entry.name + (entry.type === 'dir' ? '/' : ''); + nameSpan.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.9em;cursor:pointer;'; + nameSpan.title = entry.name; + nameSpan.ondblclick = function(e) { + e.stopPropagation(); + var input = document.createElement('input'); + input.type = 'text'; + input.value = entry.name; + input.style.cssText = 'width:100%;background:#1a1a1a;border:1px solid var(--success-color);color:var(--text-color);padding:2px 6px;border-radius:3px;font-size:0.9em;'; + input.onblur = function() { + var newName = this.value.trim(); + if (newName && newName !== entry.name) { + var parentPath = path.replace(/\/+$/, '') || ''; + var oldFull = parentPath + '/' + entry.name; + var newFull = parentPath + '/' + newName; + self._webdavRequest('MOVE', oldFull, { headers: { 'Destination': newFull } }) + .then(function(r) { + if (r.ok || r.status === 201 || r.status === 204) { + self.terminal.print('Renamed: ' + entry.name + ' โ ' + newName, 'success'); + self.openWebdavBrowser(path); + } else { + self.terminal.print('Rename failed: HTTP ' + r.status, 'error'); + nameSpan.textContent = entry.name; + } + }); + } else { + nameSpan.textContent = entry.name; + } + }; + input.onkeydown = function(ev) { + if (ev.key === 'Enter') this.blur(); + if (ev.key === 'Escape') { nameSpan.textContent = entry.name; } + }; + nameSpan.textContent = ''; + nameSpan.appendChild(input); + input.focus(); + input.select(); + }; + // Click = navigate dir / preview file + if (entry.type === 'dir') { + row.onclick = function(e) { + if (e.target.type === 'checkbox') return; + self.openWebdavBrowser(entry.href); + }; + } else { + row.onclick = function(e) { + if (e.target.type === 'checkbox') return; + self.previewWebdavFile(entry); + }; + } + // Size var sizeSpan = document.createElement('span'); - sizeSpan.style.cssText = 'font-size:0.8em; color:var(--text-muted);'; + sizeSpan.style.cssText = 'font-size:0.75em;color:var(--text-muted);flex-shrink:0;min-width:60px;text-align:right;'; if (entry.type === 'file') { sizeSpan.textContent = self._formatSize(entry.size); } - item.appendChild(nameSpan); - item.appendChild(sizeSpan); + // Context menu on right-click + row.oncontextmenu = function(e) { + e.preventDefault(); + self._showContextMenu(e, entry, path); + }; - if (entry.type === 'dir') { - item.onclick = function() { self.openWebdavBrowser(entry.href); }; - } else { - item.onclick = function() { self.previewWebdavFile(entry); }; - // Right-click or context menu could download โ but for simplicity, long tap or regular click previews - // Double-click to download - item.ondblclick = function() { self.downloadFromWebdav(entry); }; - } - frag.appendChild(item); + row.appendChild(iconSpan); + row.appendChild(nameSpan); + row.appendChild(sizeSpan); + listContainer.appendChild(row); }); - body.appendChild(frag); + body.appendChild(listContainer); + + // Keyboard navigation + body.tabIndex = -1; + body.style.outline = 'none'; + var focusIdx = 0; + body.onkeydown = function(e) { + var rows = listContainer.querySelectorAll('.wdav-row'); + if (e.key === 'ArrowDown' || e.key === 'j') { + e.preventDefault(); + focusIdx = Math.min(focusIdx + 1, rows.length - 1); + rows[focusIdx].scrollIntoView({ block: 'nearest' }); + rows.forEach(function(r, i) { r.style.borderLeft = i === focusIdx ? '2px solid var(--success-color)' : ''; }); + } else if (e.key === 'ArrowUp' || e.key === 'k') { + e.preventDefault(); + focusIdx = Math.max(focusIdx - 1, 0); + rows[focusIdx].scrollIntoView({ block: 'nearest' }); + rows.forEach(function(r, i) { r.style.borderLeft = i === focusIdx ? '2px solid var(--success-color)' : ''; }); + } else if (e.key === ' ') { + e.preventDefault(); + var cb = rows[focusIdx]?.querySelector('.wdav-checkbox'); + if (cb) { cb.checked = !cb.checked; cb.onchange({ stopPropagation: function(){} }); } + } else if (e.key === 'Enter') { + e.preventDefault(); + rows[focusIdx]?.click(); + } + }; + setTimeout(function() { body.focus(); }, 100); } content.appendChild(body); + // Bulk action bar + const bulkBar = document.createElement('div'); + bulkBar.id = 'wdav-bulk-bar'; + bulkBar.style.cssText = 'display:none;padding:8px 10px;border-top:1px solid var(--success-color);background:var(--bg-tertiary);gap:6px;align-items:center;flex-wrap:wrap;'; + bulkBar.innerHTML = '<span id="bulk-count" style="font-size:0.85em;color:var(--text-color);flex:1;">0 selected</span>' + + '<button class="btn" id="bulk-delete" style="font-size:0.8em;padding:3px 10px;">๐ Delete</button>' + + '<button class="btn" id="bulk-move" style="font-size:0.8em;padding:3px 10px;">๐ฆ Move</button>' + + '<button class="btn" id="bulk-download" style="font-size:0.8em;padding:3px 10px;">โฌ Download</button>'; + self._updateSelectionCount = function() { + var count = Object.values(self._selected).filter(Boolean).length; + var el = document.getElementById('sel-count'); + if (el) el.textContent = count > 0 ? count + ' selected' : ''; + var bar = document.getElementById('wdav-bulk-bar'); + var cnt = document.getElementById('bulk-count'); + if (bar) bar.style.display = count > 0 ? 'flex' : 'none'; + if (cnt) cnt.textContent = count + ' selected'; + }; + + // Wire bulk actions + setTimeout(function() { + document.getElementById('bulk-delete')?.addEventListener('click', function() { + var names = Object.entries(self._selected).filter(function(e) { return e[1]; }).map(function(e) { return e[0]; }); + if (!names.length || !confirm('Delete ' + names.length + ' item(s) from ' + path + '?')) return; + names.forEach(function(name) { + var fullPath = (path.replace(/\/+$/, '') || '') + '/' + name; + self._deleteFile(fullPath).then(function(ok) { + if (ok) self.terminal.print('Deleted: ' + name, 'success'); + else self.terminal.print('Delete failed: ' + name, 'error'); + }); + }); + setTimeout(function() { self.openWebdavBrowser(path); }, 500); + }); + document.getElementById('bulk-move')?.addEventListener('click', function() { + var names = Object.entries(self._selected).filter(function(e) { return e[1]; }).map(function(e) { return e[0]; }); + if (!names.length) return; + var target = prompt('Move ' + names.length + ' item(s) to directory:', '/'); + if (!target) return; + target = target.replace(/\/+$/, ''); + names.forEach(function(name) { + var oldFull = (path.replace(/\/+$/, '') || '') + '/' + name; + var newFull = target + '/' + name; + self._webdavRequest('MOVE', oldFull, { headers: { 'Destination': newFull } }) + .then(function(r) { + if (r.ok || r.status === 201 || r.status === 204) self.terminal.print('Moved: ' + name, 'success'); + else self.terminal.print('Move failed: ' + name + ' (HTTP ' + r.status + ')', 'error'); + }); + }); + setTimeout(function() { self.openWebdavBrowser(path); }, 500); + }); + document.getElementById('bulk-download')?.addEventListener('click', function() { + var names = Object.entries(self._selected).filter(function(e) { return e[1]; }).map(function(e) { return e[0]; }); + names.forEach(function(name) { + var fullPath = (path.replace(/\/+$/, '') || '') + '/' + name; + // Find entry from entries + var entry = entries.find(function(e) { return e.name === name; }); + if (entry && entry.type === 'file') self.downloadFromWebdav(entry); + }); + }); + }, 50); + + content.appendChild(bulkBar); + // Footer with breadcrumb + buttons const footer = document.createElement('div'); footer.className = 'modal-footer'; @@ -815,17 +1069,30 @@ class WebDAVModule { for (var i = 0; i < parts.length; i++) { cumulative += '/' + parts[i]; var seg = parts[i]; + breadHtml += ' <span style="color:var(--text-muted);font-size:0.7em;">โธ</span> '; breadHtml += '<span style="cursor:pointer;text-decoration:underline;" onclick="var m=document.getElementById(\'webdav-browser-modal\');if(m)m.__nav(\'' + cumulative.replace(/'/g, "\\'") + '/\');return false;">' + seg + '</span>'; - if (i < parts.length - 1) breadHtml += ' โธ '; } breadDiv.innerHTML = breadHtml; - // Attach nav function to modal element for breadcrumb clicks overlay.__nav = function(p) { self.openWebdavBrowser(p); }; // Button group var btnGroup = document.createElement('div'); btnGroup.style.cssText = 'display:flex; gap:4px; flex-shrink:0;'; + var mkdirBtn = document.createElement('button'); + mkdirBtn.className = 'btn'; + mkdirBtn.textContent = '๐+'; + mkdirBtn.title = 'New Directory'; + mkdirBtn.onclick = function() { + var name = prompt('Directory name:'); + if (name) { + self._ensureDir((path.replace(/\/+$/, '') || '') + '/' + name).then(function(ok) { + if (ok) { self.terminal.print('Created: ' + name, 'success'); self.openWebdavBrowser(path); } + else self.terminal.print('Failed to create directory', 'error'); + }); + } + }; + var uploadBtn = document.createElement('button'); uploadBtn.className = 'btn'; uploadBtn.textContent = 'โฌ Upload'; @@ -842,6 +1109,7 @@ class WebDAVModule { closeBtn.textContent = 'Close'; closeBtn.onclick = function() { self.closeWebdavBrowser(); }; + btnGroup.appendChild(mkdirBtn); btnGroup.appendChild(uploadBtn); btnGroup.appendChild(refreshBtn); btnGroup.appendChild(closeBtn); @@ -853,7 +1121,71 @@ class WebDAVModule { overlay.appendChild(content); document.body.appendChild(overlay); - this.terminal.print('๐ Opened WebDAV browser: ' + path, 'info'); + this.terminal.print('๐ Opened WebDAV file browser: ' + path, 'info'); + } + + _showContextMenu(e, entry, currentPath) { + var existing = document.getElementById('wdav-context-menu'); + if (existing) existing.remove(); + + var menu = document.createElement('div'); + menu.id = 'wdav-context-menu'; + menu.style.cssText = 'position:fixed;left:' + e.clientX + 'px;top:' + e.clientY + 'px;background:#111;border:1px solid #333;border-radius:6px;padding:4px 0;z-index:1001;min-width:160px;box-shadow:0 4px 12px rgba(0,0,0,0.5);'; + menu.onclick = function() { this.remove(); }; + + var items = []; + if (entry.type === 'file') { + items.push({ label: '๐ Preview', fn: function() { this.previewWebdavFile(entry); }.bind(this) }); + items.push({ label: 'โฌ Download', fn: function() { this.downloadFromWebdav(entry); }.bind(this) }); + items.push({ label: '๐ Copy Path', fn: function() { + var full = (currentPath.replace(/\/+$/, '') || '') + '/' + entry.name; + navigator.clipboard.writeText(full).then(function() { this.terminal.print('Path copied: ' + full, 'success'); }.bind(this)); + }.bind(this) }); + } else { + items.push({ label: '๐ Open', fn: function() { this.openWebdavBrowser(entry.href); }.bind(this) }); + items.push({ label: '๐ Copy Path', fn: function() { + var full = (currentPath.replace(/\/+$/, '') || '') + '/' + entry.name + '/'; + navigator.clipboard.writeText(full).then(function() { this.terminal.print('Path copied: ' + full, 'success'); }.bind(this)); + }.bind(this) }); + } + items.push({ label: 'โ๏ธ Rename', fn: function() { + var newName = prompt('Rename "' + entry.name + '" to:'); + if (newName && newName !== entry.name) { + var oldFull = (currentPath.replace(/\/+$/, '') || '') + '/' + entry.name; + var newFull = (currentPath.replace(/\/+$/, '') || '') + '/' + newName; + this._webdavRequest('MOVE', oldFull, { headers: { 'Destination': newFull } }).then(function(r) { + if (r.ok || r.status === 201 || r.status === 204) { + this.terminal.print('Renamed: ' + entry.name + ' โ ' + newName, 'success'); + this.openWebdavBrowser(currentPath); + } else { this.terminal.print('Rename failed: HTTP ' + r.status, 'error'); } + }.bind(this)); + } + }.bind(this) }); + items.push({ label: '๐ Delete', fn: function() { + if (!confirm('Delete ' + entry.name + '?')) return; + var full = (currentPath.replace(/\/+$/, '') || '') + '/' + entry.name; + this._deleteFile(full).then(function(ok) { + if (ok) { this.terminal.print('Deleted: ' + entry.name, 'success'); this.openWebdavBrowser(currentPath); } + else { this.terminal.print('Delete failed', 'error'); } + }.bind(this)); + }.bind(this) }); + + items.forEach(function(item) { + var div = document.createElement('div'); + div.textContent = item.label; + div.style.cssText = 'padding:6px 14px;cursor:pointer;font-size:0.85em;'; + div.onmouseover = function() { this.style.background = '#222'; }; + div.onmouseout = function() { this.style.background = ''; }; + div.onclick = function(e) { e.stopPropagation(); menu.remove(); item.fn(); }; + menu.appendChild(div); + }); + + document.body.appendChild(menu); + // Close on click outside + var closeMenu = function(ev) { + if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', closeMenu); } + }; + setTimeout(function() { document.addEventListener('click', closeMenu); }, 10); } closeWebdavBrowser() { @@ -949,7 +1281,10 @@ class WebDAVModule { async downloadFromWebdav(entry) { try { - var resp = await this._webdavRequest('GET', entry.href); + // Construct path from current browsing path + entry name (avoid autoindex href fragility) + var basePath = (this._browsingPath || '/').replace(/\/+$/, ''); + var fullPath = basePath + '/' + entry.name; + var resp = await this._webdavRequest('GET', fullPath); if (!resp.ok) { this.terminal.print('Download failed: HTTP ' + resp.status, 'error'); return; } var content = await resp.text(); var blob = new Blob([content], { type: 'application/octet-stream' }); @@ -957,7 +1292,7 @@ class WebDAVModule { var a = document.createElement('a'); a.href = url; a.download = entry.name; - a.style.display = 'none'; + a.style.cssText = 'display:none;'; document.body.appendChild(a); a.click(); setTimeout(function() { |