aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
Diffstat (limited to 'modules')
-rw-r--r--modules/core/core-bundle.js200
-rw-r--r--modules/core/module-aiassist.js62
-rw-r--r--modules/core/module-billing.js33
-rw-r--r--modules/core/module-fetcher.js17
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/core/module-orchestrator.js88
-rw-r--r--modules/ext/cryptogzip.js67
-rw-r--r--modules/ext/media.js6
-rw-r--r--modules/ext/webdav.js20
-rw-r--r--modules/test/test-orchestrator-parallel-harvest.js132
10 files changed, 516 insertions, 111 deletions
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index 8b6e4cb..4483709 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)
@@ -9121,7 +9144,8 @@ buildSystemPrompt() {
method: { type: 'string', enum: ['GET', 'POST'], description: 'HTTP method (default GET)' },
body: { type: 'string', description: 'Request body for POST (JSON string, max 10KB)' },
content_type: { type: 'string', description: 'Content-Type header (default application/json for POST)' },
-max_chars: { type: 'integer', description: 'Max characters of page content to return (default from the model profile; max 50000)' },
+max_chars: { type: 'integer', description: 'Max characters of page content to return (default from the model profile; a page is capped by the tool-result budget, so page with offset/next_offset for longer pages)' },
+ offset: { type: 'integer', description: 'Start character offset into the extracted text (default 0). Use the next_offset from the previous reply to continue.' },
},
required: ['url']
}
@@ -10350,6 +10374,11 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
if (keep < obj.content.length) {
const c = obj.content.charCodeAt(keep - 1);
if (c >= 0xD800 && c <= 0xDBFF) keep -= 1;
+ } else if (obj.content.length <= keep) {
+ // The page fits; only the envelope is fat. Do not claim a loss that
+ // did not happen (measured 2026-11-06: "clipped: 0 of 11800 withheld"
+ // appended while every character was returned).
+ return JSON.stringify(obj);
}
const hadMore = (obj.next_offset !== null && obj.next_offset !== undefined);
const full = obj.content.length;
@@ -11601,9 +11630,14 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
const contentType = a.content_type || (method === 'POST' ? 'application/json' : '');
try {
// Use /api/fetch for POST, /api/extract for GET (extract is cleaner for readable content)
- const maxChars = Math.max(500, Math.min(parseInt(a.max_chars, 10) || this._toolLimit('fetchChars'), 50000));
+ // A page larger than toolResultChars-200 is a page the model never sees
+ // (measured 2026-11-06: asked 50 000, received 11 800, no next_offset).
+ const wanted = parseInt(a.max_chars, 10) || this._toolLimit('fetchChars');
+ const clipMax = this._toolLimit('toolResultChars');
+ const limit = Math.max(500, Math.min(wanted, clipMax - 200)); // request window
+ const offset = Math.max(0, parseInt(a.offset, 10) || 0);
const endpoint = method === 'POST' ? '/api/fetch' : '/api/extract';
- const params = new URLSearchParams({ url, max_chars: String(maxChars) });
+ const params = new URLSearchParams({ url, max_chars: String(Math.min(100000, offset + limit)) });
if (method === 'POST') { params.set('method', 'POST'); params.set('body', body); if (contentType) params.set('content_type', contentType); }
const resp = await fetch(endpoint + '?' + params.toString());
if (resp.status === 403) return JSON.stringify({ error: 'URL blocked — internal network' });
@@ -11611,18 +11645,47 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
const err = await resp.json().catch(() => ({}));
return JSON.stringify({ error: 'Fetch failed: ' + (err.error || `HTTP ${resp.status}`), status: resp.status, details: err.details || '' });
}
- // For POST/route, the response is raw text; for GET/extract it's JSON
+ // POST: /api/fetch returns raw text, so page it by characters.
if (method === 'POST') {
const text = await resp.text();
- return JSON.stringify({ method, content: text.substring(0, maxChars), content_chars: text.length,
- returned_chars: Math.min(text.length, maxChars), truncated: text.length > maxChars });
+ const start = Math.min(offset, text.length);
+ let lim = Math.max(500, Math.min(limit, clipMax - 200));
+ let env;
+ do {
+ const slice = text.slice(start, start + lim);
+ const nextOffset = start + slice.length < text.length ? start + slice.length : null;
+ env = { method, offset: start, content: slice, returned_chars: slice.length,
+ content_chars: text.length, total_chars: text.length, source_bytes: text.length,
+ truncated: nextOffset !== null, next_offset: nextOffset };
+ if (JSON.stringify(env).length <= clipMax || lim <= 500) break;
+ lim -= Math.max(1, JSON.stringify(env).length - clipMax);
+ } while (true);
+ return JSON.stringify(env);
}
+ // GET: /api/extract carries content_chars = the true length of the extracted
+ // text, so offset/next_offset are exact rather than hopeful.
const data = await resp.json();
- return JSON.stringify({ method, title: data.title || '', content: (data.content || '').substring(0, maxChars),
- content_chars: data.content_chars || (data.content || '').length,
- returned_chars: Math.min((data.content || '').length, maxChars),
- truncated: !!(data.truncated || (data.content_chars > maxChars)),
- word_count: data.word_count || 0, method_proxy: data.method || 'extract' });
+ const full = data.content || '';
+ // UTF-16 length when the proxy supplies it: Python len() counts code points,
+ // JS counts UTF-16 units, and 7 astral chars were enough to leave a 7-char
+ // gap at the end of a 90 KB page (measured 2026-11-06).
+ const totalChars = Number(data.content_chars_utf16) || Number(data.content_chars) || full.length;
+ let start = Math.min(offset, totalChars);
+ if (start > 0) { const prev = full.charCodeAt(start - 1); if (prev >= 0xD800 && prev <= 0xDBFF) start += 1; }
+ let lim = Math.max(500, Math.min(limit, clipMax - 200));
+ let env;
+ do {
+ const slice = full.slice(start, start + lim);
+ const nextOffset = start + slice.length < totalChars ? start + slice.length : null;
+ env = { method, title: data.title || '', offset: start, content: slice, returned_chars: slice.length,
+ content_chars: totalChars, total_chars: totalChars,
+ source_bytes: (data.source_bytes === undefined ? null : data.source_bytes),
+ truncated: nextOffset !== null, next_offset: nextOffset,
+ word_count: data.word_count || 0, method_proxy: data.method || 'extract' };
+ if (JSON.stringify(env).length <= clipMax || lim <= 500) break;
+ lim -= Math.max(1, JSON.stringify(env).length - clipMax);
+ } while (true);
+ return JSON.stringify(env);
} catch (e) { return JSON.stringify({ error: 'Fetch failed: ' + e.message }); }
}
case 'wpm_install': {
@@ -14039,7 +14102,7 @@ class WebFetcherModule {
}
getHelpInfo() {
return {
- 'fetch': 'fetch <url> [--post=BODY] [--type=CT] [--raw] — Fetch URL (raw HTML/text); --post enables POST with body, --type sets Content-Type, --raw shows untruncated content',
+ 'fetch': 'fetch <url> [--post=BODY] [--type=CT] [--raw] [--save=FILE] — Fetch URL (raw HTML/text); --post enables POST with body, --type sets Content-Type, --raw shows untruncated content, --save writes it to the VFS',
'extract': 'extract <url> — Extract readable article content (title + body text) from a URL',
};
}
@@ -14103,6 +14166,21 @@ class WebFetcherModule {
return;
}
const text = await resp.text();
+ // --save: the only terminal path from a remote URL to the VFS. Without it a
+ // 100 KB source page can only be read through 3 000-char scrollback, which is
+ // what sent the in-OS agent looking for a shell redirect (reported 2026-11-06).
+ if (flags.save) {
+ const path = String(flags.save === true ? (url.split('/').pop().split('?')[0] || 'fetch.txt') : flags.save);
+ const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
+ if (dir) { try { this.terminal.fs.mkdir(dir); } catch (e) {} }
+ const wrote = this.terminal.fs.writeFile(path, text);
+ if (!wrote || this.terminal.fs.readFile(path) !== text) {
+ this.terminal.print(`Save failed: could not write ${path}`, 'error');
+ return;
+ }
+ this.terminal.print(`💾 Saved ${text.length} chars to ${path}`, 'success');
+ return;
+ }
const limit = showRaw ? text.length : 3000;
this.terminal.print(`\n=== Content from ${url} (${method}) ===\n`, 'success');
this.terminal.print(text.substring(0, limit) + (text.length > limit && !showRaw ? '...' : ''));
@@ -17464,6 +17542,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 +17574,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 +17634,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 +17654,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 +17667,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 +17748,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 +17762,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-aiassist.js b/modules/core/module-aiassist.js
index 0a0ea71..a3ffb6f 100644
--- a/modules/core/module-aiassist.js
+++ b/modules/core/module-aiassist.js
@@ -226,7 +226,8 @@ buildSystemPrompt() {
method: { type: 'string', enum: ['GET', 'POST'], description: 'HTTP method (default GET)' },
body: { type: 'string', description: 'Request body for POST (JSON string, max 10KB)' },
content_type: { type: 'string', description: 'Content-Type header (default application/json for POST)' },
-max_chars: { type: 'integer', description: 'Max characters of page content to return (default from the model profile; max 50000)' },
+max_chars: { type: 'integer', description: 'Max characters of page content to return (default from the model profile; a page is capped by the tool-result budget, so page with offset/next_offset for longer pages)' },
+ offset: { type: 'integer', description: 'Start character offset into the extracted text (default 0). Use the next_offset from the previous reply to continue.' },
},
required: ['url']
}
@@ -1455,6 +1456,11 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
if (keep < obj.content.length) {
const c = obj.content.charCodeAt(keep - 1);
if (c >= 0xD800 && c <= 0xDBFF) keep -= 1;
+ } else if (obj.content.length <= keep) {
+ // The page fits; only the envelope is fat. Do not claim a loss that
+ // did not happen (measured 2026-11-06: "clipped: 0 of 11800 withheld"
+ // appended while every character was returned).
+ return JSON.stringify(obj);
}
const hadMore = (obj.next_offset !== null && obj.next_offset !== undefined);
const full = obj.content.length;
@@ -2706,9 +2712,14 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
const contentType = a.content_type || (method === 'POST' ? 'application/json' : '');
try {
// Use /api/fetch for POST, /api/extract for GET (extract is cleaner for readable content)
- const maxChars = Math.max(500, Math.min(parseInt(a.max_chars, 10) || this._toolLimit('fetchChars'), 50000));
+ // A page larger than toolResultChars-200 is a page the model never sees
+ // (measured 2026-11-06: asked 50 000, received 11 800, no next_offset).
+ const wanted = parseInt(a.max_chars, 10) || this._toolLimit('fetchChars');
+ const clipMax = this._toolLimit('toolResultChars');
+ const limit = Math.max(500, Math.min(wanted, clipMax - 200)); // request window
+ const offset = Math.max(0, parseInt(a.offset, 10) || 0);
const endpoint = method === 'POST' ? '/api/fetch' : '/api/extract';
- const params = new URLSearchParams({ url, max_chars: String(maxChars) });
+ const params = new URLSearchParams({ url, max_chars: String(Math.min(100000, offset + limit)) });
if (method === 'POST') { params.set('method', 'POST'); params.set('body', body); if (contentType) params.set('content_type', contentType); }
const resp = await fetch(endpoint + '?' + params.toString());
if (resp.status === 403) return JSON.stringify({ error: 'URL blocked — internal network' });
@@ -2716,18 +2727,47 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
const err = await resp.json().catch(() => ({}));
return JSON.stringify({ error: 'Fetch failed: ' + (err.error || `HTTP ${resp.status}`), status: resp.status, details: err.details || '' });
}
- // For POST/route, the response is raw text; for GET/extract it's JSON
+ // POST: /api/fetch returns raw text, so page it by characters.
if (method === 'POST') {
const text = await resp.text();
- return JSON.stringify({ method, content: text.substring(0, maxChars), content_chars: text.length,
- returned_chars: Math.min(text.length, maxChars), truncated: text.length > maxChars });
+ const start = Math.min(offset, text.length);
+ let lim = Math.max(500, Math.min(limit, clipMax - 200));
+ let env;
+ do {
+ const slice = text.slice(start, start + lim);
+ const nextOffset = start + slice.length < text.length ? start + slice.length : null;
+ env = { method, offset: start, content: slice, returned_chars: slice.length,
+ content_chars: text.length, total_chars: text.length, source_bytes: text.length,
+ truncated: nextOffset !== null, next_offset: nextOffset };
+ if (JSON.stringify(env).length <= clipMax || lim <= 500) break;
+ lim -= Math.max(1, JSON.stringify(env).length - clipMax);
+ } while (true);
+ return JSON.stringify(env);
}
+ // GET: /api/extract carries content_chars = the true length of the extracted
+ // text, so offset/next_offset are exact rather than hopeful.
const data = await resp.json();
- return JSON.stringify({ method, title: data.title || '', content: (data.content || '').substring(0, maxChars),
- content_chars: data.content_chars || (data.content || '').length,
- returned_chars: Math.min((data.content || '').length, maxChars),
- truncated: !!(data.truncated || (data.content_chars > maxChars)),
- word_count: data.word_count || 0, method_proxy: data.method || 'extract' });
+ const full = data.content || '';
+ // UTF-16 length when the proxy supplies it: Python len() counts code points,
+ // JS counts UTF-16 units, and 7 astral chars were enough to leave a 7-char
+ // gap at the end of a 90 KB page (measured 2026-11-06).
+ const totalChars = Number(data.content_chars_utf16) || Number(data.content_chars) || full.length;
+ let start = Math.min(offset, totalChars);
+ if (start > 0) { const prev = full.charCodeAt(start - 1); if (prev >= 0xD800 && prev <= 0xDBFF) start += 1; }
+ let lim = Math.max(500, Math.min(limit, clipMax - 200));
+ let env;
+ do {
+ const slice = full.slice(start, start + lim);
+ const nextOffset = start + slice.length < totalChars ? start + slice.length : null;
+ env = { method, title: data.title || '', offset: start, content: slice, returned_chars: slice.length,
+ content_chars: totalChars, total_chars: totalChars,
+ source_bytes: (data.source_bytes === undefined ? null : data.source_bytes),
+ truncated: nextOffset !== null, next_offset: nextOffset,
+ word_count: data.word_count || 0, method_proxy: data.method || 'extract' };
+ if (JSON.stringify(env).length <= clipMax || lim <= 500) break;
+ lim -= Math.max(1, JSON.stringify(env).length - clipMax);
+ } while (true);
+ return JSON.stringify(env);
} catch (e) { return JSON.stringify({ error: 'Fetch failed: ' + e.message }); }
}
case 'wpm_install': {
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-fetcher.js b/modules/core/module-fetcher.js
index 45505a7..d1b7bcc 100644
--- a/modules/core/module-fetcher.js
+++ b/modules/core/module-fetcher.js
@@ -5,7 +5,7 @@ class WebFetcherModule {
}
getHelpInfo() {
return {
- 'fetch': 'fetch <url> [--post=BODY] [--type=CT] [--raw] — Fetch URL (raw HTML/text); --post enables POST with body, --type sets Content-Type, --raw shows untruncated content',
+ 'fetch': 'fetch <url> [--post=BODY] [--type=CT] [--raw] [--save=FILE] — Fetch URL (raw HTML/text); --post enables POST with body, --type sets Content-Type, --raw shows untruncated content, --save writes it to the VFS',
'extract': 'extract <url> — Extract readable article content (title + body text) from a URL',
};
}
@@ -69,6 +69,21 @@ class WebFetcherModule {
return;
}
const text = await resp.text();
+ // --save: the only terminal path from a remote URL to the VFS. Without it a
+ // 100 KB source page can only be read through 3 000-char scrollback, which is
+ // what sent the in-OS agent looking for a shell redirect (reported 2026-11-06).
+ if (flags.save) {
+ const path = String(flags.save === true ? (url.split('/').pop().split('?')[0] || 'fetch.txt') : flags.save);
+ const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
+ if (dir) { try { this.terminal.fs.mkdir(dir); } catch (e) {} }
+ const wrote = this.terminal.fs.writeFile(path, text);
+ if (!wrote || this.terminal.fs.readFile(path) !== text) {
+ this.terminal.print(`Save failed: could not write ${path}`, 'error');
+ return;
+ }
+ this.terminal.print(`💾 Saved ${text.length} chars to ${path}`, 'success');
+ return;
+ }
const limit = showRaw ? text.length : 3000;
this.terminal.print(`\n=== Content from ${url} (${method}) ===\n`, 'success');
this.terminal.print(text.substring(0, limit) + (text.length > limit && !showRaw ? '...' : ''));
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index c9162d0..1f931fd 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.151';
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);
}
diff --git a/modules/ext/cryptogzip.js b/modules/ext/cryptogzip.js
index 200abdd..cc275df 100644
--- a/modules/ext/cryptogzip.js
+++ b/modules/ext/cryptogzip.js
@@ -15,7 +15,8 @@ class CryptoGzipModule {
'decrypt': 'decrypt <path.enc> <passphrase> — Decrypt a .enc file',
'tar': 'tar <src-dir> <out.tar[.gz]> — Bundle a VFS directory into a tar archive (gzip if .gz)',
'untar': 'untar <path.tar[.gz]> [dest-dir] — Extract a tar archive into VFS (auto-detects gzip)',
- 'wget': 'wget <url> [filename] — Download a URL and save to VFS'
+ 'wget': 'wget <url> [filename] — Download a URL (via /api/fetch) and save it to VFS',
+ 'curl': 'curl [-s] [-o FILE] <url> — Same as wget: proxy fetch + verified VFS write'
};
}
@@ -342,7 +343,10 @@ class CryptoGzipModule {
}
// ============================================================
- // WGET — fetch URL and save to VFS (binary-safe)
+ // WGET — fetch a URL through the /api/fetch proxy and save to VFS
+ // A direct browser fetch() only works same-origin: cross-origin URLs
+ // throw "Failed to fetch" (CORS), measured 2026-11-06 on the served
+ // v4.6.150 page. /api/fetch returns the raw source as UTF-8 text.
// ============================================================
async _cmdWget(args) {
@@ -350,20 +354,40 @@ class CryptoGzipModule {
this.terminal.print('Usage: wget <url> [filename] — Download a URL and save to VFS', 'warning');
return;
}
- const url = args[0];
- const filename = args[1] || url.split('/').pop() || 'download.bin';
+ const url = /^https?:\/\//i.test(args[0]) ? args[0] : 'https://' + args[0];
+ const filename = args[1] || url.split('/').pop().split('?')[0] || 'download.bin';
this.terminal.print(`Downloading ${url}...`, 'info');
+ let text;
try {
- const resp = await fetch(url);
- if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
- const buf = await resp.arrayBuffer();
- const bytes = new Uint8Array(buf);
- this._vfsWriteBytes(filename, bytes);
- const sizeKb = (bytes.length / 1024).toFixed(1);
- this.terminal.print(`✅ wget: ${filename} (${sizeKb} KB, ${bytes.length} bytes)`, 'success');
+ const resp = await fetch('/api/fetch?url=' + encodeURIComponent(url));
+ if (resp.status === 403) {
+ this.terminal.print('wget error: URL blocked — internal network', 'error');
+ return;
+ }
+ if (!resp.ok) {
+ let detail = 'HTTP ' + resp.status;
+ try { const j = await resp.json(); detail = j.error || detail; } catch (e) {}
+ this.terminal.print('wget error: ' + detail, 'error');
+ return;
+ }
+ text = await resp.text();
} catch (e) {
- this.terminal.print(`wget error: ${e.message}`, 'error');
+ this.terminal.print('wget error: ' + e.message, 'error');
+ return;
+ }
+ // writeFile() returns false rather than throwing when the parent directory is
+ // missing — the old code printed ✅ on that path (measured: writeFile('nosuchdir/x.txt') === false).
+ const dir = filename.includes('/') ? filename.slice(0, filename.lastIndexOf('/')) : '';
+ if (dir) { try { this.terminal.fs.mkdir(dir); } catch (e) {} }
+ if (!this.terminal.fs.writeFile(filename, text)) {
+ this.terminal.print(`wget error: could not write ${filename} (missing directory, or path is a directory)`, 'error');
+ return;
}
+ if (this.terminal.fs.readFile(filename) !== text) {
+ this.terminal.print(`wget error: write verification failed for ${filename}`, 'error');
+ return;
+ }
+ this.terminal.print(`✅ wget: ${filename} (${(text.length / 1024).toFixed(1)} KB, ${text.length} chars)`, 'success');
}
// ============================================================
@@ -582,6 +606,22 @@ class CryptoGzipModule {
this.commands['tar'] = async (args) => self._cmdTar(args);
this.commands['untar'] = async (args) => self._cmdUntar(args);
this.commands['wget'] = async (args) => self._cmdWget(args);
+ // curl — the flags an agent actually types. No shell semantics: -s/-L/-f are
+ // accepted and ignored, -o/--output selects the VFS target. Delegates to the
+ // single verified writer above so the two cannot drift.
+ this.commands['curl'] = async (args) => {
+ const positional = [];
+ let out = null;
+ for (let i = 0; i < args.length; i++) {
+ const a = String(args[i]);
+ if (a === '-o' || a === '-O' || a === '--output') { out = args[++i]; }
+ else if (a.startsWith('-')) { /* accepted, ignored */ }
+ else positional.push(a);
+ }
+ if (!positional[0]) { this.terminal.print('Usage: curl [-s] [-o FILE] <url>', 'warning'); return; }
+ const target = out || (positional[0].split('/').pop().split('?')[0] || 'download.bin');
+ return self._cmdWget([positional[0], target]);
+ };
this.commandsMeta = {
gzip: { argType: 'file', description: 'Compress a VFS file to .gz' },
gunzip: { argType: 'file', description: 'Decompress a .gz file (binary-safe)' },
@@ -589,7 +629,8 @@ class CryptoGzipModule {
decrypt: { argType: 'file', description: 'Decrypt a .enc file' },
tar: { argType: 'file', description: 'Bundle a VFS directory into a tar archive' },
untar: { argType: 'file', description: 'Extract a tar archive into VFS' },
- wget: { argType: 'url', description: 'Download a URL and save to VFS' }
+ wget: { argType: 'url', description: 'Download a URL and save to VFS' },
+ curl: { argType: 'url', description: 'curl [-s] [-o FILE] <url> — same as wget' }
};
}
}
diff --git a/modules/ext/media.js b/modules/ext/media.js
index 5723e27..bdc50e2 100644
--- a/modules/ext/media.js
+++ b/modules/ext/media.js
@@ -1,13 +1,13 @@
// newOS4 Media Analysis Module (v4.5.79)
// Commands: analyze <path> [--flags]
// Uses gemini-3-flash-preview:cloud for vision (images, video frames)
-// Uses deepseek-v4-flash:cloud for text analysis (audio features, summaries)
+// Uses deepseek-v4.1-flash:cloud for text analysis (audio features, summaries)
class MediaModule {
constructor() {
this.commands = {};
this.terminal = null;
this._visionModel = 'gemini-3-flash-preview:cloud';
- this._textModel = 'deepseek-v4-flash:cloud';
+ this._textModel = 'deepseek-v4.1-flash:cloud';
this._maxFrames = 10; // max video frames to analyze
this._frameInterval = 5; // seconds between video frames
}
@@ -118,7 +118,7 @@ class MediaModule {
}
// Send features to text model for AI interpretation
- this.terminal.print('\n🤖 AI interpretation (deepseek-v4-flash)...', 'info');
+ this.terminal.print('\n🤖 AI interpretation (deepseek-v4.1-flash)...', 'info');
const prompt = `Analyze these audio features and describe what the audio likely sounds like. Consider the genre, mood, and characteristics:
Duration: ${features.duration.toFixed(1)}s
diff --git a/modules/ext/webdav.js b/modules/ext/webdav.js
index a97a8bc..fa42b96 100644
--- a/modules/ext/webdav.js
+++ b/modules/ext/webdav.js
@@ -976,12 +976,20 @@ class WebDAVModule {
};
const mount = document.getElementById('wdav-tg-mount');
// A Telegram widget cannot log anyone in inside an Android WebView or an in-app
- // browser: it needs a popup that can postMessage back to the opener, and those hosts
- // hand the link to the system browser, which has no opener. Measured 2026-11-02 (the
- // operator's phone UA ends "...; wv)"): every attempt landed in Chrome and died there.
- // In that case skip the widget entirely and start the code flow, which needs no popup.
- const _tgWV = /;\s*wv\)/.test(navigator.userAgent || '') ||
- /\bTelegram\b/i.test(navigator.userAgent || '');
+ // browser: tapping it hands the whole tab to oauth.telegram.org and must come back to
+ // this page with #tgAuthResult. Those hosts either hand the navigation to the system
+ // browser (no way back) or never return. Measured 2026-11-03, operator's phone.
+ // Fallback only: modules/wx11-tg-webview.js is the source of truth when the host page
+ // loaded it (the dashboard does). This inline copy missed "Version/4.0" WebViews — the
+ // operator's own phone — until 2026-11-03, and the Meta/Line classes until the review
+ // that followed. Probe: /tg-widget-test.html.
+ const _ua = navigator.userAgent || '';
+ const _tgWV = (window.WX11TG && window.WX11TG.isWebView)
+ ? window.WX11TG.isWebView()
+ : (/;\s*wv\)/.test(_ua) ||
+ (/\bVersion\/4\.0\b/.test(_ua) && /Android/i.test(_ua) && !/\bSamsungBrowser\b/.test(_ua)) ||
+ /\bTelegram\b/i.test(_ua) ||
+ /\bFBAN\b|\bFBAV\b|\bInstagram\b|\bLine\//i.test(_ua));
const tw = document.createElement('script');
tw.src = 'https://telegram.org/js/telegram-widget.js?22';
tw.setAttribute('data-telegram-login', 'roducbot');
diff --git a/modules/test/test-orchestrator-parallel-harvest.js b/modules/test/test-orchestrator-parallel-harvest.js
new file mode 100644
index 0000000..4381699
--- /dev/null
+++ b/modules/test/test-orchestrator-parallel-harvest.js
@@ -0,0 +1,132 @@
+// node modules/test/test-orchestrator-parallel-harvest.js
+// Defect 2b (2026-11-02/03): orchestrate-parallel reported success it could not prove.
+//
+// Three separate lies, all in one block:
+// 1. The harvest re-found each sub-task's agent by task STRING (`currentTask === sub.goal ||
+// lastTask === sub.goal`). An agent that had already moved on to another sub-task no longer
+// carried the old goal, so a task that had demonstrably finished was dropped as
+// "agent not found — result unverifiable".
+// 2. The "N/N succeeded" header counted Promise.allSettled resolutions, not outcomes — a spawn
+// refusal returns terminal.print() (undefined), which FULFILS, so a batch where nothing ran
+// printed green.
+// 3. At pool cap, delegateTask stole a BUSY agent, so two sub-tasks overwrote
+// currentTask/leaseId and erased each other's results.
+//
+// This file pins the contract: results are harvested by sub-task id, the tally comes from the
+// summaries, and a sub-task that could not get an agent is a failure, never a green row.
+const fs = require('fs'), path = require('path');
+const ROOT = path.join(__dirname, '..', 'core');
+// Point ORCH_SRC at modules/core/core-bundle.js to run these assertions against the DEPLOYED
+// artifact instead of the source file — a green source test says nothing about the bundle.
+const SRC_FILE = process.env.ORCH_SRC ? path.resolve(process.env.ORCH_SRC) : path.join(ROOT, 'module-orchestrator.js');
+
+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.window = global;
+global.addEventListener = () => {};
+
+const Orch = new Function(fs.readFileSync(SRC_FILE, 'utf8') + '\nreturn OrchestratorModule;')();
+
+let fails = 0, passes = 0;
+const ok = (n, c, d) => { if (c) { passes++; return; } fails++; console.log('FAIL ' + n + (d ? ' — ' + d : '')); };
+
+// A VFS stub that behaves like the real one where it matters: paths normalise, and
+// fsReadJSON returns null (never throws) for a missing/corrupt file.
+function mkEnv(opts) {
+ const prints = [];
+ const files = new Map();
+ const norm = p => String(p).replace(/^\/+/, '');
+ const term = {
+ prints,
+ print(msg, type) { prints.push({ msg: String(msg), type: type || '' }); },
+ fs: {
+ readFile: p => (files.has(norm(p)) ? files.get(norm(p)) : null),
+ writeFile: (p, c) => { files.set(norm(p), c); return true; },
+ readDir: () => true,
+ mkdir: () => true,
+ rm: () => true,
+ fsReadJSON: p => { try { const raw = files.get(norm(p)); return raw === undefined ? null : JSON.parse(raw); } catch (e) { return null; } },
+ },
+ moduleManager: { modules: {}, _setInterval: () => 0, _setTimeout: () => 0 },
+ ollamaManager: null,
+ };
+ const ai = { _sessions: new Map(), chat: opts.chat, terminal: term };
+ term.moduleManager.modules['WebX11AIAssistantModule'] = ai;
+
+ const orch = Object.create(Orch.prototype);
+ orch.terminal = term;
+ orch.commands = {};
+ orch._agents = {};
+ orch._a2aMessages = {};
+ orch._eventBus = {};
+ orch._restartBackoffs = {};
+ orch._delegations = 0;
+ orch._SPAWN_LIMIT = opts.spawnLimit || 5;
+ orch._RESTART_POLICY = 'none';
+ return { orch, term, files, prints };
+}
+
+const GOAL = 'read alpha log and write beta report and check gamma status';
+const tick = (ms) => new Promise(r => setTimeout(r, ms || 5));
+
+(async () => {
+ // ── T1: three independent sub-tasks are harvested by identity, not by task string ──
+ {
+ const { orch, files, prints } = mkEnv({
+ chat: async (msgs) => { await tick(); return { content: 'ANSWER[' + msgs[0] + ']', toolTurns: 0 }; },
+ });
+ const merged = await orch.delegateTask(GOAL, { parallel: true });
+ const decomposed = prints.some(p => /Decomposed into 3 sub-tasks/.test(p.msg));
+ ok('T1a the goal decomposes into 3 sub-tasks (harness precondition)', decomposed, prints.map(p => p.msg).join(' | '));
+ ok('T1b all three sub-tasks are harvested', merged.summaries.length === 3, 'summaries=' + merged.summaries.length);
+ ok('T1c every sub-task carries its own answer', merged.summaries.every(s => s.status === 'ok' && /^ANSWER\[\[DELEGATED\]/.test(s.result || '')), JSON.stringify(merged.summaries.map(s => [s.status, s.result])));
+ ok('T1d no sub-task is discarded as unverifiable', !merged.summaries.some(s => /agent not found/.test(s.error || '')), JSON.stringify(merged.summaries.map(s => s.error)));
+ ok('T1e successes come from the summaries', merged.successes === 3 && merged.failures === 0, 'successes=' + merged.successes + ' failures=' + merged.failures);
+ const onDisk = JSON.parse(files.get('orchestrator/parallel-results.json'));
+ ok('T1f the persisted payload agrees with the returned tally', onDisk.successes === merged.successes && onDisk.summaries.length === 3, JSON.stringify({ onDisk: onDisk.successes, merged: merged.successes }));
+ ok('T1g each summary is keyed by its own sub-task id', new Set(merged.summaries.map(s => s.taskId)).size === 3, JSON.stringify(merged.summaries.map(s => s.taskId)));
+ }
+
+ // ── T2: at pool cap a sub-task is REFUSED, never run on a busy agent ──
+ // Old behaviour: all three ran on the one agent, clobbering each other, and the header
+ // still read "3/3 succeeded" while two summaries said failed.
+ {
+ const { orch, prints } = mkEnv({
+ spawnLimit: 1,
+ chat: async () => { await tick(10); return { content: 'single-agent answer', toolTurns: 0 }; },
+ });
+ const merged = await orch.delegateTask(GOAL, { parallel: true });
+ const oks = merged.summaries.filter(s => s.status === 'ok').length;
+ ok('T2a only the one agent that was free produced a result', oks === 1 && merged.successes === 1, 'ok=' + oks + ' successes=' + merged.successes);
+ ok('T2b the refused sub-tasks are reported as failures with a reason', merged.failures === 2 && merged.summaries.filter(s => s.status === 'failed').every(s => /idle|busy|limit/i.test(s.error || '')), JSON.stringify(merged.summaries.map(s => [s.status, s.error])));
+ ok('T2c the tally never exceeds the green rows', !merged.summaries.some(s => s.status !== 'ok') || merged.successes < merged.total, 'successes=' + merged.successes + '/' + merged.total);
+ const header = prints.map(p => p.msg).find(m => /Parallel batch:/.test(m)) || '';
+ ok('T2d the header reports the real outcome', /1\/3 succeeded, 2 failed/.test(header), header);
+ ok('T2e a batch with failures is not printed as a green header', !/^✅/.test(header), header);
+ }
+
+ // ── T3: a worker that answers nothing is a failure, not a success ──
+ {
+ const { orch } = mkEnv({
+ chat: async (msgs) => { await tick(); return { content: /beta/.test(msgs[0]) ? '' : 'ok answer', toolTurns: 0 }; },
+ });
+ const merged = await orch.delegateTask(GOAL, { parallel: true });
+ const silent = merged.summaries.filter(s => /beta/.test(s.task));
+ ok('T3a the silent worker is a failed row', silent.length === 1 && silent[0].status === 'failed', JSON.stringify(silent));
+ ok('T3b successes count the real answers only', merged.successes === 2 && merged.failures === 1, 'successes=' + merged.successes + ' failures=' + merged.failures);
+ }
+
+ // ── T4: a thrown sub-task is not swallowed, and does not inflate the tally ──
+ {
+ const { orch } = mkEnv({
+ chat: async (msgs) => { await tick(); if (/gamma/.test(msgs[0])) throw new Error('worker exploded'); return { content: 'fine', toolTurns: 0 }; },
+ });
+ const merged = await orch.delegateTask(GOAL, { parallel: true });
+ const bad = merged.summaries.filter(s => /gamma/.test(s.task));
+ ok('T4a the throwing sub-task is a failed row with its error', bad.length === 1 && bad[0].status === 'failed' && /worker exploded/.test(bad[0].error || ''), JSON.stringify(bad));
+ ok('T4b the tally reflects 2 real answers out of 3', merged.successes === 2 && merged.failures === 1, 'successes=' + merged.successes + ' failures=' + merged.failures);
+ }
+
+ console.log('test-orchestrator-parallel-harvest: ' + passes + ' passed, ' + fails + ' failed');
+ process.exit(fails ? 1 : 0);
+})();