aiden-runtime 4.8.0 → 4.9.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 (99) hide show
  1. package/README.md +88 -1
  2. package/dist/cli/v4/aidenCLI.js +35 -4
  3. package/dist/cli/v4/chatSession.js +43 -16
  4. package/dist/cli/v4/commands/daemon.js +47 -2
  5. package/dist/cli/v4/commands/daemonDoctor.js +212 -0
  6. package/dist/cli/v4/commands/daemonStatus.js +1 -1
  7. package/dist/cli/v4/commands/help.js +2 -0
  8. package/dist/cli/v4/commands/hooks.js +428 -0
  9. package/dist/cli/v4/commands/index.js +5 -1
  10. package/dist/cli/v4/commands/mcp.js +89 -1
  11. package/dist/cli/v4/commands/mcpClientInstall.js +359 -0
  12. package/dist/cli/v4/commands/memory.js +702 -0
  13. package/dist/cli/v4/commands/recovery.js +1 -1
  14. package/dist/cli/v4/commands/skin.js +7 -0
  15. package/dist/cli/v4/commands/theme.js +217 -0
  16. package/dist/cli/v4/commands/trigger.js +1 -1
  17. package/dist/cli/v4/commands/update.js +14 -2
  18. package/dist/cli/v4/design/tokens.js +52 -4
  19. package/dist/cli/v4/display.js +102 -46
  20. package/dist/cli/v4/pasteIntercept.js +214 -70
  21. package/dist/cli/v4/replyRenderer.js +145 -5
  22. package/dist/cli/v4/skinEngine.js +67 -0
  23. package/dist/core/v4/aidenAgent.js +45 -2
  24. package/dist/core/v4/daemon/api/runs.js +131 -0
  25. package/dist/core/v4/daemon/bootstrap.js +368 -13
  26. package/dist/core/v4/daemon/db/migrations.js +169 -0
  27. package/dist/core/v4/daemon/idempotency/runIdempotencyStore.js +128 -0
  28. package/dist/core/v4/daemon/incarnationStore.js +47 -0
  29. package/dist/core/v4/daemon/runs/attemptStore.js +67 -0
  30. package/dist/core/v4/daemon/runs/reclaim.js +88 -0
  31. package/dist/core/v4/daemon/runs/retryPolicy.js +73 -0
  32. package/dist/core/v4/daemon/runs/runWithRetry.js +80 -0
  33. package/dist/core/v4/daemon/runs/stuckAttemptWatchdog.js +72 -0
  34. package/dist/core/v4/daemon/spans/spanHelpers.js +272 -0
  35. package/dist/core/v4/daemon/spans/spanStore.js +113 -0
  36. package/dist/core/v4/daemon/triggerBus.js +50 -19
  37. package/dist/core/v4/hooks/auditQuery.js +67 -0
  38. package/dist/core/v4/hooks/dispatcher.js +286 -0
  39. package/dist/core/v4/hooks/index.js +46 -0
  40. package/dist/core/v4/hooks/lifecycle.js +27 -0
  41. package/dist/core/v4/hooks/manifest.js +142 -0
  42. package/dist/core/v4/hooks/registry.js +149 -0
  43. package/dist/core/v4/hooks/runtime/subprocessRunner.js +188 -0
  44. package/dist/core/v4/hooks/toolHookGate.js +76 -0
  45. package/dist/core/v4/hooks/trust.js +14 -0
  46. package/dist/core/v4/identity/contextManager.js +83 -0
  47. package/dist/core/v4/identity/daemonId.js +85 -0
  48. package/dist/core/v4/identity/enforcement.js +103 -0
  49. package/dist/core/v4/identity/executionContext.js +153 -0
  50. package/dist/core/v4/identity/hookExecution.js +62 -0
  51. package/dist/core/v4/identity/httpContext.js +68 -0
  52. package/dist/core/v4/identity/ids.js +185 -0
  53. package/dist/core/v4/identity/index.js +60 -0
  54. package/dist/core/v4/identity/subprocessContext.js +98 -0
  55. package/dist/core/v4/identity/traceparent.js +114 -0
  56. package/dist/core/v4/logger/index.js +3 -1
  57. package/dist/core/v4/logger/logger.js +28 -1
  58. package/dist/core/v4/logger/redact.js +149 -0
  59. package/dist/core/v4/logger/sinks/fileSink.js +13 -0
  60. package/dist/core/v4/logger/sinks/stdSink.js +19 -1
  61. package/dist/core/v4/mcp/install/backup.js +78 -0
  62. package/dist/core/v4/mcp/install/clientPaths.js +90 -0
  63. package/dist/core/v4/mcp/install/clients.js +203 -0
  64. package/dist/core/v4/mcp/install/healthCheck.js +83 -0
  65. package/dist/core/v4/mcp/install/jsoncMerge.js +174 -0
  66. package/dist/core/v4/mcp/install/profiles.js +109 -0
  67. package/dist/core/v4/mcp/install/wslDetect.js +62 -0
  68. package/dist/core/v4/memory/namespaceRegistry.js +117 -0
  69. package/dist/core/v4/memory/projectRoot.js +76 -0
  70. package/dist/core/v4/memory/reviewer/index.js +162 -0
  71. package/dist/core/v4/memory/reviewer/pendingStore.js +136 -0
  72. package/dist/core/v4/memory/reviewer/prompt.js +105 -0
  73. package/dist/core/v4/memory/reviewer/skipRules.js +92 -0
  74. package/dist/core/v4/memoryManager.js +57 -10
  75. package/dist/core/v4/paths.js +2 -0
  76. package/dist/core/v4/promptBuilder.js +6 -0
  77. package/dist/core/v4/subagent/spawnSubAgent.js +20 -7
  78. package/dist/core/v4/theme/bundledThemes.js +106 -0
  79. package/dist/core/v4/theme/themeLoader.js +160 -0
  80. package/dist/core/v4/theme/themeRegistry.js +97 -0
  81. package/dist/core/v4/theme/themeWatcher.js +95 -0
  82. package/dist/core/v4/toolRegistry.js +71 -8
  83. package/dist/core/v4/update/executeInstall.js +10 -6
  84. package/dist/core/v4/update/installMethodDetect.js +7 -0
  85. package/dist/core/version.js +67 -2
  86. package/dist/moat/approvalEngine.js +4 -0
  87. package/dist/moat/memoryGuard.js +8 -1
  88. package/dist/providers/v4/anthropicAdapter.js +10 -4
  89. package/dist/tools/v4/backends/local.js +19 -2
  90. package/dist/tools/v4/sessions/recallSession.js +6 -1
  91. package/package.json +3 -3
  92. package/plugins/aiden-plugin-chatgpt-plus/index.js +10 -1
  93. package/themes/default.yaml +52 -0
  94. package/themes/dracula.yaml +32 -0
  95. package/themes/light.yaml +32 -0
  96. package/themes/monochrome.yaml +31 -0
  97. package/themes/tokyo-night.yaml +32 -0
  98. package/dist/core/pluginSystem.js +0 -121
  99. package/dist/tools/v4/ui/_uiSmokeTool.js +0 -60
@@ -6,19 +6,52 @@
6
6
  * Aiden — local-first agent.
7
7
  */
8
8
  /**
9
- * cli/v4/pasteIntercept.ts — Tier-3.1a (v4.1-tier3.1a)
9
+ * cli/v4/pasteIntercept.ts — stdin pre-tap for bracketed paste.
10
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.
11
+ * Modern @inquirer/prompts treats any embedded `\n` as Enter and
12
+ * resolves early, so a multi-line paste would auto-submit one line
13
+ * at a time. This module intercepts paste payloads BEFORE inquirer
14
+ * sees them, persists them to a manifest, and substitutes a
15
+ * `[paste #<id>: <N> lines, <bytes>]` label on stdin. The user sees
16
+ * the label inside inquirer's input buffer, edits it like any other
17
+ * text, then presses Enter to submit; `chatSession.readUserInput`
18
+ * swaps the label back for the original via `getPasteOriginal(id)`
19
+ * before handing to the agent.
18
20
  *
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.
21
+ * v4.8.1 Slice 2 hotfix #6 robustness rebuild for terminal-
22
+ * environment diversity:
23
+ *
24
+ * • State machine survives reads split across chunk boundaries.
25
+ * The begin or end marker can arrive partially in one chunk
26
+ * and be completed by the next; the parser keeps state in `buf`
27
+ * until a full marker is observed.
28
+ *
29
+ * • 800ms watchdog flushes a stuck `in_marker_paste` state if
30
+ * the terminal never delivers PASTE_END (mosh/tmux/SSH paths
31
+ * have all been observed to drop end markers under load).
32
+ *
33
+ * • Degraded marker forms get normalised to canonical at the
34
+ * intercept boundary. Visible-escape variants (`^[[200~`) are
35
+ * the common case from terminals that escape control sequences
36
+ * for display.
37
+ *
38
+ * • CRLF/CR → LF normalisation is applied universally on every
39
+ * incoming chunk, not just inside marker payloads. Some
40
+ * clipboard payloads carry CR-only line endings.
41
+ *
42
+ * • 30ms timing accumulation catches line-by-line paste delivery
43
+ * — the failure mode that surfaced after hotfix #5. When a
44
+ * terminal delivers a paste as N small `"<line>\n"` chunks
45
+ * instead of one bulk chunk, each chunk has a single trailing
46
+ * `\n` and would otherwise pass through as an Enter keystroke.
47
+ * The accumulator holds candidate chunks (`length > 1` so the
48
+ * bare Enter keystroke `"\n"` is never held) for a 30ms window;
49
+ * if another candidate arrives, both are accumulated as a
50
+ * multi-line paste and substituted with the placeholder before
51
+ * any `\n` reaches inquirer. If no follow-up arrives within the
52
+ * window, the held chunk is emitted unchanged (normal Enter).
53
+ * 30ms is imperceptible to humans and well below sustained
54
+ * keystroke timing.
22
55
  */
23
56
  var __importDefault = (this && this.__importDefault) || function (mod) {
24
57
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -33,7 +66,16 @@ const node_path_1 = __importDefault(require("node:path"));
33
66
  const paths_1 = require("../../core/v4/paths");
34
67
  const PASTE_BEGIN = '\x1b[200~';
35
68
  const PASTE_END = '\x1b[201~';
36
- /** id → original text (in-memory swap table). */
69
+ /**
70
+ * Degraded marker patterns observed in the wild. Each is rewritten
71
+ * to canonical at the normalisation boundary so the parser only
72
+ * needs to know about one form.
73
+ */
74
+ const DEGRADED_BEGIN = /\^\[\[200~/g;
75
+ const DEGRADED_END = /\^\[\[201~/g;
76
+ const ACCUMULATION_MS = 30;
77
+ const WATCHDOG_MS = 800;
78
+ /** id → original text (in-memory swap table). Disk has /pastes/paste_<id>.txt as source of truth for /show. */
37
79
  const originals = new Map();
38
80
  function pastesDir() {
39
81
  return node_path_1.default.join((0, paths_1.resolveAidenPaths)().root, 'pastes');
@@ -74,18 +116,17 @@ function compressSync(text) {
74
116
  }
75
117
  /**
76
118
  * 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.
119
+ * id was never seen by this process. Disk (/pastes/paste_<id>.txt)
120
+ * is the source of truth for /show <id>; this map is the fast path
121
+ * for the in-flight prompt swap.
80
122
  */
81
123
  function getPasteOriginal(id) {
82
124
  return originals.get(id);
83
125
  }
84
126
  /**
85
127
  * 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.
128
+ * original text. Patterns whose id we don't know are left intact
129
+ * (might be user-typed by hand).
89
130
  */
90
131
  function expandPasteLabels(input) {
91
132
  return input.replace(/\[paste #(\d+):[^\]]*\]/g, (m, id) => {
@@ -93,6 +134,38 @@ function expandPasteLabels(input) {
93
134
  return orig !== undefined ? orig : m;
94
135
  });
95
136
  }
137
+ /**
138
+ * Universal normalisation applied at the intercept boundary:
139
+ * CRLF + bare CR → LF, then degraded marker variants → canonical.
140
+ */
141
+ function normalize(text) {
142
+ let t = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
143
+ t = t.replace(DEGRADED_BEGIN, PASTE_BEGIN);
144
+ t = t.replace(DEGRADED_END, PASTE_END);
145
+ return t;
146
+ }
147
+ /**
148
+ * Decide whether `payload` should emit inline (small single-line) or
149
+ * be funnelled through the disk-backed placeholder system. Same
150
+ * thresholds for marker-wrapped and timing-accumulated paths so the
151
+ * user sees identical chrome regardless of how the paste arrived.
152
+ */
153
+ function payloadToEmission(payload) {
154
+ const trimmed = payload.replace(/\n+$/, '');
155
+ if (!trimmed.includes('\n') && trimmed.length <= 500) {
156
+ return trimmed;
157
+ }
158
+ try {
159
+ const { id, label } = compressSync(trimmed);
160
+ originals.set(id, trimmed);
161
+ return label;
162
+ }
163
+ catch {
164
+ // Disk failure: collapse newlines so the auto-submit we're
165
+ // preventing doesn't fire downstream.
166
+ return trimmed.replace(/\n/g, ' ');
167
+ }
168
+ }
96
169
  let installed = null;
97
170
  /**
98
171
  * Install the stdin pre-tap. Wraps `process.stdin.emit('data', …)`
@@ -103,72 +176,140 @@ let installed = null;
103
176
  * MCP serve mode: never call this — `aiden mcp serve` doesn't run
104
177
  * the REPL.
105
178
  */
106
- function installPasteInterceptor(stdin) {
179
+ function installPasteInterceptor(stdin, opts = {}) {
107
180
  if (installed)
108
181
  return installed.restore;
182
+ const accumulationMs = opts.accumulationMs ?? ACCUMULATION_MS;
183
+ const watchdogMs = opts.watchdogMs ?? WATCHDOG_MS;
109
184
  const origEmit = stdin.emit.bind(stdin);
110
- const state = { inPaste: false, buf: '' };
111
- function processChunk(text) {
112
- let out = '';
185
+ // State machine
186
+ // normal : default; chunks pass through or accumulate
187
+ // in_marker_paste : between PASTE_BEGIN and PASTE_END; buf accumulates payload
188
+ let mode = 'normal';
189
+ let buf = '';
190
+ let markerTimer = null;
191
+ let pendingChunk = null;
192
+ let pendingTimer = null;
193
+ function emitDownstream(text) {
194
+ if (text.length === 0)
195
+ return;
196
+ origEmit('data', Buffer.from(text, 'utf8'));
197
+ }
198
+ function clearMarkerWatchdog() {
199
+ if (markerTimer) {
200
+ clearTimeout(markerTimer);
201
+ markerTimer = null;
202
+ }
203
+ }
204
+ function armMarkerWatchdog() {
205
+ clearMarkerWatchdog();
206
+ markerTimer = setTimeout(() => {
207
+ // PASTE_END never arrived. Flush whatever we have and reset.
208
+ const payload = buf;
209
+ buf = '';
210
+ mode = 'normal';
211
+ markerTimer = null;
212
+ emitDownstream(payloadToEmission(payload));
213
+ }, watchdogMs);
214
+ }
215
+ function clearPending() {
216
+ if (pendingTimer) {
217
+ clearTimeout(pendingTimer);
218
+ pendingTimer = null;
219
+ }
220
+ pendingChunk = null;
221
+ }
222
+ function flushPendingAsIs() {
223
+ if (pendingChunk === null)
224
+ return;
225
+ const chunk = pendingChunk;
226
+ clearPending();
227
+ // Pending was a normal Enter — emit as-is, don't placeholder.
228
+ emitDownstream(chunk);
229
+ }
230
+ function flushPendingAsPaste() {
231
+ if (pendingChunk === null)
232
+ return;
233
+ const chunk = pendingChunk;
234
+ clearPending();
235
+ emitDownstream(payloadToEmission(chunk));
236
+ }
237
+ function processNormalised(text) {
113
238
  let cursor = 0;
114
239
  while (cursor < text.length) {
115
- if (state.inPaste) {
240
+ if (mode === 'in_marker_paste') {
116
241
  const endIdx = text.indexOf(PASTE_END, cursor);
117
242
  if (endIdx === -1) {
118
- state.buf += text.slice(cursor);
243
+ buf += text.slice(cursor);
119
244
  cursor = text.length;
245
+ // Watchdog stays armed — extending the buf without an end
246
+ // marker doesn't restart the clock; we still want to flush
247
+ // if the entire turn never produces PASTE_END.
120
248
  }
121
249
  else {
122
- state.buf += text.slice(cursor, endIdx);
250
+ buf += text.slice(cursor, endIdx);
123
251
  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;
252
+ // Swallow a trailing newline that some terminals emit
253
+ // immediately after PASTE_END.
132
254
  if (text[cursor] === '\n')
133
255
  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
- }
256
+ mode = 'normal';
257
+ clearMarkerWatchdog();
258
+ const payload = buf;
259
+ buf = '';
260
+ emitDownstream(payloadToEmission(payload));
156
261
  }
262
+ continue;
157
263
  }
158
- else {
159
- const beginIdx = text.indexOf(PASTE_BEGIN, cursor);
160
- if (beginIdx === -1) {
161
- out += text.slice(cursor);
162
- cursor = text.length;
264
+ // mode === 'normal'
265
+ const beginIdx = text.indexOf(PASTE_BEGIN, cursor);
266
+ if (beginIdx !== -1) {
267
+ // Pre-marker content: flush any pending and emit inline so
268
+ // it lands in inquirer's buffer ahead of the placeholder
269
+ // (preserves typed prefix when the user pastes after typing).
270
+ flushPendingAsIs();
271
+ if (beginIdx > cursor)
272
+ emitDownstream(text.slice(cursor, beginIdx));
273
+ cursor = beginIdx + PASTE_BEGIN.length;
274
+ mode = 'in_marker_paste';
275
+ armMarkerWatchdog();
276
+ continue;
277
+ }
278
+ // No marker in the remainder.
279
+ const remainder = text.slice(cursor);
280
+ cursor = text.length;
281
+ const nlCount = (remainder.match(/\n/g) ?? []).length;
282
+ const hasInternalNl = nlCount > 1 || (nlCount === 1 && !remainder.endsWith('\n'));
283
+ if (hasInternalNl) {
284
+ // Single bulk chunk with internal newlines — instant
285
+ // placeholder. Flush pending first so any prior single-line
286
+ // candidate isn't lost.
287
+ flushPendingAsIs();
288
+ emitDownstream(payloadToEmission(remainder));
289
+ continue;
290
+ }
291
+ // Candidate paste-line: non-empty content ending in `\n` with
292
+ // length > 1 (excludes bare Enter keystroke `"\n"`).
293
+ const isCandidate = remainder.endsWith('\n') && remainder.length > 1;
294
+ if (isCandidate) {
295
+ if (pendingChunk !== null) {
296
+ // Already pending — append, restart the window.
297
+ pendingChunk += remainder;
298
+ if (pendingTimer)
299
+ clearTimeout(pendingTimer);
300
+ pendingTimer = setTimeout(flushPendingAsPaste, accumulationMs);
163
301
  }
164
302
  else {
165
- out += text.slice(cursor, beginIdx);
166
- cursor = beginIdx + PASTE_BEGIN.length;
167
- state.inPaste = true;
303
+ pendingChunk = remainder;
304
+ pendingTimer = setTimeout(flushPendingAsIs, accumulationMs);
168
305
  }
306
+ continue;
169
307
  }
308
+ // Non-candidate (bare Enter, or non-`\n`-terminated keystroke).
309
+ // Flush pending first since this chunk closes the window.
310
+ flushPendingAsIs();
311
+ emitDownstream(remainder);
170
312
  }
171
- return out;
172
313
  }
173
314
  const wrappedEmit = function (event, ...args) {
174
315
  if (event !== 'data')
@@ -176,19 +317,22 @@ function installPasteInterceptor(stdin) {
176
317
  const chunk = args[0];
177
318
  if (chunk == null)
178
319
  return origEmit(event, ...args);
179
- const text = Buffer.isBuffer(chunk)
320
+ const raw = Buffer.isBuffer(chunk)
180
321
  ? chunk.toString('utf8')
181
322
  : (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);
323
+ const normalised = normalize(raw);
324
+ processNormalised(normalised);
325
+ // We always claim to have handled the emit. Downstream listeners
326
+ // fire from `emitDownstream` immediately on the same tick OR
327
+ // from a deferred timer in the accumulation case.
328
+ return true;
187
329
  };
188
330
  stdin.emit = wrappedEmit;
189
331
  const restore = () => {
190
332
  if (!installed)
191
333
  return;
334
+ clearPending();
335
+ clearMarkerWatchdog();
192
336
  stdin.emit = origEmit;
193
337
  installed = null;
194
338
  };
@@ -39,6 +39,7 @@ const tokens_1 = require("./design/tokens");
39
39
  // callsites in this file with `getBodyWidth()` and adds soft-wrap for
40
40
  // code-block lines that previously overflowed the viewport.
41
41
  const frame_1 = require("./display/frame");
42
+ const box_1 = require("./box");
42
43
  // eslint-disable-next-line @typescript-eslint/no-var-requires
43
44
  const TerminalRenderer = require('marked-terminal').default ?? require('marked-terminal');
44
45
  function paint(kind) {
@@ -182,7 +183,7 @@ function renderBlockquote(quote) {
182
183
  return quote
183
184
  .split('\n')
184
185
  .map((ln) => (ln.length === 0 ? rail.trimEnd() : `${rail}${ln}`))
185
- .join('\n') + '\n';
186
+ .join('\n') + '\n\n'; // v4.9.0 pre-ship UI: blank line after blockquote
186
187
  }
187
188
  /**
188
189
  * v4.1.3-essentials reply-polish: 4-tier heading hierarchy using the
@@ -632,11 +633,150 @@ function getReplyRenderer() {
632
633
  }
633
634
  }
634
635
  proto._listDepth -= 1;
635
- // Top-level list closes with a trailing newline to separate from
636
- // the next block; nested lists return without extra padding so
637
- // they nest cleanly inside their parent item.
636
+ // v4.9.0 pre-ship UI: top-level list closes with a BLANK LINE
637
+ // (`\n\n`) so a following paragraph / heading / table reads with
638
+ // breathing room. Nested lists stay tight (`\n`) so they nest
639
+ // cleanly under their parent item.
638
640
  const out = lines.join('\n');
639
- return proto._listDepth === 0 ? out + '\n' : out + '\n';
641
+ return proto._listDepth === 0 ? out + '\n\n' : out + '\n';
642
+ };
643
+ // ── v4.8.1 Slice 2 — markdown table override ──────────────────────────
644
+ //
645
+ // Why: marked-terminal's default table renderer (cli-table3) auto-
646
+ // wraps cells but doesn't keep wrap-continuation lines aligned to
647
+ // the original row — wide tables with 5+ columns fragment into
648
+ // vertical pipe rails that don't read as rows. The narrow 2-col
649
+ // tables that smoke-tested fine were within the no-wrap budget.
650
+ //
651
+ // Strategy: own the entire render from the marked v15 token object.
652
+ // Use `parser.parseInline(cell.tokens)` to get ANSI-painted cell
653
+ // text, then proportionally distribute the terminal-width budget
654
+ // across columns (clamping to natural max width), wrap each cell
655
+ // to its column width, and render the box with the same row
656
+ // height for every cell in the row so visual rows stay tight.
657
+ //
658
+ // Token-source the box chars from `glyphs.chrome.*` so a single
659
+ // glyph swap propagates here automatically (consistent with the
660
+ // rest of v4.8.x chrome).
661
+ renderer.table = function (header, body) {
662
+ // marked v15 token: { header: [cellTok], rows: [[cellTok]] }.
663
+ // Older string-based API: (headerHtml, bodyHtml) — we fall back
664
+ // to a naive concatenation so the reply isn't lost entirely.
665
+ if (typeof header !== 'object' || header === null) {
666
+ return String(header ?? '') + (body !== undefined ? String(body) : '') + '\n';
667
+ }
668
+ const tok = header;
669
+ const parser = this.parser;
670
+ const renderCell = (c) => {
671
+ if (c.tokens && parser?.parseInline) {
672
+ try {
673
+ return parser.parseInline(c.tokens).trim();
674
+ }
675
+ catch {
676
+ return (c.text ?? '').trim();
677
+ }
678
+ }
679
+ return (c.text ?? '').trim();
680
+ };
681
+ const headers = (tok.header ?? []).map(renderCell);
682
+ const rows = (tok.rows ?? []).map((r) => r.map(renderCell));
683
+ const cols = headers.length;
684
+ if (cols === 0)
685
+ return '';
686
+ // Layout budget. Reply chrome family lives at col 2.
687
+ const indent = ' ';
688
+ const termCols = process.stdout.columns ?? 100;
689
+ const innerBudget = Math.max(40, Math.min(termCols, 110) - indent.length);
690
+ // Chrome per row = `│ ` (2) per col + trailing `│` (1) + 1 trailing
691
+ // space per cell already absorbed in the budget below.
692
+ const chromeCost = 3 * cols + 1;
693
+ const contentBudget = Math.max(cols * 4, innerBudget - chromeCost);
694
+ // Natural width = max(header, body) visible width per column.
695
+ const naturalW = headers.map((h, i) => {
696
+ const hw = (0, box_1.visibleLength)(h);
697
+ const cw = rows.reduce((m, r) => Math.max(m, (0, box_1.visibleLength)(r[i] ?? '')), 0);
698
+ return Math.max(hw, cw, 1);
699
+ });
700
+ // v4.8.1 Slice 2 hotfix #2 — header-floor + proportional allocation.
701
+ //
702
+ // Each column's minimum is `max(headerWidth, MIN_COL_W)` so column
703
+ // headers NEVER wrap — they are the column identifier; wrapping
704
+ // them ("Framework" → "Framew/ork") fragments scanability worse
705
+ // than wrapping body cells. Body content above the header width
706
+ // is what gets compressed under width pressure.
707
+ //
708
+ // Algorithm:
709
+ // 1. Compute `minPerCol = max(headerW[i], MIN_COL_W)` per column.
710
+ // 2. If sum(minPerCol) >= contentBudget (very narrow terminal),
711
+ // use minPerCol as-is — body cells will wrap to fit, headers
712
+ // stay intact.
713
+ // 3. Else if sum(naturalW) <= contentBudget, use natural widths
714
+ // (no wrap needed anywhere).
715
+ // 4. Else: floor at minPerCol, distribute remaining budget
716
+ // proportionally to each column's "extra need above min",
717
+ // then hand rounding leftover to widest-natural cols first.
718
+ const MIN_COL_W = 4;
719
+ const headerW = headers.map((h) => (0, box_1.visibleLength)(h));
720
+ const minPerCol = naturalW.map((_, i) => Math.max(headerW[i], MIN_COL_W));
721
+ const totalMin = minPerCol.reduce((a, b) => a + b, 0);
722
+ const totalNatW = naturalW.reduce((a, b) => a + b, 0);
723
+ let colWidths;
724
+ if (totalMin >= contentBudget) {
725
+ colWidths = minPerCol.slice();
726
+ }
727
+ else if (totalNatW <= contentBudget) {
728
+ colWidths = naturalW.slice();
729
+ }
730
+ else {
731
+ colWidths = minPerCol.slice();
732
+ const extraNeed = naturalW.map((w, i) => Math.max(0, w - minPerCol[i]));
733
+ const totalNeed = extraNeed.reduce((a, b) => a + b, 0);
734
+ const pool = contentBudget - totalMin;
735
+ if (totalNeed > 0) {
736
+ for (let i = 0; i < cols; i += 1) {
737
+ colWidths[i] += Math.floor((extraNeed[i] * pool) / totalNeed);
738
+ }
739
+ }
740
+ let leftover = contentBudget - colWidths.reduce((a, b) => a + b, 0);
741
+ const order = naturalW.map((_, i) => i).sort((a, b) => naturalW[b] - naturalW[a]);
742
+ for (let k = 0; leftover > 0 && k < cols * 2; k += 1) {
743
+ const idx = order[k % cols];
744
+ if (colWidths[idx] < naturalW[idx]) {
745
+ colWidths[idx] += 1;
746
+ leftover -= 1;
747
+ }
748
+ }
749
+ }
750
+ // ANSI-aware cell wrap. frameWrap handles colour-code-aware width.
751
+ const wrapCell = (text, w) => w <= 0 ? [''] : (0, frame_1.wrap)(text, w, { trim: false, hard: true }).split('\n');
752
+ const sk = (0, skinEngine_1.getSkinEngine)();
753
+ const ch = tokens_1.glyphs.chrome;
754
+ const rule = (l, m, r) => indent + sk.applyColors(l + colWidths.map((w) => ch.hLine.repeat(w + 2)).join(m) + r, 'muted');
755
+ const vBar = sk.applyColors(ch.vLine, 'muted');
756
+ const renderRow = (cells) => {
757
+ const height = Math.max(...cells.map((c) => c.length), 1);
758
+ const out = [];
759
+ for (let line = 0; line < height; line += 1) {
760
+ const cellLines = cells.map((cellLines2, ci) => {
761
+ const cellLine = cellLines2[line] ?? '';
762
+ const pad = Math.max(0, colWidths[ci] - (0, box_1.visibleLength)(cellLine));
763
+ return ' ' + cellLine + ' '.repeat(pad) + ' ';
764
+ });
765
+ out.push(indent + vBar + cellLines.join(vBar) + vBar);
766
+ }
767
+ return out.join('\n');
768
+ };
769
+ const wrappedHeader = headers.map((h, i) => wrapCell(h, colWidths[i]));
770
+ const wrappedRows = rows.map((r) => r.map((c, i) => wrapCell(c, colWidths[i])));
771
+ const lines = [rule(ch.topLeft, ch.teeDown, ch.topRight)];
772
+ if (headers.length > 0) {
773
+ lines.push(renderRow(wrappedHeader));
774
+ lines.push(rule(ch.teeRight, ch.cross, ch.teeLeft));
775
+ }
776
+ for (const row of wrappedRows)
777
+ lines.push(renderRow(row));
778
+ lines.push(rule(ch.botLeft, ch.teeUp, ch.botRight));
779
+ return lines.join('\n') + '\n';
640
780
  };
641
781
  cachedRenderer = {
642
782
  render(text) {
@@ -32,10 +32,58 @@ const node_fs_1 = require("node:fs");
32
32
  const node_path_1 = __importDefault(require("node:path"));
33
33
  const node_os_1 = __importDefault(require("node:os"));
34
34
  const js_yaml_1 = __importDefault(require("js-yaml"));
35
+ // v4.9.0 Slice 1a hotfix — read live theme overrides from tokens.ts.
36
+ const tokens_1 = require("./design/tokens");
37
+ const themeRegistry_1 = require("../../core/v4/theme/themeRegistry");
35
38
  /** Wrap text with a 24-bit ANSI foreground colour. */
36
39
  function ansiRgb(text, r, g, b) {
37
40
  return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`;
38
41
  }
42
+ /**
43
+ * v4.9.0 Slice 1a hotfix — map each SkinEngine `ColorKind` to a dotted
44
+ * path inside the v4.8 `tokens.ts` colour tree so the legacy paint API
45
+ * (`applyColors(text, kind)`) can resolve user-theme overrides without
46
+ * touching the legacy skin YAML cache. Each kind picks the closest
47
+ * semantic equivalent from the new tree; kinds without a natural fit
48
+ * (e.g. `agent`, `user`) fall through to the skin's own RGB tuple.
49
+ *
50
+ * The lookup is only consulted when a user theme is active (i.e.
51
+ * `getActiveThemePath() !== null`). When no theme is loaded, the
52
+ * legacy skin path runs unchanged — preserves /skin custom-palette
53
+ * users from being silently overridden by tokens.ts baselines.
54
+ */
55
+ const COLOR_KIND_TO_TOKEN_PATH = {
56
+ brand: 'brand.primary',
57
+ accent: 'brand.primary',
58
+ heading: 'brand.primary',
59
+ tool: 'metrics.model',
60
+ session: 'metrics.model',
61
+ error: 'semantic.error',
62
+ warn: 'semantic.warn',
63
+ success: 'semantic.success',
64
+ muted: 'content.secondary',
65
+ tertiary: 'content.tertiary',
66
+ metric_turn: 'metrics.turnCount',
67
+ degraded: 'semantic.warn',
68
+ };
69
+ function hexToRgb(hex) {
70
+ const m3 = /^#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])$/.exec(hex);
71
+ if (m3)
72
+ return [parseInt(m3[1] + m3[1], 16), parseInt(m3[2] + m3[2], 16), parseInt(m3[3] + m3[3], 16)];
73
+ const m6 = /^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/.exec(hex);
74
+ if (m6)
75
+ return [parseInt(m6[1], 16), parseInt(m6[2], 16), parseInt(m6[3], 16)];
76
+ return null;
77
+ }
78
+ function readDottedPath(root, dotted) {
79
+ let node = root;
80
+ for (const seg of dotted.split('.')) {
81
+ if (node === null || typeof node !== 'object')
82
+ return undefined;
83
+ node = node[seg];
84
+ }
85
+ return node;
86
+ }
39
87
  const BRAND_ORANGE = [0xff, 0x6b, 0x35];
40
88
  const DEFAULT_SKIN = {
41
89
  name: 'default',
@@ -250,6 +298,25 @@ class SkinEngine {
250
298
  applyColors(text, kind) {
251
299
  if (this.forceMono)
252
300
  return text;
301
+ // v4.9.0 Slice 1a hotfix — when a user theme is active, resolve
302
+ // the colour from the live tokens.ts tree FIRST. This lets a
303
+ // ~/.aiden/theme.yaml override every paint surface that routes
304
+ // through SkinEngine (Aiden reply chrome, panel bars, status
305
+ // footer text, tool rows) without requiring users to also
306
+ // re-author a parallel ~/.aiden/skins/<name>.yaml. When no user
307
+ // theme is active, the legacy skin RGB path runs unchanged —
308
+ // preserves /skin custom-palette users from regression.
309
+ if ((0, themeRegistry_1.getActivePath)() !== null) {
310
+ const dotted = COLOR_KIND_TO_TOKEN_PATH[kind];
311
+ if (dotted) {
312
+ const hex = readDottedPath(tokens_1.colors, dotted);
313
+ if (typeof hex === 'string') {
314
+ const rgb = hexToRgb(hex);
315
+ if (rgb)
316
+ return ansiRgb(text, rgb[0], rgb[1], rgb[2]);
317
+ }
318
+ }
319
+ }
253
320
  const rgb = this.current.colors[kind];
254
321
  if (!rgb)
255
322
  return text;