aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--help.html12
-rw-r--r--main.html6
-rw-r--r--modules/core/core-bundle.js159
-rw-r--r--modules/core/module-debug-supervisor.js159
-rw-r--r--modules/core/module-loader.js2
-rwxr-xr-xscripts/newos4-feedback-apply.py203
-rwxr-xr-xscripts/newos4-feedback-collect.py147
7 files changed, 681 insertions, 7 deletions
diff --git a/help.html b/help.html
index 4eec9ef..9a06399 100644
--- a/help.html
+++ b/help.html
@@ -483,7 +483,9 @@ diff a.txt b.txt</pre>
<!-- ── Commands ── -->
<section id="commands">
- <h2>📖 Full Command Reference</h2>
+ <p>📖 <b>New to feedback?</b> <a href="/feedback-guide.html">How feedback works</a> — which channel to use, what gets sent, and when to expect an answer.</p>
+
+<h2>📖 Full Command Reference</h2>
<div class="callout">
<strong>⚠️ There is no shell.</strong> The terminal splits your line into argv and hands it to
@@ -557,6 +559,14 @@ diff a.txt b.txt</pre>
<tr><td><code>debug-trends</code></td><td>Error frequency over time, grouped by day and type</td><td><code>debug-trends 7</code></td></tr>
<tr><td><code>debug-fix</code></td><td>Suggest and optionally auto-apply fix for an error</td><td><code>debug-fix a1b2c3d4</code></td></tr>
<tr><td><code>debug-health</code></td><td>Check DebugSupervisorModule health status</td><td><code>debug-health</code></td></tr>
+ <tr><td><code>feedback &lt;message&gt;</code></td><td>Send a message to the operator agent (alias <code>hermes</code>); <code>feedback -- &lt;text&gt;</code> forces a message</td><td><code>feedback download link 403s on mobile</code></td></tr>
+ <tr><td><code>feedback list [status]</code></td><td>List stored /stear reports — status <code>new</code>, <code>read</code> or <code>archived</code> (admin)</td><td><code>feedback list new</code></td></tr>
+ <tr><td><code>feedback read &lt;id&gt;</code></td><td>Show one report in full, including any reply (admin)</td><td><code>feedback read 12</code></td></tr>
+ <tr><td><code>feedback reply &lt;id&gt; &lt;text&gt;</code></td><td>Publish a reply — the reporter sees it in /stear → My reports; status becomes read (admin)</td><td><code>feedback reply 12 shipped in v4.6.92</code></td></tr>
+ <tr><td><code>feedback archive|reopen &lt;id&gt;</code></td><td>Move a report between archived and new (admin)</td><td><code>feedback archive 12</code></td></tr>
+ <tr><td><code>feedback stats</code></td><td>Counts per status plus the dashboard URL (admin)</td><td><code>feedback stats</code></td></tr>
+ <tr><td><code>feedback dashboard</code></td><td>Open /admin/feedback.html in a new tab (admin)</td><td><code>feedback dashboard</code></td></tr>
+ <tr><td><code>feedback-admin &lt;sub&gt;</code></td><td>Admin alias for the feedback subcommands (<code>list|read|reply|archive|reopen|stats|dashboard</code>)</td><td><code>feedback-admin stats</code></td></tr>
</table></div>
diff --git a/main.html b/main.html
index 921c8a8..a44540d 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.91"></script>
+<script src="modules/core/core-bundle.js?v=v4.6.94"></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.91"></script>
+<script src="modules/core/module-loader.js?v=v4.6.94"></script>
<!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work -->
-<script src="modules/ext/critical-ext-bundle.js?v=v4.6.91"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=v4.6.94"></script>
<script>
// ═══ ONE TRUE VERSION SOURCE ═══
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index 8e19189..32ff73b 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -20753,6 +20753,9 @@ class DebugSupervisorModule {
this.commands['feedback-inbox'] = () => this._cmdFeedbackInbox();
this.commands['hermes-poll'] = () => this._cmdFeedbackInbox();
this.commands['hermes-reset'] = () => this._cmdChatReset();
+ // Admin management of stored /stear reports (2026-10-26). `feedback <text>` still
+ // sends a chat message: a subcommand needs plausible arguments (see _cmdFeedback).
+ this.commands['feedback-admin'] = (args) => this._fbDispatch(args);
}
seed() {
@@ -21650,9 +21653,25 @@ class DebugSupervisorModule {
}
_cmdFeedback(args) {
- const msg = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim();
+ const raw = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim();
+ const argv = raw.split(/\s+/);
+ const sub = (argv[0] || '').toLowerCase();
+ // A subcommand only when its ARGUMENTS are plausible. Measured failure without this
+ // check: "feedback list is broken on mobile" became a filtered list of reports.
+ const STATUS = ['', 'new', 'read', 'archived'];
+ const isNum = (v) => /^\d+$/.test(v || '');
+ const forced = raw.startsWith('--');
+ const isSub = !forced && (
+ (sub === 'list' && STATUS.indexOf((argv[1] || '').toLowerCase()) >= 0) ||
+ ((sub === 'stats' || sub === 'dashboard' || sub === 'help') && argv.length === 1) ||
+ (['read', 'archive', 'reopen'].indexOf(sub) >= 0 && isNum(argv[1])) ||
+ (sub === 'reply' && isNum(argv[1]) && argv.length >= 3)
+ );
+ if (isSub) { this._fbDispatch(args); return; }
+ const msg = forced ? raw.replace(/^--\s*/, '') : raw;
if (!msg) {
this.terminal.print('usage: hermes <message> (alias: feedback <message>)', 'error');
+ this.terminal.print(' feedback list|read|reply|archive|reopen|stats|dashboard|help', '');
return;
}
this.terminal.print('📨 Sending…', '');
@@ -21688,6 +21707,144 @@ class DebugSupervisorModule {
});
}
+ // ── feedback management (admin) ──────────────────────────────────────────
+ _fbHelp() {
+ const rows = [
+ 'feedback <message> send a message to the operator agent (alias: hermes)',
+ 'feedback list [status] list stored /stear reports (new|read|archived, default all)',
+ 'feedback read <id> show one report in full',
+ 'feedback reply <id> <t> publish a reply (status becomes read, user sees it)',
+ 'feedback archive <id> archive a report',
+ 'feedback reopen <id> move a report back to new',
+ 'feedback stats counts per status',
+ 'feedback dashboard open /admin/feedback.html in a new tab',
+ 'feedback-inbox read agent replies from the chat bridge',
+ ];
+ this.terminal.print('feedback — commands', '');
+ rows.forEach(r => this.terminal.print(' ' + r, ''));
+ this.terminal.print(' admin subcommands need a valid admin session (log in at /admin-login.html)', '');
+ }
+
+ async _fbToken() {
+ if (!this._fbKey) {
+ const r = await fetch('/admin/api/admin-token.php', { credentials: 'same-origin' });
+ if (!r.ok) throw new Error(r.status === 302 || r.redirected ? 'admin session required' : 'token HTTP ' + r.status);
+ const d = await r.json();
+ if (!d || !d.token) throw new Error('no admin token in response');
+ this._fbKey = d.token;
+ }
+ const w = Math.floor(Date.now() / 1000 / 300) * 300;
+ const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(this._fbKey + ':' + w));
+ return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
+ }
+
+ async _fbCall(path, opts) {
+ const tok = await this._fbToken();
+ const o = Object.assign({ credentials: 'same-origin' }, opts || {});
+ o.headers = Object.assign({ 'X-Admin-Token': tok, 'Content-Type': 'application/json' },
+ (opts && opts.headers) || {});
+ const r = await fetch('/api/feedback' + path, o);
+ if (r.status === 403) { this._fbKey = null; throw new Error('admin token rejected (rejected window, retry)'); }
+ if (!r.ok) throw new Error('HTTP ' + r.status);
+ return r.json();
+ }
+
+ _fbPad(s, n) { s = String(s == null ? '' : s); return s.length >= n ? s.slice(0, n - 1) + '…' : s + ' '.repeat(n - s.length); }
+
+ _fbFail(e) { this.terminal.print('❌ feedback: ' + (e && e.message ? e.message : e), 'error'); }
+
+ _fbList(status) {
+ const q = status ? '?status=' + encodeURIComponent(status) + '&limit=50' : '?limit=50';
+ this.terminal.print('📋 loading…', '');
+ this._fbCall('/list' + q).then(d => {
+ const items = d.items || [];
+ if (!items.length) { this.terminal.print('no reports' + (status ? ' with status ' + status : ''), ''); return; }
+ this.terminal.print(this._fbPad('#', 6) + this._fbPad('status', 10) + this._fbPad('cat', 10) +
+ this._fbPad('user', 12) + this._fbPad('date', 12) + 'message', '');
+ items.forEach(it => this.terminal.print(
+ this._fbPad(it.id, 6) + this._fbPad(it.status, 10) + this._fbPad(it.category, 10) +
+ this._fbPad(it.username || 'anon', 12) + this._fbPad(String(it.created_at || '').slice(0, 10), 12) +
+ String(it.message || '').replace(/\s+/g, ' ').slice(0, 60), ''));
+ const total = (typeof d.total === 'number' ? d.total : items.length);
+ const more = total > items.length ? ' (showing ' + items.length + ' of ' + total +
+ ' — feedback list ' + (status || '') + ' with a higher limit via the dashboard)' : '';
+ this.terminal.print(total + ' item(s)' + more + ' — feedback read <id>, reply <id> <text>', '');
+ }).catch(e => this._fbFail(e));
+ }
+
+ _fbRead(id) {
+ if (!id) { this.terminal.print('usage: feedback read <id>', 'error'); return; }
+ const want = String(id);
+ // The server understands ?id= since v4.6.93's API change; an older process ignores it and
+ // returns the newest row instead, so verify the id before trusting the answer.
+ this._fbCall('/list?limit=1&id=' + encodeURIComponent(want)).then(d => {
+ const hit = (d.items || []).find(x => String(x.id) === want);
+ if (hit) return hit;
+ return this._fbCall('/list?limit=200').then(d2 =>
+ (d2.items || []).find(x => String(x.id) === want) || null);
+ }).then(it => {
+ if (!it) { this.terminal.print('no report #' + want, 'error'); return; }
+ this.terminal.print('#' + it.id + ' ' + it.status + ' ' + it.category + ' ' + (it.username || 'anonymous'), '');
+ this.terminal.print('page: ' + (it.page || '—') + ' at: ' + (it.created_at || ''), '');
+ this.terminal.print('', '');
+ this.terminal.print(String(it.message || ''), '');
+ if (it.admin_reply) { this.terminal.print('', ''); this.terminal.print('reply (' + (it.replied_at || '') + '): ' + it.admin_reply, ''); }
+ this.terminal.print('', '');
+ this.terminal.print('feedback reply ' + it.id + ' <text> | feedback archive ' + it.id, '');
+ }).catch(e => this._fbFail(e));
+ }
+
+ _fbReply(id, text) {
+ if (!id || !text) { this.terminal.print('usage: feedback reply <id> <text>', 'error'); return; }
+ this._fbCall('/' + encodeURIComponent(id), { method: 'PATCH',
+ body: JSON.stringify({ status: 'read', admin_reply: text }) })
+ .then(() => this.terminal.print('✅ reply published on #' + id + ' (the user sees it in /stear → My reports)', ''))
+ .catch(e => this._fbFail(e));
+ }
+
+ _fbStatus(id, status) {
+ if (!id) { this.terminal.print('usage: feedback ' + (status === 'new' ? 'reopen' : 'archive') + ' <id>', 'error'); return; }
+ this._fbCall('/' + encodeURIComponent(id), { method: 'PATCH', body: JSON.stringify({ status: status }) })
+ .then(() => this.terminal.print('✅ #' + id + ' → ' + status, ''))
+ .catch(e => this._fbFail(e));
+ }
+
+ _fbStats() {
+ const one = (st) => this._fbCall('/list?limit=1' + (st ? '&status=' + st : ''))
+ .then(d => {
+ if (typeof d.total === 'number') return d.total; // COUNT(*) from the server
+ return this._fbCall('/list?limit=200' + (st ? '&status=' + st : ''))
+ .then(d2 => (d2.items || []).filter(x => !st || x.status === st).length);
+ });
+ Promise.all([one(''), one('new'), one('read'), one('archived')]).then(([all, nw, rd, ar]) => {
+ this.terminal.print('feedback: ' + all + ' total — new ' + nw + ', read ' + rd +
+ ', archived ' + ar, '');
+ this.terminal.print('dashboard: /admin/feedback.html', '');
+ }).catch(e => this._fbFail(e));
+ }
+
+ _fbDashboard() {
+ this.terminal.print('🪟 opening /admin/feedback.html …', '');
+ window.open('/admin/feedback.html', '_blank');
+ }
+
+ _fbDispatch(args) {
+ const argv = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim().split(/\s+/);
+ const sub = (argv[0] || '').toLowerCase();
+ const arg = argv[1];
+ const rest = argv.slice(2).join(' ');
+ switch (sub) {
+ case 'list': return this._fbList(arg || '');
+ case 'read': return this._fbRead(arg);
+ case 'reply': return this._fbReply(arg, rest);
+ case 'archive': return this._fbStatus(arg, 'archived');
+ case 'reopen': return this._fbStatus(arg, 'new');
+ case 'stats': return this._fbStats();
+ case 'dashboard': return this._fbDashboard();
+ case 'help': default: return this._fbHelp();
+ }
+ }
+
_cmdSync(args) {
const url = (args && args[0]) ? args[0] : null;
if (url) {
diff --git a/modules/core/module-debug-supervisor.js b/modules/core/module-debug-supervisor.js
index 25e0566..ed97aff 100644
--- a/modules/core/module-debug-supervisor.js
+++ b/modules/core/module-debug-supervisor.js
@@ -63,6 +63,9 @@ class DebugSupervisorModule {
this.commands['feedback-inbox'] = () => this._cmdFeedbackInbox();
this.commands['hermes-poll'] = () => this._cmdFeedbackInbox();
this.commands['hermes-reset'] = () => this._cmdChatReset();
+ // Admin management of stored /stear reports (2026-10-26). `feedback <text>` still
+ // sends a chat message: a subcommand needs plausible arguments (see _cmdFeedback).
+ this.commands['feedback-admin'] = (args) => this._fbDispatch(args);
}
seed() {
@@ -960,9 +963,25 @@ class DebugSupervisorModule {
}
_cmdFeedback(args) {
- const msg = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim();
+ const raw = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim();
+ const argv = raw.split(/\s+/);
+ const sub = (argv[0] || '').toLowerCase();
+ // A subcommand only when its ARGUMENTS are plausible. Measured failure without this
+ // check: "feedback list is broken on mobile" became a filtered list of reports.
+ const STATUS = ['', 'new', 'read', 'archived'];
+ const isNum = (v) => /^\d+$/.test(v || '');
+ const forced = raw.startsWith('--');
+ const isSub = !forced && (
+ (sub === 'list' && STATUS.indexOf((argv[1] || '').toLowerCase()) >= 0) ||
+ ((sub === 'stats' || sub === 'dashboard' || sub === 'help') && argv.length === 1) ||
+ (['read', 'archive', 'reopen'].indexOf(sub) >= 0 && isNum(argv[1])) ||
+ (sub === 'reply' && isNum(argv[1]) && argv.length >= 3)
+ );
+ if (isSub) { this._fbDispatch(args); return; }
+ const msg = forced ? raw.replace(/^--\s*/, '') : raw;
if (!msg) {
this.terminal.print('usage: hermes <message> (alias: feedback <message>)', 'error');
+ this.terminal.print(' feedback list|read|reply|archive|reopen|stats|dashboard|help', '');
return;
}
this.terminal.print('📨 Sending…', '');
@@ -998,6 +1017,144 @@ class DebugSupervisorModule {
});
}
+ // ── feedback management (admin) ──────────────────────────────────────────
+ _fbHelp() {
+ const rows = [
+ 'feedback <message> send a message to the operator agent (alias: hermes)',
+ 'feedback list [status] list stored /stear reports (new|read|archived, default all)',
+ 'feedback read <id> show one report in full',
+ 'feedback reply <id> <t> publish a reply (status becomes read, user sees it)',
+ 'feedback archive <id> archive a report',
+ 'feedback reopen <id> move a report back to new',
+ 'feedback stats counts per status',
+ 'feedback dashboard open /admin/feedback.html in a new tab',
+ 'feedback-inbox read agent replies from the chat bridge',
+ ];
+ this.terminal.print('feedback — commands', '');
+ rows.forEach(r => this.terminal.print(' ' + r, ''));
+ this.terminal.print(' admin subcommands need a valid admin session (log in at /admin-login.html)', '');
+ }
+
+ async _fbToken() {
+ if (!this._fbKey) {
+ const r = await fetch('/admin/api/admin-token.php', { credentials: 'same-origin' });
+ if (!r.ok) throw new Error(r.status === 302 || r.redirected ? 'admin session required' : 'token HTTP ' + r.status);
+ const d = await r.json();
+ if (!d || !d.token) throw new Error('no admin token in response');
+ this._fbKey = d.token;
+ }
+ const w = Math.floor(Date.now() / 1000 / 300) * 300;
+ const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(this._fbKey + ':' + w));
+ return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
+ }
+
+ async _fbCall(path, opts) {
+ const tok = await this._fbToken();
+ const o = Object.assign({ credentials: 'same-origin' }, opts || {});
+ o.headers = Object.assign({ 'X-Admin-Token': tok, 'Content-Type': 'application/json' },
+ (opts && opts.headers) || {});
+ const r = await fetch('/api/feedback' + path, o);
+ if (r.status === 403) { this._fbKey = null; throw new Error('admin token rejected (rejected window, retry)'); }
+ if (!r.ok) throw new Error('HTTP ' + r.status);
+ return r.json();
+ }
+
+ _fbPad(s, n) { s = String(s == null ? '' : s); return s.length >= n ? s.slice(0, n - 1) + '…' : s + ' '.repeat(n - s.length); }
+
+ _fbFail(e) { this.terminal.print('❌ feedback: ' + (e && e.message ? e.message : e), 'error'); }
+
+ _fbList(status) {
+ const q = status ? '?status=' + encodeURIComponent(status) + '&limit=50' : '?limit=50';
+ this.terminal.print('📋 loading…', '');
+ this._fbCall('/list' + q).then(d => {
+ const items = d.items || [];
+ if (!items.length) { this.terminal.print('no reports' + (status ? ' with status ' + status : ''), ''); return; }
+ this.terminal.print(this._fbPad('#', 6) + this._fbPad('status', 10) + this._fbPad('cat', 10) +
+ this._fbPad('user', 12) + this._fbPad('date', 12) + 'message', '');
+ items.forEach(it => this.terminal.print(
+ this._fbPad(it.id, 6) + this._fbPad(it.status, 10) + this._fbPad(it.category, 10) +
+ this._fbPad(it.username || 'anon', 12) + this._fbPad(String(it.created_at || '').slice(0, 10), 12) +
+ String(it.message || '').replace(/\s+/g, ' ').slice(0, 60), ''));
+ const total = (typeof d.total === 'number' ? d.total : items.length);
+ const more = total > items.length ? ' (showing ' + items.length + ' of ' + total +
+ ' — feedback list ' + (status || '') + ' with a higher limit via the dashboard)' : '';
+ this.terminal.print(total + ' item(s)' + more + ' — feedback read <id>, reply <id> <text>', '');
+ }).catch(e => this._fbFail(e));
+ }
+
+ _fbRead(id) {
+ if (!id) { this.terminal.print('usage: feedback read <id>', 'error'); return; }
+ const want = String(id);
+ // The server understands ?id= since v4.6.93's API change; an older process ignores it and
+ // returns the newest row instead, so verify the id before trusting the answer.
+ this._fbCall('/list?limit=1&id=' + encodeURIComponent(want)).then(d => {
+ const hit = (d.items || []).find(x => String(x.id) === want);
+ if (hit) return hit;
+ return this._fbCall('/list?limit=200').then(d2 =>
+ (d2.items || []).find(x => String(x.id) === want) || null);
+ }).then(it => {
+ if (!it) { this.terminal.print('no report #' + want, 'error'); return; }
+ this.terminal.print('#' + it.id + ' ' + it.status + ' ' + it.category + ' ' + (it.username || 'anonymous'), '');
+ this.terminal.print('page: ' + (it.page || '—') + ' at: ' + (it.created_at || ''), '');
+ this.terminal.print('', '');
+ this.terminal.print(String(it.message || ''), '');
+ if (it.admin_reply) { this.terminal.print('', ''); this.terminal.print('reply (' + (it.replied_at || '') + '): ' + it.admin_reply, ''); }
+ this.terminal.print('', '');
+ this.terminal.print('feedback reply ' + it.id + ' <text> | feedback archive ' + it.id, '');
+ }).catch(e => this._fbFail(e));
+ }
+
+ _fbReply(id, text) {
+ if (!id || !text) { this.terminal.print('usage: feedback reply <id> <text>', 'error'); return; }
+ this._fbCall('/' + encodeURIComponent(id), { method: 'PATCH',
+ body: JSON.stringify({ status: 'read', admin_reply: text }) })
+ .then(() => this.terminal.print('✅ reply published on #' + id + ' (the user sees it in /stear → My reports)', ''))
+ .catch(e => this._fbFail(e));
+ }
+
+ _fbStatus(id, status) {
+ if (!id) { this.terminal.print('usage: feedback ' + (status === 'new' ? 'reopen' : 'archive') + ' <id>', 'error'); return; }
+ this._fbCall('/' + encodeURIComponent(id), { method: 'PATCH', body: JSON.stringify({ status: status }) })
+ .then(() => this.terminal.print('✅ #' + id + ' → ' + status, ''))
+ .catch(e => this._fbFail(e));
+ }
+
+ _fbStats() {
+ const one = (st) => this._fbCall('/list?limit=1' + (st ? '&status=' + st : ''))
+ .then(d => {
+ if (typeof d.total === 'number') return d.total; // COUNT(*) from the server
+ return this._fbCall('/list?limit=200' + (st ? '&status=' + st : ''))
+ .then(d2 => (d2.items || []).filter(x => !st || x.status === st).length);
+ });
+ Promise.all([one(''), one('new'), one('read'), one('archived')]).then(([all, nw, rd, ar]) => {
+ this.terminal.print('feedback: ' + all + ' total — new ' + nw + ', read ' + rd +
+ ', archived ' + ar, '');
+ this.terminal.print('dashboard: /admin/feedback.html', '');
+ }).catch(e => this._fbFail(e));
+ }
+
+ _fbDashboard() {
+ this.terminal.print('🪟 opening /admin/feedback.html …', '');
+ window.open('/admin/feedback.html', '_blank');
+ }
+
+ _fbDispatch(args) {
+ const argv = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim().split(/\s+/);
+ const sub = (argv[0] || '').toLowerCase();
+ const arg = argv[1];
+ const rest = argv.slice(2).join(' ');
+ switch (sub) {
+ case 'list': return this._fbList(arg || '');
+ case 'read': return this._fbRead(arg);
+ case 'reply': return this._fbReply(arg, rest);
+ case 'archive': return this._fbStatus(arg, 'archived');
+ case 'reopen': return this._fbStatus(arg, 'new');
+ case 'stats': return this._fbStats();
+ case 'dashboard': return this._fbDashboard();
+ case 'help': default: return this._fbHelp();
+ }
+ }
+
_cmdSync(args) {
const url = (args && args[0]) ? args[0] : null;
if (url) {
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index 3ab51d7..7d851ee 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.91';
+ const LATEST_VER = 'v4.6.94';
const CACHE = '?v=' + LATEST_VER;
const stored = localStorage.getItem('webx11_cache_ver');
if (stored !== LATEST_VER) {
diff --git a/scripts/newos4-feedback-apply.py b/scripts/newos4-feedback-apply.py
new file mode 100755
index 0000000..38188fb
--- /dev/null
+++ b/scripts/newos4-feedback-apply.py
@@ -0,0 +1,203 @@
+#!/usr/bin/env python3
+"""Apply queued operator decisions from data/feedback/decisions.jsonl.
+
+Runs as darkbot (cron). Mutates exactly two things, both assert-guarded:
+ approve + plan -> flip the exact '<!-- autonomy: review -->' line to '<!-- autonomy: safe -->'
+ reject + plan -> flip it back (an approval that was reconsidered before the night run)
+ approve + report -> append a high-priority directive via scripts/newos4-steer.py add
+Every decision is recorded in data/feedback/applied.json (darkbot-owned, 0600) so nothing is
+applied twice and php never needs write access to the sources.
+
+Ordering rules (2026-10-26 review):
+ * the LAST decision per item wins — approve followed by reject must not ship the plan;
+ * a decision that is superseded in the same run is recorded, not executed;
+ * one failing item never aborts the run, and applied.json is written after every mutation.
+"""
+import json, os, re, subprocess, sys
+from datetime import datetime, timezone
+
+ROOT = '/var/www/webx11.duckdns.org'
+FB = os.path.join(ROOT, 'spaces/newos4/data/feedback')
+DEC = os.path.join(FB, 'decisions.jsonl')
+APPLIED = os.path.join(FB, 'applied.json')
+STEER = os.path.join(ROOT, 'spaces/newos4/scripts/newos4-steer.py')
+PLANS = os.path.join(ROOT, '.hermes/plans')
+REPORTS = os.path.join(ROOT, 'spaces/newos4/data/reports')
+ROTATE_BYTES = 1024 * 1024 # move decisions.jsonl aside once it passes 1 MB
+
+
+def load_applied():
+ try:
+ with open(APPLIED, encoding='utf-8') as f:
+ d = json.load(f)
+ return d if isinstance(d, dict) else {}
+ except Exception:
+ return {}
+
+
+def save_applied(d):
+ os.makedirs(FB, exist_ok=True)
+ tmp = APPLIED + '.tmp'
+ with open(tmp, 'w', encoding='utf-8') as f:
+ json.dump(d, f, indent=1, ensure_ascii=False)
+ os.chmod(tmp, 0o600) # www-data must not be able to wipe the ledger
+ os.replace(tmp, APPLIED)
+
+
+def read_decisions():
+ rows = []
+ if not os.path.exists(DEC):
+ return rows
+ with open(DEC, encoding='utf-8', errors='ignore') as f:
+ for i, line in enumerate(f):
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ d = json.loads(line)
+ except Exception:
+ continue # torn write: skip, never crash the run
+ d['_row'] = i
+ rows.append(d)
+ return rows
+
+
+def _marker_count(src):
+ return sum(1 for l in src.splitlines() if l.strip() == '<!-- autonomy: review -->')
+
+
+def _flip_marker(path, src, old, new, note):
+ """Flip one standalone marker LINE (not a substring) and keep the file mode."""
+ lines = src.splitlines(keepends=True)
+ hits = [i for i, l in enumerate(lines) if l.strip() == old]
+ if len(hits) != 1:
+ return None, 'error: expected 1 standalone %s line, found %d' % (old, len(hits))
+ i = hits[0]
+ indent = lines[i][:len(lines[i]) - len(lines[i].lstrip())]
+ eol = '\n' if lines[i].endswith('\n') else ''
+ lines[i] = '%s%s%s' % (indent, new, eol)
+ stamp = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%MZ')
+ body = ''.join(lines)
+ if not body.endswith('\n'):
+ body += '\n'
+ body += '\n<!-- %s by operator via /admin/feedback.html %s%s -->\n' % (
+ 'approved' if new.endswith('safe -->') else 'reverted',
+ stamp, (' — ' + note.replace('-->', '')) if note else '')
+ return body, None
+
+
+def apply_plan(name, decision, note):
+ plans_dir = os.path.realpath(PLANS)
+ path = os.path.realpath(os.path.join(plans_dir, name))
+ if os.path.dirname(path) != plans_dir:
+ return 'error: refused, outside plans dir'
+ if not re.fullmatch(r'[A-Za-z0-9._-]+\.md', os.path.basename(path)):
+ return 'error: refused, bad plan name'
+ try:
+ src = open(path, encoding='utf-8').read()
+ mode = os.stat(path).st_mode & 0o777
+ except Exception as e:
+ return 'error: unreadable (%s)' % e
+
+ if decision == 'approve':
+ if _marker_count(src) != 1:
+ return 'error: expected 1 review marker, found %d' % _marker_count(src)
+ body, err = _flip_marker(path, src, '<!-- autonomy: review -->', '<!-- autonomy: safe -->', note)
+ elif decision == 'reject':
+ # only meaningful if a previous run already approved it
+ if src.count('<!-- autonomy: safe -->') == 0:
+ return 'recorded (no action: was never approved)'
+ body, err = _flip_marker(path, src, '<!-- autonomy: safe -->', '<!-- autonomy: review -->', note)
+ else:
+ return 'recorded (no action)'
+ if err or body is None:
+ return err or 'error: no output produced'
+ tmp = path + '.tmp'
+ with open(tmp, 'w', encoding='utf-8') as f:
+ f.write(body)
+ os.chmod(tmp, mode) # os.replace would otherwise apply the umask (600 -> 644)
+ os.replace(tmp, path)
+ return 'plan %s (%s, mode %o)' % ('approved' if decision == 'approve' else 'reverted', decision, mode)
+
+
+def report_title(rid):
+ """The bare id is useless in a directive once the report file rotates away."""
+ try:
+ with open(os.path.join(REPORTS, rid + '.json'), encoding='utf-8') as f:
+ d = json.load(f)
+ return str(d.get('title') or d.get('message') or rid)[:160]
+ except Exception:
+ return rid
+
+
+def apply_report(name, decision, note):
+ if decision != 'approve':
+ return 'recorded (no action)'
+ text = '%s — %s' % (report_title(name), name)
+ if note:
+ text = '%s — %s' % (text, note)
+ r = subprocess.run([sys.executable, STEER, 'add', text, '--priority', 'high'],
+ capture_output=True, text=True, timeout=60)
+ if r.returncode != 0:
+ return 'error: steer add exit %d (%s)' % (r.returncode, (r.stderr or '')[-120:])
+ return 'steering directive added'
+
+
+def main():
+ applied = load_applied()
+ rows = read_decisions()
+ if not rows:
+ return 0
+
+ # last decision per item wins; everything earlier in the file is superseded
+ last_row = {}
+ for d in rows:
+ last_row[d.get('item_id', '')] = d['_row']
+
+ done = 0
+ for d in rows:
+ item = d.get('item_id', '')
+ dec = d.get('decision', '')
+ note = d.get('note', '') or ''
+ key = '%s|%s|%s' % (d.get('ts', ''), item, dec)
+ if key in applied:
+ continue
+ kind, _, name = item.partition(':')
+
+ if last_row.get(item) != d['_row']:
+ applied[key] = {'item_id': item, 'decision': dec, 'result': 'superseded by a later decision',
+ 'applied_at': datetime.now(timezone.utc).isoformat(timespec='seconds')}
+ save_applied(applied)
+ continue
+
+ try:
+ if kind == 'plan':
+ result = apply_plan(name, dec, note)
+ elif kind == 'report':
+ result = apply_report(name, dec, note)
+ else:
+ result = 'error: unknown item kind'
+ except subprocess.TimeoutExpired:
+ result = 'error: steer add timed out'
+ except Exception as e: # one bad item must not replay the batch
+ result = 'error: %s: %s' % (type(e).__name__, e)
+
+ applied[key] = {'item_id': item, 'decision': dec, 'result': result,
+ 'applied_at': datetime.now(timezone.utc).isoformat(timespec='seconds')}
+ save_applied(applied)
+ done += 1
+ print(('APPLY FAILED %s: %s' if result.startswith('error') else 'applied %s -> %s (%s)')
+ % ((item, result) if result.startswith('error') else (item, dec, result)))
+
+ # keep the queue file bounded; applied.json stays as the permanent ledger
+ try:
+ if os.path.getsize(DEC) > ROTATE_BYTES:
+ os.replace(DEC, DEC + '.1')
+ print('rotated decisions.jsonl -> decisions.jsonl.1 (%d bytes)' % ROTATE_BYTES)
+ except Exception:
+ pass
+ return 0
+
+
+if __name__ == '__main__':
+ raise SystemExit(main())
diff --git a/scripts/newos4-feedback-collect.py b/scripts/newos4-feedback-collect.py
new file mode 100755
index 0000000..66f1653
--- /dev/null
+++ b/scripts/newos4-feedback-collect.py
@@ -0,0 +1,147 @@
+#!/usr/bin/env python3
+"""Snapshot agent-side review items for /admin/feedback.html.
+
+Runs as darkbot (Hermes cron): .hermes/plans/*.md are 600 darkbot, unreadable to php-fpm,
+so the snapshot is the only way the page can see them. Writes atomically, 644 www-data.
+Read-only with respect to every source file.
+"""
+import glob, json, os, re, time
+from datetime import datetime, timezone
+
+ROOT = '/var/www/webx11.duckdns.org'
+OUT_DIR = os.path.join(ROOT, 'admin/docs/feedback')
+OUT = os.path.join(OUT_DIR, 'agent-items.json')
+PLANS = os.path.join(ROOT, '.hermes/plans')
+TRIAGE = os.path.join(ROOT, 'admin/docs/newos4-triage-latest.json')
+STEER = os.path.join(ROOT, 'spaces/newos4/data/maintenance/steering.json')
+DECISIONS = os.path.join(ROOT, 'spaces/newos4/data/feedback/decisions.jsonl')
+
+
+def load(path, default):
+ try:
+ with open(path, encoding='utf-8') as f:
+ return json.load(f)
+ except Exception:
+ return default
+
+
+def decisions():
+ """Latest decision per item id, from the append-only log written by php."""
+ out = {}
+ if not os.path.exists(DECISIONS):
+ return out
+ try:
+ with open(DECISIONS, encoding='utf-8') as f:
+ for line in f:
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ d = json.loads(line)
+ except Exception:
+ continue # torn line: ignore, never a crash
+ out[d.get('item_id', '')] = d
+ except Exception:
+ pass
+ return out
+
+
+def plans_needing_decision(dec):
+ items = []
+ for p in sorted(glob.glob(os.path.join(PLANS, '*.md'))):
+ try:
+ with open(p, encoding='utf-8', errors='ignore') as fh:
+ head = ''.join(fh.readline() for _ in range(40))
+ except Exception:
+ continue
+ # Exact-line match: the analyser contract puts the marker on the line after the
+ # title. A substring test also matches plans that merely *document* the marker
+ # (measured 2026-10-26: this plan's own audit table inflated the count to 4).
+ if not any(l.strip() == '<!-- autonomy: review -->' for l in head.splitlines()):
+ continue
+ name = os.path.basename(p)
+ title = next((l.lstrip('# ').strip() for l in head.splitlines() if l.startswith('# ')), name)
+ d = dec.get('plan:' + name, {})
+ items.append({
+ 'id': 'plan:' + name, 'kind': 'plan', 'title': title, 'path': p,
+ 'size': os.path.getsize(p), 'mtime': datetime.fromtimestamp(
+ os.path.getmtime(p), timezone.utc).isoformat(timespec='seconds'),
+ 'decision': d.get('decision'), 'decided_at': d.get('decided_at') or d.get('ts'),
+ 'note': d.get('note', ''),
+ })
+ return items
+
+
+def reports_needing_decision(dec):
+ triage = load(TRIAGE, {}) or {}
+ new_ids = {r.get('id') for r in (triage.get('new_reports') or [])}
+ items = []
+ for p in sorted(glob.glob(os.path.join(ROOT, 'spaces/newos4/data/reports/*.json'))):
+ try:
+ r = json.load(open(p, encoding='utf-8'))
+ except Exception:
+ continue
+ rid = r.get('id') or os.path.basename(p)[:-5]
+ d = dec.get('report:' + rid, {})
+ items.append({
+ 'id': 'report:' + rid, 'kind': 'report', 'title': (r.get('title') or rid)[:180],
+ 'created_at': r.get('created_at'), 'auto': bool((r.get('diagnostics') or {}).get('auto')),
+ 'new': rid in new_ids, 'decision': d.get('decision'),
+ 'decided_at': d.get('decided_at') or d.get('ts'), 'note': d.get('note', ''),
+ })
+ items.sort(key=lambda x: (x.get('created_at') or ''), reverse=True)
+ return items
+
+
+def recent_apply(n):
+ """Last n decision outcomes, newest first — failures included, so the dashboard can show them."""
+ try:
+ with open(APPLIED, encoding='utf-8') as f:
+ d = json.load(f)
+ rows = [v for v in d.values() if isinstance(v, dict)]
+ rows.sort(key=lambda r: str(r.get('applied_at') or ''), reverse=True)
+ return rows[:n]
+ except Exception:
+ return []
+
+
+def main():
+ dec = decisions()
+ steer_raw = load(STEER, [])
+ steer_ok = isinstance(steer_raw, list)
+ steer = [s for s in (steer_raw if steer_ok else []) if isinstance(s, dict) and not s.get('done')]
+ triage = load(TRIAGE, {}) or {}
+ plans = plans_needing_decision(dec)
+ reports = reports_needing_decision(dec)
+ awaiting = [x for x in plans + reports if not x.get('decision')]
+ doc = {
+ 'generated_at': datetime.now(timezone.utc).isoformat(timespec='seconds'),
+ 'counts': {
+ 'plans_review': len(plans), 'reports_total': len(reports),
+ 'awaiting_decision': len(awaiting),
+ 'steering_pending': len(steer) if steer_ok else -1, # -1 = steering.json unreadable/wrong shape
+ 'triage_reports_new': (triage.get('counts') or {}).get('reports_new', 0),
+ },
+ 'plans': plans, 'reports': reports, 'steering': steer[:20],
+ 'recent_apply': recent_apply(8),
+ 'triage_checks': (triage.get('checks') or {}),
+ 'triage_generated_at': triage.get('generated_at'),
+ }
+ os.makedirs(OUT_DIR, exist_ok=True)
+ tmp = OUT + '.tmp'
+ with open(tmp, 'w', encoding='utf-8') as f:
+ json.dump(doc, f, ensure_ascii=False, indent=1)
+ os.replace(tmp, OUT)
+ os.chmod(OUT, 0o644)
+ try:
+ import grp
+ os.chown(OUT, -1, grp.getgrnam('www-data').gr_gid)
+ except Exception:
+ pass
+ print(f"feedback snapshot: plans={len(plans)} reports={len(reports)} "
+ f"awaiting={len(awaiting)} steering={len(steer)} -> {OUT}")
+ return 0
+
+
+if __name__ == '__main__':
+ raise SystemExit(main())