aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-27 15:33:33 +0100
committerAgando <roduccomm@gmail.com>2026-10-27 15:33:33 +0100
commit240cbf75c7c3cbf23fc95d6b4e0bf807dcd91360 (patch)
tree3da9a2684b80b3af99441e602b0195e39f4ab300
parent6b4049d3ba27dd68935c0565cccfe44752175393 (diff)
fix(newos4): agent registration is absolute-path and honest about what it stored
Three of the four agent-cluster items, each with a test that is red on HEAD. - MAINT-008 (module-agent-registry.js + module-orchestrator.js): registerAgent took args[0] as the agent id and only args[1] as the capability list, while both orchestrator callers passed the command word first — so the boot log read "Agent 'register' registered with 1 capabilities" and four capabilities were stored as one. The command word is now stripped, args[1..] are joined and split on commas (both "a,b" and "a b c" work), and the callers pass [id, ...caps]. - MAINT-007 (registry + aiassist): mkdir/rm used relative paths, so a registration made from /docs created /docs/agents/<id> and an unregister from any other cwd removed nothing — the leaked directories the report suspected. Every path in the registry (and the four /_sessions writes in module-aiassist.js) is absolute now. - MAINT-009 (listAgents): a directory alone counted as a registered agent, so a hand-made /agents/foo appeared as "foo — unknown (0 capabilities)". Only a readable status.json with an id counts now; the rest are reported as unregistered directories. Verification: test-agent-registry.js (11 asserts). RED on HEAD: 9 of 11 fail, including the exact `✅ Agent 'register' registered with 1 capabilities` line and `hand-made — unknown (0 capabilities)`. Suite 15/15 files green; maintain --fix "node tests 15/15 pass". Live on v4.6.109: boot prints "✅ Agent 'orchestrator-0' registered with 4 capabilities"; `agent-list` warns "1 directory(ies) in /agents have no status.json (not registered): hand-made"; register from / with a comma list stores 3; unregister from /docs removes the directory and leaves no /docs/agents. MAINT-006 stays open with a narrower note: the hard stop was only reachable when every call in a batch was blocked, so a mixed batch (one repeated + one novel call) never ended. The same ceiling now applies on the mixed path, but that branch lives inside agenticLoop and is not covered by a unit test — it needs a live agent run that repeats a call.
-rw-r--r--main.html6
-rw-r--r--modules/core/core-bundle.js85
-rw-r--r--modules/core/module-agent-registry.js62
-rw-r--r--modules/core/module-aiassist.js17
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/core/module-orchestrator.js6
-rw-r--r--modules/test/test-agent-registry.js93
7 files changed, 225 insertions, 46 deletions
diff --git a/main.html b/main.html
index 79c77cd..83cb081 100644
--- a/main.html
+++ b/main.html
@@ -1349,13 +1349,13 @@
</script>
<!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix -->
-<script src="modules/core/core-bundle.js?v=v4.6.108"></script>
+<script src="modules/core/core-bundle.js?v=v4.6.109"></script>
<!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules -->
-<script src="modules/core/module-loader.js?v=v4.6.108"></script>
+<script src="modules/core/module-loader.js?v=v4.6.109"></script>
<!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work -->
-<script src="modules/ext/critical-ext-bundle.js?v=v4.6.108"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=v4.6.109"></script>
<script>
// ═══ ONE TRUE VERSION SOURCE ═══
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index d7d9d61..6e9542e 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -10198,6 +10198,15 @@ buildSystemPrompt() {
// assistant message is pushed below.
res.tool_calls = _remaining;
this._pendingBlockedReplays = blocked;
+ // MAINT-006: the hard stop was only reachable when EVERY call in the
+ // batch was blocked, so a turn that mixed one repeated call with one
+ // novel call never ended — the reported "agents loop on repetitive
+ // turns". Apply the same ceiling on the mixed path.
+ const _hsMixed = Math.min(_hardStopAt, maxTurns);
+ if (blocked.some(b => b.entry.count >= _hsMixed)) {
+ this.terminal.print(`⛔ ${_agentTag}: no progress after ${_hsMixed} identical calls — ending loop with partial result`, 'error');
+ return { finalAnswer: this._loopSummary(toolTurns), toolTurns };
+ }
}
this.terminal.print(`🔧 AI wants ${res.tool_calls.length} tool(s) — turn ${toolTurns}/${maxTurns} (${thinkTime}s)`, 'info');
messages.push(res.message);
@@ -11219,7 +11228,7 @@ buildSystemPrompt() {
case 'session_terminal': {
const tfs = this.terminal.fs;
try {
- const raw = tfs.readFile('_sessions/_auto.json');
+ const raw = tfs.readFile('/_sessions/_auto.json');
if (!raw) return JSON.stringify({ error: 'No auto-saved session found' });
const data = JSON.parse(raw);
const lines = (data.terminalLines || []).slice(-50);
@@ -12193,7 +12202,7 @@ buildSystemPrompt() {
const fs = this.terminal.fs;
if (fs) {
fs.mkdirRecursive('_sessions');
- fs.writeFile('_sessions/_auto-context.json', data);
+ fs.writeFile('/_sessions/_auto-context.json', data);
}
} catch (e) {}
} catch (e) {
@@ -12319,8 +12328,8 @@ buildSystemPrompt() {
try {
const fs = this.terminal.fs;
if (fs) {
- fs.deleteFile('_sessions/_auto-context.json');
- fs.deleteFile('_sessions/_auto.json');
+ fs.deleteFile('/_sessions/_auto-context.json');
+ fs.deleteFile('/_sessions/_auto.json');
}
} catch (e) {}
@@ -15189,8 +15198,8 @@ class AgentRegistryModule {
}
init(terminal) {
this.terminal = terminal;
- if (!this.terminal.fs.readDir('/agents/')) this.terminal.fs.mkdir('agents');
- if (!this.terminal.fs.readDir('/.contracts/')) this.terminal.fs.mkdir('.contracts');
+ if (!this.terminal.fs.readDir('/agents/')) this.terminal.fs.mkdirRecursive('/agents');
+ if (!this.terminal.fs.readDir('/.contracts/')) this.terminal.fs.mkdirRecursive('/.contracts');
this._loadContracts();
// Initialize scratchpad
this._ensureScratchpad();
@@ -15204,7 +15213,7 @@ class AgentRegistryModule {
_saveDeltaTable() {
try {
const fs = this.terminal.fs;
- if (!fs.readDir('/_docs/')) fs.mkdir('_docs');
+ if (!fs.readDir('/_docs/')) fs.mkdirRecursive('/_docs');
const delta = `# Spec-vs-Reality Delta — 2026-06-17\n\n` +
`| # | Whitepaper Concept | Spec'd Behavior | Current Reality | Δ | Priority |\n` +
`|---|---|---|---|---|---|\n` +
@@ -15231,7 +15240,7 @@ class AgentRegistryModule {
const data = this.terminal.fs.fsReadJSON('/_agents/scratchpad.json');
if (!data) throw new Error('no scratchpad');
} catch (e) {
- this.terminal.fs.mkdir('_agents');
+ this.terminal.fs.mkdirRecursive('/_agents');
this.terminal.fs.fsWriteJSON('/_agents/scratchpad.json', {
messages: [],
seq: 0,
@@ -15248,7 +15257,7 @@ class AgentRegistryModule {
}
_saveScratchpad(sp) {
try {
- this.terminal.fs.mkdir('_agents');
+ this.terminal.fs.mkdirRecursive('/_agents');
this.terminal.fs.fsWriteJSON('/_agents/scratchpad.json', sp);
} catch (e) {
console.warn('Scratchpad save error:', e.message);
@@ -15507,11 +15516,24 @@ class AgentRegistryModule {
this.terminal.fs.fsWriteJSON('/.contracts/index.json', this._contracts);
} catch (e) { console.error('Contract save error:', e); }
}
- registerAgent(args) {
+ registerAgent(rawArgs) {
+ // MAINT-008: callers pass the command word first (['register', id, cap, cap…]), and the
+ // old code took args[0] as the id and only args[1] as the capability list — so the boot
+ // log read `Agent 'register' registered with 1 capabilities`. Strip the command word and
+ // accept both `cap1,cap2` and `cap1 cap2 cap3`.
+ const args = (rawArgs && rawArgs[0] === 'register') ? rawArgs.slice(1) : (rawArgs || []);
const id = args[0] || `agent-${Date.now()}`;
- const capabilities = args[1] ? args[1].split(',').map(s => s.trim()).filter(Boolean) : ['list_files', 'read_file'];
- const agentDir = `agents/${id}`;
- if (!this.terminal.fs.readDir('/' + agentDir + '/')) this.terminal.fs.mkdir(agentDir);
+ const capsArg = args.slice(1).join(',');
+ const capabilities = capsArg
+ ? capsArg.split(',').map(s => s.trim()).filter(Boolean)
+ : ['list_files', 'read_file'];
+ // MAINT-007/009: relative mkdir/rm resolved against the user's cwd, so a registration made
+ // from /docs created /docs/agents/<id> and an unregister from anywhere else removed
+ // nothing. Every path here is absolute.
+ const agentDir = `/agents/${id}`;
+ if (!this.terminal.fs.readDir(agentDir + '/')) {
+ this.terminal.fs.mkdirRecursive(agentDir);
+ }
this.terminal.fs.writeFile(`${agentDir}/status.json`, JSON.stringify({
id,
status: 'idle',
@@ -15585,19 +15607,38 @@ class AgentRegistryModule {
if (!agents || agents.length === 0) {
return this.terminal.print('No agents registered', 'info');
}
- this.terminal.print(`📋 Registered Agents (${agents.length}):`, 'header');
+ // MAINT-009: a directory alone is not a registration — anything without a readable
+ // status.json is a leftover directory, not an agent. Listing them as agents made a
+ // hand-made /agents/foo look like a live agent with unknown status.
+ const registered = [], orphaned = [];
agents.forEach(a => {
- if (a.type === 'dir' && a.name !== 'configured') {
+ if (a.type !== 'dir' || a.name === 'configured') return;
+ let s = null;
+ try {
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)${s.contract ? ` → contract:${s.contract}` : ''}`, 'info');
- }
+ if (data) s = JSON.parse(data);
+ } catch (e) { s = null; }
+ if (s && s.id) registered.push({ name: a.name, s });
+ else orphaned.push(a.name);
});
+ if (registered.length) {
+ this.terminal.print(`📋 Registered Agents (${registered.length}):`, 'header');
+ registered.forEach(({ name, s }) => {
+ this.terminal.print(` ${name} — ${s.status} (${(s.capabilities || []).length} capabilities)${s.contract ? ` → contract:${s.contract}` : ''}`, 'info');
+ });
+ } else {
+ this.terminal.print('No agents registered', 'info');
+ }
+ if (orphaned.length) {
+ this.terminal.print(`⚠️ ${orphaned.length} directory(ies) in /agents have no status.json (not registered): ${orphaned.join(', ')}`, 'warning');
+ }
}
unregisterAgent(id) {
// Unregister from A2A scratchpad
this.a2aUnregister(id);
- const ok = this.terminal.fs.rm('agents/' + id);
+ // MAINT-007: absolute path — a relative rm resolved against the cwd and left the
+ // directory behind whenever the user was not sitting in /.
+ const ok = this.terminal.fs.rm('/agents/' + id);
if (ok) this.terminal.print(`✅ Agent '${id}' unregistered`, 'success');
else this.terminal.print(`❌ Agent '${id}' not found`, 'error');
}
@@ -15948,7 +15989,9 @@ class OrchestratorModule {
// Register orchestrator in the agent registry
const registry = this.terminal.moduleManager.modules['AgentRegistryModule'];
if (registry) {
- registry.registerAgent(['register', 'orchestrator-0', 'delegate', 'spawn', 'monitor', 'purge']);
+ // MAINT-008: the agent id comes first; passing the command word as args[0] registered an
+ // agent literally named "register" with one capability.
+ registry.registerAgent(['orchestrator-0', 'delegate', 'spawn', 'monitor', 'purge']);
}
// Reconcile + pre-spawn the default team. VirtualFileSystem.loadFromStorage() is ASYNC,
// so reconciliation MUST wait for it — running it synchronously saw an empty root and
@@ -16154,7 +16197,7 @@ class OrchestratorModule {
// Register in agent registry
const registry = this.terminal.moduleManager.modules['AgentRegistryModule'];
if (registry) {
- registry.registerAgent(['register', id, ...agent.capabilities]);
+ registry.registerAgent([id, ...agent.capabilities]);
// A2A Scratchpad registration
if (typeof registry.a2aRegister === 'function') {
registry.a2aRegister(id, agent.capabilities);
diff --git a/modules/core/module-agent-registry.js b/modules/core/module-agent-registry.js
index 68dca66..b2c4797 100644
--- a/modules/core/module-agent-registry.js
+++ b/modules/core/module-agent-registry.js
@@ -35,8 +35,8 @@ class AgentRegistryModule {
}
init(terminal) {
this.terminal = terminal;
- if (!this.terminal.fs.readDir('/agents/')) this.terminal.fs.mkdir('agents');
- if (!this.terminal.fs.readDir('/.contracts/')) this.terminal.fs.mkdir('.contracts');
+ if (!this.terminal.fs.readDir('/agents/')) this.terminal.fs.mkdirRecursive('/agents');
+ if (!this.terminal.fs.readDir('/.contracts/')) this.terminal.fs.mkdirRecursive('/.contracts');
this._loadContracts();
// Initialize scratchpad
this._ensureScratchpad();
@@ -50,7 +50,7 @@ class AgentRegistryModule {
_saveDeltaTable() {
try {
const fs = this.terminal.fs;
- if (!fs.readDir('/_docs/')) fs.mkdir('_docs');
+ if (!fs.readDir('/_docs/')) fs.mkdirRecursive('/_docs');
const delta = `# Spec-vs-Reality Delta — 2026-06-17\n\n` +
`| # | Whitepaper Concept | Spec'd Behavior | Current Reality | Δ | Priority |\n` +
`|---|---|---|---|---|---|\n` +
@@ -77,7 +77,7 @@ class AgentRegistryModule {
const data = this.terminal.fs.fsReadJSON('/_agents/scratchpad.json');
if (!data) throw new Error('no scratchpad');
} catch (e) {
- this.terminal.fs.mkdir('_agents');
+ this.terminal.fs.mkdirRecursive('/_agents');
this.terminal.fs.fsWriteJSON('/_agents/scratchpad.json', {
messages: [],
seq: 0,
@@ -94,7 +94,7 @@ class AgentRegistryModule {
}
_saveScratchpad(sp) {
try {
- this.terminal.fs.mkdir('_agents');
+ this.terminal.fs.mkdirRecursive('/_agents');
this.terminal.fs.fsWriteJSON('/_agents/scratchpad.json', sp);
} catch (e) {
console.warn('Scratchpad save error:', e.message);
@@ -353,11 +353,24 @@ class AgentRegistryModule {
this.terminal.fs.fsWriteJSON('/.contracts/index.json', this._contracts);
} catch (e) { console.error('Contract save error:', e); }
}
- registerAgent(args) {
+ registerAgent(rawArgs) {
+ // MAINT-008: callers pass the command word first (['register', id, cap, cap…]), and the
+ // old code took args[0] as the id and only args[1] as the capability list — so the boot
+ // log read `Agent 'register' registered with 1 capabilities`. Strip the command word and
+ // accept both `cap1,cap2` and `cap1 cap2 cap3`.
+ const args = (rawArgs && rawArgs[0] === 'register') ? rawArgs.slice(1) : (rawArgs || []);
const id = args[0] || `agent-${Date.now()}`;
- const capabilities = args[1] ? args[1].split(',').map(s => s.trim()).filter(Boolean) : ['list_files', 'read_file'];
- const agentDir = `agents/${id}`;
- if (!this.terminal.fs.readDir('/' + agentDir + '/')) this.terminal.fs.mkdir(agentDir);
+ const capsArg = args.slice(1).join(',');
+ const capabilities = capsArg
+ ? capsArg.split(',').map(s => s.trim()).filter(Boolean)
+ : ['list_files', 'read_file'];
+ // MAINT-007/009: relative mkdir/rm resolved against the user's cwd, so a registration made
+ // from /docs created /docs/agents/<id> and an unregister from anywhere else removed
+ // nothing. Every path here is absolute.
+ const agentDir = `/agents/${id}`;
+ if (!this.terminal.fs.readDir(agentDir + '/')) {
+ this.terminal.fs.mkdirRecursive(agentDir);
+ }
this.terminal.fs.writeFile(`${agentDir}/status.json`, JSON.stringify({
id,
status: 'idle',
@@ -431,19 +444,38 @@ class AgentRegistryModule {
if (!agents || agents.length === 0) {
return this.terminal.print('No agents registered', 'info');
}
- this.terminal.print(`📋 Registered Agents (${agents.length}):`, 'header');
+ // MAINT-009: a directory alone is not a registration — anything without a readable
+ // status.json is a leftover directory, not an agent. Listing them as agents made a
+ // hand-made /agents/foo look like a live agent with unknown status.
+ const registered = [], orphaned = [];
agents.forEach(a => {
- if (a.type === 'dir' && a.name !== 'configured') {
+ if (a.type !== 'dir' || a.name === 'configured') return;
+ let s = null;
+ try {
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)${s.contract ? ` → contract:${s.contract}` : ''}`, 'info');
- }
+ if (data) s = JSON.parse(data);
+ } catch (e) { s = null; }
+ if (s && s.id) registered.push({ name: a.name, s });
+ else orphaned.push(a.name);
});
+ if (registered.length) {
+ this.terminal.print(`📋 Registered Agents (${registered.length}):`, 'header');
+ registered.forEach(({ name, s }) => {
+ this.terminal.print(` ${name} — ${s.status} (${(s.capabilities || []).length} capabilities)${s.contract ? ` → contract:${s.contract}` : ''}`, 'info');
+ });
+ } else {
+ this.terminal.print('No agents registered', 'info');
+ }
+ if (orphaned.length) {
+ this.terminal.print(`⚠️ ${orphaned.length} directory(ies) in /agents have no status.json (not registered): ${orphaned.join(', ')}`, 'warning');
+ }
}
unregisterAgent(id) {
// Unregister from A2A scratchpad
this.a2aUnregister(id);
- const ok = this.terminal.fs.deleteFile('agents/' + id);
+ // MAINT-007: absolute path — a relative rm resolved against the cwd and left the
+ // directory behind whenever the user was not sitting in /.
+ const ok = this.terminal.fs.rm('/agents/' + id);
if (ok) this.terminal.print(`✅ Agent '${id}' unregistered`, 'success');
else this.terminal.print(`❌ Agent '${id}' not found`, 'error');
}
diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js
index be5ebce..2ee95e9 100644
--- a/modules/core/module-aiassist.js
+++ b/modules/core/module-aiassist.js
@@ -1679,6 +1679,15 @@ buildSystemPrompt() {
// assistant message is pushed below.
res.tool_calls = _remaining;
this._pendingBlockedReplays = blocked;
+ // MAINT-006: the hard stop was only reachable when EVERY call in the
+ // batch was blocked, so a turn that mixed one repeated call with one
+ // novel call never ended — the reported "agents loop on repetitive
+ // turns". Apply the same ceiling on the mixed path.
+ const _hsMixed = Math.min(_hardStopAt, maxTurns);
+ if (blocked.some(b => b.entry.count >= _hsMixed)) {
+ this.terminal.print(`⛔ ${_agentTag}: no progress after ${_hsMixed} identical calls — ending loop with partial result`, 'error');
+ return { finalAnswer: this._loopSummary(toolTurns), toolTurns };
+ }
}
this.terminal.print(`🔧 AI wants ${res.tool_calls.length} tool(s) — turn ${toolTurns}/${maxTurns} (${thinkTime}s)`, 'info');
messages.push(res.message);
@@ -2700,7 +2709,7 @@ buildSystemPrompt() {
case 'session_terminal': {
const tfs = this.terminal.fs;
try {
- const raw = tfs.readFile('_sessions/_auto.json');
+ const raw = tfs.readFile('/_sessions/_auto.json');
if (!raw) return JSON.stringify({ error: 'No auto-saved session found' });
const data = JSON.parse(raw);
const lines = (data.terminalLines || []).slice(-50);
@@ -3674,7 +3683,7 @@ buildSystemPrompt() {
const fs = this.terminal.fs;
if (fs) {
fs.mkdirRecursive('_sessions');
- fs.writeFile('_sessions/_auto-context.json', data);
+ fs.writeFile('/_sessions/_auto-context.json', data);
}
} catch (e) {}
} catch (e) {
@@ -3800,8 +3809,8 @@ buildSystemPrompt() {
try {
const fs = this.terminal.fs;
if (fs) {
- fs.deleteFile('_sessions/_auto-context.json');
- fs.deleteFile('_sessions/_auto.json');
+ fs.deleteFile('/_sessions/_auto-context.json');
+ fs.deleteFile('/_sessions/_auto.json');
}
} catch (e) {}
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index 836672d..da81184 100644
--- a/modules/core/module-loader.js
+++ b/modules/core/module-loader.js
@@ -4,7 +4,7 @@
// Core module bundle loaded synchronously in main.html.
// This loader only handles version cache-busting, splash, terminal init, and optional ext modules.
- const LATEST_VER = 'v4.6.108';
+ const LATEST_VER = 'v4.6.109';
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
index 5e03b0b..9f23ceb 100644
--- a/modules/core/module-orchestrator.js
+++ b/modules/core/module-orchestrator.js
@@ -179,7 +179,9 @@ class OrchestratorModule {
// Register orchestrator in the agent registry
const registry = this.terminal.moduleManager.modules['AgentRegistryModule'];
if (registry) {
- registry.registerAgent(['register', 'orchestrator-0', 'delegate', 'spawn', 'monitor', 'purge']);
+ // MAINT-008: the agent id comes first; passing the command word as args[0] registered an
+ // agent literally named "register" with one capability.
+ registry.registerAgent(['orchestrator-0', 'delegate', 'spawn', 'monitor', 'purge']);
}
// Reconcile + pre-spawn the default team. VirtualFileSystem.loadFromStorage() is ASYNC,
// so reconciliation MUST wait for it — running it synchronously saw an empty root and
@@ -385,7 +387,7 @@ class OrchestratorModule {
// Register in agent registry
const registry = this.terminal.moduleManager.modules['AgentRegistryModule'];
if (registry) {
- registry.registerAgent(['register', id, ...agent.capabilities]);
+ registry.registerAgent([id, ...agent.capabilities]);
// A2A Scratchpad registration
if (typeof registry.a2aRegister === 'function') {
registry.a2aRegister(id, agent.capabilities);
diff --git a/modules/test/test-agent-registry.js b/modules/test/test-agent-registry.js
new file mode 100644
index 0000000..0bfc573
--- /dev/null
+++ b/modules/test/test-agent-registry.js
@@ -0,0 +1,93 @@
+// modules/test/test-agent-registry.js — run: node
+// MAINT-007/008/009: registration must not depend on the cwd, must keep every capability it is
+// given, and a bare directory in /agents must not count as a registered agent.
+const fsMod = require('fs'), path = require('path');
+const ROOT = path.join(__dirname, '..');
+const store = {};
+global.localStorage = { getItem: k => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } };
+global.indexedDB = { open() { throw new Error('no idb'); }, deleteDatabase() { throw new Error('no idb'); } };
+global.window = global;
+
+const load = (rel, cls) => new Function(fsMod.readFileSync(path.join(ROOT, rel), 'utf8') + '\nreturn ' + cls + ';')();
+const VFS = load('core/virtual-fs.js', 'VirtualFileSystem');
+const Registry = load('core/module-agent-registry.js', 'AgentRegistryModule');
+
+let fails = 0, passes = 0;
+function ok(name, cond, detail) {
+ if (cond) { passes++; return; }
+ fails++;
+ console.log('FAIL ' + name + (detail ? ' — ' + detail : ''));
+}
+
+function mk() {
+ const out = [];
+ const vfs = new VFS();
+ vfs.saveToStorage = () => true;
+ const reg = Object.create(Registry.prototype);
+ reg.terminal = { print: (m, t) => out.push({ text: String(m), type: t || '' }), log: () => {}, fs: vfs };
+ reg._contracts = {};
+ reg._a2aMessages = [];
+ reg._a2a = { post: () => 'msg-1', unregister: () => {} };
+ // the real A2A helpers touch VFS paths; keep them inert for this test
+ reg.a2aRegister = () => {};
+ reg.a2aUnregister = () => {};
+ return { reg, vfs, out };
+}
+const text = out => out.map(o => o.text).join(' | ');
+
+// A. the command word is not the agent id, and every capability is kept
+{
+ const { reg, out } = mk();
+ reg.registerAgent(['register', 'orchestrator-0', 'delegate', 'spawn', 'monitor', 'purge']);
+ const s = JSON.parse(reg.terminal.fs.readFile('/agents/orchestrator-0/status.json') || 'null');
+ ok('A1 the agent is named orchestrator-0', !!s && s.id === 'orchestrator-0', text(out));
+ ok('A2 all four capabilities are stored', !!s && s.capabilities.length === 4, s ? JSON.stringify(s.capabilities) : 'no status');
+ ok('A3 no agent literally named "register"', !reg.terminal.fs.readDir('/agents/register'));
+ ok('A4 the boot message reports 4, not 1', /registered with 4 capabilities/.test(text(out)), text(out));
+}
+
+// B. comma-separated capabilities still work
+{
+ const { reg } = mk();
+ reg.registerAgent(['worker', 'read_file,write_file,list_files']);
+ const s = JSON.parse(reg.terminal.fs.readFile('/agents/worker/status.json'));
+ ok('B1 comma list parsed', s.capabilities.length === 3 && s.capabilities[1] === 'write_file', JSON.stringify(s.capabilities));
+}
+
+// C. registering from a subdirectory still writes /agents, never <cwd>/agents
+{
+ const { reg, vfs } = mk();
+ vfs.mkdirRecursive('/docs');
+ vfs.currentPath = ['docs'];
+ reg.registerAgent(['nested', 'a,b']);
+ ok('C1 the agent dir is at /agents/nested', !!vfs.readDir('/agents/nested'), JSON.stringify(vfs.readDir('/') || []));
+ ok('C2 nothing was created under /docs', !vfs.readDir('/docs/agents'));
+}
+
+// D. unregister removes the directory even when the cwd is elsewhere
+{
+ const { reg, vfs } = mk();
+ reg.registerAgent(['temp-agent', 'a']);
+ vfs.currentPath = ['docs'];
+ reg.unregisterAgent('temp-agent');
+ ok('D1 the directory is gone after unregister', !vfs.readDir('/agents/temp-agent'));
+}
+
+// E. a bare directory is not a registered agent
+{
+ const { reg, vfs, out } = mk();
+ reg.registerAgent(['real-agent', 'a,b']);
+ vfs.mkdirRecursive('/agents/hand-made');
+ reg.listAgents();
+ const t = text(out);
+ ok('E1 the registered agent is listed', /real-agent/.test(t), t);
+ ok('E2 the bare directory is not listed as an agent', !/hand-made — /.test(t), t);
+ ok('E3 it is reported as unregistered instead', /no status\.json/.test(t) && /hand-made/.test(t), t);
+}
+
+if (fails === 0) {
+ console.log('test-agent-registry: ' + passes + ' passed, 0 failed');
+} else {
+ console.log('test-agent-registry: ' + passes + ' passed, ' + fails + ' FAILED');
+ process.exit(1);
+}