aiden-runtime 4.1.3 → 4.1.5

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.
@@ -1057,6 +1057,34 @@ async function buildAgentRuntime(cliOpts, opts) {
1057
1057
  // diagnostics must not break the loop
1058
1058
  }
1059
1059
  },
1060
+ // v4.1.5 Issue K — phase lifecycle hooks for the activity indicator
1061
+ // verb mutation. Each fires at a specific point in runConversation:
1062
+ // - onMemoryRefreshStart: before memory I/O begins
1063
+ // - onPromptBuilt: after system prompt assembly
1064
+ // - onProviderRequestStart: just before the HTTP request opens
1065
+ // chatSession registers handlers that call `indicator.setVerb()` so
1066
+ // the user sees the model's actual workflow phase during the gap.
1067
+ // All three are forwarded through `callbacks` so chatSession owns
1068
+ // the indicator handle (created per-turn). Defensive try/catch on
1069
+ // each — a misbehaving display sink never blocks the agent loop.
1070
+ onMemoryRefreshStart: () => {
1071
+ try {
1072
+ callbacks.onMemoryRefreshStart?.();
1073
+ }
1074
+ catch { /* defensive */ }
1075
+ },
1076
+ onPromptBuilt: (info) => {
1077
+ try {
1078
+ callbacks.onPromptBuilt?.(info);
1079
+ }
1080
+ catch { /* defensive */ }
1081
+ },
1082
+ onProviderRequestStart: (id) => {
1083
+ try {
1084
+ callbacks.onProviderRequestStart?.(id);
1085
+ }
1086
+ catch { /* defensive */ }
1087
+ },
1060
1088
  // Phase 23.4b — feed the agent's Stage-0 intent pre-arm with the
1061
1089
  // skill's `required_tools` from its SKILL.md frontmatter. Returns
1062
1090
  // null when the skill is unknown / unloaded / empty so the agent
@@ -63,6 +63,29 @@ class CliCallbacks {
63
63
  this.toolRows = new Map();
64
64
  this.toolStartTimes = new Map();
65
65
  this.firstToolFiredThisTurn = false;
66
+ // v4.1.5 Issue K — `firePhaseVerb` is the public entry point for the
67
+ // AidenCLI bridge. AidenAgent fires `onMemoryRefreshStart` etc.,
68
+ // aidenCLI's adapter calls into one of these `onPhase…` shims, each
69
+ // mapping a lifecycle event to a verb string. Defensive try/catch so
70
+ // a misbehaving display sink can't unwind the agent loop.
71
+ this.onMemoryRefreshStart = () => {
72
+ try {
73
+ this.phaseVerbHook?.('refreshing memory');
74
+ }
75
+ catch { /* defensive */ }
76
+ };
77
+ this.onPromptBuilt = (_info) => {
78
+ try {
79
+ this.phaseVerbHook?.('preparing prompt');
80
+ }
81
+ catch { /* defensive */ }
82
+ };
83
+ this.onProviderRequestStart = (_providerId) => {
84
+ try {
85
+ this.phaseVerbHook?.('calling provider');
86
+ }
87
+ catch { /* defensive */ }
88
+ };
66
89
  /**
67
90
  * Phase 23.5 — bound to AidenAgent.onToolCall. Emits one event row
68
91
  * per tool call: prints `[running]` on `before`, mutates the bracket
@@ -82,6 +105,23 @@ class CliCallbacks {
82
105
  }
83
106
  this.beforeFirstToolHook = undefined;
84
107
  }
108
+ // v4.1.4 reply-quality polish — Part 1.6. Pause activity
109
+ // indicator BEFORE the tool row writes so the indicator's line
110
+ // is clean when the row lands. Fires for every tool, not just
111
+ // the first. Defensive try/catch — a misbehaving hook must not
112
+ // block tool dispatch.
113
+ try {
114
+ this.beforeToolHook?.();
115
+ }
116
+ catch { /* defensive */ }
117
+ // v4.1.5+ Path A — fire the loop-trace sink BEFORE row writes.
118
+ // Captures every tool's call.id + name (including hidden ones
119
+ // suppressed from the visible trail) so the trace covers the
120
+ // full agent loop, not just user-visible work.
121
+ try {
122
+ this.toolTraceBeforeHook?.(call.id, call.name);
123
+ }
124
+ catch { /* defensive */ }
85
125
  const handle = this.display.toolRow(call.name, call.arguments);
86
126
  this.toolRows.set(call.id, handle);
87
127
  this.toolStartTimes.set(call.id, Date.now());
@@ -92,12 +132,57 @@ class CliCallbacks {
92
132
  const startedAt = this.toolStartTimes.get(call.id);
93
133
  this.toolRows.delete(call.id);
94
134
  this.toolStartTimes.delete(call.id);
95
- if (!handle || startedAt === undefined)
135
+ if (!handle || startedAt === undefined) {
136
+ // Even if we lost the handle, the indicator may still need to
137
+ // be re-armed so the next gap shows activity. Tool-name-aware
138
+ // verb selection happens in the hook itself.
139
+ try {
140
+ this.afterEachToolHook?.(call.name);
141
+ }
142
+ catch { /* defensive */ }
143
+ // v4.1.5+ Path A — loop-trace sink fires even when handle was
144
+ // lost (rare; happens if before/after pairing slipped) so the
145
+ // trace never under-counts tool calls.
146
+ try {
147
+ this.toolTraceAfterHook?.(call.id, call.name, call.arguments);
148
+ }
149
+ catch { /* defensive */ }
96
150
  return;
151
+ }
97
152
  const ms = Date.now() - startedAt;
98
153
  const err = result?.error;
99
154
  if (typeof err === 'string' && err.includes('URL provenance gate')) {
100
155
  handle.blocked();
156
+ // v4.1.5+ Path A — blocked path still needs the trace sink so
157
+ // the URL-provenance failure mode shows up in loop diagnostics.
158
+ try {
159
+ this.toolTraceAfterHook?.(call.id, call.name, call.arguments);
160
+ }
161
+ catch { /* defensive */ }
162
+ return;
163
+ }
164
+ // v4.1.4 reply-quality polish — Part 1.6. Helper used by ALL
165
+ // outcome branches below so the activity indicator gets re-armed
166
+ // for the gap that follows this tool (next tool, or final reply).
167
+ // Tool-name-aware verb selection happens in the hook (chatSession
168
+ // wires it through `verbForActivity`).
169
+ const fireAfter = () => {
170
+ try {
171
+ this.afterEachToolHook?.(call.name);
172
+ }
173
+ catch { /* defensive */ }
174
+ // v4.1.5+ Path A — also fire the loop-trace `after` sink so the
175
+ // tracer can compute duration + capture args (hidden-from-trail
176
+ // tools still flow through here, by design — the trace must see
177
+ // them to detect lookup_tool_schema / skill_view loops).
178
+ try {
179
+ this.toolTraceAfterHook?.(call.id, call.name, call.arguments);
180
+ }
181
+ catch { /* defensive */ }
182
+ };
183
+ if (typeof err === 'string' && err.includes('URL provenance gate')) {
184
+ handle.blocked();
185
+ fireAfter();
101
186
  return;
102
187
  }
103
188
  if (err) {
@@ -111,15 +196,18 @@ class CliCallbacks {
111
196
  if (result?.capabilityCard) {
112
197
  this.display.capabilityCard(result.capabilityCard);
113
198
  }
199
+ fireAfter();
114
200
  return;
115
201
  }
116
202
  // v4.1.3-repl-polish: degraded outcome — tool completed but with a
117
203
  // partial / best-effort result. Show in trail yellow instead of silent.
118
204
  if (result?.degraded) {
119
205
  handle.degraded(ms, result.degradedReason);
206
+ fireAfter();
120
207
  return;
121
208
  }
122
209
  handle.ok(ms);
210
+ fireAfter();
123
211
  };
124
212
  /** ApprovalEngine.callbacks.promptUser */
125
213
  this.promptApproval = async (req) => {
@@ -186,23 +274,29 @@ Reply with ONE word: safe, caution, or dangerous.`;
186
274
  return false;
187
275
  }
188
276
  };
189
- /** PlannerGuard sink. Quiet in compact mode. */
277
+ /**
278
+ * PlannerGuard sink. v4.1.4 Phase 3b' (Q-Planner): moved to
279
+ * verbose-only. The default `normal` mode previously emitted
280
+ * `[planner] kept N tools (reason)` mid-execution, which collided
281
+ * visually with the activity indicator's single-line paint and
282
+ * with streamed deltas. Users running with the default verbose
283
+ * level should see a clean execution surface — planner-guard
284
+ * decisions are useful for debugging but noise during normal use.
285
+ *
286
+ * `verbose` mode keeps the full breakdown for debugging. `compact`
287
+ * stays silent (unchanged).
288
+ */
190
289
  this.onPlannerGuardDecision = (decision) => {
191
290
  if (this.verboseMode === 'compact')
192
291
  return;
193
- if (decision.reason === 'no_filter')
292
+ if (this.verboseMode !== 'verbose')
194
293
  return;
195
- if (this.verboseMode === 'verbose') {
196
- const conf = decision.confidence !== undefined
197
- ? ` (conf ${decision.confidence.toFixed(2)})`
198
- : '';
199
- this.display.dim(`[planner] ${decision.reason}${conf}: kept ${decision.selectedTools.length} / dropped ${decision.excludedTools.length}`);
294
+ if (decision.reason === 'no_filter')
200
295
  return;
201
- }
202
- // normal
203
- if (decision.excludedTools.length > 0) {
204
- this.display.dim(`[planner] kept ${decision.selectedTools.length} tools (${decision.reason})`);
205
- }
296
+ const conf = decision.confidence !== undefined
297
+ ? ` (conf ${decision.confidence.toFixed(2)})`
298
+ : '';
299
+ this.display.dim(`[planner] ${decision.reason}${conf}: kept ${decision.selectedTools.length} / dropped ${decision.excludedTools.length}`);
206
300
  };
207
301
  /**
208
302
  * Phase v4.1-skill-mining — post-turn cue when the miner has
@@ -280,6 +374,47 @@ Reply with ONE word: safe, caution, or dangerous.`;
280
374
  this.beforeFirstToolHook = fn;
281
375
  this.firstToolFiredThisTurn = false;
282
376
  }
377
+ /**
378
+ * v4.1.4 reply-quality polish — Part 1.6.
379
+ *
380
+ * Register paired hooks so chatSession can pause the activity
381
+ * indicator while a tool row writes, and resume it (with a fresh
382
+ * verb derived from the just-completed tool) in the gap before the
383
+ * next tool fires or the final reply arrives.
384
+ *
385
+ * Both fire for EVERY tool, not just the first. Either can be
386
+ * omitted independently. Cleared between turns by passing `undefined`.
387
+ */
388
+ setActivityIndicatorHooks(opts) {
389
+ this.beforeToolHook = opts.beforeTool;
390
+ this.afterEachToolHook = opts.afterEachTool;
391
+ }
392
+ /**
393
+ * v4.1.5 Issue K — set/clear the phase-verb sink. chatSession
394
+ * registers a closure that captures the per-turn indicator handle
395
+ * and forwards calls to `indicator.setVerb(verb)`. Cleared between
396
+ * turns by passing `undefined`. Optional — non-indicator callers
397
+ * (test harnesses with stub displays) get no-op behaviour.
398
+ */
399
+ setPhaseVerbHook(fn) {
400
+ this.phaseVerbHook = fn;
401
+ }
402
+ /**
403
+ * v4.1.5+ Path A — register a per-turn tool-trace sink for the
404
+ * loop-trace logger. `before` fires with the call's id+name BEFORE
405
+ * the row writes; `after` fires post-execution with the same id +
406
+ * the call's args (for skill-name extraction in trace context).
407
+ * Cleared between turns by passing `undefined`.
408
+ *
409
+ * Separate from `setActivityIndicatorHooks` because the activity
410
+ * hook is name-only and fires for visible-trail purposes; this
411
+ * one captures FULL call data including hidden tools (which the
412
+ * trail suppresses via TRAIL_HIDE_TOOLS but the trace must see).
413
+ */
414
+ setToolTraceHook(opts) {
415
+ this.toolTraceBeforeHook = opts.before;
416
+ this.toolTraceAfterHook = opts.after;
417
+ }
283
418
  }
284
419
  exports.CliCallbacks = CliCallbacks;
285
420
  // Tier-3.1 (v4.1-tier3.1): replaced 🟢/🟡/🔴 emoji badges with
@@ -69,7 +69,16 @@ exports.renderProgressBar = renderProgressBar;
69
69
  exports.formatTokens = formatTokens;
70
70
  exports.formatDuration = formatDuration;
71
71
  exports.renderMemoryConfirmations = renderMemoryConfirmations;
72
+ // v4.1.5+ Path A: env-var-gated loop trace logger. Captures tool-call
73
+ // sequence + system prompt + memory hashes when a turn shows loop
74
+ // symptoms (10+ calls OR 5+ consecutive same-name). Default off via
75
+ // `AIDEN_DEBUG_LOOP=1` env-var. Zero overhead when disabled.
76
+ const loopTrace_1 = require("../../core/v4/loopTrace");
72
77
  const display_1 = require("./display");
78
+ // v4.1.4 Part 1.6 — per-turn token progress bar. Fed by `onProgress`
79
+ // events from the streaming adapter; hidden when the adapter doesn't
80
+ // emit progress (honest degradation).
81
+ const progressBar_1 = require("./display/progressBar");
73
82
  const uiBuild_1 = require("./uiBuild");
74
83
  const sessionSummaryGate_1 = require("./sessionSummaryGate");
75
84
  const aidenPrompt_1 = __importDefault(require("./aidenPrompt"));
@@ -414,9 +423,22 @@ class ChatSession {
414
423
  // Tier-3-essentials: hard-clear the screen on terminal resize so
415
424
  // dropdown re-renders + previous prompt frames don't ghost into
416
425
  // the new viewport. No-op on non-TTY / MCP serve mode.
426
+ //
427
+ // v4.1.4 reply-quality polish: also drop the per-chunk stream row
428
+ // counter so a mid-stream resize doesn't try to erase rows that
429
+ // the hard-clear already removed. See `resetStreamFrameForResize`
430
+ // in display.ts for the rationale.
417
431
  const restoreResizeGuard = this.opts.promptApi
418
432
  ? () => { }
419
- : (0, resizeGuard_1.installResizeGuard)();
433
+ : (0, resizeGuard_1.installResizeGuard)({
434
+ onCleared: () => {
435
+ try {
436
+ this.opts.display
437
+ .resetStreamFrameForResize?.();
438
+ }
439
+ catch { /* defensive — never break the resize listener */ }
440
+ },
441
+ });
420
442
  try {
421
443
  while (iter < max) {
422
444
  iter += 1;
@@ -885,40 +907,226 @@ class ChatSession {
885
907
  const baseHistory = newHistory.length > 0
886
908
  ? [...this.history, ...newHistory, userMsg]
887
909
  : [...this.history, userMsg];
888
- // Phase 16c: streaming gated on display.streaming config (default off).
889
- // Defensive: tests sometimes pass partial config stubs without the
890
- // ConfigManager API; treat that as "streaming disabled".
910
+ // Phase 16c: streaming gated on display.streaming config.
911
+ // v4.1.4 Part 1.6: PRODUCTION DEFAULT FLIPPED FROM FALSE TO TRUE.
912
+ // Streaming delivers the activity indicator, tool-row live tick,
913
+ // and token progress bar that the user feedback ("after prompt i
914
+ // just see output") was specifically asking for. Users who
915
+ // explicitly set `display.streaming: false` in config still opt
916
+ // out; the change affects only the default for users who never
917
+ // touched the flag.
918
+ //
919
+ // Test-stub fallback (no ConfigManager) stays at `false` so
920
+ // existing tests that depended on the non-streaming code path
921
+ // don't have to be rewritten in this slice — they exercise the
922
+ // batch-call path that production users on Ollama / non-streaming
923
+ // adapters still hit naturally.
891
924
  const streamingEnabled = typeof this.opts.config?.getValue === 'function'
892
- ? this.opts.config.getValue('display.streaming', false) === true
925
+ ? this.opts.config.getValue('display.streaming', true) === true
893
926
  : false;
894
- // Phase 26.2.6 random thinking phrase per turn, already wrapped
895
- // in brand orange by Display.thinkingPhrase().
896
- const spinner = this.opts.display.startSpinner(this.opts.display.thinkingPhrase());
897
- let spinnerStopped = false;
927
+ // v4.1.4 reply-quality polish Part 1.6. Activity indicator
928
+ // replaces the prior single-shot spinner. Pause/resume hooks make
929
+ // the indicator cooperate with tool rows: it pauses before each
930
+ // tool row writes and resumes (with a tool-aware verb) in the
931
+ // gap that follows, so the user always sees activity feedback
932
+ // during model-thinking time — not just the pre-first-token gap.
933
+ //
934
+ // Initial verb is "thinking" (pre-tools phase). After each tool
935
+ // completes, `verbForActivity(toolName, 'post-tool')` picks a
936
+ // category-aware verb (reading / searching / analyzing / drafting).
937
+ // When the first stream delta arrives OR the final agentTurn is
938
+ // about to write, the indicator stops permanently.
939
+ const indicator = this.opts.display.activityIndicator('thinking');
940
+ let indicatorStopped = false;
898
941
  let streamingActive = false;
899
- const stopSpinnerOnce = () => {
900
- if (spinnerStopped)
942
+ // v4.1.5 Issue O track whether this turn had any tool calls so
943
+ // we can emit a single muted rule between the tool trail and the
944
+ // reply header. Set true when the first tool's `before` phase
945
+ // fires (via the existing beforeFirstToolHook plumbing). Emitted
946
+ // once per turn — `separatorEmitted` gates idempotency against
947
+ // both streaming and non-streaming paths reaching the same hook.
948
+ //
949
+ // v4.1.5 Phase 1d (Q-OBV-b) — multi-tool separator regression:
950
+ // the prior v4.1.5 Phase 1c emission point was the streaming
951
+ // `onFirstDelta` callback, but that fires PER provider call
952
+ // (the agent resets `firstDeltaFired` each callProvider
953
+ // invocation), and on multi-tool turns where the model emits
954
+ // no preamble in early iterations + no preamble in the final
955
+ // reply iteration either, the relative ordering of "first
956
+ // delta" vs "first tool" could leave the flag/idempotency
957
+ // gate in an unexpected state. Definitive fix: tie emission
958
+ // to the FIRST STREAM BYTE LANDING ON SCREEN, which only
959
+ // happens once per turn regardless of how many provider
960
+ // iterations occurred. `firstStreamByteSeen` is the new gate;
961
+ // separator fires from inside `onDelta` BEFORE `streamPartial`
962
+ // writes the agent header.
963
+ let turnHadTools = false;
964
+ let separatorEmitted = false;
965
+ let firstStreamByteSeen = false;
966
+ // v4.1.5+ Path A: per-turn loop tracer (env-var gated, default off).
967
+ // Captures tool-call sequence + assembled system prompt + memory
968
+ // hashes + recent skills when a turn trips loop thresholds. The
969
+ // `onLoopWarning` callback surfaces a one-line dim hint to the
970
+ // user when consecutive-same-tool count crosses 8 — gives them a
971
+ // chance to Ctrl+C before the agent burns more budget.
972
+ const loopTracer = new loopTrace_1.LoopTracer({
973
+ paths: this.opts.paths,
974
+ providerId: this.currentProviderId,
975
+ modelId: this.currentModelId,
976
+ onLoopWarning: (line) => {
977
+ try {
978
+ this.opts.display.dim(line);
979
+ }
980
+ catch { /* defensive */ }
981
+ },
982
+ });
983
+ if (loopTracer.isEnabled()) {
984
+ loopTracer.setHistory(baseHistory);
985
+ }
986
+ const emitToolReplySeparator = () => {
987
+ if (separatorEmitted || !turnHadTools)
988
+ return;
989
+ separatorEmitted = true;
990
+ // Same chrome pattern as the existing pre-turn rule (line ~1100)
991
+ // and the post-reply rule (line ~1297): two-space indent + the
992
+ // body-width muted rule + newline. The 2-space indent is the
993
+ // legacy convention used by adjacent rules; the v4.1.5 frame
994
+ // gutter (3) is consciously NOT applied here so all three rules
995
+ // in a turn share one left edge.
996
+ this.opts.display.write(` ${this.opts.display.rule()}\n`);
997
+ };
998
+ const stopIndicatorOnce = () => {
999
+ if (indicatorStopped)
901
1000
  return;
902
- spinnerStopped = true;
903
- spinner.stop();
1001
+ indicatorStopped = true;
1002
+ indicator.stop();
1003
+ // Clear the per-turn pause/resume hooks so they don't fire
1004
+ // against a stopped indicator on a subsequent turn. The next
1005
+ // turn re-registers fresh hooks.
1006
+ try {
1007
+ this.opts.callbacks.setActivityIndicatorHooks?.({});
1008
+ }
1009
+ catch { /* defensive */ }
1010
+ // v4.1.5 Issue K — also clear the phase-verb sink so lifecycle
1011
+ // events fired during async cleanup don't try to update a
1012
+ // stopped indicator.
1013
+ try {
1014
+ this.opts.callbacks.setPhaseVerbHook?.(undefined);
1015
+ }
1016
+ catch { /* defensive */ }
1017
+ // v4.1.5+ Path A — clear the loop-trace sink so subsequent
1018
+ // turns don't fire into a stale tracer. Note: this clears the
1019
+ // HOOK, not the tracer's accumulated state — finalize() still
1020
+ // runs at end-of-try below to write the snapshot if thresholds
1021
+ // tripped.
1022
+ try {
1023
+ this.opts.callbacks.setToolTraceHook?.({});
1024
+ }
1025
+ catch { /* defensive */ }
904
1026
  };
905
- // Phase 23.5: stop the "thinking…" spinner the moment the first
906
- // tool row prints. The event rows are the user-facing indicator
907
- // from that point on; a spinner painting `\r` over the same line
908
- // would corrupt our row-overwrite when the row mutates to its
909
- // final bracket state.
910
- this.opts.callbacks.setBeforeFirstToolHook?.(stopSpinnerOnce);
1027
+ // v4.1.5 Issue K wire the per-turn phase-verb sink. Each
1028
+ // AidenAgent lifecycle event (memory refresh start, prompt built,
1029
+ // provider request start) flows through CliCallbacks and lands
1030
+ // here as a verb string ("refreshing memory" / "preparing prompt"
1031
+ // / "calling provider"). The closure captures the per-turn
1032
+ // indicator handle so verb mutations stay scoped to this turn.
1033
+ this.opts.callbacks.setPhaseVerbHook?.((verb) => {
1034
+ if (indicatorStopped)
1035
+ return;
1036
+ indicator.setVerb(verb);
1037
+ });
1038
+ // v4.1.5+ Path A — wire the loop-trace sink. Fires for EVERY tool
1039
+ // call (including hidden ones) so the trace captures the full
1040
+ // agent loop. Defensive — when AIDEN_DEBUG_LOOP is unset, the
1041
+ // tracer's `startTool`/`endTool` short-circuit immediately.
1042
+ this.opts.callbacks.setToolTraceHook?.({
1043
+ before: (id, name) => loopTracer.startTool(id, name),
1044
+ after: (id, name, args) => loopTracer.endTool(id, name, args),
1045
+ });
1046
+ // Phase 23.5 carried forward: stop the indicator the moment the
1047
+ // first tool row prints — the row itself is the activity surface
1048
+ // during a tool. Part 1.6 then resumes via `afterEachTool` so the
1049
+ // post-tool gap has its own indicator paint.
1050
+ //
1051
+ // v4.1.5 Issue O — also flip `turnHadTools = true` so the
1052
+ // separator emits before the reply header. Single hook captures
1053
+ // "any tool ran this turn" cleanly (it only fires for the FIRST
1054
+ // tool of the turn — subsequent tools don't re-trigger).
1055
+ this.opts.callbacks.setBeforeFirstToolHook?.(() => {
1056
+ turnHadTools = true;
1057
+ stopIndicatorOnce();
1058
+ });
1059
+ // Part 1.6: pause/resume hooks around every tool row. The
1060
+ // `beforeTool` hook fires before EACH tool row writes (not just
1061
+ // the first), so multi-tool sequences also keep the indicator
1062
+ // off the tool-row line. `afterEachTool` resumes with a verb
1063
+ // chosen from the just-completed tool's category — best guess
1064
+ // for "what the model is doing next". `lastToolName` is captured
1065
+ // for tests / observability; the verb decision happens inline.
1066
+ this.opts.callbacks.setActivityIndicatorHooks?.({
1067
+ beforeTool: () => {
1068
+ if (indicatorStopped)
1069
+ return;
1070
+ indicator.pause();
1071
+ // v4.1.4 Part 1.6: hide the progress bar while the tool row
1072
+ // owns the screen. The bar paints below the indicator, so
1073
+ // it'd otherwise sit between the tool row and any subsequent
1074
+ // stream output — visual clutter for tool-heavy turns. The
1075
+ // bar is per-turn, not per-stream-segment; once hidden it
1076
+ // stays hidden until the next turn's bar is created.
1077
+ progressBar?.hide();
1078
+ },
1079
+ afterEachTool: (toolName) => {
1080
+ if (indicatorStopped)
1081
+ return;
1082
+ indicator.resume((0, display_1.verbForActivity)(toolName, 'post-tool'));
1083
+ },
1084
+ });
1085
+ // v4.1.4 Part 1.6: per-turn progress bar. Created lazily on the
1086
+ // first `onProgress` event from the streaming adapter so the bar
1087
+ // line doesn't paint until there's something to show. Adapters
1088
+ // that don't emit progress (Ollama, most OpenAI-compat) never
1089
+ // trigger creation — honest degradation, no fake estimates.
1090
+ let progressBar = null;
911
1091
  try {
912
1092
  const result = await this.opts.agent.runConversation(baseHistory, {
913
1093
  stream: streamingEnabled,
914
1094
  onFirstDelta: streamingEnabled
915
1095
  ? () => {
916
- stopSpinnerOnce();
1096
+ stopIndicatorOnce();
917
1097
  streamingActive = true;
1098
+ // v4.1.5 Phase 1d (Q-OBV-b) — separator emission MOVED
1099
+ // out of onFirstDelta because that callback fires per
1100
+ // provider-call iteration (firstDeltaFired resets each
1101
+ // callProvider invocation). The separator-emit now
1102
+ // lives in onDelta below, gated by `firstStreamByteSeen`
1103
+ // which only flips once per turn.
918
1104
  }
919
1105
  : undefined,
920
1106
  onDelta: streamingEnabled
921
1107
  ? (text) => {
1108
+ // v4.1.5 Phase 1d (Q-OBV-b) — definitive separator
1109
+ // emission point. This is the FIRST text byte landing
1110
+ // on screen this turn. Fires the muted rule BEFORE
1111
+ // streamPartial writes the `┃ Aiden` header so the
1112
+ // visual order is:
1113
+ // ┊ tool rows...
1114
+ // ──────────── ← separator
1115
+ // ┃ Aiden
1116
+ // {text}
1117
+ // Idempotent via `firstStreamByteSeen` + the
1118
+ // `separatorEmitted` flag inside emitToolReplySeparator.
1119
+ // No-op when no tool fired (turnHadTools=false).
1120
+ if (!firstStreamByteSeen) {
1121
+ firstStreamByteSeen = true;
1122
+ emitToolReplySeparator();
1123
+ }
1124
+ // v4.1.4 Part 1.6: bar lives ABOVE streamed text. Hide
1125
+ // it before each delta writes so the stream output
1126
+ // doesn't land on the bar's line. The bar repaints on
1127
+ // the next `onProgress` event (which Anthropic emits
1128
+ // frequently enough that the bar stays usefully visible).
1129
+ progressBar?.hide();
922
1130
  this.opts.display.streamPartial(text);
923
1131
  }
924
1132
  : undefined,
@@ -927,8 +1135,30 @@ class ChatSession {
927
1135
  this.opts.display.streamToolIndicator(call.name);
928
1136
  }
929
1137
  : undefined,
1138
+ onProgress: streamingEnabled
1139
+ ? (outputTokens, maxTokens) => {
1140
+ if (indicatorStopped === false)
1141
+ return;
1142
+ // Lazy-create on first event. The indicator must already
1143
+ // be stopped (first delta arrived) so the bar paints on
1144
+ // its own line below where the indicator was. If the
1145
+ // indicator is still up, skip — the bar would land on
1146
+ // the indicator line and get clobbered by the next tick.
1147
+ if (!progressBar) {
1148
+ progressBar = (0, progressBar_1.createProgressBar)(process.stdout,
1149
+ // Display exposes its skin via getter on the
1150
+ // implementation; cast to any to avoid widening
1151
+ // the public Display surface for one-shot use.
1152
+ this.opts.display.skin);
1153
+ }
1154
+ progressBar.update(outputTokens, maxTokens);
1155
+ }
1156
+ : undefined,
930
1157
  });
931
- stopSpinnerOnce();
1158
+ stopIndicatorOnce();
1159
+ // Hide the progress bar before any post-stream content
1160
+ // (statusFooter, the next prompt) lands on its line.
1161
+ progressBar?.hide();
932
1162
  if (streamingActive)
933
1163
  this.opts.display.streamComplete();
934
1164
  this.history = result.messages;
@@ -951,6 +1181,11 @@ class ChatSession {
951
1181
  // When streaming was active and emitted the final content already,
952
1182
  // skip the markdown re-render — we'd otherwise duplicate text.
953
1183
  if (result.finalContent && !streamingActive) {
1184
+ // v4.1.5 Issue O — non-streaming reply path. Emit the muted
1185
+ // rule between the tool trail and the agent header before
1186
+ // the one-shot reply lands. Idempotent + tool-gated by
1187
+ // `emitToolReplySeparator`.
1188
+ emitToolReplySeparator();
954
1189
  this.opts.display.write(this.opts.display.agentTurn(result.finalContent));
955
1190
  }
956
1191
  if (this.sessionId) {
@@ -964,9 +1199,25 @@ class ChatSession {
964
1199
  // post-turn status footer.
965
1200
  this.opts.display.write(` ${this.opts.display.rule()}\n`);
966
1201
  this.renderStatusLine();
1202
+ // v4.1.5+ Path A — finalize the loop trace. No-op if the env
1203
+ // var is unset OR if the turn didn't trip any threshold. When
1204
+ // it DOES emit, the snapshot path goes to a dim status line so
1205
+ // the user (and any teammate they're sharing the log with)
1206
+ // knows where to grab the diagnostic file.
1207
+ try {
1208
+ const snapPath = await loopTracer.finalize();
1209
+ if (snapPath) {
1210
+ this.opts.display.dim(`[loop-trace] wrote ${snapPath}`);
1211
+ }
1212
+ }
1213
+ catch { /* defensive */ }
967
1214
  }
968
1215
  catch (err) {
969
- stopSpinnerOnce();
1216
+ stopIndicatorOnce();
1217
+ // v4.1.4 Part 1.6: error path must also hide the progress bar
1218
+ // so it doesn't leak across the boundary into the error chrome
1219
+ // or the next prompt.
1220
+ progressBar?.hide();
970
1221
  if (streamingActive)
971
1222
  this.opts.display.streamComplete();
972
1223
  const msg = err?.message ?? String(err);
@@ -1008,6 +1259,16 @@ class ChatSession {
1008
1259
  }
1009
1260
  this.setStatusState({ kind: 'ready' });
1010
1261
  this.lastTurnElapsedMs = Date.now() - turnStartedAt;
1262
+ // v4.1.5+ Path A — finalize the loop trace on the error path
1263
+ // too. Loop patterns that ended in an error are exactly the
1264
+ // ones most worth capturing for diagnosis.
1265
+ try {
1266
+ const snapPath = await loopTracer.finalize();
1267
+ if (snapPath) {
1268
+ this.opts.display.dim(`[loop-trace] wrote ${snapPath}`);
1269
+ }
1270
+ }
1271
+ catch { /* defensive */ }
1011
1272
  }
1012
1273
  }
1013
1274
  // ── Startup card (Phase 26.2.4: neofetch-style sectioned) ──────────