diff options
| author | Agando <roduccomm@gmail.com> | 2026-10-22 00:43:13 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-10-22 00:43:24 +0200 |
| commit | df6c4ec7ef576e188b68ef8e64eb1f94431b5a1c (patch) | |
| tree | 6ad3e8b985448e3c7c9ef620b261b1e73744d676 | |
| parent | 128f0f3694ae4ddeb5833c94a7c40ac9e22b4c31 (diff) | |
fix(newos4): coreutils flags + loop-guard arg key, both found by the agent's live run (v4.6.49)
Agent session surfaced two real defects:
1. The A4 prompt told it to use grep -n / sed -n 'N,Mp' / wc -l / head -N — none of those flags
parsed: grep treated '-n' as the pattern (6/6 variants failed), wc read '-l' as a filename,
head looked for a file named '-2', sed had no range parser. Fixed the tools, not just the text:
grep now accepts -n/-i/-c/-v/-l (line numbers were already printed), wc accepts -l/-w/-c,
head accepts -N and -n N, sed accepts Np and N,Mp, tail no longer prints a blank line for a
trailing newline. Prompt re-ranked to the working forms.
2. Loop guard keyed calls by tool name plus the raw arguments field. When arguments was already
an object (parsed upstream), the key became 'run_command:[object Object]', collapsing distinct
batched commands into one signature and producing false "called 3x" warnings. Now the key is
JSON.stringify(arguments) whenever it is not a string (3 sites: sig, _sigR, _sigD).
Verified live v4.6.49: grep -n/-c, wc -l, head -2, sed -n '2,3p', tail -n 1 all correct.
| -rw-r--r-- | main.html | 6 | ||||
| -rw-r--r-- | modules/core/core-bundle.js | 120 | ||||
| -rw-r--r-- | modules/core/module-aiassist.js | 17 | ||||
| -rw-r--r-- | modules/core/module-fs.js | 103 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 2 |
5 files changed, 196 insertions, 52 deletions
@@ -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.47"></script> +<script src="modules/core/core-bundle.js?v=v4.6.49"></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.47"></script> +<script src="modules/core/module-loader.js?v=v4.6.49"></script> <!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work --> -<script src="modules/ext/critical-ext-bundle.js?v=v4.6.47"></script> +<script src="modules/ext/critical-ext-bundle.js?v=v4.6.49"></script> <script> // ═══ ONE TRUE VERSION SOURCE ═══ diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js index c4bf64e..f467f84 100644 --- a/modules/core/core-bundle.js +++ b/modules/core/core-bundle.js @@ -6469,37 +6469,72 @@ class FileSystemModule { this.terminal.print('Found ' + results.length + ' match(es)', 'info'); }; this.commands['grep'] = (args) => { - if(!args[0]) return this.terminal.print('Usage: grep <pattern> [filename]', 'warning'); - const pattern = args[0]; const target = args[1]; + // 2026-10-21: accept the common GNU flags instead of treating them as the pattern. + // `grep -n "x" file` used to search for the literal "-n" inside a file named "x" + // (6/6 syntax variants failed for the agent), while plain grep already prints + // "line: text" for every hit. + const opt = { count: false, invert: false, files: false, ignoreCase: true }; + let ai = 0; + while (ai < args.length && /^-[a-zA-Z]+$/.test(args[ai])) { + const f = args[ai].slice(1); + if (f.indexOf('c') !== -1) opt.count = true; + if (f.indexOf('v') !== -1) opt.invert = true; + if (f.indexOf('l') !== -1) opt.files = true; + if (f.indexOf('i') !== -1) opt.ignoreCase = true; + ai++; + } + const pattern = args[ai]; const target = args[ai + 1]; + if (!pattern) return this.terminal.print('Usage: grep [-n] [-i] [-c] [-l] <pattern> [filename]', 'warning'); + const re = new RegExp(pattern, opt.ignoreCase ? 'i' : ''); + const hits = (line) => (opt.invert ? !re.test(line) : re.test(line)); if (target) { const c = this.terminal.fs.readFile(target); - if(c === null || c === undefined) return this.terminal.print('grep: ' + target + ': No such file', 'error'); - const lines = c.split('\n'); const re = new RegExp(pattern, 'i'); - let count = 0; lines.forEach((line, i) => { if (re.test(line)) { this.terminal.print((i+1) + ': ' + line); count++; } }); + if (c === null || c === undefined) return this.terminal.print('grep: ' + target + ': No such file', 'error'); + const lines = c.split('\n'); + let count = 0; + lines.forEach((line, i) => { + if (!hits(line)) return; + count++; + if (!opt.count && !opt.files) this.terminal.print((i + 1) + ': ' + line); + }); + if (opt.files && count) this.terminal.print(target); if (count === 0) this.terminal.print('(no matches)', 'info'); - else this.terminal.print(count + ' match(es)', 'text-muted'); + else if (opt.count) this.terminal.print(String(count)); + else if (!opt.files) this.terminal.print(count + ' match(es)', 'text-muted'); } else { const dir = this.terminal.fs.getCurrentDir(); if (!dir) return this.terminal.print('grep: cannot search', 'error'); - let total = 0; const re = new RegExp(pattern, 'i'); + let total = 0; Object.keys(dir.content).forEach(name => { const node = dir.content[name]; if (node.type === 'file') { (node.content || '').split('\n').forEach((line, i) => { - if (re.test(line)) { this.terminal.print(name + ':' + (i+1) + ': ' + line); total++; } + if (!hits(line)) return; + total++; + if (!opt.count && !opt.files) this.terminal.print(name + ':' + (i + 1) + ': ' + line); }); } }); + if (opt.files && total) this.terminal.print(dir.name || '.'); if (total === 0) this.terminal.print('(no matches)', 'info'); - else this.terminal.print(total + ' total match(es)', 'text-muted'); + else if (opt.count) this.terminal.print(String(total)); + else if (!opt.files) this.terminal.print(total + ' total match(es)', 'text-muted'); } }; this.commands['head'] = (args) => { - if(!args[0]) return this.terminal.print('Usage: head <filename> [lines]', 'warning'); - const c = this.terminal.fs.readFile(args[0]); - if(c === null || c === undefined) return this.terminal.print('head: ' + args[0] + ': No such file', 'error'); - const n = args[1] ? parseInt(args[1]) : 10; - this.terminal.print(c.split('\n').slice(0, n).join('\n')); + // 2026-10-21: accept `head -40 f`, `head -n 40 f` and `head f 40`. + let n = 10, file = null; + for (let i = 0; i < (args || []).length; i++) { + const a = args[i]; + if (/^-\d+$/.test(a)) n = parseInt(a.slice(1), 10); + else if (a === '-n') { const v = parseInt(args[i + 1], 10); if (!isNaN(v)) n = v; i++; } + else if (/^-[a-zA-Z]+$/.test(a)) { /* ignore other flags */ } + else if (!file) file = a; + } + if (!file) return this.terminal.print('Usage: head [-n] <filename> [lines]', 'warning'); + const c = this.terminal.fs.readFile(file); + if (c === null || c === undefined) return this.terminal.print('head: ' + file + ': No such file', 'error'); + this.terminal.print(c.split('\n').slice(0, Math.max(1, n)).join('\n')); }; this.commands['tail'] = (args) => { const self = this; @@ -6523,7 +6558,11 @@ class FileSystemModule { if (positional[1] && /^\d+$/.test(positional[1])) n = parseInt(positional[1], 10); // legacy back-compat: tail <file> <count> const c = this.terminal.fs.readFile(file); if (c === null || c === undefined) return this.terminal.print('tail: ' + file + ': No such file', 'error'); - this.terminal.print(c.split('\n').slice(-n).join('\n')); + // 2026-10-21: a trailing newline produced an empty last element, so `tail -n 1` + // printed a blank line instead of the final line. + const _all = c.split('\n'); + if (_all.length > 1 && _all[_all.length - 1] === '') _all.pop(); + this.terminal.print(_all.slice(-n).join('\n')); if (!follow) return; // AI tool calls cannot press Ctrl+C — auto-downgrade to a bounded read instead of a live timer if (this.terminal.security && this.terminal.security._aiToolDepth > 0) { @@ -6577,6 +6616,16 @@ class FileSystemModule { return this.terminal.print('sed: missing file operand (no piped input)', 'error'); } let subst = null, delMatch = null, delLine = null; + // 2026-10-21: `sed -n '10,40p' file` and `sed -n '7p' file` had no parser, so the + // most common "show me lines N–M" idiom failed with an unsupported-expression error. + const _range = /^(\d+)(?:,(\d+))?p$/.exec(expr); + if (_range) { + const _a = parseInt(_range[1], 10); + const _b = _range[2] ? parseInt(_range[2], 10) : _a; + const _lines = content.split('\n'); + this.terminal.print(_lines.slice(Math.max(0, _a - 1), Math.max(_a, _b)).join('\n')); + return; + } if (expr.startsWith('s/') && expr.length > 2) { const parts = []; let cur = ''; for (let i = 2; i < expr.length; i++) { @@ -6650,12 +6699,28 @@ class FileSystemModule { c.split('\n').sort().forEach(l => this.terminal.print(l)); }; this.commands['wc'] = (args) => { - if(!args[0]) return this.terminal.print('Usage: wc <filename>', 'warning'); - const content = this.terminal.fs.readFile(args[0]); - if(content === null || content === undefined) return this.terminal.print('wc: ' + args[0] + ': No such file', 'error'); + // 2026-10-21: accept -l / -w / -c (previously ANY flag was read as a filename, + // so `wc -l file` answered "wc: -l: No such file"). + const want = { l: false, w: false, c: false }; + let file = null; + (args || []).forEach(a => { + if (/^-[lwc]+$/.test(a)) a.slice(1).split('').forEach(k => { want[k] = true; }); + else if (/^-[a-zA-Z]+$/.test(a)) { /* ignore other flags */ } + else if (!file) file = a; + }); + if (!file) return this.terminal.print('Usage: wc [-l] [-w] [-c] <filename>', 'warning'); + const content = this.terminal.fs.readFile(file); + if (content === null || content === undefined) return this.terminal.print('wc: ' + file + ': No such file', 'error'); const lines = content.split('\n').length; const words = content.split(/\s+/).filter(w => w).length; - this.terminal.print(lines + ' ' + words + ' ' + content.length + ' ' + args[0]); + const chars = content.length; + const anyFlag = want.l || want.w || want.c; + const out = []; + if (want.l || !anyFlag) out.push(lines); + if (want.w || !anyFlag) out.push(words); + if (want.c || !anyFlag) out.push(chars); + if (anyFlag && !(want.l && want.w && want.c)) out.push(file); + this.terminal.print(out.join(' ')); }; this.commands['touch'] = (args) => { if(!args[0]) return this.terminal.print('Usage: touch <filename>', 'warning'); @@ -8219,7 +8284,7 @@ buildSystemPrompt() { } catch (e) {} base += '\n\n[File reads, tools, verification]\n' + '- read_file returns 8000 chars by default (limit up to 16000). If the reply says truncated=true, call read_file again with offset=<next_offset> before concluding anything about the file.\n' + - '- Prefer targeted reads over whole files: run_command with grep -n "<pattern>" <file>, sed -n \'10,40p\' <file>, head -40 <file>, tail -40 <file>, wc -l <file>.\n' + + '- Prefer targeted reads over whole files: run_command with grep "<pattern>" <file> (prints line: text), sed -n \'10,40p\' <file>, head -40 <file>, tail -n 40 <file>, wc -l <file>. Flags are tolerated, but plain `cat <file>` and read_file offset/limit always work.\n' + '- sed without -i is read-only and allowed; sed -i, rm, mv and writes need user confirmation.\n' + '- After ANY write or edit: re-read the changed lines (cat or sed -n) and run a check that exists in THIS terminal — `test` for the suite, `grep -n` to confirm the new line, `wc -l` for size. There is no shell, no node and no curl here.\n' + '- If a tool fails twice, change approach (different tool, smaller scope) instead of retrying the same call.'; @@ -9637,7 +9702,14 @@ buildSystemPrompt() { const now = Date.now(); const blocked = []; for (const tc of res.tool_calls) { - const sig = tc.function.name + ':' + (tc.function.arguments || ''); + // 2026-10-21: arguments can be an object (already parsed upstream). + // `+ object` produced "name:[object Object]", collapsing every call to + // the same tool into one key → false "called 3x" loop warnings when the + // model legitimately batched distinct commands. + const _argKey = (typeof tc.function.arguments === 'string') + ? tc.function.arguments + : JSON.stringify(tc.function.arguments || {}); + const sig = tc.function.name + ':' + _argKey; let entry = _seenCalls.get(sig); if (!entry || (now - entry.ts) > _loopWindow) { entry = { count: 0, ts: now, resultHash: null, blocked: false, lastResult: null, epoch: this._toolEpoch }; @@ -9652,7 +9724,7 @@ buildSystemPrompt() { entry.ts = now; _seenCalls.set(sig, entry); - const argPreview = String(tc.function.arguments || '') + const argPreview = String(_argKey || '') .replace(/(token|key|secret|password|bearer)[^,}]*/ig, '$1:[redacted]') .slice(0, 60); if (entry.blocked || entry.count >= _blockAt) { @@ -9765,7 +9837,7 @@ buildSystemPrompt() { this.terminal.print(`📊 Result (${t1}s): ${obs}`, 'info'); } try { - const _sigR = fn.name + ':' + (fn.arguments || ''); + const _sigR = fn.name + ':' + ((typeof fn.arguments === 'string') ? fn.arguments : JSON.stringify(fn.arguments || {})); const _eR = _seenCalls.get(_sigR); if (_eR) { const _rh = _sigHash(toolResult); @@ -9840,7 +9912,7 @@ buildSystemPrompt() { this.terminal.print(`📊 Result (${t1}s): ${dObs}`, 'info'); } try { - const _sigD = fn.name + ':' + (fn.arguments || ''); + const _sigD = fn.name + ':' + ((typeof fn.arguments === 'string') ? fn.arguments : JSON.stringify(fn.arguments || {})); const _eD = _seenCalls.get(_sigD); if (_eD) { const _rhD = _sigHash(toolResult); diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js index 8d1fc9d..f69494e 100644 --- a/modules/core/module-aiassist.js +++ b/modules/core/module-aiassist.js @@ -122,7 +122,7 @@ buildSystemPrompt() { } catch (e) {} base += '\n\n[File reads, tools, verification]\n' + '- read_file returns 8000 chars by default (limit up to 16000). If the reply says truncated=true, call read_file again with offset=<next_offset> before concluding anything about the file.\n' + - '- Prefer targeted reads over whole files: run_command with grep -n "<pattern>" <file>, sed -n \'10,40p\' <file>, head -40 <file>, tail -40 <file>, wc -l <file>.\n' + + '- Prefer targeted reads over whole files: run_command with grep "<pattern>" <file> (prints line: text), sed -n \'10,40p\' <file>, head -40 <file>, tail -n 40 <file>, wc -l <file>. Flags are tolerated, but plain `cat <file>` and read_file offset/limit always work.\n' + '- sed without -i is read-only and allowed; sed -i, rm, mv and writes need user confirmation.\n' + '- After ANY write or edit: re-read the changed lines (cat or sed -n) and run a check that exists in THIS terminal — `test` for the suite, `grep -n` to confirm the new line, `wc -l` for size. There is no shell, no node and no curl here.\n' + '- If a tool fails twice, change approach (different tool, smaller scope) instead of retrying the same call.'; @@ -1540,7 +1540,14 @@ buildSystemPrompt() { const now = Date.now(); const blocked = []; for (const tc of res.tool_calls) { - const sig = tc.function.name + ':' + (tc.function.arguments || ''); + // 2026-10-21: arguments can be an object (already parsed upstream). + // `+ object` produced "name:[object Object]", collapsing every call to + // the same tool into one key → false "called 3x" loop warnings when the + // model legitimately batched distinct commands. + const _argKey = (typeof tc.function.arguments === 'string') + ? tc.function.arguments + : JSON.stringify(tc.function.arguments || {}); + const sig = tc.function.name + ':' + _argKey; let entry = _seenCalls.get(sig); if (!entry || (now - entry.ts) > _loopWindow) { entry = { count: 0, ts: now, resultHash: null, blocked: false, lastResult: null, epoch: this._toolEpoch }; @@ -1555,7 +1562,7 @@ buildSystemPrompt() { entry.ts = now; _seenCalls.set(sig, entry); - const argPreview = String(tc.function.arguments || '') + const argPreview = String(_argKey || '') .replace(/(token|key|secret|password|bearer)[^,}]*/ig, '$1:[redacted]') .slice(0, 60); if (entry.blocked || entry.count >= _blockAt) { @@ -1668,7 +1675,7 @@ buildSystemPrompt() { this.terminal.print(`📊 Result (${t1}s): ${obs}`, 'info'); } try { - const _sigR = fn.name + ':' + (fn.arguments || ''); + const _sigR = fn.name + ':' + ((typeof fn.arguments === 'string') ? fn.arguments : JSON.stringify(fn.arguments || {})); const _eR = _seenCalls.get(_sigR); if (_eR) { const _rh = _sigHash(toolResult); @@ -1743,7 +1750,7 @@ buildSystemPrompt() { this.terminal.print(`📊 Result (${t1}s): ${dObs}`, 'info'); } try { - const _sigD = fn.name + ':' + (fn.arguments || ''); + const _sigD = fn.name + ':' + ((typeof fn.arguments === 'string') ? fn.arguments : JSON.stringify(fn.arguments || {})); const _eD = _seenCalls.get(_sigD); if (_eD) { const _rhD = _sigHash(toolResult); diff --git a/modules/core/module-fs.js b/modules/core/module-fs.js index 2b525f0..686a1a6 100644 --- a/modules/core/module-fs.js +++ b/modules/core/module-fs.js @@ -200,37 +200,72 @@ class FileSystemModule { this.terminal.print('Found ' + results.length + ' match(es)', 'info'); }; this.commands['grep'] = (args) => { - if(!args[0]) return this.terminal.print('Usage: grep <pattern> [filename]', 'warning'); - const pattern = args[0]; const target = args[1]; + // 2026-10-21: accept the common GNU flags instead of treating them as the pattern. + // `grep -n "x" file` used to search for the literal "-n" inside a file named "x" + // (6/6 syntax variants failed for the agent), while plain grep already prints + // "line: text" for every hit. + const opt = { count: false, invert: false, files: false, ignoreCase: true }; + let ai = 0; + while (ai < args.length && /^-[a-zA-Z]+$/.test(args[ai])) { + const f = args[ai].slice(1); + if (f.indexOf('c') !== -1) opt.count = true; + if (f.indexOf('v') !== -1) opt.invert = true; + if (f.indexOf('l') !== -1) opt.files = true; + if (f.indexOf('i') !== -1) opt.ignoreCase = true; + ai++; + } + const pattern = args[ai]; const target = args[ai + 1]; + if (!pattern) return this.terminal.print('Usage: grep [-n] [-i] [-c] [-l] <pattern> [filename]', 'warning'); + const re = new RegExp(pattern, opt.ignoreCase ? 'i' : ''); + const hits = (line) => (opt.invert ? !re.test(line) : re.test(line)); if (target) { const c = this.terminal.fs.readFile(target); - if(c === null || c === undefined) return this.terminal.print('grep: ' + target + ': No such file', 'error'); - const lines = c.split('\n'); const re = new RegExp(pattern, 'i'); - let count = 0; lines.forEach((line, i) => { if (re.test(line)) { this.terminal.print((i+1) + ': ' + line); count++; } }); + if (c === null || c === undefined) return this.terminal.print('grep: ' + target + ': No such file', 'error'); + const lines = c.split('\n'); + let count = 0; + lines.forEach((line, i) => { + if (!hits(line)) return; + count++; + if (!opt.count && !opt.files) this.terminal.print((i + 1) + ': ' + line); + }); + if (opt.files && count) this.terminal.print(target); if (count === 0) this.terminal.print('(no matches)', 'info'); - else this.terminal.print(count + ' match(es)', 'text-muted'); + else if (opt.count) this.terminal.print(String(count)); + else if (!opt.files) this.terminal.print(count + ' match(es)', 'text-muted'); } else { const dir = this.terminal.fs.getCurrentDir(); if (!dir) return this.terminal.print('grep: cannot search', 'error'); - let total = 0; const re = new RegExp(pattern, 'i'); + let total = 0; Object.keys(dir.content).forEach(name => { const node = dir.content[name]; if (node.type === 'file') { (node.content || '').split('\n').forEach((line, i) => { - if (re.test(line)) { this.terminal.print(name + ':' + (i+1) + ': ' + line); total++; } + if (!hits(line)) return; + total++; + if (!opt.count && !opt.files) this.terminal.print(name + ':' + (i + 1) + ': ' + line); }); } }); + if (opt.files && total) this.terminal.print(dir.name || '.'); if (total === 0) this.terminal.print('(no matches)', 'info'); - else this.terminal.print(total + ' total match(es)', 'text-muted'); + else if (opt.count) this.terminal.print(String(total)); + else if (!opt.files) this.terminal.print(total + ' total match(es)', 'text-muted'); } }; this.commands['head'] = (args) => { - if(!args[0]) return this.terminal.print('Usage: head <filename> [lines]', 'warning'); - const c = this.terminal.fs.readFile(args[0]); - if(c === null || c === undefined) return this.terminal.print('head: ' + args[0] + ': No such file', 'error'); - const n = args[1] ? parseInt(args[1]) : 10; - this.terminal.print(c.split('\n').slice(0, n).join('\n')); + // 2026-10-21: accept `head -40 f`, `head -n 40 f` and `head f 40`. + let n = 10, file = null; + for (let i = 0; i < (args || []).length; i++) { + const a = args[i]; + if (/^-\d+$/.test(a)) n = parseInt(a.slice(1), 10); + else if (a === '-n') { const v = parseInt(args[i + 1], 10); if (!isNaN(v)) n = v; i++; } + else if (/^-[a-zA-Z]+$/.test(a)) { /* ignore other flags */ } + else if (!file) file = a; + } + if (!file) return this.terminal.print('Usage: head [-n] <filename> [lines]', 'warning'); + const c = this.terminal.fs.readFile(file); + if (c === null || c === undefined) return this.terminal.print('head: ' + file + ': No such file', 'error'); + this.terminal.print(c.split('\n').slice(0, Math.max(1, n)).join('\n')); }; this.commands['tail'] = (args) => { const self = this; @@ -254,7 +289,11 @@ class FileSystemModule { if (positional[1] && /^\d+$/.test(positional[1])) n = parseInt(positional[1], 10); // legacy back-compat: tail <file> <count> const c = this.terminal.fs.readFile(file); if (c === null || c === undefined) return this.terminal.print('tail: ' + file + ': No such file', 'error'); - this.terminal.print(c.split('\n').slice(-n).join('\n')); + // 2026-10-21: a trailing newline produced an empty last element, so `tail -n 1` + // printed a blank line instead of the final line. + const _all = c.split('\n'); + if (_all.length > 1 && _all[_all.length - 1] === '') _all.pop(); + this.terminal.print(_all.slice(-n).join('\n')); if (!follow) return; // AI tool calls cannot press Ctrl+C — auto-downgrade to a bounded read instead of a live timer if (this.terminal.security && this.terminal.security._aiToolDepth > 0) { @@ -308,6 +347,16 @@ class FileSystemModule { return this.terminal.print('sed: missing file operand (no piped input)', 'error'); } let subst = null, delMatch = null, delLine = null; + // 2026-10-21: `sed -n '10,40p' file` and `sed -n '7p' file` had no parser, so the + // most common "show me lines N–M" idiom failed with an unsupported-expression error. + const _range = /^(\d+)(?:,(\d+))?p$/.exec(expr); + if (_range) { + const _a = parseInt(_range[1], 10); + const _b = _range[2] ? parseInt(_range[2], 10) : _a; + const _lines = content.split('\n'); + this.terminal.print(_lines.slice(Math.max(0, _a - 1), Math.max(_a, _b)).join('\n')); + return; + } if (expr.startsWith('s/') && expr.length > 2) { const parts = []; let cur = ''; for (let i = 2; i < expr.length; i++) { @@ -381,12 +430,28 @@ class FileSystemModule { c.split('\n').sort().forEach(l => this.terminal.print(l)); }; this.commands['wc'] = (args) => { - if(!args[0]) return this.terminal.print('Usage: wc <filename>', 'warning'); - const content = this.terminal.fs.readFile(args[0]); - if(content === null || content === undefined) return this.terminal.print('wc: ' + args[0] + ': No such file', 'error'); + // 2026-10-21: accept -l / -w / -c (previously ANY flag was read as a filename, + // so `wc -l file` answered "wc: -l: No such file"). + const want = { l: false, w: false, c: false }; + let file = null; + (args || []).forEach(a => { + if (/^-[lwc]+$/.test(a)) a.slice(1).split('').forEach(k => { want[k] = true; }); + else if (/^-[a-zA-Z]+$/.test(a)) { /* ignore other flags */ } + else if (!file) file = a; + }); + if (!file) return this.terminal.print('Usage: wc [-l] [-w] [-c] <filename>', 'warning'); + const content = this.terminal.fs.readFile(file); + if (content === null || content === undefined) return this.terminal.print('wc: ' + file + ': No such file', 'error'); const lines = content.split('\n').length; const words = content.split(/\s+/).filter(w => w).length; - this.terminal.print(lines + ' ' + words + ' ' + content.length + ' ' + args[0]); + const chars = content.length; + const anyFlag = want.l || want.w || want.c; + const out = []; + if (want.l || !anyFlag) out.push(lines); + if (want.w || !anyFlag) out.push(words); + if (want.c || !anyFlag) out.push(chars); + if (anyFlag && !(want.l && want.w && want.c)) out.push(file); + this.terminal.print(out.join(' ')); }; this.commands['touch'] = (args) => { if(!args[0]) return this.terminal.print('Usage: touch <filename>', 'warning'); diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index ccd92d8..8330eaf 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.47'; + const LATEST_VER = 'v4.6.49'; const CACHE = '?v=' + LATEST_VER; const stored = localStorage.getItem('webx11_cache_ver'); if (stored !== LATEST_VER) { |