aiden-runtime 4.8.0 → 4.8.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.
@@ -907,13 +907,15 @@ class ChatSession {
907
907
  // Phase 22 Task 4: status bar reflects the live phase. Set on
908
908
  // entry, cleared in both success and error paths below.
909
909
  this.setStatusState({ kind: 'generating', sinceMs: Date.now() });
910
- // Tier-3.1a: dim full-width rule between the user input echo and
911
- // the agent reply for clean visual rhythm.
912
- this.opts.display.write(` ${this.opts.display.rule()}\n`);
913
- // Phase 26.2.3 blank line between the user-input echo and the
914
- // spinner / response so the eye sees user agent as separate
915
- // beats instead of butting together.
916
- this.opts.display.write('\n');
910
+ // v4.8.1 Slice 2 hotfix #3 — removed the prior Tier-3.1a dim
911
+ // rule between the user input echo and the agent reply. The dim
912
+ // colour read as a near-blank row in live smoke, and stacked
913
+ // with the indicator's erase-blank residue produced two visible
914
+ // separator rows above `▎ Aiden`. With the rule gone, the layout
915
+ // is:
916
+ // user input → [indicator paints, erases — 1 blank row] → ▎ Aiden
917
+ // = exactly one blank row between user input and Aiden header,
918
+ // matching the rhythm Shiva flagged in smoke.
917
919
  const turnStartedAt = Date.now();
918
920
  const userMsg = { role: 'user', content: userInput };
919
921
  // Apply any queued system prompts (from skill slash commands) by
@@ -78,14 +78,26 @@ async function runInstall(ctx) {
78
78
  return;
79
79
  }
80
80
  ctx.display.write(`Installing aiden-runtime v${status.latest} (current: v${status.installed})…\n`);
81
- const result = await (0, executeInstall_1.executeInstall)();
81
+ // v4.8.1 Slice 2 — reuse the v4.8.0 sliding-block shimmer indicator
82
+ // so the user sees motion while npm install runs (typically 5–15s
83
+ // on a warm cache, longer on cold). The indicator paints to a TTY
84
+ // only — non-TTY callers (CI, pipes) see the static "Installing…"
85
+ // line above and the result row below, no shimmer.
86
+ const indicator = ctx.display.activityIndicator('updating');
87
+ let result;
88
+ try {
89
+ result = await (0, executeInstall_1.executeInstall)();
90
+ }
91
+ finally {
92
+ indicator.stop();
93
+ }
82
94
  if (result.success) {
83
95
  const v = result.installedVersion ?? status.latest;
84
96
  ctx.display.write(`\n ✓ aiden-runtime v${v} installed.\n`);
85
97
  ctx.display.dim('Restart Aiden to apply: type /quit then re-run `aiden`.');
86
98
  return;
87
99
  }
88
- ctx.display.warn(result.error ?? 'Install failed (no error message).');
100
+ ctx.display.write(`\n ✗ Update failed: ${result.error ?? 'no error message'}\n`);
89
101
  }
90
102
  // ── v4.5 update system — skip + auto subcommands ───────────────────────────
91
103
  async function runSkip(ctx) {
@@ -658,10 +658,16 @@ class Display {
658
658
  segments = [provModel, ctxSegFull, turnSeg, sessionSeg, stateDot];
659
659
  }
660
660
  else if (cols >= 100 && turnSeg) {
661
- segments = [provModel, ctxSegFull, turnSeg, elapsed];
661
+ // v4.8.1 Slice 2 hotfix — was `elapsed` (bare); now uses
662
+ // `sessionSeg` which includes the ⌛ timer glyph. The previous
663
+ // mid-tier dropped the glyph for "denser" packing, but Shiva's
664
+ // smoke at 80–110 cols showed only ` 5.1s` (leading space, no
665
+ // glyph). The glyph is single-cell, cheap, and load-bearing as
666
+ // the timer's identity affordance.
667
+ segments = [provModel, ctxSegFull, turnSeg, sessionSeg || elapsed];
662
668
  }
663
669
  else {
664
- segments = [provModel, ctxSegCompact, elapsed];
670
+ segments = [provModel, ctxSegCompact, sessionSeg || elapsed];
665
671
  }
666
672
  return ` ${segments.join(SEP)}`;
667
673
  }
@@ -892,6 +898,13 @@ class Display {
892
898
  let stopped = false;
893
899
  let printed = false;
894
900
  let tickTimer = null;
901
+ // v4.8.1 Slice 2 hotfix #4 — true once the indicator has paused
902
+ // and resumed at least once (i.e. a tool row interrupted it). When
903
+ // false at stop() time, the indicator is still in its initial-paint
904
+ // row immediately below the leading blank, so stop()'s erase can
905
+ // safely consume BOTH rows. When true, the leading blank is far
906
+ // above and stop() erases only the current indicator row.
907
+ let movedFromInitial = false;
895
908
  // Tunable cadence. v4.1.4 Phase 3b' (Issue G): bumped from 400ms
896
909
  // to 250ms after visual smoke — 400ms felt sluggish, made the
897
910
  // indicator look static between seconds. 250ms gives ~4 dot
@@ -951,7 +964,12 @@ class Display {
951
964
  : '';
952
965
  // Shimmer prefix (or none, when opts.waveBar === false).
953
966
  const prefix = shimmerEnabled ? `${buildShimmer()} ` : '';
954
- return `${prefix}${verb}${dots.padEnd(3, ' ')}${elapsedStr}`;
967
+ // v4.8.1 Slice 2 hotfix #4 — 2-space leading indent so the
968
+ // indicator line aligns at col 2, matching `▎ Aiden`, the
969
+ // user-prompt ` ▲ `, the panel ` │ ` bar, and every other
970
+ // structured surface. Prior buildLine started at col 0 which
971
+ // read as misaligned against the rest of the v4.8 chrome.
972
+ return ` ${prefix}${verb}${dots.padEnd(3, ' ')}${elapsedStr}`;
955
973
  };
956
974
  // v4.1.5 Part 1a — Issue M (Windows ConPTY buffering fix).
957
975
  //
@@ -1014,12 +1032,16 @@ class Display {
1014
1032
  return;
1015
1033
  out.write(`${ANSI_UP_ERASE}\n`);
1016
1034
  };
1017
- // Initial paint — only on TTY. v4.8.0 Slice 11 — prepend a blank
1018
- // `\n` so the indicator gets one visible row of breathing space
1019
- // above it. Prior behaviour butted the indicator flush against
1020
- // the user-prompt row, which read as cramped. The trailing `\n`
1021
- // on the verb row sits the cursor below the indicator, ready
1022
- // for the first tick to walk back up.
1035
+ // Initial paint — only on TTY.
1036
+ //
1037
+ // v4.8.1 Slice 2 hotfix #4 leading `\n` restored to give one
1038
+ // blank row between the user-input row and the indicator (hotfix
1039
+ // #3 dropped the dim rule that previously provided that gap).
1040
+ // To keep the post-stop layout at "exactly one blank between
1041
+ // user input and ▎ Aiden", stop() now walks up TWO rows when
1042
+ // the indicator never moved (no pause/resume), consuming both
1043
+ // the indicator row AND the leading blank. The `movedFromInitial`
1044
+ // flag below tracks that state.
1023
1045
  if (isTty) {
1024
1046
  out.write(`\n${buildLine()}\n`);
1025
1047
  printed = true;
@@ -1031,6 +1053,12 @@ class Display {
1031
1053
  return;
1032
1054
  paused = true;
1033
1055
  stopTick();
1056
+ // v4.8.1 Slice 2 hotfix #4 — mark the indicator as "moved" so
1057
+ // a subsequent stop() does NOT walk up 2 rows. The leading
1058
+ // blank from initial paint is now far above the current row
1059
+ // and shouldn't be consumed; doing so would erase tool-row
1060
+ // content instead.
1061
+ movedFromInitial = true;
1034
1062
  eraseLine();
1035
1063
  // After erase the cursor is at column 0 of the indicator's
1036
1064
  // (now empty) line. Caller is expected to write its own
@@ -1071,7 +1099,20 @@ class Display {
1071
1099
  return;
1072
1100
  stopped = true;
1073
1101
  stopTick();
1074
- eraseLine();
1102
+ // v4.8.1 Slice 2 hotfix #4 — when the indicator never moved
1103
+ // (no pause/resume happened during the turn), walk up TWO
1104
+ // rows: erase the indicator row AND the leading blank above
1105
+ // it. The trailing `\n` then lands the cursor exactly one
1106
+ // row below the user-input echo, so the next writer
1107
+ // (agentHeader → ▎ Aiden) produces a clean single-blank gap.
1108
+ if (!printed || !isTty)
1109
+ return;
1110
+ if (movedFromInitial) {
1111
+ out.write(`${ANSI_UP_ERASE}\n`);
1112
+ }
1113
+ else {
1114
+ out.write(`${ANSI_UP_ERASE}${ANSI_UP_ERASE}\n`);
1115
+ }
1075
1116
  },
1076
1117
  isPaused: () => paused,
1077
1118
  isStopped: () => stopped,
@@ -2051,23 +2092,25 @@ class Display {
2051
2092
  this.out.write(this.uiTrailRow(`${ok ? '✓' : '✗'} ${framework}: ${parts.join(', ')}${dur}`, ok ? 'success' : 'error'));
2052
2093
  this.streamLastEndedNewline = true;
2053
2094
  }
2054
- renderUiApprovalRequest(args) {
2055
- const prompt = typeof args.prompt === 'string' ? args.prompt : '';
2056
- if (!prompt)
2057
- return;
2058
- const riskTier = typeof args.risk_tier === 'string' ? args.risk_tier : 'medium';
2059
- const reason = typeof args.reason === 'string' ? args.reason : '';
2060
- this.commitStreamChunk();
2061
- const kind = riskTier === 'low' ? 'success'
2062
- : (riskTier === 'high' || riskTier === 'critical') ? 'error' : 'warn';
2063
- const shortP = prompt.length > 160 ? prompt.slice(0, 159) + '…' : prompt;
2064
- let out = this.uiTrailRow(`⚠ Approval needed: ${shortP}`, kind);
2065
- if (reason) {
2066
- const shortR = reason.length > 200 ? reason.slice(0, 199) + '…' : reason;
2067
- out += this.uiTrailRow(` ${shortR}`, 'muted');
2068
- }
2069
- this.out.write(out);
2070
- this.streamLastEndedNewline = true;
2095
+ renderUiApprovalRequest(_args) {
2096
+ // v4.8.1 Slice 1 — silent no-op. The Phase 2.5 wiring fires both
2097
+ // `ui_approval_request` (this method) AND `callbacks.promptApproval`
2098
+ // (which paints the framed approval panel via `renderApprovalBox`)
2099
+ // for every single approval request. The intent was complementary
2100
+ // succinct event row above, structured kv panel below but in live
2101
+ // smoke the two surfaces stack as a visual duplicate ("Approval
2102
+ // needed: file_write {...}" event row + "│ tool / │ reason / │ args"
2103
+ // panel). The panel is the canonical, information-rich surface; this
2104
+ // event-row paint is redundant.
2105
+ //
2106
+ // Behavioural change is renderer-side only: `approvalEngine` still
2107
+ // fires `onUiEvent('ui_approval_request', ...)` so any future
2108
+ // telemetry / daemon-side run_events subscriber will still see the
2109
+ // event. Nothing paints to the chat surface from this method.
2110
+ //
2111
+ // The `_args` parameter is retained for the dispatch signature
2112
+ // contract (`renderUiEvent` calls it positionally) and for the day
2113
+ // we re-introduce a single-paint surface keyed off args.risk_tier.
2071
2114
  }
2072
2115
  renderUiToast(args) {
2073
2116
  const message = typeof args.message === 'string' ? args.message : '';
@@ -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) {
@@ -638,6 +639,144 @@ function getReplyRenderer() {
638
639
  const out = lines.join('\n');
639
640
  return proto._listDepth === 0 ? out + '\n' : out + '\n';
640
641
  };
642
+ // ── v4.8.1 Slice 2 — markdown table override ──────────────────────────
643
+ //
644
+ // Why: marked-terminal's default table renderer (cli-table3) auto-
645
+ // wraps cells but doesn't keep wrap-continuation lines aligned to
646
+ // the original row — wide tables with 5+ columns fragment into
647
+ // vertical pipe rails that don't read as rows. The narrow 2-col
648
+ // tables that smoke-tested fine were within the no-wrap budget.
649
+ //
650
+ // Strategy: own the entire render from the marked v15 token object.
651
+ // Use `parser.parseInline(cell.tokens)` to get ANSI-painted cell
652
+ // text, then proportionally distribute the terminal-width budget
653
+ // across columns (clamping to natural max width), wrap each cell
654
+ // to its column width, and render the box with the same row
655
+ // height for every cell in the row so visual rows stay tight.
656
+ //
657
+ // Token-source the box chars from `glyphs.chrome.*` so a single
658
+ // glyph swap propagates here automatically (consistent with the
659
+ // rest of v4.8.x chrome).
660
+ renderer.table = function (header, body) {
661
+ // marked v15 token: { header: [cellTok], rows: [[cellTok]] }.
662
+ // Older string-based API: (headerHtml, bodyHtml) — we fall back
663
+ // to a naive concatenation so the reply isn't lost entirely.
664
+ if (typeof header !== 'object' || header === null) {
665
+ return String(header ?? '') + (body !== undefined ? String(body) : '') + '\n';
666
+ }
667
+ const tok = header;
668
+ const parser = this.parser;
669
+ const renderCell = (c) => {
670
+ if (c.tokens && parser?.parseInline) {
671
+ try {
672
+ return parser.parseInline(c.tokens).trim();
673
+ }
674
+ catch {
675
+ return (c.text ?? '').trim();
676
+ }
677
+ }
678
+ return (c.text ?? '').trim();
679
+ };
680
+ const headers = (tok.header ?? []).map(renderCell);
681
+ const rows = (tok.rows ?? []).map((r) => r.map(renderCell));
682
+ const cols = headers.length;
683
+ if (cols === 0)
684
+ return '';
685
+ // Layout budget. Reply chrome family lives at col 2.
686
+ const indent = ' ';
687
+ const termCols = process.stdout.columns ?? 100;
688
+ const innerBudget = Math.max(40, Math.min(termCols, 110) - indent.length);
689
+ // Chrome per row = `│ ` (2) per col + trailing `│` (1) + 1 trailing
690
+ // space per cell already absorbed in the budget below.
691
+ const chromeCost = 3 * cols + 1;
692
+ const contentBudget = Math.max(cols * 4, innerBudget - chromeCost);
693
+ // Natural width = max(header, body) visible width per column.
694
+ const naturalW = headers.map((h, i) => {
695
+ const hw = (0, box_1.visibleLength)(h);
696
+ const cw = rows.reduce((m, r) => Math.max(m, (0, box_1.visibleLength)(r[i] ?? '')), 0);
697
+ return Math.max(hw, cw, 1);
698
+ });
699
+ // v4.8.1 Slice 2 hotfix #2 — header-floor + proportional allocation.
700
+ //
701
+ // Each column's minimum is `max(headerWidth, MIN_COL_W)` so column
702
+ // headers NEVER wrap — they are the column identifier; wrapping
703
+ // them ("Framework" → "Framew/ork") fragments scanability worse
704
+ // than wrapping body cells. Body content above the header width
705
+ // is what gets compressed under width pressure.
706
+ //
707
+ // Algorithm:
708
+ // 1. Compute `minPerCol = max(headerW[i], MIN_COL_W)` per column.
709
+ // 2. If sum(minPerCol) >= contentBudget (very narrow terminal),
710
+ // use minPerCol as-is — body cells will wrap to fit, headers
711
+ // stay intact.
712
+ // 3. Else if sum(naturalW) <= contentBudget, use natural widths
713
+ // (no wrap needed anywhere).
714
+ // 4. Else: floor at minPerCol, distribute remaining budget
715
+ // proportionally to each column's "extra need above min",
716
+ // then hand rounding leftover to widest-natural cols first.
717
+ const MIN_COL_W = 4;
718
+ const headerW = headers.map((h) => (0, box_1.visibleLength)(h));
719
+ const minPerCol = naturalW.map((_, i) => Math.max(headerW[i], MIN_COL_W));
720
+ const totalMin = minPerCol.reduce((a, b) => a + b, 0);
721
+ const totalNatW = naturalW.reduce((a, b) => a + b, 0);
722
+ let colWidths;
723
+ if (totalMin >= contentBudget) {
724
+ colWidths = minPerCol.slice();
725
+ }
726
+ else if (totalNatW <= contentBudget) {
727
+ colWidths = naturalW.slice();
728
+ }
729
+ else {
730
+ colWidths = minPerCol.slice();
731
+ const extraNeed = naturalW.map((w, i) => Math.max(0, w - minPerCol[i]));
732
+ const totalNeed = extraNeed.reduce((a, b) => a + b, 0);
733
+ const pool = contentBudget - totalMin;
734
+ if (totalNeed > 0) {
735
+ for (let i = 0; i < cols; i += 1) {
736
+ colWidths[i] += Math.floor((extraNeed[i] * pool) / totalNeed);
737
+ }
738
+ }
739
+ let leftover = contentBudget - colWidths.reduce((a, b) => a + b, 0);
740
+ const order = naturalW.map((_, i) => i).sort((a, b) => naturalW[b] - naturalW[a]);
741
+ for (let k = 0; leftover > 0 && k < cols * 2; k += 1) {
742
+ const idx = order[k % cols];
743
+ if (colWidths[idx] < naturalW[idx]) {
744
+ colWidths[idx] += 1;
745
+ leftover -= 1;
746
+ }
747
+ }
748
+ }
749
+ // ANSI-aware cell wrap. frameWrap handles colour-code-aware width.
750
+ const wrapCell = (text, w) => w <= 0 ? [''] : (0, frame_1.wrap)(text, w, { trim: false, hard: true }).split('\n');
751
+ const sk = (0, skinEngine_1.getSkinEngine)();
752
+ const ch = tokens_1.glyphs.chrome;
753
+ const rule = (l, m, r) => indent + sk.applyColors(l + colWidths.map((w) => ch.hLine.repeat(w + 2)).join(m) + r, 'muted');
754
+ const vBar = sk.applyColors(ch.vLine, 'muted');
755
+ const renderRow = (cells) => {
756
+ const height = Math.max(...cells.map((c) => c.length), 1);
757
+ const out = [];
758
+ for (let line = 0; line < height; line += 1) {
759
+ const cellLines = cells.map((cellLines2, ci) => {
760
+ const cellLine = cellLines2[line] ?? '';
761
+ const pad = Math.max(0, colWidths[ci] - (0, box_1.visibleLength)(cellLine));
762
+ return ' ' + cellLine + ' '.repeat(pad) + ' ';
763
+ });
764
+ out.push(indent + vBar + cellLines.join(vBar) + vBar);
765
+ }
766
+ return out.join('\n');
767
+ };
768
+ const wrappedHeader = headers.map((h, i) => wrapCell(h, colWidths[i]));
769
+ const wrappedRows = rows.map((r) => r.map((c, i) => wrapCell(c, colWidths[i])));
770
+ const lines = [rule(ch.topLeft, ch.teeDown, ch.topRight)];
771
+ if (headers.length > 0) {
772
+ lines.push(renderRow(wrappedHeader));
773
+ lines.push(rule(ch.teeRight, ch.cross, ch.teeLeft));
774
+ }
775
+ for (const row of wrappedRows)
776
+ lines.push(renderRow(row));
777
+ lines.push(rule(ch.botLeft, ch.teeUp, ch.botRight));
778
+ return lines.join('\n') + '\n';
779
+ };
641
780
  cachedRenderer = {
642
781
  render(text) {
643
782
  try {
@@ -166,6 +166,12 @@ const UI_EVENTS_GUIDANCE = [
166
166
  'Markdown text in your reply is for explanation, not status. Status goes',
167
167
  'through events. Skip events entirely on single-shot queries that aren\'t',
168
168
  'multi-step work.',
169
+ '',
170
+ '## Comparison formatting',
171
+ '',
172
+ 'For comparison requests, prefer sectioned lists or narrow tables (3 cols max).',
173
+ 'Wide tables (4+ columns or cells over ~30 chars) render imperfectly in the',
174
+ 'CLI grid — break long content into sections with headers + bullets instead.',
169
175
  ].join('\n');
170
176
  /**
171
177
  * Llama-3.3-specific tool-call format guard. Adapter-side recovery picks
@@ -66,17 +66,21 @@ async function executeInstall(opts = {}) {
66
66
  const platform = opts.platform ?? process.platform;
67
67
  return new Promise((resolve) => {
68
68
  const args = ['install', '-g', packageSpec];
69
- // shell: true on Windows so npm.cmd is found via PATHEXT; on
70
- // POSIX we spawn npm directly. Either way the args are validated
71
- // (only npm + install + a hardcoded spec by default) — no user
72
- // input flows into argv.
69
+ // v4.8.1 Slice 2 drop `shell: true`. Node 20+ emits
70
+ // `DeprecationWarning: Passing args to a child process with shell
71
+ // option true can lead to security vulnerabilities` whenever
72
+ // shell:true is paired with an args array. We don't need the
73
+ // shell either — on Windows we spawn `npm.cmd` explicitly (the
74
+ // shim that PATHEXT would otherwise resolve to); on POSIX we
75
+ // spawn `npm` directly. No user input flows into argv on either
76
+ // path so the prior shell-resolution wasn't load-bearing.
77
+ const cmd = platform === 'win32' ? 'npm.cmd' : 'npm';
73
78
  const spawnOpts = {
74
- shell: platform === 'win32',
75
79
  stdio: ['ignore', 'pipe', 'pipe'],
76
80
  };
77
81
  let child;
78
82
  try {
79
- child = spawn('npm', args, spawnOpts);
83
+ child = spawn(cmd, args, spawnOpts);
80
84
  }
81
85
  catch (err) {
82
86
  resolve({
@@ -37,6 +37,13 @@ const NPM_GLOBAL_HINTS = [
37
37
  /[/\\]npm-global[/\\]/,
38
38
  /[/\\]\.nvm[/\\]versions[/\\]node[/\\][^/\\]+[/\\]lib[/\\]node_modules\b/,
39
39
  /Program Files[/\\]nodejs[/\\]node_modules[/\\]aiden-runtime\b/i,
40
+ // v4.8.1 Slice 2 — Windows user-mode `npm install -g` lands in
41
+ // `C:\Users\<u>\AppData\Roaming\npm\node_modules\aiden-runtime\`.
42
+ // The leading `[/\\]npm[/\\]node_modules` hint above usually catches
43
+ // it, but tests on a non-default `npm config prefix` setup
44
+ // (Cmder, Scoop, etc.) can land outside the canonical path. The
45
+ // extra hint here is a belt-and-suspenders explicit AppData match.
46
+ /[/\\]AppData[/\\]Roaming[/\\]npm[/\\]/i,
40
47
  ];
41
48
  function inferDirs(input) {
42
49
  return {
@@ -1,5 +1,70 @@
1
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
+ * core/version.ts — runtime version reader.
10
+ *
11
+ * v4.8.1 Slice 2 — switched from build-time injection to a runtime
12
+ * `package.json` walk. The previous design relied on
13
+ * `scripts/inject-version.js` (a `prebuild:cli` / `prebuild:api` hook)
14
+ * to write a hardcoded VERSION constant into this file. That design
15
+ * had a subtle ordering bug:
16
+ *
17
+ * `npm run build` ran `tsc --outDir dist` BEFORE `inject-version.js`.
18
+ * tsc compiled `core/version.ts` (still at the previously-committed
19
+ * value) into `dist/core/version.js`. Inject then mutated the
20
+ * source, but only the esbuild bundle (`dist-bundle/cli.js`) picked
21
+ * up the fresh value. The `bin` entry uses the tsc tree
22
+ * (`dist/cli/v4/aidenCLI.js`), so the globally-installed CLI
23
+ * reported the stale version.
24
+ *
25
+ * Fix: read the version at module-load time by walking up from
26
+ * `__dirname` and parsing the first `package.json` we find whose
27
+ * `name` is `aiden-runtime`. This works for:
28
+ *
29
+ * - the tsc tree (`dist/core/version.js` → walk to `<install>/package.json`)
30
+ * - the esbuild bundle (`dist-bundle/cli.js` → walk to root)
31
+ * - source / tsx dev runs (`core/version.ts` → walk to repo root)
32
+ * - tests (any `__dirname` inside the repo lands on the right pkg)
33
+ *
34
+ * Failure mode: returns `'0.0.0-unknown'` if no aiden-runtime
35
+ * package.json is found within 6 parent directories. End-user
36
+ * deployments always have one within 3 levels; the 6-level budget
37
+ * keeps the function defensive without scanning the whole filesystem.
38
+ */
2
39
  Object.defineProperty(exports, "__esModule", { value: true });
3
40
  exports.VERSION = void 0;
4
- // AUTO-GENERATED by scripts/inject-version.js — do not edit by hand
5
- exports.VERSION = '4.8.0';
41
+ const node_fs_1 = require("node:fs");
42
+ const node_path_1 = require("node:path");
43
+ function readVersion() {
44
+ let dir = __dirname;
45
+ for (let i = 0; i < 6; i += 1) {
46
+ const candidate = (0, node_path_1.join)(dir, 'package.json');
47
+ if ((0, node_fs_1.existsSync)(candidate)) {
48
+ try {
49
+ const pkg = JSON.parse((0, node_fs_1.readFileSync)(candidate, 'utf8'));
50
+ if (pkg.name === 'aiden-runtime' && typeof pkg.version === 'string') {
51
+ return pkg.version;
52
+ }
53
+ }
54
+ catch {
55
+ /* unreadable / non-JSON → keep walking */
56
+ }
57
+ }
58
+ const parent = (0, node_path_1.dirname)(dir);
59
+ if (parent === dir)
60
+ break;
61
+ dir = parent;
62
+ }
63
+ return '0.0.0-unknown';
64
+ }
65
+ /**
66
+ * Resolved at module-load time. Idempotent — multiple imports share
67
+ * the cached value. Re-reading on every access would be wasteful;
68
+ * the package.json version doesn't change during a process lifetime.
69
+ */
70
+ exports.VERSION = readVersion();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiden-runtime",
3
- "version": "4.8.0",
3
+ "version": "4.8.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -60,9 +60,7 @@
60
60
  "scripts": {
61
61
  "dev": "electron electron/main.js",
62
62
  "build": "tsc --outDir dist && npm run build:cli && npm run build:api",
63
- "prebuild:cli": "node scripts/inject-version.js",
64
63
  "build:cli": "esbuild cli/aiden.ts --bundle --platform=node --target=node18 --outfile=dist-bundle/cli.js --external:electron --external:cpu-features --external:ssh2 --external:bcrypt --external:playwright --external:playwright-core --external:@aws-sdk/client-s3",
65
- "prebuild:api": "node scripts/inject-version.js",
66
64
  "build:api": "esbuild api/entry.ts --bundle --platform=node --target=node18 --outfile=dist-bundle/index.js --external:electron --external:cpu-features --external:ssh2 --external:bcrypt --external:playwright --external:playwright-core --external:@aws-sdk/client-s3",
67
65
  "prepublishOnly": "npm run typecheck && npm run build",
68
66
  "typecheck": "tsc --noEmit",