From 4d6b7bfb0a2e32fc2438607382cc7a6113be5a17 Mon Sep 17 00:00:00 2001
From: meetsu <96637888+klNuno@users.noreply.github.com>
Date: Wed, 16 Sep 2026 00:37:54 +0200
Subject: [PATCH 1/2] docs: audit runtime costs and complexity
---
.gitattributes | 10 ++
docs/README.md | 1 +
docs/audits/2026-09-16-runtime.md | 182 +++++++++++++++++++++++++++++
packages/core/bin/audit-runtime.ts | 104 +++++++++++++++++
4 files changed, 297 insertions(+)
create mode 100644 docs/audits/2026-09-16-runtime.md
create mode 100644 packages/core/bin/audit-runtime.ts
diff --git a/.gitattributes b/.gitattributes
index c2c7c4a..bf7f649 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -4,3 +4,13 @@
*.icns binary
*.woff2 binary
docker/boite-server text eol=lf
+
+# Keep language statistics focused on TypeScript, Svelte and Rust.
+# Supporting styles, entry points and scripts remain visible in diffs.
+*.css -linguist-detectable
+*.html -linguist-detectable
+*.js -linguist-detectable
+*.ps1 -linguist-detectable
+*.sh -linguist-detectable
+Dockerfile -linguist-detectable
+docker/boite-server -linguist-detectable
diff --git a/docs/README.md b/docs/README.md
index 0835541..052171c 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -22,4 +22,5 @@
- [CI](ci.md): checks, caching, nightly builds and image publication.
- [Releasing](releasing.md): installers, channels and versioning.
- [Release reports](releases/2.0.0-beta.1.md): the first beta's scope and measurements.
+- [Runtime audit](audits/2026-09-16-runtime.md): reproduced runtime issues and complexity priorities.
- [Repository rules](../AGENTS.md): boundaries a contributor must preserve.
diff --git a/docs/audits/2026-09-16-runtime.md b/docs/audits/2026-09-16-runtime.md
new file mode 100644
index 0000000..e1be084
--- /dev/null
+++ b/docs/audits/2026-09-16-runtime.md
@@ -0,0 +1,182 @@
+# Runtime, infrastructure and complexity audit
+
+Reviewed revision: `81fc7db`. Findings describe that revision, not completed fixes.
+The accompanying change to `.gitattributes` only filters language statistics.
+
+## Reproduce
+
+Run `bun packages/core/bin/audit-runtime.ts` from the repository root. It creates
+a disposable data directory, uses scripted echo drivers, closes its servers and
+removes its journal. It does not call a live provider. Its JSON output records
+observations, not passing regression assertions. Timings are synthetic readings,
+not application latency or a comparison with an earlier release.
+
+## Findings in correction order
+
+### 1. A quoted completion marker completes an unfinished goal
+
+Priority: high. Owner: `ActivityStore.finished`,
+[activity.ts](../../packages/core/src/activity.ts), line 124.
+
+The completion regex scans every assistant text part without distinguishing prose
+from fenced examples. A reply saying it is not finished, followed by a fenced
+`[BOITE_GOAL_COMPLETE]` example, changes the goal to `complete`.
+Probe: `quoted-marker`, observed `goalStatus: complete`.
+
+Use an explicit completion signal where the protocol supports it. For the text
+fallback, define and test a terminal marker outside code blocks. Include the
+blocked marker in the same parser and test quoted examples of both.
+
+### 2. Goal completion reads a UI page rather than the whole completed turn
+
+Priority: medium. Same owner as finding 1.
+
+`finished` calls `threads.get`, which returns only the last 120 messages. A turn
+with a completion marker followed by 120 assistant messages remains active.
+Probe: `marker-before-page`, observed `goalStatus: active`. The goal can schedule
+another paid turn even though its own completion marker was emitted.
+
+Read the completed turn's messages by turn ID, independently of UI pagination.
+This is an edge case involving many messages, not simply a long text response.
+
+### 3. Every turn decodes the complete conversation to find one user message
+
+Priority: medium. Owner: `ThreadStore.lastUserInput`,
+[threads.ts](../../packages/core/src/threads.ts), line 1122.
+
+`lastUserInput` calls `journal.listMessages(threadId)`, deserializes all message
+parts, then searches backwards for its turn. This includes old tool output and
+image data. It runs synchronously while constructing the driver context, so the
+cost blocks other work on the core's event loop.
+
+The scripted turn with 5,000 historical messages decoded 5,001 messages. In one
+run, medians of seven reads with 2,048 text characters per historical message were:
+
+| Historical messages | Full read median |
+| --- | --- |
+| 100 | 0.19 ms |
+| 1,000 | 5.25 ms |
+| 5,000 | 57.43 ms |
+
+Repeated runs put the 5,000-message median between 21 and 81 ms. The deterministic
+finding is the 5,001 decoded messages, not a promised latency improvement.
+Query the user message by thread and turn, with a suitable index. Also review
+`failStuckTurn` and `retitle`, which independently read whole histories.
+
+### 4. The core client timeout stops at WebSocket open
+
+Priority: medium. Owner: `connect`,
+[client.ts](../../packages/core/src/client.ts), lines 53 and 109.
+
+The open event clears the timeout before `send('hello')`. A peer that accepts the
+socket but never answers leaves `connect` pending indefinitely. Later RPC calls
+also have no response deadline. This affects the core client used by the pairing
+CLI and automation; the UI has a separate client and is not proven affected.
+
+Probe: `hello-timeout`, `timeoutMs: 20`, `settledAfter100Ms: false` against a local
+silent WebSocket peer. The probe closes that peer to release the pending call.
+Keep a deadline through the handshake, close on failure, and give RPC callers a
+bounded or explicitly cancellable wait.
+
+### 5. The Docker HTTPS proxy instructions omit the required origin setting
+
+Priority: medium. Owners: [server.md](../server.md), remote access section;
+`isAllowedOrigin` in [server.ts](../../packages/core/src/server.ts), line 153.
+
+Preserving `Host` and `Origin`, as documented, does not admit an external HTTPS
+origin automatically. The default check compares against the core's listen port
+and local host names. A browser using a public HTTPS name receives 403 until that
+exact origin is in `browserOrigins`.
+
+Probe: `proxy-origin` sends the same request before and after configuring the
+allowlist: 403 before, 400 after. The latter is the expected missing-upgrade error
+from this HTTP probe and proves only that the origin gate accepted it.
+Document how to set `browserOrigins` through an authenticated owner connection
+before switching to the proxy. Keep the strict gate. No real proxy was deployed.
+
+### 6. Inline code is parsed again as Markdown
+
+Priority: medium. Owner: `inline`,
+[markdown.ts](../../packages/ui/src/lib/markdown.ts), line 17.
+
+The replacement pipeline first creates a code tag, then applies emphasis and
+link replacements inside it. Input containing backticks around `**literal**`
+produces ` \0 one\ntwo threeliteral` rather than literal asterisks.
+Probe: `markdown-inline-code`. This is especially visible in coding answers.
+
+Tokenize code spans before formatting surrounding text. Add cases for emphasis,
+links, escaped characters and multiple code spans. Both phone and shell share
+this renderer; the probe verifies HTML output, not browser pixels.
+
+### 7. Unchanged activity state appends journal events on startup and shutdown
+
+Priority: low. Owner: constructor, `pauseAll` and `close` in
+[activity.ts](../../packages/core/src/activity.ts).
+
+Every saved activity is loaded and saved again, even when already paused or
+complete. Shutdown similarly saves every tracked activity. With two paused goals,
+the probes recorded two new `thread.activity` events on close and another two
+when constructing the startup loader. Each event duplicates the complete task list.
+
+Only persist a startup or shutdown transition when its state changes. Keep
+notifications for actual transitions. The measured result is event amplification;
+no disk-size or startup-latency claim was measured.
+
+## Complexity shortlist
+
+The audit scanned 2,247 functions across `packages` and `apps`: 63 scored at least
+15 cyclomatic complexity. After adding the probe it scanned 2,252 functions,
+with the same 63 above the threshold. Scores count decision paths, not defects.
+No cognitive scores were available in this run. No production refactor was made.
+
+| Function | CCN before / after | Assessment |
+| --- | --- | --- |
+| `renderMarkdown` | 52 / 52 | Separate block consumption and list nesting; pair the work with parser regression cases. |
+| `ModelPicker.onkeydown` | 47 / 47 | Four navigation contexts share one handler: legacy menu, search results, account chips and general rows. Model those contexts explicitly. |
+| `Claude.handleStream` | 40 / 40 | Text and thinking duplicate block lookup, creation, writes and accumulated content. Extract that shared operation; preserve tool JSON handling. |
+| `Workspace.add` / `boot` | 34 / 34 and 31 / 31 | URL validation, connection, identity deduplication, persistence and lifecycle checks are interleaved. Separate those responsibilities with cancellation tests. |
+| `ActivityStore.observeTool` | 31 / 31 | Separate protocol-specific task extraction from the state update. Preserve full-list versus incremental updates. |
+| `FakeClient.dispatch` | 216 / 216 | Not an immediate runtime optimization. Many branches are the RPC contract; group substantial handlers by domain if fake/core behavior diverges. |
+| `Codex.onNotification` | 36 / 36 | Mainly protocol dispatch. Keep the switch; extract substantial payload normalization only. |
+| `runCommand` | 32 / 32 | The switch represents commands. Modified CCN is 15; do not introduce a class hierarchy to lower this score. |
+
+The first two production targets are the Markdown parser, which has a reproduced
+formatting defect, and the goal completion logic, which affects execution.
+Do not start by rewriting the highest-scoring fake RPC dispatcher.
+
+## Coverage and limits
+
+Core tests: 344 passed, 12 skipped, zero failures before building the UI. UI tests:
+259 passed across 34 files. Core and contracts type checks passed using the local
+TypeScript entry points. Svelte check reported zero errors and zero warnings.
+Production UI build passed. The local installation lacked command shims, so the
+package entry points were invoked directly. Svelte analysis found no issues in
+`Workspace`; its ModelPicker suggestions were not treated as confirmed defects.
+
+`bun test tests/e2e`: 56 passed, 16 skipped, one prerequisite failure because
+`boite-shell.exe` was not built in this checkout. The browser/core checks ran;
+the native shell checks did not. This is not a fully passing end-to-end run.
+
+Commands used for the available checks:
+
+```sh
+bun packages/core/node_modules/typescript/bin/tsc -p packages/contracts/tsconfig.json
+bun packages/core/node_modules/typescript/bin/tsc -p packages/core/tsconfig.json
+bun run --cwd packages/core test
+cd packages/ui
+bun node_modules/svelte-check/bin/svelte-check --tsconfig ./tsconfig.json --tsgo
+bun node_modules/vitest/vitest.mjs run --maxWorkers=8
+bun node_modules/vite/bin/vite.js build
+cd ../..
+bun test tests/e2e
+bun scripts/check-docs.ts
+git diff --check
+```
+
+The review covered shared core paths, all five driver families through their
+scripted tests, UI and fake transport, CI selection, Docker startup and health,
+and release workflows. Goal/history findings apply independently of the selected
+driver. Docker and live providers were not run. No fleet infrastructure was
+inspected or changed. RSS, throughput under load, Linux process termination and
+native shell behavior need separate measurements. This is a prioritized audit,
+not a claim that every defect has been found.
diff --git a/packages/core/bin/audit-runtime.ts b/packages/core/bin/audit-runtime.ts
new file mode 100644
index 0000000..398ca77
--- /dev/null
+++ b/packages/core/bin/audit-runtime.ts
@@ -0,0 +1,104 @@
+/** Runtime audit probes against a disposable journal and scripted drivers. */
+import { ActivityStore } from '../src/activity.ts';
+import { connect } from '../src/client.ts';
+import { setDriver } from '../src/drivers/index.ts';
+import { echoThread, startTestCore, waitFor } from '../test/harness.ts';
+import { renderMarkdown } from '../../ui/src/lib/markdown.ts';
+
+const h = await startTestCore();
+let restore: (() => void) | undefined;
+try {
+ const client = await h.connect();
+ const proxyOrigin = 'https://boite.example.test';
+ const rejected = await fetch(`${h.url}/rpc`, { headers: { origin: proxyOrigin } });
+ h.core.settings.set({ browserOrigins: [proxyOrigin] });
+ const allowed = await fetch(`${h.url}/rpc`, { headers: { origin: proxyOrigin } });
+ console.log(JSON.stringify({ probe: 'proxy-origin', beforeAllowlist: rejected.status, afterAllowlist: allowed.status }));
+ const { threadId } = await echoThread(h, client);
+ const journal = h.core.journal;
+ let written = 0;
+ for (const count of [100, 1000, 5000]) {
+ journal.db.transaction(() => {
+ for (; written < count; written++) journal.putMessage({
+ id: `audit-${written}`, threadId, turnId: 'audit-history', role: 'assistant',
+ parts: [{ type: 'text', text: 'x'.repeat(2048) }], state: 'complete', createdAt: written,
+ });
+ })();
+ const times: number[] = [];
+ for (let run = 0; run < 7; run++) {
+ const start = performance.now();
+ journal.listMessages(threadId);
+ times.push(performance.now() - start);
+ }
+ console.log(JSON.stringify({ probe: 'history-read', messages: count, medianMs: +times.sort((a, b) => a - b)[3]!.toFixed(2) }));
+ }
+ let decoded = 0;
+ const original = journal.listMessages.bind(journal);
+ journal.listMessages = (id) => { const messages = original(id); decoded += messages.length; return messages; };
+ restore = setDriver('echo', {
+ protocol: 'echo',
+ startTurn() { return { stop() {}, done: Promise.resolve({ status: 'done', sessionId: null, usage: null }) }; },
+ });
+ const turn = h.core.threads.startTurn(threadId, 'latest prompt');
+ await waitFor(() => journal.getTurn(turn.id)?.status === 'done');
+ console.log(JSON.stringify({ probe: 'start-turn', historyMessages: 5000, decodedMessages: decoded }));
+ journal.listMessages = original;
+ restore(); restore = undefined;
+
+ for (const scenario of ['quoted-marker', 'marker-before-page'] as const) {
+ const { threadId: goalThread } = await echoThread(h, client, scenario);
+ restore = setDriver('echo', {
+ protocol: 'echo',
+ startTurn(ctx) {
+ const id = ctx.emit.startMessage('assistant');
+ const text = scenario === 'quoted-marker'
+ ? 'Not finished. This is the marker format:\n```\n[BOITE_GOAL_COMPLETE]\n```'
+ : '[BOITE_GOAL_COMPLETE]';
+ ctx.emit.part(id, 0, { type: 'text', text });
+ ctx.emit.complete(id, 'complete');
+ if (scenario === 'marker-before-page') for (let i = 0; i < 120; i++) {
+ const extra = ctx.emit.startMessage('assistant');
+ ctx.emit.part(extra, 0, { type: 'text', text: 'Additional output.' });
+ ctx.emit.complete(extra, 'complete');
+ }
+ return { stop() {}, done: Promise.resolve({ status: 'done', sessionId: null, usage: null }) };
+ },
+ });
+ h.core.activity.set({ threadId: goalThread, goal: { objective: 'Audit completion detection' } });
+ await waitFor(() => h.core.activity.get(goalThread).goal!.iterations > 0 && h.core.threads.get(goalThread).status === 'idle');
+ console.log(JSON.stringify({ probe: scenario, goalStatus: h.core.activity.get(goalThread).goal?.status }));
+ h.core.activity.pauseAll(goalThread);
+ restore(); restore = undefined;
+ }
+
+ const events = () => (journal.db.query("SELECT COUNT(*) AS n FROM events WHERE type = 'thread.activity'").get() as { n: number }).n;
+ const before = events();
+ h.core.activity.close();
+ const afterClose = events();
+ console.log(JSON.stringify({ probe: 'inactive-activity-close', addedEvents: afterClose - before }));
+ // Exercise the startup loader against the saved, already paused activities.
+ const reloaded = new ActivityStore(h.core);
+ console.log(JSON.stringify({ probe: 'inactive-activity-boot', addedEvents: events() - afterClose }));
+ reloaded.close();
+ console.log(JSON.stringify({ probe: 'markdown-inline-code', html: renderMarkdown('`**literal**`') }));
+} finally {
+ restore?.();
+ await h.stop();
+}
+
+// An accepted socket whose peer never answers hello must still respect timeoutMs.
+const silent = Bun.serve({
+ hostname: '127.0.0.1', port: 0,
+ fetch(request, server) { return server.upgrade(request) ? undefined : new Response('upgrade required'); },
+ websocket: { message() {} },
+});
+let settled = false;
+const pending = connect(`http://127.0.0.1:${silent.port}`, 'synthetic-token', { timeoutMs: 20 })
+ .then(client => { settled = true; client.close(); }, () => { settled = true; });
+try {
+ await Bun.sleep(100);
+ console.log(JSON.stringify({ probe: 'hello-timeout', timeoutMs: 20, settledAfter100Ms: settled }));
+} finally {
+ void silent.stop(true);
+ await pending;
+}
From e8d6521ccdc604673df0ebf5a5546c89d2f0f127 Mon Sep 17 00:00:00 2001
From: meetsu <96637888+klNuno@users.noreply.github.com>
Date: Wed, 16 Sep 2026 11:13:19 +0200
Subject: [PATCH 2/2] fix: address runtime audit findings and simplify control
flow
---
docs/README.md | 1 -
docs/audits/2026-09-16-runtime.md | 182 -----------------
docs/server.md | 12 ++
packages/core/bin/audit-runtime.ts | 104 ----------
packages/core/src/activity.ts | 86 ++++++--
packages/core/src/client.ts | 29 ++-
packages/core/src/drivers/claude.ts | 27 ++-
packages/core/src/journal.ts | 23 ++-
packages/core/src/threads.ts | 6 +-
packages/core/test/activity.test.ts | 57 ++++++
packages/core/test/client-timeout.test.ts | 30 +++
packages/core/test/model-switch.test.ts | 4 +-
packages/ui/src/components/Composer.test.ts | 27 +++
packages/ui/src/components/ModelPicker.svelte | 103 ++++++----
packages/ui/src/lib/markdown.test.ts | 7 +
packages/ui/src/lib/markdown.ts | 181 ++++++++++-------
packages/ui/src/lib/workspace.svelte.ts | 184 +++++++++++-------
packages/ui/src/lib/workspace.test.ts | 48 +++++
tests/e2e/ui.test.ts | 19 ++
19 files changed, 624 insertions(+), 506 deletions(-)
delete mode 100644 docs/audits/2026-09-16-runtime.md
delete mode 100644 packages/core/bin/audit-runtime.ts
create mode 100644 packages/core/test/client-timeout.test.ts
diff --git a/docs/README.md b/docs/README.md
index 052171c..0835541 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -22,5 +22,4 @@
- [CI](ci.md): checks, caching, nightly builds and image publication.
- [Releasing](releasing.md): installers, channels and versioning.
- [Release reports](releases/2.0.0-beta.1.md): the first beta's scope and measurements.
-- [Runtime audit](audits/2026-09-16-runtime.md): reproduced runtime issues and complexity priorities.
- [Repository rules](../AGENTS.md): boundaries a contributor must preserve.
diff --git a/docs/audits/2026-09-16-runtime.md b/docs/audits/2026-09-16-runtime.md
deleted file mode 100644
index e1be084..0000000
--- a/docs/audits/2026-09-16-runtime.md
+++ /dev/null
@@ -1,182 +0,0 @@
-# Runtime, infrastructure and complexity audit
-
-Reviewed revision: `81fc7db`. Findings describe that revision, not completed fixes.
-The accompanying change to `.gitattributes` only filters language statistics.
-
-## Reproduce
-
-Run `bun packages/core/bin/audit-runtime.ts` from the repository root. It creates
-a disposable data directory, uses scripted echo drivers, closes its servers and
-removes its journal. It does not call a live provider. Its JSON output records
-observations, not passing regression assertions. Timings are synthetic readings,
-not application latency or a comparison with an earlier release.
-
-## Findings in correction order
-
-### 1. A quoted completion marker completes an unfinished goal
-
-Priority: high. Owner: `ActivityStore.finished`,
-[activity.ts](../../packages/core/src/activity.ts), line 124.
-
-The completion regex scans every assistant text part without distinguishing prose
-from fenced examples. A reply saying it is not finished, followed by a fenced
-`[BOITE_GOAL_COMPLETE]` example, changes the goal to `complete`.
-Probe: `quoted-marker`, observed `goalStatus: complete`.
-
-Use an explicit completion signal where the protocol supports it. For the text
-fallback, define and test a terminal marker outside code blocks. Include the
-blocked marker in the same parser and test quoted examples of both.
-
-### 2. Goal completion reads a UI page rather than the whole completed turn
-
-Priority: medium. Same owner as finding 1.
-
-`finished` calls `threads.get`, which returns only the last 120 messages. A turn
-with a completion marker followed by 120 assistant messages remains active.
-Probe: `marker-before-page`, observed `goalStatus: active`. The goal can schedule
-another paid turn even though its own completion marker was emitted.
-
-Read the completed turn's messages by turn ID, independently of UI pagination.
-This is an edge case involving many messages, not simply a long text response.
-
-### 3. Every turn decodes the complete conversation to find one user message
-
-Priority: medium. Owner: `ThreadStore.lastUserInput`,
-[threads.ts](../../packages/core/src/threads.ts), line 1122.
-
-`lastUserInput` calls `journal.listMessages(threadId)`, deserializes all message
-parts, then searches backwards for its turn. This includes old tool output and
-image data. It runs synchronously while constructing the driver context, so the
-cost blocks other work on the core's event loop.
-
-The scripted turn with 5,000 historical messages decoded 5,001 messages. In one
-run, medians of seven reads with 2,048 text characters per historical message were:
-
-| Historical messages | Full read median |
-| --- | --- |
-| 100 | 0.19 ms |
-| 1,000 | 5.25 ms |
-| 5,000 | 57.43 ms |
-
-Repeated runs put the 5,000-message median between 21 and 81 ms. The deterministic
-finding is the 5,001 decoded messages, not a promised latency improvement.
-Query the user message by thread and turn, with a suitable index. Also review
-`failStuckTurn` and `retitle`, which independently read whole histories.
-
-### 4. The core client timeout stops at WebSocket open
-
-Priority: medium. Owner: `connect`,
-[client.ts](../../packages/core/src/client.ts), lines 53 and 109.
-
-The open event clears the timeout before `send('hello')`. A peer that accepts the
-socket but never answers leaves `connect` pending indefinitely. Later RPC calls
-also have no response deadline. This affects the core client used by the pairing
-CLI and automation; the UI has a separate client and is not proven affected.
-
-Probe: `hello-timeout`, `timeoutMs: 20`, `settledAfter100Ms: false` against a local
-silent WebSocket peer. The probe closes that peer to release the pending call.
-Keep a deadline through the handshake, close on failure, and give RPC callers a
-bounded or explicitly cancellable wait.
-
-### 5. The Docker HTTPS proxy instructions omit the required origin setting
-
-Priority: medium. Owners: [server.md](../server.md), remote access section;
-`isAllowedOrigin` in [server.ts](../../packages/core/src/server.ts), line 153.
-
-Preserving `Host` and `Origin`, as documented, does not admit an external HTTPS
-origin automatically. The default check compares against the core's listen port
-and local host names. A browser using a public HTTPS name receives 403 until that
-exact origin is in `browserOrigins`.
-
-Probe: `proxy-origin` sends the same request before and after configuring the
-allowlist: 403 before, 400 after. The latter is the expected missing-upgrade error
-from this HTTP probe and proves only that the origin gate accepted it.
-Document how to set `browserOrigins` through an authenticated owner connection
-before switching to the proxy. Keep the strict gate. No real proxy was deployed.
-
-### 6. Inline code is parsed again as Markdown
-
-Priority: medium. Owner: `inline`,
-[markdown.ts](../../packages/ui/src/lib/markdown.ts), line 17.
-
-The replacement pipeline first creates a code tag, then applies emphasis and
-link replacements inside it. Input containing backticks around `**literal**`
-produces `literal` rather than literal asterisks.
-Probe: `markdown-inline-code`. This is especially visible in coding answers.
-
-Tokenize code spans before formatting surrounding text. Add cases for emphasis,
-links, escaped characters and multiple code spans. Both phone and shell share
-this renderer; the probe verifies HTML output, not browser pixels.
-
-### 7. Unchanged activity state appends journal events on startup and shutdown
-
-Priority: low. Owner: constructor, `pauseAll` and `close` in
-[activity.ts](../../packages/core/src/activity.ts).
-
-Every saved activity is loaded and saved again, even when already paused or
-complete. Shutdown similarly saves every tracked activity. With two paused goals,
-the probes recorded two new `thread.activity` events on close and another two
-when constructing the startup loader. Each event duplicates the complete task list.
-
-Only persist a startup or shutdown transition when its state changes. Keep
-notifications for actual transitions. The measured result is event amplification;
-no disk-size or startup-latency claim was measured.
-
-## Complexity shortlist
-
-The audit scanned 2,247 functions across `packages` and `apps`: 63 scored at least
-15 cyclomatic complexity. After adding the probe it scanned 2,252 functions,
-with the same 63 above the threshold. Scores count decision paths, not defects.
-No cognitive scores were available in this run. No production refactor was made.
-
-| Function | CCN before / after | Assessment |
-| --- | --- | --- |
-| `renderMarkdown` | 52 / 52 | Separate block consumption and list nesting; pair the work with parser regression cases. |
-| `ModelPicker.onkeydown` | 47 / 47 | Four navigation contexts share one handler: legacy menu, search results, account chips and general rows. Model those contexts explicitly. |
-| `Claude.handleStream` | 40 / 40 | Text and thinking duplicate block lookup, creation, writes and accumulated content. Extract that shared operation; preserve tool JSON handling. |
-| `Workspace.add` / `boot` | 34 / 34 and 31 / 31 | URL validation, connection, identity deduplication, persistence and lifecycle checks are interleaved. Separate those responsibilities with cancellation tests. |
-| `ActivityStore.observeTool` | 31 / 31 | Separate protocol-specific task extraction from the state update. Preserve full-list versus incremental updates. |
-| `FakeClient.dispatch` | 216 / 216 | Not an immediate runtime optimization. Many branches are the RPC contract; group substantial handlers by domain if fake/core behavior diverges. |
-| `Codex.onNotification` | 36 / 36 | Mainly protocol dispatch. Keep the switch; extract substantial payload normalization only. |
-| `runCommand` | 32 / 32 | The switch represents commands. Modified CCN is 15; do not introduce a class hierarchy to lower this score. |
-
-The first two production targets are the Markdown parser, which has a reproduced
-formatting defect, and the goal completion logic, which affects execution.
-Do not start by rewriting the highest-scoring fake RPC dispatcher.
-
-## Coverage and limits
-
-Core tests: 344 passed, 12 skipped, zero failures before building the UI. UI tests:
-259 passed across 34 files. Core and contracts type checks passed using the local
-TypeScript entry points. Svelte check reported zero errors and zero warnings.
-Production UI build passed. The local installation lacked command shims, so the
-package entry points were invoked directly. Svelte analysis found no issues in
-`Workspace`; its ModelPicker suggestions were not treated as confirmed defects.
-
-`bun test tests/e2e`: 56 passed, 16 skipped, one prerequisite failure because
-`boite-shell.exe` was not built in this checkout. The browser/core checks ran;
-the native shell checks did not. This is not a fully passing end-to-end run.
-
-Commands used for the available checks:
-
-```sh
-bun packages/core/node_modules/typescript/bin/tsc -p packages/contracts/tsconfig.json
-bun packages/core/node_modules/typescript/bin/tsc -p packages/core/tsconfig.json
-bun run --cwd packages/core test
-cd packages/ui
-bun node_modules/svelte-check/bin/svelte-check --tsconfig ./tsconfig.json --tsgo
-bun node_modules/vitest/vitest.mjs run --maxWorkers=8
-bun node_modules/vite/bin/vite.js build
-cd ../..
-bun test tests/e2e
-bun scripts/check-docs.ts
-git diff --check
-```
-
-The review covered shared core paths, all five driver families through their
-scripted tests, UI and fake transport, CI selection, Docker startup and health,
-and release workflows. Goal/history findings apply independently of the selected
-driver. Docker and live providers were not run. No fleet infrastructure was
-inspected or changed. RSS, throughput under load, Linux process termination and
-native shell behavior need separate measurements. This is a prioritized audit,
-not a claim that every defect has been found.
diff --git a/docs/server.md b/docs/server.md
index 90c64b2..baf0d16 100644
--- a/docs/server.md
+++ b/docs/server.md
@@ -95,6 +95,18 @@ The core does not terminate TLS. Public access needs an HTTPS reverse proxy
that forwards WebSocket upgrades and preserves `Host` and `Origin`. Serve the
UI and `/rpc` from the same origin. Do not expose plain HTTP to the internet.
+Before connecting through the proxy, add its exact browser origin, such as
+`https://boite.example.com`, to the core's `browserOrigins` setting. Connect the
+desktop shell directly as an owner, select that machine, open Machines > Allowed
+browser origins, and configure the origins there. Keep any existing origins that are
+still needed. Origins contain a scheme, hostname and optional port, with no
+path or trailing slash. See [machine connections](machines.md).
+
+Preserving the proxy headers alone is not enough: an HTTPS origin on port 443
+differs from the core listening on port 7337. An origin that is not allowed gets
+HTTP 403 on `/rpc`, even when the pairing token is valid. Keep the allowlist
+explicit; do not strip the `Origin` header to bypass this check.
+
### Image verification
From a Linux checkout with Docker:
diff --git a/packages/core/bin/audit-runtime.ts b/packages/core/bin/audit-runtime.ts
deleted file mode 100644
index 398ca77..0000000
--- a/packages/core/bin/audit-runtime.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-/** Runtime audit probes against a disposable journal and scripted drivers. */
-import { ActivityStore } from '../src/activity.ts';
-import { connect } from '../src/client.ts';
-import { setDriver } from '../src/drivers/index.ts';
-import { echoThread, startTestCore, waitFor } from '../test/harness.ts';
-import { renderMarkdown } from '../../ui/src/lib/markdown.ts';
-
-const h = await startTestCore();
-let restore: (() => void) | undefined;
-try {
- const client = await h.connect();
- const proxyOrigin = 'https://boite.example.test';
- const rejected = await fetch(`${h.url}/rpc`, { headers: { origin: proxyOrigin } });
- h.core.settings.set({ browserOrigins: [proxyOrigin] });
- const allowed = await fetch(`${h.url}/rpc`, { headers: { origin: proxyOrigin } });
- console.log(JSON.stringify({ probe: 'proxy-origin', beforeAllowlist: rejected.status, afterAllowlist: allowed.status }));
- const { threadId } = await echoThread(h, client);
- const journal = h.core.journal;
- let written = 0;
- for (const count of [100, 1000, 5000]) {
- journal.db.transaction(() => {
- for (; written < count; written++) journal.putMessage({
- id: `audit-${written}`, threadId, turnId: 'audit-history', role: 'assistant',
- parts: [{ type: 'text', text: 'x'.repeat(2048) }], state: 'complete', createdAt: written,
- });
- })();
- const times: number[] = [];
- for (let run = 0; run < 7; run++) {
- const start = performance.now();
- journal.listMessages(threadId);
- times.push(performance.now() - start);
- }
- console.log(JSON.stringify({ probe: 'history-read', messages: count, medianMs: +times.sort((a, b) => a - b)[3]!.toFixed(2) }));
- }
- let decoded = 0;
- const original = journal.listMessages.bind(journal);
- journal.listMessages = (id) => { const messages = original(id); decoded += messages.length; return messages; };
- restore = setDriver('echo', {
- protocol: 'echo',
- startTurn() { return { stop() {}, done: Promise.resolve({ status: 'done', sessionId: null, usage: null }) }; },
- });
- const turn = h.core.threads.startTurn(threadId, 'latest prompt');
- await waitFor(() => journal.getTurn(turn.id)?.status === 'done');
- console.log(JSON.stringify({ probe: 'start-turn', historyMessages: 5000, decodedMessages: decoded }));
- journal.listMessages = original;
- restore(); restore = undefined;
-
- for (const scenario of ['quoted-marker', 'marker-before-page'] as const) {
- const { threadId: goalThread } = await echoThread(h, client, scenario);
- restore = setDriver('echo', {
- protocol: 'echo',
- startTurn(ctx) {
- const id = ctx.emit.startMessage('assistant');
- const text = scenario === 'quoted-marker'
- ? 'Not finished. This is the marker format:\n```\n[BOITE_GOAL_COMPLETE]\n```'
- : '[BOITE_GOAL_COMPLETE]';
- ctx.emit.part(id, 0, { type: 'text', text });
- ctx.emit.complete(id, 'complete');
- if (scenario === 'marker-before-page') for (let i = 0; i < 120; i++) {
- const extra = ctx.emit.startMessage('assistant');
- ctx.emit.part(extra, 0, { type: 'text', text: 'Additional output.' });
- ctx.emit.complete(extra, 'complete');
- }
- return { stop() {}, done: Promise.resolve({ status: 'done', sessionId: null, usage: null }) };
- },
- });
- h.core.activity.set({ threadId: goalThread, goal: { objective: 'Audit completion detection' } });
- await waitFor(() => h.core.activity.get(goalThread).goal!.iterations > 0 && h.core.threads.get(goalThread).status === 'idle');
- console.log(JSON.stringify({ probe: scenario, goalStatus: h.core.activity.get(goalThread).goal?.status }));
- h.core.activity.pauseAll(goalThread);
- restore(); restore = undefined;
- }
-
- const events = () => (journal.db.query("SELECT COUNT(*) AS n FROM events WHERE type = 'thread.activity'").get() as { n: number }).n;
- const before = events();
- h.core.activity.close();
- const afterClose = events();
- console.log(JSON.stringify({ probe: 'inactive-activity-close', addedEvents: afterClose - before }));
- // Exercise the startup loader against the saved, already paused activities.
- const reloaded = new ActivityStore(h.core);
- console.log(JSON.stringify({ probe: 'inactive-activity-boot', addedEvents: events() - afterClose }));
- reloaded.close();
- console.log(JSON.stringify({ probe: 'markdown-inline-code', html: renderMarkdown('`**literal**`') }));
-} finally {
- restore?.();
- await h.stop();
-}
-
-// An accepted socket whose peer never answers hello must still respect timeoutMs.
-const silent = Bun.serve({
- hostname: '127.0.0.1', port: 0,
- fetch(request, server) { return server.upgrade(request) ? undefined : new Response('upgrade required'); },
- websocket: { message() {} },
-});
-let settled = false;
-const pending = connect(`http://127.0.0.1:${silent.port}`, 'synthetic-token', { timeoutMs: 20 })
- .then(client => { settled = true; client.close(); }, () => { settled = true; });
-try {
- await Bun.sleep(100);
- console.log(JSON.stringify({ probe: 'hello-timeout', timeoutMs: 20, settledAfter100Ms: settled }));
-} finally {
- void silent.stop(true);
- await pending;
-}
diff --git a/packages/core/src/activity.ts b/packages/core/src/activity.ts
index 7361559..d516b0a 100644
--- a/packages/core/src/activity.ts
+++ b/packages/core/src/activity.ts
@@ -16,13 +16,9 @@ export class ActivityStore {
for (const thread of core.journal.listThreads()) {
const saved = core.journal.getSetting(`activity:${thread.id}`) as ThreadActivity | undefined;
if (!saved) continue;
- for (const kind of ['goal', 'loop'] as const) {
- const item = saved[kind];
- if (item?.status === 'active') { item.status = 'paused'; item.error = 'Core restarted. Resume to continue.'; }
- }
- if (saved.loop) saved.loop.nextRunAt = null;
+ const changed = pauseActivity(saved, 'Core restarted. Resume to continue.');
this.states.set(thread.id, saved);
- this.save(thread.id);
+ if (changed) this.save(thread.id);
}
core.bus.onAny((name, payload) => {
if (this.closed) return;
@@ -103,12 +99,7 @@ export class ActivityStore {
const state = this.get(threadId);
let createdId: string | null = null;
if (part.name.toLowerCase() === 'taskcreate') {
- try {
- const output = JSON.parse(part.output ?? 'null');
- const id = output?.task?.id ?? output?.taskId ?? output?.id;
- if (typeof id === 'string' || typeof id === 'number') createdId = String(id);
- } catch { /* Some Claude versions return a human-readable result. */ }
- createdId ??= /Task\s+#?(\d+)\s+created/i.exec(part.output ?? '')?.[1] ?? null;
+ createdId = createdTaskId(part.output);
if (!createdId) return;
}
const id = String(input.taskId ?? createdId);
@@ -128,16 +119,28 @@ export class ActivityStore {
const state = this.states.get(turn.threadId);
if (!state) return;
if (owned?.kind === 'goal' && owned.generation === (this.generations.get(turn.threadId) ?? 0) && state.goal?.status === 'active') {
- const messages = this.core.threads.get(turn.threadId).messages;
- const completed = messages.some((message) => message.turnId === turn.id && message.role === 'assistant' && message.parts.some((part) => part.type === 'text' && /^\s*\[BOITE_GOAL_COMPLETE\]\s*$/m.test(part.text)));
- const blocked = messages.some((message) => message.turnId === turn.id && message.role === 'assistant' && message.parts.some((part) => part.type === 'text' && /^\s*\[BOITE_GOAL_BLOCKED\]\s*$/m.test(part.text)));
- if (blocked) { state.goal.status = 'paused'; state.goal.error = 'The agent reported a blocker. Read its answer before resuming.'; this.save(turn.threadId); }
- else if (completed) { state.goal.status = 'complete'; this.save(turn.threadId); }
+ const signal = this.goalResult(turn);
+ if (signal === 'blocked') { state.goal.status = 'paused'; state.goal.error = 'The agent reported a blocker. Read its answer before resuming.'; this.save(turn.threadId); }
+ else if (signal === 'complete') { state.goal.status = 'complete'; this.save(turn.threadId); }
}
// Let the scheduler release its running slot and clients submit queued user input first.
this.schedule(turn.threadId, 250);
}
+ private goalResult(turn: Turn): 'complete' | 'blocked' | null {
+ let result: 'complete' | null = null;
+ for (const message of this.core.journal.walkTurnMessages(turn.threadId, turn.id)) {
+ if (message.role !== 'assistant') continue;
+ for (const part of message.parts) {
+ if (part.type !== 'text') continue;
+ const signal = goalSignal(part.text);
+ if (signal === 'blocked') return signal;
+ if (signal === 'complete') result = signal;
+ }
+ }
+ return result;
+ }
+
private run(threadId: string): void {
if (this.closed) return;
const thread = this.core.journal.getThread(threadId);
@@ -150,7 +153,7 @@ export class ActivityStore {
return;
}
const prompt = kind === 'goal'
- ? `Work toward this goal: ${state.goal!.objective}\nContinue until the objective is achieved. When you have verified completion, write [BOITE_GOAL_COMPLETE] alone on its own line. If blocked or waiting for user input, explain what is missing and write [BOITE_GOAL_BLOCKED] alone on its own line.`
+ ? `Work toward this goal: ${state.goal!.objective}\nContinue until the objective is achieved. When you have verified completion, end your answer with [BOITE_GOAL_COMPLETE] alone on its own line, outside code blocks. If blocked or waiting for user input, explain what is missing and end with [BOITE_GOAL_BLOCKED] alone on its own line, outside code blocks.`
: state.loop!.prompt;
try {
const turn = this.core.threads.startTurn(threadId, prompt);
@@ -177,9 +180,7 @@ export class ActivityStore {
const state = this.states.get(threadId);
if (!state) return;
this.clearTimer(threadId);
- for (const kind of ['goal', 'loop'] as const) { const item = state[kind]; if (item?.status === 'active') { item.status = 'paused'; item.error = error; } }
- if (state.loop) state.loop.nextRunAt = null;
- this.save(threadId);
+ if (pauseActivity(state, error)) this.save(threadId);
}
private save(threadId: string): void {
@@ -192,6 +193,49 @@ export class ActivityStore {
}
export function taskStatus(value: unknown): AgentTask['status'] { return value === 'completed' ? 'completed' : value === 'in_progress' || value === 'inProgress' ? 'in_progress' : 'pending'; }
+
+function createdTaskId(text: string | null): string | null {
+ try {
+ const output = JSON.parse(text ?? 'null');
+ const id = output?.task?.id ?? output?.taskId ?? output?.id;
+ if (typeof id === 'string' || typeof id === 'number') return String(id);
+ } catch { /* Older agents return a human-readable confirmation. */ }
+ return /Task\s+#?(\d+)\s+created/i.exec(text ?? '')?.[1] ?? null;
+}
+
+function pauseActivity(state: ThreadActivity, error: string | null): boolean {
+ let changed = false;
+ for (const item of [state.goal, state.loop]) {
+ if (item?.status !== 'active') continue;
+ item.status = 'paused';
+ item.error = error;
+ changed = true;
+ }
+ if (state.loop && state.loop.nextRunAt !== null) {
+ state.loop.nextRunAt = null;
+ changed = true;
+ }
+ return changed;
+}
+
+/** Only a final standalone marker outside fenced or indented code is a signal. */
+export function goalSignal(text: string): 'complete' | 'blocked' | null {
+ let fence: string | null = null;
+ let last = '';
+ for (const line of text.split(/\r?\n/)) {
+ const mark = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
+ if (fence !== null) {
+ if (mark && mark[1]![0] === fence[0] && mark[1]!.length >= fence.length && mark[2]!.trim() === '') fence = null;
+ if (line.trim()) last = '';
+ continue;
+ }
+ if (mark) { fence = mark[1]!; last = ''; continue; }
+ if (line.trim()) last = line;
+ }
+ if (/^ {0,3}\[BOITE_GOAL_COMPLETE\][ \t]*$/.test(last)) return 'complete';
+ if (/^ {0,3}\[BOITE_GOAL_BLOCKED\][ \t]*$/.test(last)) return 'blocked';
+ return null;
+}
export function normalizeTasks(items: unknown[]): AgentTask[] {
return items.flatMap((value, index) => {
if (!value || typeof value !== 'object') return [];
diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts
index 7a62c4f..af66570 100644
--- a/packages/core/src/client.ts
+++ b/packages/core/src/client.ts
@@ -12,6 +12,8 @@ import type {
export interface ConnectOptions {
client?: { name: string; version: string };
timeoutMs?: number;
+ /** Response deadline for ordinary RPC calls; the handshake uses timeoutMs. */
+ requestTimeoutMs?: number;
/** Say hello with a pairing grant instead of the token; `session` then carries what came back. */
grant?: string;
}
@@ -52,18 +54,28 @@ function socketUrl(url: string): string {
export async function connect(url: string, token: string, options: ConnectOptions = {}): Promise**literal** and bold[x](https://example.test)a ` b and <tag>code and [x](https://example.test)**code**Title
Sub
');
diff --git a/packages/ui/src/lib/markdown.ts b/packages/ui/src/lib/markdown.ts
index cd0093d..e3b818a 100644
--- a/packages/ui/src/lib/markdown.ts
+++ b/packages/ui/src/lib/markdown.ts
@@ -15,8 +15,19 @@ function escape(text: string): string {
}
function inline(text: string): string {
+ const code: string[] = [];
+ let marker = '\0';
+ while (text.includes(marker)) marker += '\0';
+ const protectedText = text.replace(/(? {
+ code.push(`${escape(value)}`);
+ return `${marker}${code.length - 1}${marker}`;
+ });
+ // Format around opaque spans, then restore them without parsing their contents.
+ return inlineFormatting(protectedText).split(marker).map((part, index) => index % 2 ? code[Number(part)]! : part).join('');
+}
+
+function inlineFormatting(text: string): string {
let out = escape(text);
- out = out.replace(/`([^`\n]+)`/g, '$1');
out = out.replace(/\*\*([^*\n]+)\*\*/g, '$1');
out = out.replace(/(^|[^*\w])\*([^*\n]+)\*(?!\w)/g, '$1$2');
out = out.replace(/~~([^~\n]+)~~/g, '$1');
@@ -89,9 +100,7 @@ export function renderMarkdown(source: string): string {
const lines = source.replaceAll('\r\n', '\n').split('\n');
const html: string[] = [];
let paragraph: string[] = [];
- let list: ListItem[] | null = null;
- /** The items open at each indent, innermost last: where the next line nests. */
- let stack: ListItem[] = [];
+ const list = new MarkdownList();
const flushParagraph = (): void => {
if (paragraph.length === 0) return;
@@ -99,10 +108,7 @@ export function renderMarkdown(source: string): string {
paragraph = [];
};
const flushList = (): void => {
- if (!list) return;
- html.push(renderItems(list));
- list = null;
- stack = [];
+ html.push(list.flush());
};
const flushAll = (): void => {
flushParagraph();
@@ -111,17 +117,11 @@ export function renderMarkdown(source: string): string {
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? '';
- const fence = FENCE.exec(line);
+ const fence = readFence(lines, index);
if (fence) {
flushAll();
- const code: string[] = [];
- index += 1;
- while (index < lines.length && !FENCE.test(lines[index] ?? '')) {
- code.push(lines[index] ?? '');
- index += 1;
- }
- const language = fence[1] ? ` data-language="${escape(fence[1])}"` : '';
- html.push(`
`);
+ html.push(fence.html);
+ index = fence.end;
continue;
}
@@ -144,70 +144,32 @@ export function renderMarkdown(source: string): string {
continue;
}
- if (QUOTE.test(line)) {
+ const quote = readQuote(lines, index);
+ if (quote) {
flushAll();
- const quoted: string[] = [];
- while (index < lines.length) {
- const match = QUOTE.exec(lines[index] ?? '');
- if (!match) break;
- quoted.push(match[1] ?? '');
- index += 1;
- }
- index -= 1;
- html.push(`${escape(code.join('\n'))}${renderMarkdown(quoted.join('\n'))}
`);
+ html.push(quote.html);
+ index = quote.end;
continue;
}
// A table is a row, a delimiter row, then rows until a blank or a line with no pipe.
- const next = lines[index + 1] ?? '';
- if (line.includes('|') && TABLE_DELIMITER.test(next) && cells(next).length >= 1 && !ITEM.test(line)) {
+ const table = readTable(lines, index);
+ if (table) {
flushAll();
- const body: string[] = [];
- let cursor = index + 2;
- while (cursor < lines.length) {
- const candidate = lines[cursor] ?? '';
- if (candidate.trim() === '' || !candidate.includes('|')) break;
- body.push(candidate);
- cursor += 1;
- }
- html.push(renderTable(line, next, body));
- index = cursor - 1;
+ html.push(table.html);
+ index = table.end;
continue;
}
- const item = ITEM.exec(line);
+ const item = parseListItem(line);
if (item) {
flushParagraph();
- const indent = (item[1] ?? '').length;
- const kind: 'ul' | 'ol' = BULLET_MARK.test(line) ? 'ul' : 'ol';
- const task = TASK.exec(item[2] ?? '');
- const entry: ListItem = {
- indent,
- kind,
- text: task ? (task[2] ?? '') : (item[2] ?? ''),
- task: task ? ((task[1] ?? ' ') === ' ' ? 'open' : 'done') : null,
- children: []
- };
- // Pop every open item at this depth or deeper: the new one is their sibling or an uncle.
- while (stack.length > 0 && (stack[stack.length - 1]?.indent ?? 0) >= indent) stack.pop();
- const parent = stack[stack.length - 1];
- if (!parent) {
- if (list && list[0]?.kind !== kind && list[0]?.indent === indent) flushList();
- if (!list) list = [];
- list.push(entry);
- } else {
- parent.children.push(entry);
- }
- stack.push(entry);
+ html.push(list.add(item));
continue;
}
// Indented text under an item continues that item.
- const open = stack[stack.length - 1];
- if (open && /^\s{2,}\S/.test(line)) {
- open.text += ` ${line.trim()}`;
- continue;
- }
+ if (list.continue(line)) continue;
flushList();
paragraph.push(line);
@@ -217,6 +179,93 @@ export function renderMarkdown(source: string): string {
return html.join('');
}
+interface Block { html: string; end: number }
+
+function readFence(lines: string[], start: number): Block | null {
+ const fence = FENCE.exec(lines[start] ?? '');
+ if (!fence) return null;
+ const code: string[] = [];
+ let end = start + 1;
+ while (end < lines.length && !FENCE.test(lines[end] ?? '')) code.push(lines[end++] ?? '');
+ const language = fence[1] ? ` data-language="${escape(fence[1])}"` : '';
+ return { html: `
`, end };
+}
+
+function readQuote(lines: string[], start: number): Block | null {
+ const quoted: string[] = [];
+ let cursor = start;
+ while (cursor < lines.length) {
+ const match = QUOTE.exec(lines[cursor] ?? '');
+ if (!match) break;
+ quoted.push(match[1] ?? '');
+ cursor++;
+ }
+ return quoted.length ? { html: `${escape(code.join('\n'))}${renderMarkdown(quoted.join('\n'))}
`, end: cursor - 1 } : null;
+}
+
+function readTable(lines: string[], start: number): Block | null {
+ const head = lines[start] ?? '';
+ const delimiter = lines[start + 1] ?? '';
+ if (!head.includes('|') || !TABLE_DELIMITER.test(delimiter) || ITEM.test(head)) return null;
+ const body: string[] = [];
+ let cursor = start + 2;
+ while (cursor < lines.length) {
+ const line = lines[cursor] ?? '';
+ if (!line.trim() || !line.includes('|')) break;
+ body.push(line);
+ cursor++;
+ }
+ return { html: renderTable(head, delimiter, body), end: cursor - 1 };
+}
+
+function parseListItem(line: string): ListItem | null {
+ const item = ITEM.exec(line);
+ if (!item) return null;
+ const text = item[2] ?? '';
+ const task = TASK.exec(text);
+ return {
+ indent: (item[1] ?? '').length,
+ kind: BULLET_MARK.test(line) ? 'ul' : 'ol',
+ text: task ? task[2] ?? '' : text,
+ task: task ? (task[1] === ' ' ? 'open' : 'done') : null,
+ children: []
+ };
+}
+
+/** Open ancestors retain the list nesting while blocks consume source lines. */
+class MarkdownList {
+ private items: ListItem[] = [];
+ private stack: ListItem[] = [];
+
+ add(entry: ListItem): string {
+ while (this.stack.length && this.stack[this.stack.length - 1]!.indent >= entry.indent) this.stack.pop();
+ const parent = this.stack[this.stack.length - 1];
+ let previous = '';
+ if (parent) parent.children.push(entry);
+ else {
+ const first = this.items[0];
+ if (first && first.kind !== entry.kind && first.indent === entry.indent) previous = this.flush();
+ this.items.push(entry);
+ }
+ this.stack.push(entry);
+ return previous;
+ }
+
+ continue(line: string): boolean {
+ const open = this.stack[this.stack.length - 1];
+ if (!open || !/^\s{2,}\S/.test(line)) return false;
+ open.text += ` ${line.trim()}`;
+ return true;
+ }
+
+ flush(): string {
+ const html = this.items.length ? renderItems(this.items) : '';
+ this.items = [];
+ this.stack = [];
+ return html;
+ }
+}
+
/** Where the caret goes when the text ends on a block that closes in several tags. */
const TAILS = ['', '', '', '', '