mixdog 1.2.4 → 1.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "1.2.4",
3
+ "version": "1.2.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Open-source coding agent that matches Codex on Terminal-Bench 2.1 with 63% fewer tokens.",
@@ -9,9 +9,10 @@
9
9
  // CLI: MOBILE_MODE=auto|force|skip MOBILE_BUMP=patch|minor node scripts/mobile-release.mjs plan
10
10
  // appends mobile_release / mobile_version / mobile_tag to $GITHUB_OUTPUT.
11
11
  //
12
- // link: the Android release the relay's /app page links — the newest
13
- // published (non-draft) mobile-v* release whose APK actually downloads, or
14
- // the releases list when there is none. `--require=<tag>` instead fails
12
+ // link: the Android APK the relay's /app page links — the direct download URL
13
+ // (.../releases/download/mobile-vX.Y.Z/<asset>.apk) of the newest published
14
+ // (non-draft) mobile-v* release whose APK actually downloads, or the
15
+ // releases list when there is none. `--require=<tag>` instead fails
15
16
  // unless that exact release is the one that resolves (post-publish check).
16
17
  // CLI: GITHUB_REPOSITORY=o/r GH_TOKEN=… node scripts/mobile-release.mjs link [--require=mobile-vX.Y.Z]
17
18
  // appends url / tag to $GITHUB_OUTPUT.
@@ -148,7 +149,7 @@ export async function resolveAndroidLink({
148
149
  const candidates = androidReleaseCandidates(await listReleases(repository, { token, fetchImpl }));
149
150
  for (const candidate of requiredTag ? candidates.filter((item) => item.tag === requiredTag) : candidates) {
150
151
  if (await downloadable(candidate.apkUrl, { fetchImpl, attempts, delayMs })) {
151
- return { url: candidate.url, tag: candidate.tag };
152
+ return { url: candidate.apkUrl, tag: candidate.tag };
152
153
  }
153
154
  if (!requiredTag) console.warn(`::warning::${candidate.tag} APK is not downloadable; trying an older release.`);
154
155
  }
@@ -104,6 +104,7 @@
104
104
  },
105
105
  "outputStyles": {
106
106
  "common.md": [
107
+ "2abd510eaeb6c83702b97a0ce215d348",
107
108
  "4e6134d60ee5c54c2888e874653bcdad",
108
109
  "4ecd89b0c07ad8a81aa1f84882cf1b00",
109
110
  "899529696b7d6d3373ae83a76976ab48",
@@ -15,6 +15,7 @@ partial: true
15
15
  - Distinguish facts, inference, and uncertainty; keep exact literals and
16
16
  material risks. Caveats only when they change the reader's next step.
17
17
  - Never pad: no tangents, unsolicited offers, raw dumps, or process recaps.
18
- Explicit depth requests win; depth applies to substantive answers only.
18
+ Explicit depth requests win; depth applies to substantive answers, not the
19
+ one-line pre-tool briefing.
19
20
  - At every depth keep decision-relevant failures, security warnings, and
20
21
  destructive-action confirmations; quote needed error text exactly.
@@ -4,10 +4,12 @@
4
4
  - The user's latest explicit request overrides any internal rule; drive it to
5
5
  completion.
6
6
  - The user sees your text, and tool calls only as activity cards, never your
7
- thinking. Before the first tool call, state in one line what you are about
8
- to do; afterwards bring the user only material findings, direction or scope
9
- changes, unapproved destructive actions, blockers or meaningful delays —
10
- never per-call narration or plan repeats.
7
+ thinking. Before your first tool call, briefly state what you are about to
8
+ do. While working, give short updates at key moments: when you find
9
+ something, change direction, or make progress without an update for a
10
+ while. Otherwise bring the user only material findings, direction or scope
11
+ changes, unapproved destructive actions, blockers or meaningful delays — never per-call
12
+ narration or plan repeats.
11
13
  - Mid-task: a replacement supersedes, an addition folds in, a status question
12
14
  gets a brief answer while work continues.
13
15
  - Context is managed automatically: never stop or propose stopping because of
@@ -212,8 +212,13 @@ export function listStoredAgentWorkerLinks() {
212
212
  // The agent pool only reads top-level session fields, yet each refresh used to
213
213
  // JSON.parse every active worker's whole transcript (megabytes per worker,
214
214
  // every catalog publish). Cache the header per file fingerprint instead.
215
- const WORKER_HEADER_CACHE_MAX = 256;
215
+ // The cache holds exactly the headers the latest pool assembly read: a fixed
216
+ // LRU cap below the index size (263 headers against 256 slots) evicted every
217
+ // entry just before its reuse, so each refresh re-parsed every worker
218
+ // transcript synchronously (~130 MB, ~1 s of blocked daemon event loop).
216
219
  const workerSessionHeaderCache = new Map();
220
+ /** Assembly the cache entries were last read by; older entries are dropped. */
221
+ let headerPass = 0;
217
222
  // Pool rows need identity, routing, and lifecycle metadata only. Keeping the
218
223
  // rest of a session here also retained its tool catalogs and provider state.
219
224
  const WORKER_HEADER_FIELDS = [
@@ -251,9 +256,7 @@ function readWorkerSessionHeader(sessionId) {
251
256
  if (probe.state !== PROBE_PRESENT) return null;
252
257
  const cached = workerSessionHeaderCache.get(path);
253
258
  if (cached && cached.mtimeMs === probe.mtimeMs && cached.size === probe.size) {
254
- // Refresh insertion order so the eviction below stays LRU-ish.
255
- workerSessionHeaderCache.delete(path);
256
- workerSessionHeaderCache.set(path, cached);
259
+ cached.pass = headerPass;
257
260
  return cached.header;
258
261
  }
259
262
  let session = null;
@@ -267,10 +270,7 @@ function readWorkerSessionHeader(sessionId) {
267
270
  for (const field of WORKER_HEADER_FIELDS) {
268
271
  if (Object.hasOwn(session, field)) header[field] = session[field];
269
272
  }
270
- workerSessionHeaderCache.set(path, { mtimeMs: probe.mtimeMs, size: probe.size, header });
271
- if (workerSessionHeaderCache.size > WORKER_HEADER_CACHE_MAX) {
272
- workerSessionHeaderCache.delete(workerSessionHeaderCache.keys().next().value);
273
- }
273
+ workerSessionHeaderCache.set(path, { mtimeMs: probe.mtimeMs, size: probe.size, header, pass: headerPass });
274
274
  return header;
275
275
  }
276
276
 
@@ -487,13 +487,21 @@ export function listStoredAgentWorkers({ runningSessionIds = null } = {}) {
487
487
  };
488
488
  return tombstoneBlocksWork(work, findTagTombstone(work, tombstones));
489
489
  };
490
- return assembleStoredAgentWorkers({
491
- now,
492
- runningSessionIds,
493
- storedAgentWorkerIndexRows: () => rows.filter((row) => !isReaped(cleanValue(row.sessionId))),
494
- projectChildWorkerRow,
495
- storedLeadWorkerRows,
496
- projectLeadWorkerRow,
497
- pruneOrphanChildRows,
498
- });
490
+ headerPass += 1;
491
+ try {
492
+ return assembleStoredAgentWorkers({
493
+ now,
494
+ runningSessionIds,
495
+ storedAgentWorkerIndexRows: () => rows.filter((row) => !isReaped(cleanValue(row.sessionId))),
496
+ projectChildWorkerRow,
497
+ storedLeadWorkerRows,
498
+ projectLeadWorkerRow,
499
+ pruneOrphanChildRows,
500
+ });
501
+ } finally {
502
+ // Keep what this assembly read, whatever the index size; forget the rest.
503
+ for (const [path, entry] of workerSessionHeaderCache) {
504
+ if (entry.pass !== headerPass) workerSessionHeaderCache.delete(path);
505
+ }
506
+ }
499
507
  }
@@ -165,6 +165,8 @@ export class DesktopServiceRegistry {
165
165
  moduleUrl: requestedModule,
166
166
  instance,
167
167
  subscribers: new Set(),
168
+ visibilityEpochs: new Map(),
169
+ nextVisibilityEpoch: 0,
168
170
  };
169
171
  this.#servicesById.set(requestedId, record);
170
172
  this.#servicesByModule.set(requestedModule, record);
@@ -187,7 +189,14 @@ export class DesktopServiceRegistry {
187
189
  const service = await this.#initialize(params);
188
190
  if (this.#closed) throw new Error('session service is closed');
189
191
  const token = subscriberToken(ctx);
190
- if (token) service.subscribers.add(token);
192
+ if (token) {
193
+ service.subscribers.add(token);
194
+ // The adapter outlives desktop processes, whose request counters restart.
195
+ // Allocate the ordering namespace here, not from a client's wall clock.
196
+ if (!service.visibilityEpochs.has(token)) {
197
+ service.visibilityEpochs.set(token, ++service.nextVisibilityEpoch);
198
+ }
199
+ }
191
200
  this.#syncModelProxyPresence();
192
201
  return { desktopId: service.desktopId };
193
202
  }
@@ -215,14 +224,23 @@ export class DesktopServiceRegistry {
215
224
  }
216
225
 
217
226
  async invoke({ desktopId, method, args = [] } = {}, ctx = null) {
218
- const { service } = this.#requireSubscribed(desktopId, ctx);
227
+ const { service, token } = this.#requireSubscribed(desktopId, ctx);
219
228
  const name = String(method || '');
220
229
  if (!name) throw new TypeError('desktop service method is required');
230
+ let callArgs = Array.isArray(args) ? args : [];
231
+ if (name === 'setVisibleSessions' && token) {
232
+ const epoch = service.visibilityEpochs.get(token);
233
+ if (epoch === undefined) {
234
+ service.subscribers.delete(token);
235
+ throw new Error('desktop service client is not initialized');
236
+ }
237
+ callArgs = [callArgs[0], callArgs[1], epoch];
238
+ }
221
239
  // `[slow-op] desktop.invoke` names the call it timed: a desktop operation
222
240
  // (Git reads) or a session capability, not only the generic route.
223
241
  const target = name === 'invokeDesktopOperation' || name === 'invokeCapability' ? String(args?.[0] || '') : '';
224
242
  annotateSpan(target ? `${name}:${target}` : name);
225
- return sanitizeForWire(await service.instance.invoke(name, Array.isArray(args) ? args : [])) ?? null;
243
+ return sanitizeForWire(await service.instance.invoke(name, callArgs)) ?? null;
226
244
  }
227
245
 
228
246
  async control({ desktopId, message } = {}, ctx = null) {
@@ -241,7 +259,7 @@ export class DesktopServiceRegistry {
241
259
  async unsubscribe({ desktopId } = {}, ctx = null) {
242
260
  const service = this.#require(desktopId);
243
261
  const token = subscriberToken(ctx);
244
- if (token) service.subscribers.delete(token);
262
+ if (token) await this.#releaseSubscriber(service, token);
245
263
  this.#syncModelProxyPresence();
246
264
  return { ok: true, unsubscribed: true };
247
265
  }
@@ -250,11 +268,20 @@ export class DesktopServiceRegistry {
250
268
  const token = String(clientToken || '');
251
269
  if (!token) return;
252
270
  for (const service of this.#servicesById.values()) {
253
- service.subscribers.delete(token);
271
+ void this.#releaseSubscriber(service, token).catch((error) => {
272
+ this.#log(`desktop visibility release failed: ${error?.message || error}`);
273
+ });
254
274
  }
255
275
  this.#syncModelProxyPresence();
256
276
  }
257
277
 
278
+ async #releaseSubscriber(service, token) {
279
+ service.subscribers.delete(token);
280
+ const epoch = service.visibilityEpochs.get(token);
281
+ service.visibilityEpochs.delete(token);
282
+ if (epoch !== undefined) await service.instance.releaseClient?.(epoch);
283
+ }
284
+
258
285
  notifySessionRuntimeReleased(sessionId, reason) {
259
286
  // An evicted session has NO session subscribers. Its desktop resources
260
287
  // still belong to the desktop service and must be notified on that lane.
@@ -38488,19 +38488,34 @@ var HEALABLE_MARKDOWN_SYNTAX = /[`*_~[]/;
38488
38488
  function openFence(text) {
38489
38489
  let marker = "";
38490
38490
  let length = 0;
38491
+ let start = 0;
38492
+ let lineStart2 = 0;
38491
38493
  for (const rawLine of String(text ?? "").split("\n")) {
38492
38494
  const fence = FENCE_LINE.exec(rawLine.replace(/\r$/, ""));
38493
- if (!fence) continue;
38494
- const marks = fence[1];
38495
- if (!marker) {
38496
- marker = marks[0];
38497
- length = marks.length;
38498
- } else if (marks[0] === marker && marks.length >= length && !fence[2].trim()) {
38499
- marker = "";
38500
- length = 0;
38495
+ if (fence) {
38496
+ const marks = fence[1];
38497
+ if (!marker) {
38498
+ marker = marks[0];
38499
+ length = marks.length;
38500
+ start = lineStart2;
38501
+ } else if (marks[0] === marker && marks.length >= length && !fence[2].trim()) {
38502
+ marker = "";
38503
+ length = 0;
38504
+ }
38501
38505
  }
38506
+ lineStart2 += rawLine.length + 1;
38502
38507
  }
38503
- return marker ? { marker, length } : null;
38508
+ return marker ? { marker, length, start } : null;
38509
+ }
38510
+ function trimPartialOpeningFence(text, fence) {
38511
+ const lineEnd2 = text.indexOf("\n", fence.start);
38512
+ return lineEnd2 >= 0 && /\S/.test(text.slice(lineEnd2 + 1)) ? text : text.slice(0, fence.start);
38513
+ }
38514
+ var PENDING_BLOCK_MARKER_LINE = /(?:^|\n)[ \t]*(?:[-+*=_#>~`]+|\d{1,9}[.)]?)[ \t]*$/;
38515
+ function trimPendingBlockMarker(text) {
38516
+ const pending = PENDING_BLOCK_MARKER_LINE.exec(text);
38517
+ if (!pending) return text;
38518
+ return text.slice(0, pending.index + (pending[0].startsWith("\n") ? 1 : 0));
38504
38519
  }
38505
38520
  function trimPartialClosingFence(text, fence) {
38506
38521
  const partial = /(?:^|\r?\n) {0,3}([`~]+)$/.exec(text);
@@ -38531,7 +38546,7 @@ function closeInlineCode(text, isPendingLocalPath) {
38531
38546
  } else if (run === open) open = 0;
38532
38547
  index = end;
38533
38548
  }
38534
- if (open && isPendingLocalPath?.(text.slice(openIndex + open))) {
38549
+ if (open && (!text.slice(openIndex + open) || isPendingLocalPath?.(text.slice(openIndex + open)))) {
38535
38550
  return text.slice(0, openIndex);
38536
38551
  }
38537
38552
  return open > 0 ? `${text}${"`".repeat(open)}` : text;
@@ -38561,6 +38576,7 @@ function scanEmphasisRuns(masked) {
38561
38576
  let end = index + 1;
38562
38577
  while (masked[end] === marker) end += 1;
38563
38578
  const length = end - index;
38579
+ const at = index;
38564
38580
  const before = index > 0 ? masked[index - 1] : "";
38565
38581
  const after = end < masked.length ? masked[end] : "";
38566
38582
  const beforeSpace = isMarkdownSpace(before);
@@ -38570,16 +38586,25 @@ function scanEmphasisRuns(masked) {
38570
38586
  const left = !afterSpace && (!afterPunctuation || beforeSpace || beforePunctuation);
38571
38587
  const right = !beforeSpace && (!beforePunctuation || afterSpace || afterPunctuation);
38572
38588
  if (marker === "~") {
38573
- if (length >= 2) runs.push({ marker, length: 2, canOpen: left, canClose: right });
38589
+ runs.push({
38590
+ marker,
38591
+ length: Math.min(length, 2),
38592
+ canOpen: left && length >= 2,
38593
+ canClose: right && length >= 2,
38594
+ at,
38595
+ end
38596
+ });
38574
38597
  } else if (marker === "_") {
38575
38598
  runs.push({
38576
38599
  marker,
38577
38600
  length,
38578
38601
  canOpen: left && (!right || beforePunctuation),
38579
- canClose: right && (!left || afterPunctuation)
38602
+ canClose: right && (!left || afterPunctuation),
38603
+ at,
38604
+ end
38580
38605
  });
38581
38606
  } else {
38582
- runs.push({ marker, length, canOpen: left, canClose: right });
38607
+ runs.push({ marker, length, canOpen: left, canClose: right, at, end });
38583
38608
  }
38584
38609
  index = end;
38585
38610
  }
@@ -38587,7 +38612,9 @@ function scanEmphasisRuns(masked) {
38587
38612
  }
38588
38613
  function closeEmphasis(text) {
38589
38614
  const open = [];
38590
- for (const run of scanEmphasisRuns(maskCode(text))) {
38615
+ const runs = scanEmphasisRuns(maskCode(text));
38616
+ const last = runs[runs.length - 1];
38617
+ for (const run of runs) {
38591
38618
  let length = run.length;
38592
38619
  while (run.canClose && length > 0) {
38593
38620
  let match = -1;
@@ -38604,13 +38631,17 @@ function closeEmphasis(text) {
38604
38631
  length -= used;
38605
38632
  open.length = opener.length > 0 ? match + 1 : match;
38606
38633
  }
38634
+ if (run === last && run.end === text.length && length === run.length) {
38635
+ return closeEmphasis(text.slice(0, run.at));
38636
+ }
38607
38637
  if (length > 0 && run.canOpen) open.push({ marker: run.marker, length });
38608
38638
  }
38609
- let healed = text;
38639
+ const body = text.trimEnd();
38640
+ let closers = "";
38610
38641
  for (let index = open.length - 1; index >= 0; index -= 1) {
38611
- healed += open[index].marker.repeat(Math.min(open[index].length, 3));
38642
+ closers += open[index].marker.repeat(Math.min(open[index].length, 3));
38612
38643
  }
38613
- return healed;
38644
+ return closers ? `${body}${closers}${text.slice(body.length)}` : text;
38614
38645
  }
38615
38646
  function linkDestinationEnd(text, start) {
38616
38647
  let depth = 1;
@@ -38694,8 +38725,9 @@ function trimPendingTableHead(text) {
38694
38725
  function healStreamingMarkdownTail(text, isPendingLocalPath) {
38695
38726
  const source = String(text ?? "");
38696
38727
  const fence = source.includes("`") || source.includes("~") ? openFence(source) : null;
38697
- if (fence) return trimPartialClosingFence(source, fence);
38698
- const value = source.includes("|") ? trimPendingTableHead(source) : source;
38728
+ if (fence) return trimPartialOpeningFence(trimPartialClosingFence(source, fence), fence);
38729
+ const unmarked = trimPendingBlockMarker(source);
38730
+ const value = unmarked.includes("|") ? trimPendingTableHead(unmarked) : unmarked;
38699
38731
  const hasSyntax = HEALABLE_MARKDOWN_SYNTAX.test(value);
38700
38732
  if (!value || !hasSyntax && !isPendingLocalPath) return value;
38701
38733
  const healed = hasSyntax ? closeEmphasis(healIncompleteLink(closeInlineCode(value, isPendingLocalPath), isPendingLocalPath)) : value;
@@ -8,19 +8,47 @@ const HEALABLE_MARKDOWN_SYNTAX = /[`*_~[]/;
8
8
  function openFence(text) {
9
9
  let marker = '';
10
10
  let length = 0;
11
+ let start = 0;
12
+ let lineStart = 0;
11
13
  for (const rawLine of String(text ?? '').split('\n')) {
12
14
  const fence = FENCE_LINE.exec(rawLine.replace(/\r$/, ''));
13
- if (!fence) continue;
14
- const marks = fence[1];
15
- if (!marker) {
16
- marker = marks[0];
17
- length = marks.length;
18
- } else if (marks[0] === marker && marks.length >= length && !fence[2].trim()) {
19
- marker = '';
20
- length = 0;
15
+ if (fence) {
16
+ const marks = fence[1];
17
+ if (!marker) {
18
+ marker = marks[0];
19
+ length = marks.length;
20
+ start = lineStart;
21
+ } else if (marks[0] === marker && marks.length >= length && !fence[2].trim()) {
22
+ marker = '';
23
+ length = 0;
24
+ }
21
25
  }
26
+ lineStart += rawLine.length + 1;
22
27
  }
23
- return marker ? { marker, length } : null;
28
+ return marker ? { marker, length, start } : null;
29
+ }
30
+
31
+ // The opening fence line arrives a token at a time: ``` → ```py → ```python.
32
+ // Rendered at once, the card opened unlabelled and then relabelled (and
33
+ // re-highlighted) per token. The card appears with its first code character:
34
+ // an opening line alone would paint an empty box before any code.
35
+ function trimPartialOpeningFence(text, fence) {
36
+ const lineEnd = text.indexOf('\n', fence.start);
37
+ return lineEnd >= 0 && /\S/.test(text.slice(lineEnd + 1)) ? text : text.slice(0, fence.start);
38
+ }
39
+
40
+ // A line holding only a block marker so far has no content to show, and the
41
+ // marker alone parses as something else: `-` or `=` under a paragraph is a
42
+ // setext underline (the paragraph flashed into a heading until the item text
43
+ // arrived), `2` is a lazy paragraph continuation until its `.` arrives, `#`
44
+ // grows into `##`, and a lone backtick run is literal text until it becomes a
45
+ // fence. Hold the line back until its first content character.
46
+ const PENDING_BLOCK_MARKER_LINE = /(?:^|\n)[ \t]*(?:[-+*=_#>~`]+|\d{1,9}[.)]?)[ \t]*$/;
47
+
48
+ function trimPendingBlockMarker(text) {
49
+ const pending = PENDING_BLOCK_MARKER_LINE.exec(text);
50
+ if (!pending) return text;
51
+ return text.slice(0, pending.index + (pending[0].startsWith('\n') ? 1 : 0));
24
52
  }
25
53
 
26
54
  // A closing fence arrives a token at a time. Until its run is long enough to
@@ -57,7 +85,9 @@ function closeInlineCode(text, isPendingLocalPath) {
57
85
  } else if (run === open) open = 0;
58
86
  index = end;
59
87
  }
60
- if (open && isPendingLocalPath?.(text.slice(openIndex + open))) {
88
+ // An opener with nothing after it yet closes into an empty span, which
89
+ // parses as a literal backtick run that then vanishes into code.
90
+ if (open && (!text.slice(openIndex + open) || isPendingLocalPath?.(text.slice(openIndex + open)))) {
61
91
  return text.slice(0, openIndex);
62
92
  }
63
93
  return open > 0 ? `${text}${'`'.repeat(open)}` : text;
@@ -93,6 +123,7 @@ function scanEmphasisRuns(masked) {
93
123
  let end = index + 1;
94
124
  while (masked[end] === marker) end += 1;
95
125
  const length = end - index;
126
+ const at = index;
96
127
  const before = index > 0 ? masked[index - 1] : '';
97
128
  const after = end < masked.length ? masked[end] : '';
98
129
  const beforeSpace = isMarkdownSpace(before);
@@ -102,16 +133,26 @@ function scanEmphasisRuns(masked) {
102
133
  const left = !afterSpace && (!afterPunctuation || beforeSpace || beforePunctuation);
103
134
  const right = !beforeSpace && (!beforePunctuation || afterSpace || afterPunctuation);
104
135
  if (marker === '~') {
105
- if (length >= 2) runs.push({ marker, length: 2, canOpen: left, canClose: right });
136
+ // A single tilde is inert, but at the end it may be half of `~~`.
137
+ runs.push({
138
+ marker,
139
+ length: Math.min(length, 2),
140
+ canOpen: left && length >= 2,
141
+ canClose: right && length >= 2,
142
+ at,
143
+ end,
144
+ });
106
145
  } else if (marker === '_') {
107
146
  runs.push({
108
147
  marker,
109
148
  length,
110
149
  canOpen: left && (!right || beforePunctuation),
111
150
  canClose: right && (!left || afterPunctuation),
151
+ at,
152
+ end,
112
153
  });
113
154
  } else {
114
- runs.push({ marker, length, canOpen: left, canClose: right });
155
+ runs.push({ marker, length, canOpen: left, canClose: right, at, end });
115
156
  }
116
157
  index = end;
117
158
  }
@@ -120,7 +161,9 @@ function scanEmphasisRuns(masked) {
120
161
 
121
162
  function closeEmphasis(text) {
122
163
  const open = [];
123
- for (const run of scanEmphasisRuns(maskCode(text))) {
164
+ const runs = scanEmphasisRuns(maskCode(text));
165
+ const last = runs[runs.length - 1];
166
+ for (const run of runs) {
124
167
  let length = run.length;
125
168
  while (run.canClose && length > 0) {
126
169
  let match = -1;
@@ -137,13 +180,21 @@ function closeEmphasis(text) {
137
180
  length -= used;
138
181
  open.length = opener.length > 0 ? match + 1 : match;
139
182
  }
183
+ // A run ending the text that closed nothing has no content after it yet:
184
+ // it painted as literal `**` until the next token turned it into style.
185
+ if (run === last && run.end === text.length && length === run.length) {
186
+ return closeEmphasis(text.slice(0, run.at));
187
+ }
140
188
  if (length > 0 && run.canOpen) open.push({ marker: run.marker, length });
141
189
  }
142
- let healed = text;
190
+ // Closers go before trailing whitespace: `**word ` closed after the space
191
+ // could not close (a closing run must follow non-space) and stayed literal.
192
+ const body = text.trimEnd();
193
+ let closers = '';
143
194
  for (let index = open.length - 1; index >= 0; index -= 1) {
144
- healed += open[index].marker.repeat(Math.min(open[index].length, 3));
195
+ closers += open[index].marker.repeat(Math.min(open[index].length, 3));
145
196
  }
146
- return healed;
197
+ return closers ? `${body}${closers}${text.slice(body.length)}` : text;
147
198
  }
148
199
 
149
200
  function linkDestinationEnd(text, start) {
@@ -246,8 +297,9 @@ function trimPendingTableHead(text) {
246
297
  export function healStreamingMarkdownTail(text, isPendingLocalPath) {
247
298
  const source = String(text ?? '');
248
299
  const fence = source.includes('`') || source.includes('~') ? openFence(source) : null;
249
- if (fence) return trimPartialClosingFence(source, fence);
250
- const value = source.includes('|') ? trimPendingTableHead(source) : source;
300
+ if (fence) return trimPartialOpeningFence(trimPartialClosingFence(source, fence), fence);
301
+ const unmarked = trimPendingBlockMarker(source);
302
+ const value = unmarked.includes('|') ? trimPendingTableHead(unmarked) : unmarked;
251
303
  const hasSyntax = HEALABLE_MARKDOWN_SYNTAX.test(value);
252
304
  if (!value || (!hasSyntax && !isPendingLocalPath)) return value;
253
305
  const healed = hasSyntax