aboutsummaryrefslogtreecommitdiff
path: root/modules/core
diff options
context:
space:
mode:
Diffstat (limited to 'modules/core')
-rw-r--r--modules/core/core-bundle.js121
-rw-r--r--modules/core/module-billing.js33
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/core/module-orchestrator.js88
4 files changed, 179 insertions, 65 deletions
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index 8b6e4cb..c37d13d 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -7773,8 +7773,10 @@ class OllamaModule {
// Billing / pricing surface for newOS4 (2026-10-28)
//
// Why: the terminal could show a model's name but never what it costs. Prices and the anonymous
-// message allowance are public data, so they need no login — the account BALANCE does, and newOS4
-// has no session layer, so that is reported honestly as "not signed in" rather than guessed.
+// message allowance are public data, so they need no login — the account BALANCE does.
+// 2026-11-03: newOS4 DOES have a session — the browser sends the HttpOnly cookie and
+// parse_bearer_token() reads it — so the balance below is real when logged in. The old comment
+// claimed otherwise and made the 401 look expected.
//
// Commands: cost [on|off], usage
class BillingModule {
@@ -7828,7 +7830,9 @@ class BillingModule {
}
async _loadBalance() {
- // Only answers when a session exists; newOS4 currently has none, so this is expected to 401.
+ // Cookie auth is what makes this work: the browser sends webx11_session_token and
+ // parse_bearer_token() falls back to it. If this 401s, the user is logged out — say so,
+ // do not assume newOS4 cannot authenticate (that comment was wrong until 2026-11-03).
try {
const r = await fetch('/api/billing/subscription', { credentials: 'include' });
if (!r.ok) return null;
@@ -7839,6 +7843,16 @@ class BillingModule {
}
}
+ async _loadUsage(days) {
+ try {
+ const r = await fetch('/api/billing/usage?days=' + (days || 1), { credentials: 'include' });
+ if (!r.ok) return null;
+ return await r.json();
+ } catch (e) {
+ return null;
+ }
+ }
+
// --- session spend, measured where the tokens actually are ----------------
_hookUsage() {
const om = this.terminal && this.terminal.ollamaManager;
@@ -7881,8 +7895,10 @@ class BillingModule {
}
}
+ // On by default since 2026-11-03: "the terminal shows tokens but not cost" was the default,
+ // not a bug — the flag was opt-in and nobody knew the command existed. `cost off` opts out.
_costLineOn() {
- try { return localStorage.getItem('webx11_cost_line') === '1'; } catch (e) { return false; }
+ try { return localStorage.getItem('webx11_cost_line') !== '0'; } catch (e) { return true; }
}
// --- the command ----------------------------------------------------------
@@ -7910,13 +7926,20 @@ class BillingModule {
p('📊 Free allowance: unavailable (billing API did not answer)', 'warning');
}
- p(`💰 Balance: ${bal && bal.balance_usd != null ? '$' + Number(bal.balance_usd).toFixed(2) : 'not signed in — newOS4 sends no session token, so a balance cannot be shown here yet'}`, 'info');
+ p(`💰 Balance: ${bal && bal.balance_usd != null ? '$' + Number(bal.balance_usd).toFixed(2) : 'not signed in — log in at /spaces/dashboard.html to see your balance'}`, 'info');
if (bal && bal.plan_allowance) {
const pa = bal.plan_allowance;
p(`🎁 Included usage (${pa.plan}): $${Number(pa.used_usd).toFixed(2)} of $${Number(pa.limit_usd).toFixed(2)} ${pa.period === 'day' ? 'today' : 'this month'} — $${Number(pa.remaining_usd).toFixed(2)} left, then your balance pays`, 'info');
}
+ const u = await this._loadUsage(1);
+ if (u && u.totals) {
+ p(`📅 Today (all newOS4 sessions): ${u.totals.requests} call(s), ` +
+ `${((u.totals.prompt_tokens + u.totals.completion_tokens) / 1000).toFixed(1)}k tokens, ` +
+ `≈ $${Number(u.totals.retail_usd).toFixed(2)} retail`, 'info');
+ }
+
if (s && s.msgs) {
p(`🧮 This session: ${s.msgs} call(s), ${(s.prompt / 1000).toFixed(1)}k in / ${(s.completion / 1000).toFixed(1)}k out ≈ $${s.cost.toFixed(4)} retail`, 'info');
Object.entries(s.byModel)
@@ -17464,6 +17487,18 @@ class OrchestratorModule {
if (!task) return this.terminal.print('Usage: orchestrate <task description>', 'error');
this._delegations = (this._delegations || 0) + 1;
+ // Parallel fan-out bookkeeping. A sub-task carries its own id, and the batch that
+ // spawned it hands in a sink, so the harvest below matches results by identity —
+ // never by a task string that a later delegation on the same agent can overwrite.
+ const taskId = opts.taskId || null;
+ const resultSink = opts.resultSink || null;
+ const recordResult = (status, payload) => {
+ if (!taskId || !resultSink || typeof resultSink.set !== 'function') return;
+ resultSink.set(taskId, Object.assign({
+ taskId, task, status, at: new Date().toISOString()
+ }, payload || {}));
+ };
+
// Parallel fan-out: decompose and run sub-tasks concurrently
if (opts.parallel) {
const parts = this._decomposeGoal(task);
@@ -17484,38 +17519,45 @@ class OrchestratorModule {
}
}
- const results = await Promise.allSettled(parts.map(sub => this.delegateTask(sub.goal)));
- const successes = results.filter(r => r.status === 'fulfilled').length;
- const failures = results.filter(r => r.status === 'rejected').length;
+ const runId = 'pb' + Date.now().toString(36) + '-' + (this._parallelSeq = (this._parallelSeq || 0) + 1);
+ const resultSink = new Map();
+ const results = await Promise.allSettled(parts.map((sub, i) =>
+ this.delegateTask(sub.goal, { taskId: runId + ':' + i, resultSink })));
- // Result synthesis: collect outbox results from each sub-task's agent
+ // Result synthesis: one row per sub-task, looked up by the id handed to it.
const summaries = [];
for (let i = 0; i < parts.length; i++) {
const sub = parts[i];
const r = results[i];
- if (r.status === 'fulfilled') {
- // Find the agent that handled this sub-task
- const agent = Object.values(this._agents).find(a => a.currentTask === sub.goal || (a.lastTask === sub.goal));
- if (!agent) {
- // "ok / completed (agent recycled)" told the caller a task succeeded
- // when we could not even find its agent.
- summaries.push({ task: sub.goal, status: 'failed', error: 'agent not found — result unverifiable' });
- continue;
- }
- const outbox = this.terminal.fs.fsReadJSON(`/agents/${agent.id}/outbox.json`);
- const body = outbox && typeof outbox.result === 'string' ? outbox.result : '';
- const snippet = body.substring(0, 200);
+ const subTaskId = runId + ':' + i;
+ const rec = resultSink.get(subTaskId);
+ if (rec) {
summaries.push({
- task: sub.goal, agent: agent.id,
- status: body ? 'ok' : 'failed',
- result: body || null,
- error: body ? undefined : (outbox && outbox.error) || 'no result in outbox'
+ task: sub.goal,
+ taskId: subTaskId,
+ agent: rec.agentId,
+ status: rec.status === 'ok' ? 'ok' : 'failed',
+ result: rec.status === 'ok' ? (rec.result || null) : null,
+ error: rec.status === 'ok' ? undefined : (rec.error || 'no result recorded')
});
- } else {
- summaries.push({ task: sub.goal, status: 'failed', error: r.reason?.message || 'unknown error' });
+ continue;
}
+ // The call died before it could report itself (a throw from a path that never
+ // reached a recordResult call): the promise state is the only evidence left.
+ summaries.push({
+ task: sub.goal,
+ taskId: subTaskId,
+ status: 'failed',
+ error: (r.status === 'rejected' && ((r.reason && r.reason.message) || String(r.reason))) || 'no result recorded'
+ });
}
+ // The tally IS the summaries — never the promise states. allSettled counts
+ // resolutions, and a spawn refusal returns terminal.print() (undefined), which
+ // FULFILS: that is how "3/3 succeeded" printed over three failed rows.
+ const successes = summaries.filter(s => s.status === 'ok').length;
+ const failures = summaries.length - successes;
+
// Store merged result to VFS for persistence
const merged = {
type: 'parallel_result',
@@ -17537,7 +17579,8 @@ class OrchestratorModule {
const snippet = s.result ? s.result.replace(/\n/g, ' ').substring(0, 60) : '';
this.terminal.print(' ' + icon + ' ' + s.task.substring(0, 35) + agentTag + ' → ' + snippet, 'info');
}
- this.terminal.print('✅ Parallel batch: ' + successes + '/' + parts.length + ' succeeded' + (failures > 0 ? ', ' + failures + ' failed' : ''), 'header');
+ // The header must agree with the rows above it: ✅ only when nothing failed.
+ this.terminal.print((failures === 0 ? '✅' : '❌') + ' Parallel batch: ' + successes + '/' + parts.length + ' succeeded' + (failures > 0 ? ', ' + failures + ' failed' : ''), failures === 0 ? 'header' : 'error');
return merged;
}
// Single sub-task — fall through to normal path
@@ -17556,8 +17599,10 @@ class OrchestratorModule {
// Auto-spawn with role-derived capabilities
if (!agent) {
if (Object.keys(this._agents).length >= this._SPAWN_LIMIT) {
- // Try finding any non-failed agent to reuse
- const reuse = Object.values(this._agents).find(a => a.status !== 'failed' && a.status !== 'sleeping');
+ // Reuse an agent that is NOT running. A BUSY agent must never be handed a
+ // second task: concurrent sub-tasks then overwrite currentTask/leaseId and
+ // erase each other's results (reproduced 4/4 — Defect 2b).
+ const reuse = Object.values(this._agents).find(a => a.status !== 'busy' && a.status !== 'failed');
if (reuse) {
reuse.status = 'busy';
agent = reuse;
@@ -17567,11 +17612,18 @@ class OrchestratorModule {
const role = this._deriveRole(task);
const caps = this._deriveCapabilities(task);
agent = this.spawnAgent(role, caps);
- if (!agent) return this.terminal.print('❌ No available agents — spawn limit reached', 'error');
+ if (!agent) {
+ const msg = 'no idle agent — ' + Object.keys(this._agents).length + '/' + this._SPAWN_LIMIT + ' agents busy, refusing to share one';
+ recordResult('failed', { error: msg });
+ return this.terminal.print('❌ ' + msg, 'error');
+ }
agent.status = 'busy'; // claim immediately after spawn
}
}
- if (!agent) return this.terminal.print('❌ No available agents', 'error');
+ if (!agent) {
+ recordResult('failed', { error: 'no agent available' });
+ return this.terminal.print('❌ No available agents', 'error');
+ }
// A2A Contract handshake
const registry = this.terminal.moduleManager.modules['AgentRegistryModule'];
@@ -17641,6 +17693,9 @@ class OrchestratorModule {
// Return result for callers that await delegateTask
outboxResult = _answer; // '' when the worker returned nothing; callers see that
+ recordResult(_answer ? 'ok' : 'failed', _answer
+ ? { result: _answer, agentId: agent.id }
+ : { agentId: agent.id, error: 'agent produced no answer' });
// Check restart policy — if always, set back to idle for next task
if (agent.restartPolicy === 'always') {
@@ -17652,8 +17707,10 @@ class OrchestratorModule {
} else {
this.terminal.print(`ℹ️ AI module not available — task queued for ${agent.id}`, 'info');
outboxResult = 'queued — AI module not loaded, nothing executed';
+ recordResult('failed', { agentId: agent.id, error: 'AI module not loaded — nothing executed' });
}
} catch (e) {
+ recordResult('failed', { agentId: agent.id, error: e.message || String(e) });
this._handleAgentFailure(agent.id, e.message);
}
diff --git a/modules/core/module-billing.js b/modules/core/module-billing.js
index 87f8dc3..4ccf196 100644
--- a/modules/core/module-billing.js
+++ b/modules/core/module-billing.js
@@ -1,8 +1,10 @@
// Billing / pricing surface for newOS4 (2026-10-28)
//
// Why: the terminal could show a model's name but never what it costs. Prices and the anonymous
-// message allowance are public data, so they need no login — the account BALANCE does, and newOS4
-// has no session layer, so that is reported honestly as "not signed in" rather than guessed.
+// message allowance are public data, so they need no login — the account BALANCE does.
+// 2026-11-03: newOS4 DOES have a session — the browser sends the HttpOnly cookie and
+// parse_bearer_token() reads it — so the balance below is real when logged in. The old comment
+// claimed otherwise and made the 401 look expected.
//
// Commands: cost [on|off], usage
class BillingModule {
@@ -56,7 +58,9 @@ class BillingModule {
}
async _loadBalance() {
- // Only answers when a session exists; newOS4 currently has none, so this is expected to 401.
+ // Cookie auth is what makes this work: the browser sends webx11_session_token and
+ // parse_bearer_token() falls back to it. If this 401s, the user is logged out — say so,
+ // do not assume newOS4 cannot authenticate (that comment was wrong until 2026-11-03).
try {
const r = await fetch('/api/billing/subscription', { credentials: 'include' });
if (!r.ok) return null;
@@ -67,6 +71,16 @@ class BillingModule {
}
}
+ async _loadUsage(days) {
+ try {
+ const r = await fetch('/api/billing/usage?days=' + (days || 1), { credentials: 'include' });
+ if (!r.ok) return null;
+ return await r.json();
+ } catch (e) {
+ return null;
+ }
+ }
+
// --- session spend, measured where the tokens actually are ----------------
_hookUsage() {
const om = this.terminal && this.terminal.ollamaManager;
@@ -109,8 +123,10 @@ class BillingModule {
}
}
+ // On by default since 2026-11-03: "the terminal shows tokens but not cost" was the default,
+ // not a bug — the flag was opt-in and nobody knew the command existed. `cost off` opts out.
_costLineOn() {
- try { return localStorage.getItem('webx11_cost_line') === '1'; } catch (e) { return false; }
+ try { return localStorage.getItem('webx11_cost_line') !== '0'; } catch (e) { return true; }
}
// --- the command ----------------------------------------------------------
@@ -138,13 +154,20 @@ class BillingModule {
p('📊 Free allowance: unavailable (billing API did not answer)', 'warning');
}
- p(`💰 Balance: ${bal && bal.balance_usd != null ? '$' + Number(bal.balance_usd).toFixed(2) : 'not signed in — newOS4 sends no session token, so a balance cannot be shown here yet'}`, 'info');
+ p(`💰 Balance: ${bal && bal.balance_usd != null ? '$' + Number(bal.balance_usd).toFixed(2) : 'not signed in — log in at /spaces/dashboard.html to see your balance'}`, 'info');
if (bal && bal.plan_allowance) {
const pa = bal.plan_allowance;
p(`🎁 Included usage (${pa.plan}): $${Number(pa.used_usd).toFixed(2)} of $${Number(pa.limit_usd).toFixed(2)} ${pa.period === 'day' ? 'today' : 'this month'} — $${Number(pa.remaining_usd).toFixed(2)} left, then your balance pays`, 'info');
}
+ const u = await this._loadUsage(1);
+ if (u && u.totals) {
+ p(`📅 Today (all newOS4 sessions): ${u.totals.requests} call(s), ` +
+ `${((u.totals.prompt_tokens + u.totals.completion_tokens) / 1000).toFixed(1)}k tokens, ` +
+ `≈ $${Number(u.totals.retail_usd).toFixed(2)} retail`, 'info');
+ }
+
if (s && s.msgs) {
p(`🧮 This session: ${s.msgs} call(s), ${(s.prompt / 1000).toFixed(1)}k in / ${(s.completion / 1000).toFixed(1)}k out ≈ $${s.cost.toFixed(4)} retail`, 'info');
Object.entries(s.byModel)
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index c9162d0..1005463 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.146';
+ const LATEST_VER = 'v4.6.150';
const CACHE = '?v=' + LATEST_VER;
// Exposed so ext modules can register their own URL with the same cache-busting version
// instead of an unversioned path the browser may already have cached (shell.js did).
diff --git a/modules/core/module-orchestrator.js b/modules/core/module-orchestrator.js
index 6b8ee18..05d7392 100644
--- a/modules/core/module-orchestrator.js
+++ b/modules/core/module-orchestrator.js
@@ -1084,6 +1084,18 @@ class OrchestratorModule {
if (!task) return this.terminal.print('Usage: orchestrate <task description>', 'error');
this._delegations = (this._delegations || 0) + 1;
+ // Parallel fan-out bookkeeping. A sub-task carries its own id, and the batch that
+ // spawned it hands in a sink, so the harvest below matches results by identity —
+ // never by a task string that a later delegation on the same agent can overwrite.
+ const taskId = opts.taskId || null;
+ const resultSink = opts.resultSink || null;
+ const recordResult = (status, payload) => {
+ if (!taskId || !resultSink || typeof resultSink.set !== 'function') return;
+ resultSink.set(taskId, Object.assign({
+ taskId, task, status, at: new Date().toISOString()
+ }, payload || {}));
+ };
+
// Parallel fan-out: decompose and run sub-tasks concurrently
if (opts.parallel) {
const parts = this._decomposeGoal(task);
@@ -1104,38 +1116,45 @@ class OrchestratorModule {
}
}
- const results = await Promise.allSettled(parts.map(sub => this.delegateTask(sub.goal)));
- const successes = results.filter(r => r.status === 'fulfilled').length;
- const failures = results.filter(r => r.status === 'rejected').length;
+ const runId = 'pb' + Date.now().toString(36) + '-' + (this._parallelSeq = (this._parallelSeq || 0) + 1);
+ const resultSink = new Map();
+ const results = await Promise.allSettled(parts.map((sub, i) =>
+ this.delegateTask(sub.goal, { taskId: runId + ':' + i, resultSink })));
- // Result synthesis: collect outbox results from each sub-task's agent
+ // Result synthesis: one row per sub-task, looked up by the id handed to it.
const summaries = [];
for (let i = 0; i < parts.length; i++) {
const sub = parts[i];
const r = results[i];
- if (r.status === 'fulfilled') {
- // Find the agent that handled this sub-task
- const agent = Object.values(this._agents).find(a => a.currentTask === sub.goal || (a.lastTask === sub.goal));
- if (!agent) {
- // "ok / completed (agent recycled)" told the caller a task succeeded
- // when we could not even find its agent.
- summaries.push({ task: sub.goal, status: 'failed', error: 'agent not found — result unverifiable' });
- continue;
- }
- const outbox = this.terminal.fs.fsReadJSON(`/agents/${agent.id}/outbox.json`);
- const body = outbox && typeof outbox.result === 'string' ? outbox.result : '';
- const snippet = body.substring(0, 200);
+ const subTaskId = runId + ':' + i;
+ const rec = resultSink.get(subTaskId);
+ if (rec) {
summaries.push({
- task: sub.goal, agent: agent.id,
- status: body ? 'ok' : 'failed',
- result: body || null,
- error: body ? undefined : (outbox && outbox.error) || 'no result in outbox'
+ task: sub.goal,
+ taskId: subTaskId,
+ agent: rec.agentId,
+ status: rec.status === 'ok' ? 'ok' : 'failed',
+ result: rec.status === 'ok' ? (rec.result || null) : null,
+ error: rec.status === 'ok' ? undefined : (rec.error || 'no result recorded')
});
- } else {
- summaries.push({ task: sub.goal, status: 'failed', error: r.reason?.message || 'unknown error' });
+ continue;
}
+ // The call died before it could report itself (a throw from a path that never
+ // reached a recordResult call): the promise state is the only evidence left.
+ summaries.push({
+ task: sub.goal,
+ taskId: subTaskId,
+ status: 'failed',
+ error: (r.status === 'rejected' && ((r.reason && r.reason.message) || String(r.reason))) || 'no result recorded'
+ });
}
+ // The tally IS the summaries — never the promise states. allSettled counts
+ // resolutions, and a spawn refusal returns terminal.print() (undefined), which
+ // FULFILS: that is how "3/3 succeeded" printed over three failed rows.
+ const successes = summaries.filter(s => s.status === 'ok').length;
+ const failures = summaries.length - successes;
+
// Store merged result to VFS for persistence
const merged = {
type: 'parallel_result',
@@ -1157,7 +1176,8 @@ class OrchestratorModule {
const snippet = s.result ? s.result.replace(/\n/g, ' ').substring(0, 60) : '';
this.terminal.print(' ' + icon + ' ' + s.task.substring(0, 35) + agentTag + ' → ' + snippet, 'info');
}
- this.terminal.print('✅ Parallel batch: ' + successes + '/' + parts.length + ' succeeded' + (failures > 0 ? ', ' + failures + ' failed' : ''), 'header');
+ // The header must agree with the rows above it: ✅ only when nothing failed.
+ this.terminal.print((failures === 0 ? '✅' : '❌') + ' Parallel batch: ' + successes + '/' + parts.length + ' succeeded' + (failures > 0 ? ', ' + failures + ' failed' : ''), failures === 0 ? 'header' : 'error');
return merged;
}
// Single sub-task — fall through to normal path
@@ -1176,8 +1196,10 @@ class OrchestratorModule {
// Auto-spawn with role-derived capabilities
if (!agent) {
if (Object.keys(this._agents).length >= this._SPAWN_LIMIT) {
- // Try finding any non-failed agent to reuse
- const reuse = Object.values(this._agents).find(a => a.status !== 'failed' && a.status !== 'sleeping');
+ // Reuse an agent that is NOT running. A BUSY agent must never be handed a
+ // second task: concurrent sub-tasks then overwrite currentTask/leaseId and
+ // erase each other's results (reproduced 4/4 — Defect 2b).
+ const reuse = Object.values(this._agents).find(a => a.status !== 'busy' && a.status !== 'failed');
if (reuse) {
reuse.status = 'busy';
agent = reuse;
@@ -1187,11 +1209,18 @@ class OrchestratorModule {
const role = this._deriveRole(task);
const caps = this._deriveCapabilities(task);
agent = this.spawnAgent(role, caps);
- if (!agent) return this.terminal.print('❌ No available agents — spawn limit reached', 'error');
+ if (!agent) {
+ const msg = 'no idle agent — ' + Object.keys(this._agents).length + '/' + this._SPAWN_LIMIT + ' agents busy, refusing to share one';
+ recordResult('failed', { error: msg });
+ return this.terminal.print('❌ ' + msg, 'error');
+ }
agent.status = 'busy'; // claim immediately after spawn
}
}
- if (!agent) return this.terminal.print('❌ No available agents', 'error');
+ if (!agent) {
+ recordResult('failed', { error: 'no agent available' });
+ return this.terminal.print('❌ No available agents', 'error');
+ }
// A2A Contract handshake
const registry = this.terminal.moduleManager.modules['AgentRegistryModule'];
@@ -1261,6 +1290,9 @@ class OrchestratorModule {
// Return result for callers that await delegateTask
outboxResult = _answer; // '' when the worker returned nothing; callers see that
+ recordResult(_answer ? 'ok' : 'failed', _answer
+ ? { result: _answer, agentId: agent.id }
+ : { agentId: agent.id, error: 'agent produced no answer' });
// Check restart policy — if always, set back to idle for next task
if (agent.restartPolicy === 'always') {
@@ -1272,8 +1304,10 @@ class OrchestratorModule {
} else {
this.terminal.print(`ℹ️ AI module not available — task queued for ${agent.id}`, 'info');
outboxResult = 'queued — AI module not loaded, nothing executed';
+ recordResult('failed', { agentId: agent.id, error: 'AI module not loaded — nothing executed' });
}
} catch (e) {
+ recordResult('failed', { agentId: agent.id, error: e.message || String(e) });
this._handleAgentFailure(agent.id, e.message);
}