aiden-runtime 4.0.2 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +19 -11
  2. package/config/hardware.json +2 -2
  3. package/dist/api/server.js +50 -52
  4. package/dist/cli/v4/aidenCLI.js +424 -7
  5. package/dist/cli/v4/aidenPrompt.js +317 -0
  6. package/dist/cli/v4/box.js +105 -39
  7. package/dist/cli/v4/callbacks.js +39 -6
  8. package/dist/cli/v4/chatSession.js +256 -55
  9. package/dist/cli/v4/citationFooter.js +97 -0
  10. package/dist/cli/v4/commands/channel.js +656 -0
  11. package/dist/cli/v4/commands/clear.js +1 -1
  12. package/dist/cli/v4/commands/compress.js +1 -1
  13. package/dist/cli/v4/commands/cron.js +44 -16
  14. package/dist/cli/v4/commands/fanout.js +236 -0
  15. package/dist/cli/v4/commands/help.js +15 -4
  16. package/dist/cli/v4/commands/history.js +84 -0
  17. package/dist/cli/v4/commands/index.js +16 -1
  18. package/dist/cli/v4/commands/mcp.js +358 -0
  19. package/dist/cli/v4/commands/show.js +43 -0
  20. package/dist/cli/v4/commands/skills.js +169 -4
  21. package/dist/cli/v4/commands/status.js +84 -0
  22. package/dist/cli/v4/commands/subagent.js +78 -0
  23. package/dist/cli/v4/commands/verbose.js +1 -1
  24. package/dist/cli/v4/commands/voice.js +218 -0
  25. package/dist/cli/v4/cronCli.js +103 -0
  26. package/dist/cli/v4/display.js +297 -13
  27. package/dist/cli/v4/doctor.js +102 -1
  28. package/dist/cli/v4/doctorLiveness.js +329 -0
  29. package/dist/cli/v4/envSources.js +105 -0
  30. package/dist/cli/v4/ghostMatch.js +74 -0
  31. package/dist/cli/v4/historyStore.js +163 -0
  32. package/dist/cli/v4/pasteCompression.js +124 -0
  33. package/dist/cli/v4/pasteIntercept.js +203 -0
  34. package/dist/cli/v4/replyRenderer.js +209 -0
  35. package/dist/cli/v4/resizeGuard.js +92 -0
  36. package/dist/cli/v4/shellInterpolation.js +139 -0
  37. package/dist/cli/v4/skinEngine.js +21 -1
  38. package/dist/cli/v4/streamingPrefix.js +121 -0
  39. package/dist/cli/v4/syntaxHighlight.js +345 -0
  40. package/dist/cli/v4/table.js +216 -0
  41. package/dist/cli/v4/themeDetect.js +81 -0
  42. package/dist/cli/v4/uiBuild.js +74 -0
  43. package/dist/cli/v4/voiceCli.js +113 -0
  44. package/dist/cli/v4/voicePromptApi.js +196 -0
  45. package/dist/core/channels/discord.js +16 -10
  46. package/dist/core/channels/email.js +13 -9
  47. package/dist/core/channels/imessage.js +13 -9
  48. package/dist/core/channels/manager.js +25 -7
  49. package/dist/core/channels/pdf-extract.js +180 -0
  50. package/dist/core/channels/photo-vision.js +157 -0
  51. package/dist/core/channels/signal.js +11 -7
  52. package/dist/core/channels/slack.js +13 -10
  53. package/dist/core/channels/telegram-commands.js +154 -0
  54. package/dist/core/channels/telegram-groups.js +198 -0
  55. package/dist/core/channels/telegram-rate-limit.js +124 -0
  56. package/dist/core/channels/telegram.js +1980 -0
  57. package/dist/core/channels/twilio.js +11 -7
  58. package/dist/core/channels/webhook.js +9 -5
  59. package/dist/core/channels/whatsapp.js +15 -11
  60. package/dist/core/channels/whisper-transcribe.js +163 -0
  61. package/dist/core/cronManager.js +33 -294
  62. package/dist/core/gateway.js +29 -8
  63. package/dist/core/playwrightBridge.js +90 -0
  64. package/dist/core/v4/aidenAgent.js +35 -0
  65. package/dist/core/v4/auxiliaryClient.js +2 -2
  66. package/dist/core/v4/cron/atomicWrite.js +18 -4
  67. package/dist/core/v4/cron/cronExecute.js +300 -0
  68. package/dist/core/v4/cron/cronManager.js +502 -0
  69. package/dist/core/v4/cron/cronState.js +314 -0
  70. package/dist/core/v4/cron/cronTick.js +90 -0
  71. package/dist/core/v4/cron/diagnostics.js +104 -0
  72. package/dist/core/v4/cron/graceWindow.js +79 -0
  73. package/dist/core/v4/logger/factory.js +110 -0
  74. package/dist/core/v4/logger/index.js +22 -0
  75. package/dist/core/v4/logger/logger.js +101 -0
  76. package/dist/core/v4/logger/sinks/fileSink.js +110 -0
  77. package/dist/core/v4/logger/sinks/multiSink.js +43 -0
  78. package/dist/core/v4/logger/sinks/nullSink.js +53 -0
  79. package/dist/core/v4/logger/sinks/stdSink.js +81 -0
  80. package/dist/core/v4/mcp/server/diagnostics.js +40 -0
  81. package/dist/core/v4/mcp/server/skillBridge.js +94 -0
  82. package/dist/core/v4/mcp/server/stdioServer.js +119 -0
  83. package/dist/core/v4/mcp/server/toolBridge.js +168 -0
  84. package/dist/core/v4/platformPaths.js +105 -0
  85. package/dist/core/v4/providerFallback.js +25 -0
  86. package/dist/core/v4/skillLoader.js +21 -5
  87. package/dist/core/v4/skillMining/candidateStore.js +164 -0
  88. package/dist/core/v4/skillMining/extractorPrompt.js +118 -0
  89. package/dist/core/v4/skillMining/proposalBuilder.js +140 -0
  90. package/dist/core/v4/skillMining/skillMiner.js +191 -0
  91. package/dist/core/v4/skillMining/traceFingerprint.js +51 -0
  92. package/dist/core/v4/subagent/budget.js +76 -0
  93. package/dist/core/v4/subagent/diagnostics.js +22 -0
  94. package/dist/core/v4/subagent/fanout.js +216 -0
  95. package/dist/core/v4/subagent/merger.js +148 -0
  96. package/dist/core/v4/subagent/providerRotation.js +54 -0
  97. package/dist/core/v4/voice/audioStream.js +373 -0
  98. package/dist/core/v4/voice/cliVoice.js +393 -0
  99. package/dist/core/v4/voice/diagnostics.js +66 -0
  100. package/dist/core/v4/voice/ttsStream.js +193 -0
  101. package/dist/core/version.js +1 -1
  102. package/dist/core/visionAnalyze.js +291 -90
  103. package/dist/core/voice/audio.js +61 -5
  104. package/dist/core/voice/audioBackend.js +134 -0
  105. package/dist/core/voice/stt.js +61 -6
  106. package/dist/core/voice/tts.js +19 -3
  107. package/dist/moat/dangerousPatterns.js +1 -1
  108. package/dist/providers/v4/codexResponsesAdapter.js +7 -2
  109. package/dist/providers/v4/errors.js +51 -1
  110. package/dist/providers/v4/ollamaPromptToolsAdapter.js +9 -2
  111. package/dist/tools/v4/index.js +32 -1
  112. package/dist/tools/v4/subagent/subagentFanout.js +190 -0
  113. package/package.json +11 -2
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * cli/v4/pasteCompression.ts — Tier-3.1 (v4.1-tier3.1)
10
+ *
11
+ * When a user pastes a large block (>5 lines OR >500 chars), the
12
+ * REPL replaces the visible echo with a compact label
13
+ * `[paste #<id>: <N> lines, <KB>]`
14
+ * and stores the original at `<aidenRoot>/pastes/paste_<id>.txt`.
15
+ * The agent receives the original text as input — only the visible
16
+ * echo is compressed, so the LLM still sees full content.
17
+ *
18
+ * The id counter is persisted in `<aidenRoot>/pastes/manifest.json`
19
+ * so it increments across sessions. Concurrent writes from the same
20
+ * process are serialised through an in-process latch; cross-process
21
+ * concurrency is best-effort (the manifest is read+rewritten atomic-
22
+ * ally enough for a single-user CLI).
23
+ *
24
+ * `expandPaste(id)` reads the original back from disk, used by the
25
+ * `/show <id>` slash command.
26
+ */
27
+ var __importDefault = (this && this.__importDefault) || function (mod) {
28
+ return (mod && mod.__esModule) ? mod : { "default": mod };
29
+ };
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.PASTE_COMPRESS_CHARS = exports.PASTE_COMPRESS_LINES = void 0;
32
+ exports.compressPaste = compressPaste;
33
+ exports.expandPaste = expandPaste;
34
+ exports._resetLatchForTests = _resetLatchForTests;
35
+ const node_fs_1 = require("node:fs");
36
+ const node_path_1 = __importDefault(require("node:path"));
37
+ const paths_1 = require("../../core/v4/paths");
38
+ /** Heuristic threshold — copy-paste a code block of >5 lines or >500
39
+ * chars and we compress; smaller pastes echo verbatim. */
40
+ exports.PASTE_COMPRESS_LINES = 5;
41
+ exports.PASTE_COMPRESS_CHARS = 500;
42
+ /** Per-process write latch so concurrent compresses don't race the
43
+ * manifest. Cross-process safety is non-goal for the single-user CLI. */
44
+ let writeLatch = Promise.resolve();
45
+ function pastesDir() {
46
+ const paths = (0, paths_1.resolveAidenPaths)();
47
+ return node_path_1.default.join(paths.root, 'pastes');
48
+ }
49
+ function manifestPath() {
50
+ return node_path_1.default.join(pastesDir(), 'manifest.json');
51
+ }
52
+ async function readNextId() {
53
+ try {
54
+ const raw = await node_fs_1.promises.readFile(manifestPath(), 'utf8');
55
+ const j = JSON.parse(raw);
56
+ if (typeof j.nextId === 'number' && j.nextId >= 1)
57
+ return j.nextId;
58
+ }
59
+ catch {
60
+ // missing or malformed — start at 1
61
+ }
62
+ return 1;
63
+ }
64
+ async function writeNextId(next) {
65
+ const dir = pastesDir();
66
+ await node_fs_1.promises.mkdir(dir, { recursive: true });
67
+ await node_fs_1.promises.writeFile(manifestPath(), JSON.stringify({ nextId: next }, null, 2), 'utf8');
68
+ }
69
+ function formatBytes(text) {
70
+ const bytes = Buffer.byteLength(text, 'utf8');
71
+ if (bytes < 1024)
72
+ return `${bytes}B`;
73
+ return `${(bytes / 1024).toFixed(1)}KB`;
74
+ }
75
+ /**
76
+ * Decide whether `text` should be compressed and (if so) persist the
77
+ * original. Always returns the original text for the agent path; only
78
+ * the echo path consults `compressed`/`label`.
79
+ */
80
+ async function compressPaste(text) {
81
+ const lineCount = (text.match(/\n/g)?.length ?? 0) + 1;
82
+ const big = lineCount > exports.PASTE_COMPRESS_LINES || text.length > exports.PASTE_COMPRESS_CHARS;
83
+ if (!big) {
84
+ return { compressed: false, original: text };
85
+ }
86
+ // Atomic ID allocation under in-process latch.
87
+ let id = '';
88
+ let label = '';
89
+ await (writeLatch = writeLatch.then(async () => {
90
+ const next = await readNextId();
91
+ id = String(next);
92
+ const dir = pastesDir();
93
+ await node_fs_1.promises.mkdir(dir, { recursive: true });
94
+ await node_fs_1.promises.writeFile(node_path_1.default.join(dir, `paste_${id}.txt`), text, 'utf8');
95
+ await writeNextId(next + 1);
96
+ label = `[paste #${id}: ${lineCount} lines, ${formatBytes(text)}]`;
97
+ }));
98
+ return { compressed: true, id, label, original: text };
99
+ }
100
+ /**
101
+ * Read back a previously stored paste by id. Returns `null` if the
102
+ * id is unknown or the file vanished.
103
+ */
104
+ async function expandPaste(id) {
105
+ // Defence-in-depth: only allow numeric ids — keeps the path-join
106
+ // from straying outside the pastes directory if someone ever
107
+ // wires an untrusted argument here.
108
+ if (!/^\d+$/.test(id))
109
+ return null;
110
+ try {
111
+ const file = node_path_1.default.join(pastesDir(), `paste_${id}.txt`);
112
+ return await node_fs_1.promises.readFile(file, 'utf8');
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
118
+ /**
119
+ * Test/reset hook: drop the in-process latch so a fresh test run
120
+ * starts with a clean serialiser. Disk state untouched.
121
+ */
122
+ function _resetLatchForTests() {
123
+ writeLatch = Promise.resolve();
124
+ }
@@ -0,0 +1,203 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * cli/v4/pasteIntercept.ts — Tier-3.1a (v4.1-tier3.1a)
10
+ *
11
+ * Stdin pre-tap that handles bracketed paste sequences before
12
+ * @inquirer/prompts sees them. Modern inquirer treats any internal
13
+ * `\n` as Enter and resolves early, so a multi-line paste auto-
14
+ * submits before the user has a chance to review. This module
15
+ * intercepts paste boundaries (CSI 2004), captures the content,
16
+ * persists it via the existing pasteCompression manifest, and
17
+ * substitutes a `[paste #<id>: <N> lines, <KB>]` label on stdin.
18
+ *
19
+ * The user sees the label in inquirer's input buffer, presses Enter
20
+ * to submit, and chatSession.readUserInput swaps the label for the
21
+ * original via getPasteOriginal(id) before handing to the agent.
22
+ */
23
+ var __importDefault = (this && this.__importDefault) || function (mod) {
24
+ return (mod && mod.__esModule) ? mod : { "default": mod };
25
+ };
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.getPasteOriginal = getPasteOriginal;
28
+ exports.expandPasteLabels = expandPasteLabels;
29
+ exports.installPasteInterceptor = installPasteInterceptor;
30
+ exports._resetForTests = _resetForTests;
31
+ const node_fs_1 = require("node:fs");
32
+ const node_path_1 = __importDefault(require("node:path"));
33
+ const paths_1 = require("../../core/v4/paths");
34
+ const PASTE_BEGIN = '\x1b[200~';
35
+ const PASTE_END = '\x1b[201~';
36
+ /** id → original text (in-memory swap table). */
37
+ const originals = new Map();
38
+ function pastesDir() {
39
+ return node_path_1.default.join((0, paths_1.resolveAidenPaths)().root, 'pastes');
40
+ }
41
+ function manifestPath() {
42
+ return node_path_1.default.join(pastesDir(), 'manifest.json');
43
+ }
44
+ function readNextIdSync() {
45
+ try {
46
+ const raw = (0, node_fs_1.readFileSync)(manifestPath(), 'utf8');
47
+ const j = JSON.parse(raw);
48
+ if (typeof j.nextId === 'number' && j.nextId >= 1)
49
+ return j.nextId;
50
+ }
51
+ catch { /* missing or malformed */ }
52
+ return 1;
53
+ }
54
+ function writeNextIdSync(next) {
55
+ const dir = pastesDir();
56
+ if (!(0, node_fs_1.existsSync)(dir))
57
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
58
+ (0, node_fs_1.writeFileSync)(manifestPath(), JSON.stringify({ nextId: next }, null, 2), 'utf8');
59
+ }
60
+ function formatBytes(text) {
61
+ const bytes = Buffer.byteLength(text, 'utf8');
62
+ return bytes < 1024 ? `${bytes}B` : `${(bytes / 1024).toFixed(1)}KB`;
63
+ }
64
+ function compressSync(text) {
65
+ const dir = pastesDir();
66
+ if (!(0, node_fs_1.existsSync)(dir))
67
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
68
+ const next = readNextIdSync();
69
+ const id = String(next);
70
+ (0, node_fs_1.writeFileSync)(node_path_1.default.join(dir, `paste_${id}.txt`), text, 'utf8');
71
+ writeNextIdSync(next + 1);
72
+ const lineCount = (text.match(/\n/g)?.length ?? 0) + 1;
73
+ return { id, label: `[paste #${id}: ${lineCount} lines, ${formatBytes(text)}]` };
74
+ }
75
+ /**
76
+ * Look up the original text for a paste id. Returns undefined if the
77
+ * id was never seen by this process (e.g. the user typed a label by
78
+ * hand). Disk is the source of truth for /show <id>; this map is the
79
+ * fast path for the in-flight prompt swap.
80
+ */
81
+ function getPasteOriginal(id) {
82
+ return originals.get(id);
83
+ }
84
+ /**
85
+ * Replace `[paste #N: …]` patterns in `input` with the corresponding
86
+ * original text from the in-process map. Patterns whose id we don't
87
+ * know are left intact (might be user-typed). Returns the swapped
88
+ * string.
89
+ */
90
+ function expandPasteLabels(input) {
91
+ return input.replace(/\[paste #(\d+):[^\]]*\]/g, (m, id) => {
92
+ const orig = originals.get(id);
93
+ return orig !== undefined ? orig : m;
94
+ });
95
+ }
96
+ let installed = null;
97
+ /**
98
+ * Install the stdin pre-tap. Wraps `process.stdin.emit('data', …)`
99
+ * so paste payloads are captured + replaced with labels before any
100
+ * downstream listener (inquirer) sees them. Idempotent. Returns an
101
+ * uninstall function.
102
+ *
103
+ * MCP serve mode: never call this — `aiden mcp serve` doesn't run
104
+ * the REPL.
105
+ */
106
+ function installPasteInterceptor(stdin) {
107
+ if (installed)
108
+ return installed.restore;
109
+ const origEmit = stdin.emit.bind(stdin);
110
+ const state = { inPaste: false, buf: '' };
111
+ function processChunk(text) {
112
+ let out = '';
113
+ let cursor = 0;
114
+ while (cursor < text.length) {
115
+ if (state.inPaste) {
116
+ const endIdx = text.indexOf(PASTE_END, cursor);
117
+ if (endIdx === -1) {
118
+ state.buf += text.slice(cursor);
119
+ cursor = text.length;
120
+ }
121
+ else {
122
+ state.buf += text.slice(cursor, endIdx);
123
+ cursor = endIdx + PASTE_END.length;
124
+ // Tier-3.1c: terminals (and some clipboard payloads) emit a
125
+ // trailing CR/LF immediately after PASTE_END. Without this
126
+ // swallow the bytes pass through to readline, where they
127
+ // become an Enter event and auto-submit the prompt before
128
+ // the user has reviewed the paste. Eat at most one CR + one
129
+ // LF (in either order) right after PASTE_END.
130
+ if (text[cursor] === '\r')
131
+ cursor += 1;
132
+ if (text[cursor] === '\n')
133
+ cursor += 1;
134
+ state.inPaste = false;
135
+ const original = state.buf.replace(/\r\n/g, '\n');
136
+ state.buf = '';
137
+ // Strip a single trailing newline (Enter at end of paste).
138
+ const trimmed = original.replace(/\n+$/, '');
139
+ if (!trimmed.includes('\n') && trimmed.length <= 500) {
140
+ // Single-line, small — emit as-is so user can edit.
141
+ out += trimmed;
142
+ }
143
+ else {
144
+ // Multi-line or large — disk-back + emit label.
145
+ try {
146
+ const { id, label } = compressSync(trimmed);
147
+ originals.set(id, trimmed);
148
+ out += label;
149
+ }
150
+ catch {
151
+ // Disk failure: fall back to a single-space substitute
152
+ // so internal newlines don't trigger auto-submit.
153
+ out += trimmed.replace(/\n/g, ' ');
154
+ }
155
+ }
156
+ }
157
+ }
158
+ else {
159
+ const beginIdx = text.indexOf(PASTE_BEGIN, cursor);
160
+ if (beginIdx === -1) {
161
+ out += text.slice(cursor);
162
+ cursor = text.length;
163
+ }
164
+ else {
165
+ out += text.slice(cursor, beginIdx);
166
+ cursor = beginIdx + PASTE_BEGIN.length;
167
+ state.inPaste = true;
168
+ }
169
+ }
170
+ }
171
+ return out;
172
+ }
173
+ const wrappedEmit = function (event, ...args) {
174
+ if (event !== 'data')
175
+ return origEmit(event, ...args);
176
+ const chunk = args[0];
177
+ if (chunk == null)
178
+ return origEmit(event, ...args);
179
+ const text = Buffer.isBuffer(chunk)
180
+ ? chunk.toString('utf8')
181
+ : (typeof chunk === 'string' ? chunk : String(chunk));
182
+ const processed = processChunk(text);
183
+ if (processed.length === 0)
184
+ return true; // suppress entirely
185
+ const nextArgs = [Buffer.from(processed, 'utf8'), ...args.slice(1)];
186
+ return origEmit(event, ...nextArgs);
187
+ };
188
+ stdin.emit = wrappedEmit;
189
+ const restore = () => {
190
+ if (!installed)
191
+ return;
192
+ stdin.emit = origEmit;
193
+ installed = null;
194
+ };
195
+ installed = { restore };
196
+ return restore;
197
+ }
198
+ /** Test helper: clear the in-memory map (does not touch disk). */
199
+ function _resetForTests() {
200
+ originals.clear();
201
+ if (installed)
202
+ installed.restore();
203
+ }
@@ -0,0 +1,209 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod). Licensed under AGPL-3.0.
4
+ *
5
+ * Aiden — local-first agent.
6
+ */
7
+ /**
8
+ * cli/v4/replyRenderer.ts — Phase v4.1-reply-formatting
9
+ *
10
+ * Configures marked-terminal with skin-aware renderers so Aiden's
11
+ * agent replies render as structured markdown instead of raw walls
12
+ * of text. Headers, lists, code blocks, blockquotes, inline emphasis,
13
+ * and links all get terminal-friendly painting.
14
+ *
15
+ * The renderer is an instance — `getReplyRenderer().render(text)`
16
+ * returns the painted string. Used by:
17
+ * - `display.markdown(text)` (non-streaming agent reply)
18
+ * - `display.streamComplete()` (post-stream re-render, optional)
19
+ * - the citation footer composer
20
+ *
21
+ * Stable-prefix split for streaming lives in `streamingPrefix.ts`
22
+ * (pure function over the buffered text); this module is only the
23
+ * static renderer.
24
+ *
25
+ * NO_COLOR honour: the skin engine already returns plain text when
26
+ * `NO_COLOR` is set, so every paint call gracefully degrades.
27
+ */
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.getReplyRenderer = getReplyRenderer;
30
+ exports._resetForTests = _resetForTests;
31
+ const marked_1 = require("marked");
32
+ const skinEngine_1 = require("./skinEngine");
33
+ const syntaxHighlight_1 = require("./syntaxHighlight");
34
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
35
+ const TerminalRenderer = require('marked-terminal').default ?? require('marked-terminal');
36
+ function paint(kind) {
37
+ return (text) => (0, skinEngine_1.getSkinEngine)().applyColors(text, kind);
38
+ }
39
+ /**
40
+ * Render a fenced code block: top divider with language label, body
41
+ * with optional syntax highlighting, bottom divider.
42
+ *
43
+ * ── typescript ─────────────
44
+ * const x = 1;
45
+ * ──────────────────────────
46
+ *
47
+ * Used by the prototype-override path below — marked-terminal's
48
+ * internal `Renderer.prototype.code` ignores user `opts.code` and
49
+ * runs its own highlighter, so we override the prototype method
50
+ * directly. The token-object signature is what marked v15 calls
51
+ * the renderer with; the older positional path is kept for
52
+ * compatibility.
53
+ */
54
+ function renderCodeBlock(code, lang) {
55
+ const sk = (0, skinEngine_1.getSkinEngine)();
56
+ const width = Math.min(process.stdout.columns ?? 80, 100) - 4;
57
+ const langLabel = (lang ?? '').trim();
58
+ const top = langLabel
59
+ ? `── ${langLabel} ${'─'.repeat(Math.max(0, width - langLabel.length - 4))}`
60
+ : '─'.repeat(width);
61
+ const bot = '─'.repeat(width);
62
+ const body = (0, syntaxHighlight_1.isSupportedLang)(langLabel)
63
+ ? (0, syntaxHighlight_1.highlightCode)(code, langLabel)
64
+ : code;
65
+ const indented = body.split('\n').map((ln) => ` ${ln}`).join('\n');
66
+ return [
67
+ sk.applyColors(top, 'muted'),
68
+ indented,
69
+ sk.applyColors(bot, 'muted'),
70
+ '',
71
+ ].join('\n') + '\n';
72
+ }
73
+ /**
74
+ * Render a block quote with a `┃` left rail in muted colour.
75
+ * Multi-line quotes get the rail on every line.
76
+ */
77
+ function renderBlockquote(quote) {
78
+ const rail = paint('muted')('┃ ');
79
+ return quote
80
+ .split('\n')
81
+ .map((ln) => (ln.length === 0 ? rail.trimEnd() : `${rail}${ln}`))
82
+ .join('\n') + '\n';
83
+ }
84
+ /**
85
+ * Marked-terminal heading callback gets the rendered heading text +
86
+ * level. We paint h1 in brand-bold, h2 in brand, h3+ in heading.
87
+ */
88
+ function renderHeading(text, level, _raw) {
89
+ if (level <= 1)
90
+ return paint('brand')(text.toUpperCase()) + '\n\n';
91
+ if (level === 2)
92
+ return paint('brand')(text) + '\n\n';
93
+ return paint('heading')(text) + '\n\n';
94
+ }
95
+ /**
96
+ * List items get a `▸ ` glyph in muted; numbered lists keep their
97
+ * numeric prefix (marked-terminal already prepends `N.` for ordered
98
+ * lists, so we just paint the body).
99
+ */
100
+ function renderListItem(text) {
101
+ // marked-terminal feeds us the rendered child text. Strip its
102
+ // default tab prefix so our two-space indent stays consistent.
103
+ const body = text.replace(/^\s+/, '');
104
+ return ` ${paint('muted')('▸')} ${body}\n`;
105
+ }
106
+ /**
107
+ * Singleton — caching is fine since options bind to the active skin
108
+ * via paint callbacks (which read getSkinEngine() each call).
109
+ */
110
+ let cachedRenderer = null;
111
+ function getReplyRenderer() {
112
+ if (cachedRenderer)
113
+ return cachedRenderer;
114
+ // marked-terminal's `opts.<X>` callbacks are invoked with ALREADY-
115
+ // assembled strings, not raw token data — they're meant for ANSI
116
+ // wrapping, not structural override. So `opts.code` for example is
117
+ // never actually called for fenced blocks: marked-terminal's
118
+ // prototype.code runs its own internal highlighter and skips opts.
119
+ // To emit our structured code blocks (top divider + lang label +
120
+ // syntax highlight + bottom divider) we override the prototype
121
+ // method directly below.
122
+ const opts = {
123
+ blockquote: renderBlockquote,
124
+ heading: renderHeading,
125
+ firstHeading: (text, _level, _raw) => paint('brand')(text.toUpperCase()) + '\n\n',
126
+ hr: () => paint('muted')('─'.repeat(Math.min(process.stdout.columns ?? 80, 100) - 4)) + '\n',
127
+ listitem: renderListItem,
128
+ paragraph: (text) => `${text}\n\n`,
129
+ strong: paint('brand'),
130
+ em: paint('muted'),
131
+ codespan: (text) => paint('accent')(`\`${text}\``),
132
+ del: paint('muted'),
133
+ // marked-terminal calls opts.link with the ASSEMBLED visual
134
+ // (already OSC8-wrapped when the host terminal supports it),
135
+ // so we just paint it.
136
+ link: (assembled) => paint('accent')(assembled),
137
+ href: paint('accent'),
138
+ text: (text) => text,
139
+ width: Math.min(process.stdout.columns ?? 80, 100),
140
+ showSectionPrefix: false,
141
+ reflowText: false,
142
+ tab: 2,
143
+ };
144
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
145
+ const renderer = new TerminalRenderer(opts);
146
+ // Override the prototype `code` method on this instance so we get
147
+ // structured code blocks (divider + lang label + syntax highlight
148
+ // + divider) instead of marked-terminal's plain yellow-highlighted
149
+ // output. Token-object signature handles marked v15.
150
+ renderer.code = function (code, lang, _escaped) {
151
+ let text;
152
+ let langOut;
153
+ if (typeof code === 'object' && code !== null) {
154
+ // marked v15 passes a token object: { text, lang, escaped }.
155
+ const tok = code;
156
+ text = tok.text ?? '';
157
+ langOut = tok.lang;
158
+ }
159
+ else {
160
+ text = String(code ?? '');
161
+ langOut = lang;
162
+ }
163
+ return renderCodeBlock(text, langOut);
164
+ };
165
+ // Override `link` to ALWAYS emit OSC8 hyperlinks (marked-terminal's
166
+ // default uses `supports-hyperlinks` which returns false on piped
167
+ // stdout — but Aiden's REPL targets modern terminals that support
168
+ // OSC8 universally). Visible label gets accent paint; href is the
169
+ // OSC8 target. Token-object signature handles marked v15.
170
+ renderer.link = function (href, _title, text) {
171
+ let url;
172
+ let label;
173
+ if (typeof href === 'object' && href !== null) {
174
+ const tok = href;
175
+ url = tok.href ?? '';
176
+ label = this
177
+ .parser?.parseInline?.(tok.tokens ?? []) ?? '';
178
+ }
179
+ else {
180
+ url = String(href ?? '');
181
+ label = String(text ?? url);
182
+ }
183
+ if (!label)
184
+ label = url;
185
+ const painted = paint('accent')(label);
186
+ return `\x1b]8;;${url}\x1b\\${painted}\x1b]8;;\x1b\\`;
187
+ };
188
+ cachedRenderer = {
189
+ render(text) {
190
+ try {
191
+ // Bind the renderer globally before each parse — marked v15
192
+ // applies the renderer at parse time, so re-setting before
193
+ // each call is safe and ensures our custom options win even
194
+ // if other code transiently swaps the renderer.
195
+ marked_1.marked.setOptions({ renderer: renderer });
196
+ const out = marked_1.marked.parse(text);
197
+ return typeof out === 'string' ? out : String(out);
198
+ }
199
+ catch {
200
+ return text;
201
+ }
202
+ },
203
+ };
204
+ return cachedRenderer;
205
+ }
206
+ /** Test reset — drops the cached renderer so a skin change picks up. */
207
+ function _resetForTests() {
208
+ cachedRenderer = null;
209
+ }
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * cli/v4/resizeGuard.ts — Phase v4.1-tier3-essentials
10
+ *
11
+ * Hard-clear the terminal on `process.stdout` resize so dropdown
12
+ * re-renders, prompt frames, and dirty escape state from before the
13
+ * resize don't ghost into the new viewport.
14
+ *
15
+ * The clear is a single `\x1b[2J\x1b[H` (erase display + cursor home);
16
+ * every mainstream emulator honours it. A virtualised transcript could
17
+ * do this more surgically via React state, but we don't have one yet,
18
+ * so the brute-force clear is the right minimum for v4.1.
19
+ *
20
+ * Skipped in non-TTY (`process.stdout.isTTY` falsy) and in MCP serve
21
+ * mode (`isMcpServeMode()` true). 100ms debounce so a continuous
22
+ * resize drag doesn't issue dozens of clears.
23
+ *
24
+ * `installResizeGuard()` returns a teardown function. Idempotent.
25
+ */
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.HARD_CLEAR_SEQUENCE = void 0;
28
+ exports.installResizeGuard = installResizeGuard;
29
+ exports._resetForTests = _resetForTests;
30
+ const uiBuild_1 = require("./uiBuild");
31
+ /** Single ANSI sequence: `ED 2` (erase display) + `CUP` (cursor home). */
32
+ const HARD_CLEAR = '\x1b[2J\x1b[H';
33
+ const DEFAULT_DEBOUNCE_MS = 100;
34
+ let installed = null;
35
+ /**
36
+ * Install a 'resize' listener on `process.stdout`. No-op when stdout
37
+ * is non-TTY or MCP serve mode is active. Idempotent — calling twice
38
+ * returns the same teardown.
39
+ */
40
+ function installResizeGuard(opts = {}) {
41
+ if (installed)
42
+ return installed.uninstall;
43
+ const out = opts.out ?? process.stdout;
44
+ if (!out || !out.isTTY) {
45
+ const noop = () => { };
46
+ installed = { uninstall: noop };
47
+ return noop;
48
+ }
49
+ if ((0, uiBuild_1.isMcpServeMode)()) {
50
+ const noop = () => { };
51
+ installed = { uninstall: noop };
52
+ return noop;
53
+ }
54
+ const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
55
+ let pending = null;
56
+ const onResize = () => {
57
+ if (pending)
58
+ clearTimeout(pending);
59
+ pending = setTimeout(() => {
60
+ pending = null;
61
+ try {
62
+ out.write(HARD_CLEAR);
63
+ }
64
+ catch { /* defensive */ }
65
+ try {
66
+ opts.onCleared?.();
67
+ }
68
+ catch { /* re-render must not crash the listener */ }
69
+ }, debounceMs);
70
+ };
71
+ out.on('resize', onResize);
72
+ const uninstall = () => {
73
+ if (!installed)
74
+ return;
75
+ out.removeListener('resize', onResize);
76
+ if (pending) {
77
+ clearTimeout(pending);
78
+ pending = null;
79
+ }
80
+ installed = null;
81
+ };
82
+ installed = { uninstall };
83
+ return uninstall;
84
+ }
85
+ /** Test helper: drop install state. */
86
+ function _resetForTests() {
87
+ if (installed)
88
+ installed.uninstall();
89
+ installed = null;
90
+ }
91
+ /** Constant exposed for smokes — they assert we emit this exact bytes. */
92
+ exports.HARD_CLEAR_SEQUENCE = HARD_CLEAR;