aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
Diffstat (limited to 'modules')
-rw-r--r--modules/core/module-agent-registry.js79
-rw-r--r--modules/core/module-aiassist.js74
-rw-r--r--modules/core/module-chatstudio.js32
-rw-r--r--modules/core/module-fs.js34
-rw-r--r--modules/core/module-leases.js102
-rw-r--r--modules/core/module-loader.js7
-rw-r--r--modules/core/module-orchestrator.js151
-rw-r--r--modules/core/module-system.js20
-rw-r--r--modules/core/terminal.js7
-rw-r--r--modules/ext/skill-pack-core.js284
-rw-r--r--modules/ext/webdav.js515
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() {