aiden-runtime 4.0.2 → 4.1.0

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 (108) hide show
  1. package/README.md +11 -7
  2. package/config/hardware.json +2 -2
  3. package/dist/api/server.js +50 -52
  4. package/dist/cli/v4/aidenCLI.js +421 -5
  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 +41 -0
  28. package/dist/cli/v4/envSources.js +105 -0
  29. package/dist/cli/v4/ghostMatch.js +74 -0
  30. package/dist/cli/v4/historyStore.js +163 -0
  31. package/dist/cli/v4/pasteCompression.js +124 -0
  32. package/dist/cli/v4/pasteIntercept.js +203 -0
  33. package/dist/cli/v4/replyRenderer.js +209 -0
  34. package/dist/cli/v4/resizeGuard.js +92 -0
  35. package/dist/cli/v4/shellInterpolation.js +139 -0
  36. package/dist/cli/v4/skinEngine.js +21 -1
  37. package/dist/cli/v4/streamingPrefix.js +121 -0
  38. package/dist/cli/v4/syntaxHighlight.js +345 -0
  39. package/dist/cli/v4/table.js +216 -0
  40. package/dist/cli/v4/themeDetect.js +81 -0
  41. package/dist/cli/v4/uiBuild.js +74 -0
  42. package/dist/cli/v4/voiceCli.js +113 -0
  43. package/dist/cli/v4/voicePromptApi.js +196 -0
  44. package/dist/core/channels/discord.js +16 -10
  45. package/dist/core/channels/email.js +13 -9
  46. package/dist/core/channels/imessage.js +13 -9
  47. package/dist/core/channels/manager.js +25 -7
  48. package/dist/core/channels/pdf-extract.js +180 -0
  49. package/dist/core/channels/photo-vision.js +157 -0
  50. package/dist/core/channels/signal.js +11 -7
  51. package/dist/core/channels/slack.js +13 -10
  52. package/dist/core/channels/telegram-commands.js +154 -0
  53. package/dist/core/channels/telegram-groups.js +198 -0
  54. package/dist/core/channels/telegram-rate-limit.js +124 -0
  55. package/dist/core/channels/telegram.js +1980 -0
  56. package/dist/core/channels/twilio.js +11 -7
  57. package/dist/core/channels/webhook.js +9 -5
  58. package/dist/core/channels/whatsapp.js +15 -11
  59. package/dist/core/channels/whisper-transcribe.js +163 -0
  60. package/dist/core/cronManager.js +33 -294
  61. package/dist/core/gateway.js +29 -8
  62. package/dist/core/playwrightBridge.js +90 -0
  63. package/dist/core/v4/aidenAgent.js +35 -0
  64. package/dist/core/v4/auxiliaryClient.js +2 -2
  65. package/dist/core/v4/cron/atomicWrite.js +18 -4
  66. package/dist/core/v4/cron/cronExecute.js +300 -0
  67. package/dist/core/v4/cron/cronManager.js +502 -0
  68. package/dist/core/v4/cron/cronState.js +314 -0
  69. package/dist/core/v4/cron/cronTick.js +90 -0
  70. package/dist/core/v4/cron/diagnostics.js +104 -0
  71. package/dist/core/v4/cron/graceWindow.js +79 -0
  72. package/dist/core/v4/logger/factory.js +110 -0
  73. package/dist/core/v4/logger/index.js +22 -0
  74. package/dist/core/v4/logger/logger.js +101 -0
  75. package/dist/core/v4/logger/sinks/fileSink.js +110 -0
  76. package/dist/core/v4/logger/sinks/multiSink.js +43 -0
  77. package/dist/core/v4/logger/sinks/nullSink.js +53 -0
  78. package/dist/core/v4/logger/sinks/stdSink.js +81 -0
  79. package/dist/core/v4/mcp/server/diagnostics.js +40 -0
  80. package/dist/core/v4/mcp/server/skillBridge.js +94 -0
  81. package/dist/core/v4/mcp/server/stdioServer.js +119 -0
  82. package/dist/core/v4/mcp/server/toolBridge.js +168 -0
  83. package/dist/core/v4/platformPaths.js +105 -0
  84. package/dist/core/v4/providerFallback.js +25 -0
  85. package/dist/core/v4/skillLoader.js +21 -5
  86. package/dist/core/v4/skillMining/candidateStore.js +164 -0
  87. package/dist/core/v4/skillMining/extractorPrompt.js +111 -0
  88. package/dist/core/v4/skillMining/proposalBuilder.js +139 -0
  89. package/dist/core/v4/skillMining/skillMiner.js +191 -0
  90. package/dist/core/v4/skillMining/traceFingerprint.js +51 -0
  91. package/dist/core/v4/subagent/budget.js +76 -0
  92. package/dist/core/v4/subagent/diagnostics.js +22 -0
  93. package/dist/core/v4/subagent/fanout.js +216 -0
  94. package/dist/core/v4/subagent/merger.js +148 -0
  95. package/dist/core/v4/subagent/providerRotation.js +54 -0
  96. package/dist/core/v4/voice/audioStream.js +373 -0
  97. package/dist/core/v4/voice/cliVoice.js +393 -0
  98. package/dist/core/v4/voice/diagnostics.js +66 -0
  99. package/dist/core/v4/voice/ttsStream.js +193 -0
  100. package/dist/core/version.js +1 -1
  101. package/dist/core/visionAnalyze.js +291 -90
  102. package/dist/core/voice/audio.js +61 -5
  103. package/dist/core/voice/audioBackend.js +134 -0
  104. package/dist/core/voice/stt.js +61 -6
  105. package/dist/core/voice/tts.js +19 -3
  106. package/dist/tools/v4/index.js +32 -1
  107. package/dist/tools/v4/subagent/subagentFanout.js +166 -0
  108. package/package.json +11 -2
@@ -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;
@@ -0,0 +1,139 @@
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/shellInterpolation.ts — Phase v4.1-tier3-essentials
10
+ *
11
+ * Inline shell expansion. When a user prompt contains one or more
12
+ * `{!cmd}` spans, we run each command, splice the output back in,
13
+ * and submit the rewritten prompt to the agent.
14
+ *
15
+ * Rules:
16
+ * - Each `{!cmd}` runs via `child_process.exec` with a 5s wallclock
17
+ * timeout (kill on overrun).
18
+ * - Output is stdout (or stderr if stdout empty), trimmed, capped
19
+ * at 500 visible chars. Multi-line output is collapsed to the
20
+ * first 500 chars verbatim — newlines preserved.
21
+ * - On non-zero exit / timeout / spawn failure, the span is
22
+ * replaced with `[shell:error]` so the rest of the prompt still
23
+ * submits.
24
+ * - Every span runs in parallel; total wait bounded by the slowest
25
+ * single command.
26
+ *
27
+ * MCP serve mode never reaches this path (REPL doesn't run there).
28
+ *
29
+ * Security: this expands BEFORE the agent loop, so the same
30
+ * `approvalEngine` gate the user has on the in-agent `shell_exec`
31
+ * tool does NOT apply here. To prevent an unattended REPL from
32
+ * exfiltrating arbitrary command output, callers SHOULD only invoke
33
+ * this on user-typed prompts (never on tool-emitted text). chatSession
34
+ * applies it to `readUserInput`'s return value, which is exactly that.
35
+ */
36
+ Object.defineProperty(exports, "__esModule", { value: true });
37
+ exports.INTERPOLATION_RE = void 0;
38
+ exports.hasInterpolation = hasInterpolation;
39
+ exports.expand = expand;
40
+ exports.countSpans = countSpans;
41
+ const node_child_process_1 = require("node:child_process");
42
+ /** Matches `{!cmd}` spans non-greedily so `{!a} {!b}` produces two matches. */
43
+ exports.INTERPOLATION_RE = /\{!(.+?)\}/g;
44
+ /** Truthy check used by callers that want to skip the work entirely. */
45
+ function hasInterpolation(text) {
46
+ return /\{![^}]+\}/.test(text);
47
+ }
48
+ /** Default output cap (visible chars per span). */
49
+ const OUTPUT_CAP = 500;
50
+ /** Default wallclock timeout per span (ms). */
51
+ const TIMEOUT_MS = 5000;
52
+ /**
53
+ * Run a single `cmd` and return the trimmed output (or `[shell:error]`).
54
+ * Always resolves; never rejects.
55
+ */
56
+ async function runOne(cmd, opts) {
57
+ return new Promise((resolve) => {
58
+ let settled = false;
59
+ const child = (0, node_child_process_1.exec)(cmd, { timeout: opts.timeoutMs, windowsHide: true }, (err, stdout, stderr) => {
60
+ if (settled)
61
+ return;
62
+ settled = true;
63
+ if (err) {
64
+ // exec sets err.killed=true on timeout. Either way:
65
+ // surface a marker rather than a partial command output.
66
+ resolve('[shell:error]');
67
+ return;
68
+ }
69
+ const out = (stdout && stdout.length > 0 ? stdout : stderr ?? '').trim();
70
+ if (out.length === 0) {
71
+ resolve('');
72
+ return;
73
+ }
74
+ if (out.length <= opts.outputCap) {
75
+ resolve(out);
76
+ }
77
+ else {
78
+ resolve(out.slice(0, opts.outputCap) + '…');
79
+ }
80
+ });
81
+ // Defensive: kill on timeout in case `exec`'s built-in timeout
82
+ // misses the window (Windows shell quirks). The exec callback
83
+ // above will still fire.
84
+ setTimeout(() => {
85
+ if (!settled) {
86
+ try {
87
+ child.kill('SIGKILL');
88
+ }
89
+ catch { /* */ }
90
+ }
91
+ }, opts.timeoutMs + 500);
92
+ });
93
+ }
94
+ /**
95
+ * Expand every `{!cmd}` in `text`, returning the rewritten string.
96
+ * If `text` contains no spans, returned verbatim with no work done.
97
+ */
98
+ async function expand(text, optsIn = {}) {
99
+ if (!hasInterpolation(text))
100
+ return text;
101
+ const opts = {
102
+ timeoutMs: optsIn.timeoutMs ?? TIMEOUT_MS,
103
+ outputCap: optsIn.outputCap ?? OUTPUT_CAP,
104
+ };
105
+ // Collect all matches up front so we can splice in order.
106
+ const matches = [];
107
+ for (const m of text.matchAll(exports.INTERPOLATION_RE)) {
108
+ if (m.index === undefined)
109
+ continue;
110
+ matches.push({
111
+ start: m.index,
112
+ end: m.index + m[0].length,
113
+ cmd: (m[1] ?? '').trim(),
114
+ });
115
+ }
116
+ // Run all spans in parallel.
117
+ const replacements = await Promise.all(matches.map(async (m) => ({
118
+ start: m.start,
119
+ end: m.end,
120
+ text: m.cmd.length > 0 ? await runOne(m.cmd, opts) : '',
121
+ })));
122
+ // Splice from right-to-left so earlier spans' positions stay valid.
123
+ let out = text;
124
+ for (let i = replacements.length - 1; i >= 0; i -= 1) {
125
+ const r = replacements[i];
126
+ out = out.slice(0, r.start) + r.text + out.slice(r.end);
127
+ }
128
+ return out;
129
+ }
130
+ /**
131
+ * Cheap surface count for the pre-submit "[shell] running N
132
+ * interpolations…" status line.
133
+ */
134
+ function countSpans(text) {
135
+ let n = 0;
136
+ for (const _ of text.matchAll(exports.INTERPOLATION_RE))
137
+ n += 1;
138
+ return n;
139
+ }
@@ -57,7 +57,12 @@ const DEFAULT_SKIN = {
57
57
  glyphs: {
58
58
  bullet: '•',
59
59
  arrow: '›',
60
- spinner: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
60
+ // Tier-3.1 (v4.1-tier3.1): replaced the generic braille spinner
61
+ // with a custom Aiden frame set derived from the ▲ prompt glyph.
62
+ // Six-frame rotating-triangle cadence reads as motion at the
63
+ // standard ~80ms tick without depending on colour, so it works
64
+ // identically under monochrome forks of this skin.
65
+ spinner: ['▲', '△', '▴', '▵', '▴', '△'],
61
66
  },
62
67
  };
63
68
  const LIGHT_SKIN = {
@@ -150,6 +155,21 @@ class SkinEngine {
150
155
  * The loaded skin becomes the active skin.
151
156
  */
152
157
  async loadSkin(name) {
158
+ // Tier-3-essentials: 'auto' resolves to a concrete skin via the
159
+ // multi-signal detector (AIDEN_THEME / NO_COLOR / COLORFGBG /
160
+ // TERM_PROGRAM, falling back to 'default'). Resolves once per
161
+ // call — caller can re-invoke loadSkin('auto') if env changes.
162
+ if (name === 'auto') {
163
+ // Lazy import keeps the skin loader free of detector deps in
164
+ // synchronous test paths that bypass loadSkin.
165
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
166
+ const { detectTheme, detectedToSkinName } = require('./themeDetect');
167
+ const detected = detectTheme();
168
+ const resolved = detectedToSkinName(detected);
169
+ // Re-enter loadSkin with the resolved name (no infinite loop —
170
+ // the detector never returns 'auto').
171
+ return this.loadSkin(resolved);
172
+ }
153
173
  if (this.cache.has(name)) {
154
174
  this.current = this.cache.get(name);
155
175
  return this.current;
@@ -0,0 +1,121 @@
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/streamingPrefix.ts — Phase v4.1-reply-formatting
9
+ *
10
+ * Stable-prefix split for streaming markdown. Given the running
11
+ * buffered text, return the index of the last "safe" boundary —
12
+ * a `\n\n` that lies OUTSIDE any open code fence. Content above
13
+ * the boundary is locked (already rendered, won't redraw); content
14
+ * below the boundary is the suffix the caller may re-render.
15
+ *
16
+ * safePrefixBoundary("# H\n\nbody\n\nmore") === <index after 2nd \n\n>
17
+ * safePrefixBoundary("```ts\nstill open") === 0 // inside fence
18
+ *
19
+ * Pure function. Used by display.streamComplete to decide whether to
20
+ * re-render the whole stream as markdown or only the trailing chunk.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.safePrefixBoundary = safePrefixBoundary;
24
+ exports.splitAtBoundary = splitAtBoundary;
25
+ exports.endsInsideFence = endsInsideFence;
26
+ /**
27
+ * Returns the index in `text` immediately AFTER the last `\n\n` that
28
+ * lies outside an open code fence. Returns 0 when no safe boundary
29
+ * exists (e.g. when the text is entirely inside an open fence).
30
+ */
31
+ function safePrefixBoundary(text) {
32
+ let inFence = false;
33
+ let fenceMarker = null;
34
+ let lastBoundary = 0;
35
+ let i = 0;
36
+ const n = text.length;
37
+ while (i < n) {
38
+ if (!inFence) {
39
+ // Open fence?
40
+ if (text.startsWith('```', i)) {
41
+ inFence = true;
42
+ fenceMarker = '```';
43
+ i += 3;
44
+ continue;
45
+ }
46
+ if (text.startsWith('~~~', i)) {
47
+ inFence = true;
48
+ fenceMarker = '~~~';
49
+ i += 3;
50
+ continue;
51
+ }
52
+ // Paragraph break — `\n\n` (or `\n \n` with whitespace gap).
53
+ if (text[i] === '\n' && /\n[ \t]*\n/.test(text.slice(i, i + 4))) {
54
+ // Advance past the consecutive whitespace+newlines.
55
+ let j = i;
56
+ while (j < n && (text[j] === '\n' || text[j] === ' ' || text[j] === '\t')) {
57
+ j += 1;
58
+ }
59
+ // Boundary is the START of the post-break content.
60
+ lastBoundary = j;
61
+ i = j;
62
+ continue;
63
+ }
64
+ }
65
+ else {
66
+ // Close fence?
67
+ if (fenceMarker && text.startsWith(fenceMarker, i)) {
68
+ inFence = false;
69
+ fenceMarker = null;
70
+ i += 3;
71
+ continue;
72
+ }
73
+ }
74
+ i += 1;
75
+ }
76
+ return lastBoundary;
77
+ }
78
+ /**
79
+ * Split `text` at the safe boundary. `prefix` is locked content
80
+ * (already rendered), `suffix` is the unstable tail the caller
81
+ * should re-render on the next pass.
82
+ */
83
+ function splitAtBoundary(text) {
84
+ const idx = safePrefixBoundary(text);
85
+ return { prefix: text.slice(0, idx), suffix: text.slice(idx) };
86
+ }
87
+ /**
88
+ * Helper: detect whether `text` ends inside an open code fence.
89
+ * The streaming renderer uses this to decide whether to defer
90
+ * markdown rendering until the fence closes.
91
+ */
92
+ function endsInsideFence(text) {
93
+ let inFence = false;
94
+ let fenceMarker = null;
95
+ let i = 0;
96
+ const n = text.length;
97
+ while (i < n) {
98
+ if (!inFence) {
99
+ if (text.startsWith('```', i)) {
100
+ inFence = true;
101
+ fenceMarker = '```';
102
+ i += 3;
103
+ continue;
104
+ }
105
+ if (text.startsWith('~~~', i)) {
106
+ inFence = true;
107
+ fenceMarker = '~~~';
108
+ i += 3;
109
+ continue;
110
+ }
111
+ }
112
+ else if (fenceMarker && text.startsWith(fenceMarker, i)) {
113
+ inFence = false;
114
+ fenceMarker = null;
115
+ i += 3;
116
+ continue;
117
+ }
118
+ i += 1;
119
+ }
120
+ return inFence;
121
+ }