fraim-hub 2.0.266 → 2.0.268

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.
@@ -62,8 +62,24 @@ const KNOWN_LABEL_OVERRIDES = {
62
62
  // shouldn't be split on dashes by the default humanizer.
63
63
  '1-1': '1:1',
64
64
  };
65
+ // Issue #1090: this reads JOB-LEVEL `## Intent` / `## Outcome` only.
66
+ //
67
+ // The previous expression was unanchored (`## ${heading}`), which had two
68
+ // consequences on a personalized override that customizes phases:
69
+ //
70
+ // 1. `#### Intent` CONTAINS the substring `## Intent`, so a phase-level heading
71
+ // satisfied a job-level query and the first phase's Intent was promoted to
72
+ // job-level metadata.
73
+ // 2. The terminator `\r?\n## ` requires a space as the 4th character, so it
74
+ // could not stop at `#### Steps` and Outcome swallowed the Steps and Skills
75
+ // blocks until `---` or end of file.
76
+ //
77
+ // The start anchor is written as `(?:^|\r?\n)` rather than using the `m` flag,
78
+ // because under `m` the trailing `$` would match end-of-LINE and truncate a
79
+ // multi-line Outcome to its first bullet. Terminating on any heading level
80
+ // `#{1,6} ` fixes (2).
65
81
  const sectionValue = (content, heading) => {
66
- const match = content.match(new RegExp(`## ${heading}\\r?\\n([\\s\\S]*?)(?:\\r?\\n## |\\r?\\n---|$)`));
82
+ const match = content.match(new RegExp(`(?:^|\\r?\\n)## ${heading}[ \\t]*\\r?\\n([\\s\\S]*?)(?:\\r?\\n#{1,6} |\\r?\\n---|$)`));
67
83
  if (!match)
68
84
  return [];
69
85
  return match[1]
@@ -72,6 +88,62 @@ const sectionValue = (content, heading) => {
72
88
  .filter(Boolean)
73
89
  .map((line) => line.replace(/^[-*]\s*/, '').trim());
74
90
  };
91
+ /**
92
+ * Issue #1090: resolve the baseline stub that an `extends`-only override inherits
93
+ * from, so job-level Intent/Outcome can be inherited rather than scavenged from
94
+ * the override's first phase.
95
+ *
96
+ * `extends` is written as `<category>/<job-name>`. Only NON-personalized layers
97
+ * are searched: an override extending another override is not a supported shape,
98
+ * and following one would risk a cycle.
99
+ */
100
+ function resolveExtendedStubPath(projectPath, extendsValue) {
101
+ const relative = toPosix(extendsValue).replace(/\.md$/i, '');
102
+ if (!relative || relative.includes('..'))
103
+ return null;
104
+ const candidateRoots = [
105
+ path_1.default.join(projectPath, 'fraim', 'ai-employee', 'jobs'),
106
+ path_1.default.join(projectPath, 'fraim', 'ai-manager', 'jobs'),
107
+ path_1.default.join(projectPath, 'registry', 'jobs', 'ai-employee'),
108
+ path_1.default.join(projectPath, 'registry', 'jobs', 'ai-manager'),
109
+ path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'ai-employee', 'jobs'),
110
+ ];
111
+ for (const root of candidateRoots) {
112
+ const candidate = path_1.default.join(root, `${relative}.md`);
113
+ if (fs_1.default.existsSync(candidate))
114
+ return candidate;
115
+ }
116
+ return null;
117
+ }
118
+ /**
119
+ * Job-level Intent/Outcome for a stub, following `extends` when the stub itself
120
+ * declares none. Returns empty arrays rather than throwing when nothing resolves,
121
+ * so a broken `extends` degrades to the existing "No intent summary available."
122
+ * behaviour instead of breaking catalog discovery.
123
+ */
124
+ function jobLevelSections(content, filePath, projectPath) {
125
+ const intent = sectionValue(content, 'Intent');
126
+ const outcome = sectionValue(content, 'Outcome');
127
+ if (intent.length > 0 || outcome.length > 0)
128
+ return { intent, outcome };
129
+ const frontmatter = readJobFrontmatter(filePath);
130
+ const extendsValue = typeof frontmatter?.extends === 'string' ? frontmatter.extends.trim() : '';
131
+ if (!extendsValue)
132
+ return { intent, outcome };
133
+ const basePath = resolveExtendedStubPath(projectPath, extendsValue);
134
+ if (!basePath)
135
+ return { intent, outcome };
136
+ try {
137
+ const baseContent = fs_1.default.readFileSync(basePath, 'utf8');
138
+ return {
139
+ intent: sectionValue(baseContent, 'Intent'),
140
+ outcome: sectionValue(baseContent, 'Outcome'),
141
+ };
142
+ }
143
+ catch {
144
+ return { intent, outcome };
145
+ }
146
+ }
75
147
  const humanizeName = (raw) => {
76
148
  const lower = raw.toLowerCase();
77
149
  if (KNOWN_LABEL_OVERRIDES[lower])
@@ -122,8 +194,9 @@ function parseJobStub(filePath, categoryId, categoryLabel, projectPath, personal
122
194
  const fileName = path_1.default.basename(filePath, '.md');
123
195
  const frontmatter = readJobFrontmatter(filePath);
124
196
  const title = frontmatter?.displayName?.trim() || humanizeName(fileName);
125
- const intent = sectionValue(content, 'Intent')[0] || 'No intent summary available.';
126
- const outcome = sectionValue(content, 'Outcome');
197
+ const sections = jobLevelSections(content, filePath, projectPath);
198
+ const intent = sections.intent[0] || 'No intent summary available.';
199
+ const outcome = sections.outcome;
127
200
  return {
128
201
  id: fileName,
129
202
  title,
@@ -27,6 +27,7 @@ const HEADER_OMITTED_FIELDS = [
27
27
  'artifacts',
28
28
  'run',
29
29
  'delegation',
30
+ 'handoffSummary',
30
31
  '_bodyLoaded',
31
32
  '_stopping',
32
33
  '_priorEvents',
@@ -174,6 +174,63 @@ function getHubPersonaForJob(jobName) {
174
174
  return null;
175
175
  return getProtectedPersonaForHubJob(jobName) ?? DEFAULT_UNASSIGNED_PERSONA_KEY;
176
176
  }
177
+ /**
178
+ * Issue #1090: derive a conversation's owning persona at read time instead of
179
+ * trusting the value frozen onto the record when it was created.
180
+ *
181
+ * The stored value is a snapshot. Re-assigning a job to a different employee,
182
+ * hiring a custom employee for it, or changing the protected-persona bundle used
183
+ * to leave every existing run reporting the old owner forever, with no way to
184
+ * correct it. Deriving on read means the Hub always reflects current ownership.
185
+ *
186
+ * One case must NOT be derived: a Mandy-delegated child workstream gets its
187
+ * persona per-task from the delegation ledger, which is not a function of jobId.
188
+ * Deriving there would silently reassign every delegated workstream to the job's
189
+ * default owner, so those keep their stored key.
190
+ */
191
+ function resolveConversationPersonaKey(conversation, projectPath, customJobOwners) {
192
+ const isDelegatedChild = Boolean(conversation.managedByRunId || conversation.delegationTaskId);
193
+ if (isDelegatedChild)
194
+ return conversation.personaKey ?? null;
195
+ const jobId = conversation.jobId ?? '';
196
+ if (!jobId || jobId === '__freeform__')
197
+ return null;
198
+ // `customJobOwners` is the hoisted form of getCustomPersonaForJob, used when
199
+ // deriving a whole list. See buildCustomJobOwnerIndex for why.
200
+ const custom = customJobOwners
201
+ ? customJobOwners.get(jobId) ?? null
202
+ : getCustomPersonaForJob(projectPath, jobId);
203
+ if (!custom && conversation.personaKey?.startsWith('custom:')) {
204
+ return conversation.personaKey;
205
+ }
206
+ return custom ?? getHubPersonaForJob(jobId);
207
+ }
208
+ /**
209
+ * Issue #1090: `getCustomPersonaForJob` calls `readCustomEmployees`, which walks
210
+ * the org, manager, and project employee directories from disk on every call.
211
+ * Deriving persona per conversation in a list would therefore do one full disk
212
+ * walk PER CONVERSATION - O(N) filesystem work on a hot read path, which the Hub
213
+ * performance budgets correctly flag on a project with a large rail.
214
+ *
215
+ * Building the jobId -> persona index once per request keeps the derivation O(1)
216
+ * per conversation and the disk walk at exactly one.
217
+ */
218
+ function buildCustomJobOwnerIndex(projectPath) {
219
+ const index = new Map();
220
+ for (const employee of (0, custom_employees_1.readCustomEmployees)(projectPath)) {
221
+ if (!Array.isArray(employee.jobIds))
222
+ continue;
223
+ for (const jobId of employee.jobIds) {
224
+ if (!index.has(jobId))
225
+ index.set(jobId, employee.key);
226
+ }
227
+ }
228
+ return index;
229
+ }
230
+ /** Apply read-time persona derivation to a conversation record before serving it. */
231
+ function withDerivedPersona(conversation, projectPath, customJobOwners) {
232
+ return { ...conversation, personaKey: resolveConversationPersonaKey(conversation, projectPath, customJobOwners) };
233
+ }
177
234
  // Issue #991: consult custom employee records before falling back to the catalog.
178
235
  // Returns the key of the first custom employee whose jobIds array includes jobId,
179
236
  // or null if no custom employee owns it.
@@ -2758,7 +2815,7 @@ class AiHubServer {
2758
2815
  issueNumber: conversation.issueNumber ?? null,
2759
2816
  agentSwitches: conversation.agentSwitches || [],
2760
2817
  handoffSummary: conversation.handoffSummary || null,
2761
- personaKey: conversation.personaKey ?? getHubPersonaForJob(conversation.jobId),
2818
+ personaKey: resolveConversationPersonaKey(conversation, projectPath),
2762
2819
  orchestratedDelegationTaskIds: conversation.delegation?.tasks
2763
2820
  ?.filter((task) => task.taskId && (task.conversationId || task.runId || ['running', 'submitted', 'completed', 'reviewed', 'blocked'].includes(task.status)))
2764
2821
  .map((task) => task.taskId) || [],
@@ -3886,14 +3943,23 @@ class AiHubServer {
3886
3943
  const conversation = this.conversationStore.loadConversation(key, conversationId);
3887
3944
  if (!conversation)
3888
3945
  return res.status(404).json({ error: 'conversation not found' });
3889
- return res.json({ projectPath: key, scope: scope ?? 'project', conversation, source: 'disk' });
3946
+ // Issue #1090: ownership is derived on read, never served from the frozen record.
3947
+ return res.json({ projectPath: key, scope: scope ?? 'project', conversation: withDerivedPersona(conversation, key), source: 'disk' });
3890
3948
  }
3891
3949
  if (req.query.headersOnly === '1' || req.query.headersOnly === 'true') {
3892
- const headers = this.conversationStore.loadProjectHeaders(key);
3950
+ // One disk walk for the whole list, not one per conversation.
3951
+ const customOwners = buildCustomJobOwnerIndex(key);
3952
+ const headers = this.conversationStore.loadProjectHeaders(key)
3953
+ .map((header) => withDerivedPersona(header, key, customOwners));
3893
3954
  return res.json({ projectPath: key, scope: scope ?? 'project', headersOnly: true, conversations: headers, source: 'disk' });
3894
3955
  }
3895
3956
  const loaded = this.conversationStore.loadProject(key);
3896
- return res.json({ projectPath: key, scope: scope ?? 'project', ...loaded, source: 'disk' });
3957
+ const fullCustomOwners = buildCustomJobOwnerIndex(key);
3958
+ const derived = {
3959
+ ...loaded,
3960
+ conversations: (loaded.conversations || []).map((conversation) => withDerivedPersona(conversation, key, fullCustomOwners)),
3961
+ };
3962
+ return res.json({ projectPath: key, scope: scope ?? 'project', ...derived, source: 'disk' });
3897
3963
  });
3898
3964
  this.app.put('/api/ai-hub/conversations', (req, res) => {
3899
3965
  try {
@@ -4089,7 +4155,7 @@ class AiHubServer {
4089
4155
  totals: persistedRun?.totals || emptyTotals(),
4090
4156
  lastStatusChangeAt: now,
4091
4157
  runDiscriminant: persistedRun?.runDiscriminant || undefined,
4092
- personaKey: conversation.personaKey ?? getHubPersonaForJob(conversation.jobId),
4158
+ personaKey: resolveConversationPersonaKey(conversation, bucketKey),
4093
4159
  artifacts: Array.isArray(conversation.artifacts) ? conversation.artifacts : [],
4094
4160
  reviewHandoff: conversation.reviewHandoff || null,
4095
4161
  delegation: conversation.delegation || null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.266",
3
+ "version": "2.0.268",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "bin": {
6
6
  "fraim-hub": "bin/fraim-hub.js",
@@ -163,7 +163,7 @@
163
163
  "electron": "^41.2.2",
164
164
  "electron-updater": "^6.8.9",
165
165
  "express": "^5.2.1",
166
- "fraim": "2.0.266",
166
+ "fraim": "2.0.268",
167
167
  "mongodb": "^7.0.0",
168
168
  "node-cron": "4.2.1",
169
169
  "node-edge-tts": "^1.2.10",
@@ -760,13 +760,19 @@ function ensureTaskPaneLauncher() {
760
760
  root.appendChild(actions);
761
761
 
762
762
  document.body.insertBefore(root, document.body.firstChild);
763
- document.getElementById('task-pane-project-select')?.addEventListener('change', (event) => {
764
- const statusEl = document.getElementById('task-pane-start-status');
765
- const project = taskPaneProjectEntries().find((entry) => entry.folderPath === event.target.value);
766
- if (statusEl && project) statusEl.textContent = `Project: ${project.name || friendlyProjectShortName(project.folderPath)}`;
767
- });
768
- return root;
769
- }
763
+ document.getElementById('task-pane-project-select')?.addEventListener('change', (event) => {
764
+ const statusEl = document.getElementById('task-pane-start-status');
765
+ const project = taskPaneProjectEntries().find((entry) => entry.folderPath === event.target.value);
766
+ if (statusEl && project) statusEl.textContent = `Project: ${project.name || friendlyProjectShortName(project.folderPath)}`;
767
+ });
768
+ document.getElementById('task-pane-employee-select')?.addEventListener('change', (event) => {
769
+ const value = event.target && event.target.value;
770
+ if (value && hubConfiguredAgents().some((agent) => agent.id === value)) {
771
+ state.selectedEmployeeId = value;
772
+ }
773
+ });
774
+ return root;
775
+ }
770
776
 
771
777
  function renderTaskPaneLauncher() {
772
778
  if (!isTaskPaneSurface() || !state.bootstrap) return;
@@ -851,20 +857,23 @@ async function taskPaneStartSelectedJob() {
851
857
  if (start) start.disabled = true;
852
858
  if (statusEl) statusEl.textContent = 'Starting...';
853
859
  try {
854
- const result = await startRun(job, instructions ? instructions.value : '', employeeId, undefined, 'projects');
855
- if (result && result.ok) {
856
- taskPaneLauncherForcedOpen = false;
857
- if (statusEl) statusEl.textContent = 'Started.';
858
- } else if (statusEl) {
859
- statusEl.textContent = (result && result.error) || 'Could not start.';
860
- }
860
+ const result = await startRun(job, instructions ? instructions.value : '', employeeId, undefined, 'projects');
861
+ if (result && result.ok) {
862
+ taskPaneLauncherForcedOpen = false;
863
+ if (statusEl) statusEl.textContent = 'Started.';
864
+ renderActive();
865
+ syncTaskPaneSurfaceMode();
866
+ } else if (statusEl) {
867
+ statusEl.textContent = (result && result.error) || 'Could not start.';
868
+ }
861
869
  } catch (error) {
862
870
  if (statusEl) statusEl.textContent = 'Could not start.';
863
871
  console.warn('[ai-hub] task-pane start failed:', error);
864
- } finally {
865
- renderTaskPaneLauncher();
866
- }
867
- }
872
+ } finally {
873
+ renderTaskPaneLauncher();
874
+ syncTaskPaneSurfaceMode();
875
+ }
876
+ }
868
877
 
869
878
  // ---------------------------------------------------------------------------
870
879
  // Concept popover population (drives the welcome-line "see ... jobs" lists)
@@ -1028,8 +1037,18 @@ function normalizeGeminiConversationMessages(conv) {
1028
1037
  // request 413s, the error is swallowed, and the client-owned fields in the SAME
1029
1038
  // payload (e.g. pauseReason='done' from "Mark complete") never persist. Strip them
1030
1039
  // here; the server's PUT handler merges them back from the stored record.
1031
- const SERVER_OWNED_CONV_FIELDS = ['messages', 'events', 'artifacts', 'run', 'delegation'];
1032
- const CLIENT_ONLY_CONV_FIELDS = ['_bodyLoaded', '_stopping'];
1040
+ const SERVER_OWNED_CONV_FIELDS = ['messages', 'events', 'artifacts', 'run', 'delegation', 'handoffSummary'];
1041
+ // Issue #1090: `_bodyFetched` records that THIS CLIENT successfully fetched the
1042
+ // body, which is a different claim from "the record has body fields". A run can
1043
+ // legitimately finish with no messages, events, artifacts, run, or delegation;
1044
+ // before this marker existed such a body was indistinguishable from one that had
1045
+ // never been hydrated, so the transcript hung on "Loading conversation…" and
1046
+ // re-fetched on every poll tick forever.
1047
+ //
1048
+ // It is listed in CLIENT_ONLY_CONV_FIELDS so `cleanConversationHeader()` strips
1049
+ // any inbound value. That is what preserves issue #913: a marker arriving over
1050
+ // the wire is never trusted, only one this client set after a real fetch.
1051
+ const CLIENT_ONLY_CONV_FIELDS = ['_bodyLoaded', '_bodyFetched', '_stopping'];
1033
1052
  function slimConversationForPersist(conv) {
1034
1053
  if (!conv || typeof conv !== 'object') return conv;
1035
1054
  const slim = { ...conv };
@@ -1043,6 +1062,11 @@ function slimConversationsForPersist(list) {
1043
1062
 
1044
1063
  function conversationHasBody(conv) {
1045
1064
  if (!conv) return false;
1065
+ // Issue #1090: a body this client already fetched counts as hydrated even when
1066
+ // it carries none of the server-owned fields, so an empty transcript settles
1067
+ // instead of looping. Issue #913 stays satisfied because `_bodyFetched` is
1068
+ // client-only and stripped from every inbound header.
1069
+ if (conv._bodyFetched === true) return true;
1046
1070
  return SERVER_OWNED_CONV_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(conv, field));
1047
1071
  }
1048
1072
 
@@ -1062,6 +1086,12 @@ function mergeConversationHeader(existing, header) {
1062
1086
  merged[field] = existing[field];
1063
1087
  }
1064
1088
  }
1089
+ // Issue #1090: carry the client's own fetch marker forward. A header refresh
1090
+ // must not erase the fact that we already hydrated this body, or the next
1091
+ // render re-enters the loading state and re-fetches. This is deliberately NOT
1092
+ // recomputed from field presence, which is what made the marker inert before.
1093
+ if (existing._bodyFetched === true) merged._bodyFetched = true;
1094
+ else delete merged._bodyFetched;
1065
1095
  if (conversationHasBody(existing)) merged._bodyLoaded = true;
1066
1096
  else delete merged._bodyLoaded;
1067
1097
  return merged;
@@ -1070,6 +1100,9 @@ function mergeConversationHeader(existing, header) {
1070
1100
  function mergeConversationBody(existing, body) {
1071
1101
  const merged = existing ? { ...existing, ...body } : body;
1072
1102
  merged._bodyLoaded = true;
1103
+ // Issue #1090: the body request came back, so this conversation is hydrated
1104
+ // regardless of which fields the response happened to contain.
1105
+ merged._bodyFetched = true;
1073
1106
  return merged;
1074
1107
  }
1075
1108
 
@@ -3162,9 +3195,12 @@ let renderedConvId = null;
3162
3195
  let renderedMessageCount = 0;
3163
3196
  let renderedMessageFingerprints = [];
3164
3197
  let renderedEventCount = 0;
3198
+ let renderedEventWindowStart = 0;
3165
3199
  let renderedStatus = null;
3166
3200
  let renderedDirectEventCount = 0;
3167
3201
  let threadMessageViewportObserver = null;
3202
+ const MICRO_LOG_VISIBLE_EVENT_LIMIT = 300;
3203
+ const MICRO_LOG_EVENT_TEXT_LIMIT = 1000;
3168
3204
  const conversationFocusState = {
3169
3205
  open: false,
3170
3206
  previousFocus: null,
@@ -3192,6 +3228,43 @@ function tfApplyWorkspaceMode() {
3192
3228
  if (briefNav) briefNav.classList.toggle('active', !hasConv);
3193
3229
  }
3194
3230
 
3231
+ function microLogVisibleWindow(events) {
3232
+ const list = Array.isArray(events) ? events : [];
3233
+ const start = Math.max(0, list.length - MICRO_LOG_VISIBLE_EVENT_LIMIT);
3234
+ return {
3235
+ start,
3236
+ events: list.slice(start),
3237
+ hiddenCount: start,
3238
+ };
3239
+ }
3240
+
3241
+ function formatMicroLogLine(event) {
3242
+ const channel = event && event.channel ? event.channel : 'system';
3243
+ let text = event && event.text !== undefined && event.text !== null ? String(event.text) : '';
3244
+ if (text.length > MICRO_LOG_EVENT_TEXT_LIMIT) {
3245
+ text = text.slice(0, MICRO_LOG_EVENT_TEXT_LIMIT) + '\n... [event truncated in UI; full event is stored in the run history]';
3246
+ }
3247
+ return `[${channel}] ${text}\n`;
3248
+ }
3249
+
3250
+ function renderMicroLogEvents(events) {
3251
+ const windowed = microLogVisibleWindow(events);
3252
+ if (windowed.start !== renderedEventWindowStart || events.length < renderedEventCount) {
3253
+ els['micro-log'].textContent = '';
3254
+ renderedEventCount = windowed.start;
3255
+ renderedEventWindowStart = windowed.start;
3256
+ if (windowed.hiddenCount > 0) {
3257
+ els['micro-log'].appendChild(document.createTextNode(
3258
+ `[system] Showing latest ${windowed.events.length} of ${events.length} events. Full event history remains stored.\n`
3259
+ ));
3260
+ }
3261
+ }
3262
+ for (let i = Math.max(renderedEventCount, windowed.start); i < events.length; i += 1) {
3263
+ els['micro-log'].appendChild(document.createTextNode(formatMicroLogLine(events[i])));
3264
+ }
3265
+ renderedEventCount = events.length;
3266
+ }
3267
+
3195
3268
  function syncConversationFocusTitle(conv) {
3196
3269
  if (!els['conversation-focus-title']) return;
3197
3270
  els['conversation-focus-title'].textContent = conv ? conversationTitle(conv) : 'Focused conversation';
@@ -3304,6 +3377,7 @@ function renderActive() {
3304
3377
  renderedMessageCount = 0;
3305
3378
  renderedMessageFingerprints = [];
3306
3379
  renderedEventCount = 0;
3380
+ renderedEventWindowStart = 0;
3307
3381
  renderedStatus = null;
3308
3382
  renderedDirectEventCount = 0;
3309
3383
  syncConversationRefreshPolling();
@@ -3337,6 +3411,7 @@ function renderActive() {
3337
3411
  renderedMessageCount = 0;
3338
3412
  renderedMessageFingerprints = [];
3339
3413
  renderedEventCount = 0;
3414
+ renderedEventWindowStart = 0;
3340
3415
  renderedDirectEventCount = 0;
3341
3416
  // A pending coaching job is scoped to the active conversation — discard it
3342
3417
  // whenever the user switches to a different conversation.
@@ -3425,15 +3500,7 @@ function renderActive() {
3425
3500
  // Micro-manage — only append new events. textContent assignment on the
3426
3501
  // <pre> wipes the entire log every tick which is wasteful.
3427
3502
  const events = conv.events || [];
3428
- if (events.length < renderedEventCount) {
3429
- els['micro-log'].textContent = '';
3430
- renderedEventCount = 0;
3431
- }
3432
- for (let i = renderedEventCount; i < events.length; i += 1) {
3433
- const line = `[${events[i].channel || 'system'}] ${events[i].text}\n`;
3434
- els['micro-log'].appendChild(document.createTextNode(line));
3435
- }
3436
- renderedEventCount = events.length;
3503
+ renderMicroLogEvents(events);
3437
3504
  renderResumeCommand(conv);
3438
3505
 
3439
3506
  // Coaching state — only enable Send when there's text and the run is resumable.