@maincode-ai/channel-base 0.18.3

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/README.md ADDED
@@ -0,0 +1,295 @@
1
+ # @maincode-ai/channel-base
2
+
3
+ Base infrastructure for building Matilda Code channel adapters. Provides the abstract base class, access control, session routing, and the ACP bridge that communicates with the agent.
4
+
5
+ If you're building a channel plugin, this is your only dependency.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @maincode-ai/channel-base
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ Subclass `ChannelBase` and implement three methods:
16
+
17
+ ```typescript
18
+ import { ChannelBase } from '@maincode-ai/channel-base';
19
+ import type {
20
+ ChannelConfig,
21
+ Envelope,
22
+ AcpBridge,
23
+ } from '@maincode-ai/channel-base';
24
+
25
+ class MyChannel extends ChannelBase {
26
+ async connect(): Promise<void> {
27
+ // Connect to platform API, register message handlers.
28
+ // When a message arrives, build an Envelope and call:
29
+ // this.handleInbound(envelope)
30
+ }
31
+
32
+ async sendMessage(chatId: string, text: string): Promise<void> {
33
+ // Deliver the agent's response to the platform.
34
+ }
35
+
36
+ disconnect(): void {
37
+ // Clean up connections on shutdown.
38
+ }
39
+ }
40
+ ```
41
+
42
+ Export a `ChannelPlugin` object so the extension loader can discover it:
43
+
44
+ ```typescript
45
+ import type { ChannelPlugin } from '@maincode-ai/channel-base';
46
+
47
+ export const plugin: ChannelPlugin = {
48
+ channelType: 'my-platform',
49
+ displayName: 'My Platform',
50
+ requiredConfigFields: ['apiKey'],
51
+ createChannel: (name, config, bridge, options) =>
52
+ new MyChannel(name, config, bridge, options),
53
+ };
54
+ ```
55
+
56
+ For a complete working example, see [`@matilda-code/channel-plugin-example`](../plugin-example/).
57
+
58
+ ## Architecture
59
+
60
+ ```
61
+ Inbound: Platform message
62
+ → Envelope (with attachments)
63
+ → GroupGate (group policy + mention gating)
64
+ → SenderGate (allowlist / pairing / open)
65
+ → Slash commands (/clear, /help, /status)
66
+ → SessionRouter (resolve or create ACP session)
67
+ → Resolve attachments (images → bridge, files → prompt text)
68
+ → AcpBridge.prompt() → agent
69
+
70
+ Outbound: Agent response
71
+ → BlockStreamer (if enabled: split into blocks at paragraph boundaries)
72
+ → sendMessage() → platform
73
+ ```
74
+
75
+ Everything between `handleInbound()` and `sendMessage()` is handled by the base class — your adapter only deals with platform I/O.
76
+
77
+ ## Exports
78
+
79
+ ### Classes
80
+
81
+ | Class | Purpose |
82
+ | --------------- | ------------------------------------------------------------------- |
83
+ | `ChannelBase` | Abstract base class — extend this to build a channel adapter |
84
+ | `AcpBridge` | Spawns and communicates with the `matilda-code --acp` agent process |
85
+ | `BlockStreamer` | Progressive multi-message delivery for block streaming |
86
+ | `SessionRouter` | Maps senders to ACP sessions with configurable scoping |
87
+ | `SenderGate` | DM access control (allowlist / pairing / open) |
88
+ | `GroupGate` | Group chat policy and @mention gating |
89
+ | `PairingStore` | Pairing code generation, approval, and allowlist persistence |
90
+
91
+ ### Types
92
+
93
+ | Type | Description |
94
+ | --------------- | ---------------------------------------------- |
95
+ | `Attachment` | Structured file/image/audio/video attachment |
96
+ | `ChannelConfig` | Channel configuration from `settings.json` |
97
+ | `ChannelPlugin` | Plugin factory interface (what you export) |
98
+ | `Envelope` | Normalized inbound message format |
99
+ | `SenderPolicy` | `'allowlist' \| 'pairing' \| 'open'` |
100
+ | `GroupPolicy` | `'disabled' \| 'allowlist' \| 'open'` |
101
+ | `SessionScope` | `'user' \| 'thread' \| 'single'` |
102
+ | `GroupConfig` | Per-group settings (e.g. `requireMention`) |
103
+ | `SessionTarget` | Maps a session back to its channel/sender/chat |
104
+
105
+ ## API reference
106
+
107
+ ### ChannelBase
108
+
109
+ ```typescript
110
+ constructor(name: string, config: ChannelConfig, bridge: AcpBridge, options?: ChannelBaseOptions)
111
+ ```
112
+
113
+ **Abstract methods** (you must implement):
114
+
115
+ | Method | Signature |
116
+ | --------------- | ---------------------------------------------------------------------------- |
117
+ | `connect()` | `() => Promise<void>` — Connect to the platform and start receiving messages |
118
+ | `sendMessage()` | `(chatId: string, text: string) => Promise<void>` — Deliver agent response |
119
+ | `disconnect()` | `() => void` — Clean up on shutdown |
120
+
121
+ **Provided methods:**
122
+
123
+ | Method | Description |
124
+ | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
125
+ | `handleInbound(envelope)` | Route an inbound message through the full pipeline (gate checks, commands, session, prompt). Call this from your message handler. |
126
+ | `setBridge(bridge)` | Replace the ACP bridge after crash recovery |
127
+ | `registerCommand(name, handler)` | Register a custom slash command (e.g. `/mycommand`) |
128
+ | `onToolCall(chatId, event)` | Hook called on agent tool invocations — override to show indicators |
129
+ | `onResponseChunk(chatId, chunk, sessionId)` | Hook called per streaming text chunk — override for progressive display (default: no-op) |
130
+ | `onResponseComplete(chatId, fullText, sessionId)` | Hook called when full response is ready — override to customize delivery (default: `sendMessage()`) |
131
+
132
+ **Block streaming:** When `blockStreaming: "on"` is set in the channel config, the base class automatically splits the agent's streaming response into multiple messages at paragraph boundaries. See [Block Streaming](#block-streaming) below.
133
+
134
+ **Built-in slash commands:** `/clear` (`/reset`, `/new`), `/help`, `/status`
135
+
136
+ ### AcpBridge
137
+
138
+ Manages the `matilda-code --acp` child process and ACP sessions.
139
+
140
+ ```typescript
141
+ constructor(options: { cliEntryPath: string; cwd: string; model?: string })
142
+ ```
143
+
144
+ | Method | Description |
145
+ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
146
+ | `start()` | Spawn the agent process |
147
+ | `stop()` | Kill the agent process |
148
+ | `newSession(cwd)` | Create a new ACP session, returns `sessionId` |
149
+ | `loadSession(sessionId, cwd)` | Restore an existing session |
150
+ | `prompt(sessionId, text, options?)` | Send a message to the agent, returns the full response text. Supports optional `imageBase64` and `imageMimeType`. |
151
+ | `isConnected` | Whether the agent process is alive |
152
+
153
+ **Events** (EventEmitter):
154
+
155
+ | Event | Payload | Description |
156
+ | -------------- | ------------------------ | ------------------------ |
157
+ | `textChunk` | `(sessionId, chunk)` | Streaming response chunk |
158
+ | `toolCall` | `(event: ToolCallEvent)` | Agent invoked a tool |
159
+ | `disconnected` | `(code, signal)` | Agent process exited |
160
+
161
+ ### SessionRouter
162
+
163
+ Maps senders to ACP sessions based on the configured scope.
164
+
165
+ ```typescript
166
+ constructor(bridge: AcpBridge, defaultCwd: string, scope?: SessionScope, persistPath?: string)
167
+ ```
168
+
169
+ **Routing keys by scope:**
170
+
171
+ | Scope | Key format | Effect |
172
+ | ---------------- | ------------------------- | ----------------------------------------- |
173
+ | `user` (default) | `channel:senderId:chatId` | Each user gets their own session per chat |
174
+ | `thread` | `channel:threadId` | One session per thread |
175
+ | `single` | `channel:__single__` | One shared session for the entire channel |
176
+
177
+ | Method | Description |
178
+ | --------------------------------------------------------- | ----------------------------------------------------------- |
179
+ | `resolve(channelName, senderId, chatId, threadId?, cwd?)` | Get or create a session for the given sender |
180
+ | `removeSession(channelName, senderId, chatId?)` | Remove session(s) — used by `/clear` |
181
+ | `restoreSessions()` | Reload sessions from disk after bridge restart |
182
+ | `clearAll()` | Clear all sessions and delete persist file (clean shutdown) |
183
+
184
+ ### SenderGate
185
+
186
+ ```typescript
187
+ constructor(policy: SenderPolicy, allowedUsers?: string[], pairingStore?: PairingStore)
188
+ ```
189
+
190
+ | Method | Description |
191
+ | ------------------------------ | ------------------------------------------------------------ |
192
+ | `check(senderId, senderName?)` | Returns `{ allowed: boolean, pairingCode?: string \| null }` |
193
+
194
+ **Policy behavior:**
195
+
196
+ | Policy | Behavior |
197
+ | ----------- | --------------------------------------------------------------------------------------------------------- |
198
+ | `open` | Everyone allowed |
199
+ | `allowlist` | Only `allowedUsers` allowed |
200
+ | `pairing` | Check allowlist, then approved pairings, then generate a pairing code (8-char, 1hr expiry, max 3 pending) |
201
+
202
+ ### GroupGate
203
+
204
+ ```typescript
205
+ constructor(policy?: GroupPolicy, groups?: Record<string, GroupConfig>)
206
+ ```
207
+
208
+ | Method | Description |
209
+ | ----------------- | ---------------------------------------------------------------------------------------------- |
210
+ | `check(envelope)` | Returns `{ allowed: boolean, reason?: 'disabled' \| 'not_allowlisted' \| 'mention_required' }` |
211
+
212
+ **Policy behavior:**
213
+
214
+ | Policy | Behavior |
215
+ | ----------- | ---------------------------------------- |
216
+ | `disabled` | All group messages rejected |
217
+ | `allowlist` | Only groups listed in config are allowed |
218
+ | `open` | All groups allowed |
219
+
220
+ When `requireMention` is `true` (default), group messages are only processed if the bot is @mentioned or the message is a reply to the bot.
221
+
222
+ ### PairingStore
223
+
224
+ ```typescript
225
+ constructor(channelName: string)
226
+ ```
227
+
228
+ Persists pairing state to `~/.matilda/channels/{channelName}-pairing.json` and `{channelName}-allowlist.json`.
229
+
230
+ | Method | Description |
231
+ | ------------------------------------- | --------------------------------------------------------------------------------------------------------- |
232
+ | `createRequest(senderId, senderName)` | Generate an 8-char pairing code (or return existing). Returns `null` if 3 pending requests already exist. |
233
+ | `approve(code)` | Approve a pairing request, adds sender to allowlist. Returns the request or `null`. |
234
+ | `isApproved(senderId)` | Check if sender is in the approved allowlist |
235
+ | `listPending()` | Get active (non-expired) pending requests |
236
+
237
+ ## Envelope
238
+
239
+ The normalized message format your adapter must construct:
240
+
241
+ ```typescript
242
+ interface Envelope {
243
+ channelName: string; // your channel instance name
244
+ senderId: string; // stable, unique sender ID
245
+ senderName: string; // display name
246
+ chatId: string; // distinguishes DMs from groups
247
+ text: string; // message text (@mentions stripped)
248
+ messageId?: string; // platform message ID
249
+ threadId?: string; // for thread-scoped sessions
250
+ isGroup: boolean; // true for group chats
251
+ isMentioned: boolean; // true if bot was @mentioned
252
+ isReplyToBot: boolean; // true if replying to bot's message
253
+ referencedText?: string; // quoted message text
254
+ imageBase64?: string; // base64-encoded image (legacy — prefer attachments)
255
+ imageMimeType?: string; // e.g. 'image/jpeg' (legacy — prefer attachments)
256
+ attachments?: Attachment[]; // structured file/image/audio/video attachments
257
+ }
258
+
259
+ interface Attachment {
260
+ type: 'image' | 'file' | 'audio' | 'video';
261
+ data?: string; // base64-encoded data (images, small files)
262
+ filePath?: string; // absolute path to local file (large files)
263
+ mimeType: string; // e.g. 'application/pdf', 'image/jpeg'
264
+ fileName?: string; // original file name from the platform
265
+ }
266
+ ```
267
+
268
+ `handleInbound()` automatically resolves attachments: images with `data` are sent to the model as vision input, files with `filePath` get their path appended to the prompt text so the agent can read them with its tools.
269
+
270
+ ## Block Streaming
271
+
272
+ When `blockStreaming: "on"` is set in a channel's config, the agent's response is delivered as multiple separate messages instead of one large wall of text. The `BlockStreamer` accumulates streaming chunks and emits completed blocks based on paragraph boundaries and size heuristics.
273
+
274
+ **Config fields** (on `ChannelConfig`):
275
+
276
+ | Field | Type | Default | Description |
277
+ | ------------------------ | ------------------------ | --------------- | --------------------------------------------------------------------------- |
278
+ | `blockStreaming` | `'on' \| 'off'` | `'off'` | Enable/disable block streaming |
279
+ | `blockStreamingChunk` | `{ minChars, maxChars }` | `{ 400, 1000 }` | `minChars`: don't emit until this size. `maxChars`: force-emit at this size |
280
+ | `blockStreamingCoalesce` | `{ idleMs }` | `{ 1500 }` | Emit buffered text after this many ms of silence from the agent |
281
+
282
+ **How it works:**
283
+
284
+ 1. Text accumulates as the agent streams its response
285
+ 2. When the buffer reaches `minChars` and hits a paragraph break (`\n\n`), that block is sent as a separate message
286
+ 3. If the buffer reaches `maxChars` without a paragraph break, it force-splits at the best break point (newline > space)
287
+ 4. If the agent goes quiet for `idleMs`, the buffer is flushed (as long as it's past `minChars`)
288
+ 5. When the agent finishes, any remaining text is sent immediately regardless of `minChars`
289
+
290
+ Block streaming and `onResponseChunk` work independently — plugins can override `onResponseChunk` for their own purposes while block streaming handles delivery.
291
+
292
+ ## Further reading
293
+
294
+ - [Channel Plugin Developer Guide](../../docs/developers/channel-plugins.md)
295
+ - [`@matilda-code/channel-plugin-example`](../plugin-example/) — working reference implementation
@@ -0,0 +1,41 @@
1
+ import { EventEmitter } from 'node:events';
2
+ export interface AcpBridgeOptions {
3
+ cliEntryPath: string;
4
+ cwd: string;
5
+ model?: string;
6
+ }
7
+ export interface AvailableCommand {
8
+ name: string;
9
+ description: string;
10
+ input?: {
11
+ hint: string;
12
+ } | null;
13
+ }
14
+ export interface ToolCallEvent {
15
+ sessionId: string;
16
+ toolCallId: string;
17
+ kind: string;
18
+ title: string;
19
+ status: string;
20
+ rawInput?: Record<string, unknown>;
21
+ }
22
+ export declare class AcpBridge extends EventEmitter {
23
+ private child;
24
+ private connection;
25
+ private options;
26
+ private _availableCommands;
27
+ constructor(options: AcpBridgeOptions);
28
+ get availableCommands(): AvailableCommand[];
29
+ start(): Promise<void>;
30
+ newSession(cwd: string): Promise<string>;
31
+ loadSession(sessionId: string, cwd: string): Promise<string>;
32
+ prompt(sessionId: string, text: string, options?: {
33
+ imageBase64?: string;
34
+ imageMimeType?: string;
35
+ }): Promise<string>;
36
+ cancelSession(sessionId: string): Promise<void>;
37
+ stop(): void;
38
+ get isConnected(): boolean;
39
+ private handleSessionUpdate;
40
+ private ensureConnection;
41
+ }
@@ -0,0 +1,174 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { Readable, Writable } from 'node:stream';
3
+ import { EventEmitter } from 'node:events';
4
+ import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION, } from '@agentclientprotocol/sdk';
5
+ export class AcpBridge extends EventEmitter {
6
+ child = null;
7
+ connection = null;
8
+ options;
9
+ _availableCommands = [];
10
+ constructor(options) {
11
+ super();
12
+ this.options = options;
13
+ }
14
+ get availableCommands() {
15
+ return this._availableCommands;
16
+ }
17
+ async start() {
18
+ const { cliEntryPath, cwd } = this.options;
19
+ const args = [
20
+ ...process.execArgv.filter((a) => !/^--inspect(-brk)?($|=)/.test(a)),
21
+ cliEntryPath,
22
+ '--acp',
23
+ ];
24
+ if (this.options.model) {
25
+ args.push('--model', this.options.model);
26
+ }
27
+ this.child = spawn(process.execPath, args, {
28
+ cwd,
29
+ stdio: ['pipe', 'pipe', 'pipe'],
30
+ env: { ...process.env },
31
+ shell: false,
32
+ });
33
+ this.child.stderr?.on('data', (data) => {
34
+ const msg = data.toString().trim();
35
+ if (msg) {
36
+ process.stderr.write(`[AcpBridge] ${msg}\n`);
37
+ }
38
+ });
39
+ this.child.on('exit', (code, signal) => {
40
+ process.stderr.write(`[AcpBridge] Process exited (code=${code}, signal=${signal})\n`);
41
+ this.connection = null;
42
+ this.child = null;
43
+ this.emit('disconnected', code, signal);
44
+ });
45
+ // Give the process a moment to start
46
+ await new Promise((resolve) => setTimeout(resolve, 1000));
47
+ if (!this.child || this.child.killed) {
48
+ throw new Error('ACP process failed to start');
49
+ }
50
+ const stdout = Readable.toWeb(this.child.stdout);
51
+ const stdin = Writable.toWeb(this.child.stdin);
52
+ const stream = ndJsonStream(stdin, stdout);
53
+ this.connection = new ClientSideConnection(() => ({
54
+ sessionUpdate: (params) => {
55
+ this.handleSessionUpdate(params);
56
+ return Promise.resolve();
57
+ },
58
+ requestPermission: async (params) => {
59
+ // Auto-approve for now; Phase 5 will add interactive approval
60
+ const options = Array.isArray(params.options) ? params.options : [];
61
+ const optionId = options.find((o) => o.optionId === 'proceed_once')?.optionId ||
62
+ options[0]?.optionId ||
63
+ 'proceed_once';
64
+ return { outcome: { outcome: 'selected', optionId } };
65
+ },
66
+ extNotification: async () => { },
67
+ }), stream);
68
+ await this.connection.initialize({
69
+ protocolVersion: PROTOCOL_VERSION,
70
+ clientCapabilities: {},
71
+ });
72
+ }
73
+ async newSession(cwd) {
74
+ const conn = this.ensureConnection();
75
+ const response = await conn.newSession({ cwd, mcpServers: [] });
76
+ return response.sessionId;
77
+ }
78
+ async loadSession(sessionId, cwd) {
79
+ const conn = this.ensureConnection();
80
+ const response = await conn.loadSession({
81
+ sessionId,
82
+ cwd,
83
+ mcpServers: [],
84
+ });
85
+ return response.sessionId;
86
+ }
87
+ async prompt(sessionId, text, options) {
88
+ const conn = this.ensureConnection();
89
+ const chunks = [];
90
+ const onChunk = (sid, chunk) => {
91
+ if (sid === sessionId)
92
+ chunks.push(chunk);
93
+ };
94
+ this.on('textChunk', onChunk);
95
+ const prompt = [];
96
+ if (options?.imageBase64 && options.imageMimeType) {
97
+ prompt.push({
98
+ type: 'image',
99
+ data: options.imageBase64,
100
+ mimeType: options.imageMimeType,
101
+ });
102
+ }
103
+ prompt.push({ type: 'text', text });
104
+ try {
105
+ await conn.prompt({
106
+ sessionId,
107
+ prompt: prompt,
108
+ });
109
+ }
110
+ finally {
111
+ this.off('textChunk', onChunk);
112
+ }
113
+ return chunks.join('');
114
+ }
115
+ async cancelSession(sessionId) {
116
+ const conn = this.ensureConnection();
117
+ await conn.cancel({ sessionId });
118
+ }
119
+ stop() {
120
+ if (this.child) {
121
+ this.child.kill();
122
+ this.child = null;
123
+ }
124
+ this.connection = null;
125
+ }
126
+ get isConnected() {
127
+ return (this.child !== null && !this.child.killed && this.child.exitCode === null);
128
+ }
129
+ handleSessionUpdate(params) {
130
+ const { sessionId } = params;
131
+ const update = params['update'];
132
+ if (!update)
133
+ return;
134
+ const type = update['sessionUpdate'];
135
+ switch (type) {
136
+ case 'agent_message_chunk': {
137
+ const content = update['content'];
138
+ if (content?.type === 'text' && content.text) {
139
+ this.emit('textChunk', sessionId, content.text);
140
+ }
141
+ break;
142
+ }
143
+ case 'tool_call': {
144
+ const event = {
145
+ sessionId,
146
+ toolCallId: update['toolCallId'],
147
+ kind: update['kind'] || '',
148
+ title: update['title'] || '',
149
+ status: update['status'] || 'pending',
150
+ rawInput: update['rawInput'],
151
+ };
152
+ this.emit('toolCall', event);
153
+ break;
154
+ }
155
+ case 'available_commands_update': {
156
+ if (Array.isArray(update['availableCommands'])) {
157
+ this._availableCommands = update['availableCommands'];
158
+ }
159
+ break;
160
+ }
161
+ default:
162
+ // Ignore other session update types
163
+ break;
164
+ }
165
+ this.emit('sessionUpdate', params);
166
+ }
167
+ ensureConnection() {
168
+ if (!this.connection || !this.isConnected) {
169
+ throw new Error('Not connected to ACP agent');
170
+ }
171
+ return this.connection;
172
+ }
173
+ }
174
+ //# sourceMappingURL=AcpBridge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AcpBridge.js","sourceRoot":"","sources":["../src/AcpBridge.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * BlockStreamer — progressive multi-message delivery for channels.
3
+ *
4
+ * Accumulates text chunks from the agent's streaming response and emits
5
+ * completed "blocks" (paragraphs / sections) as separate channel messages
6
+ * while the agent is still working. This gives users a natural conversation
7
+ * flow instead of waiting 30–120 seconds for a single wall of text.
8
+ *
9
+ * Emission triggers:
10
+ * 1. Buffer ≥ maxChars → force-split at best break point
11
+ * 2. Buffer ≥ minChars AND a paragraph boundary (\n\n) exists → emit up to boundary
12
+ * 3. Idle timer fires (no chunk for idleMs) AND buffer ≥ minChars → emit buffer
13
+ * 4. flush() called (response complete) → emit everything remaining
14
+ *
15
+ * All sends are serialized — the next block waits for the previous send to complete.
16
+ */
17
+ export interface BlockStreamerOptions {
18
+ /** Minimum characters before emitting a block. Default: 400. */
19
+ minChars: number;
20
+ /** Force-emit when buffer exceeds this size. Default: 1000. */
21
+ maxChars: number;
22
+ /** Emit buffered text after this many ms of inactivity. Default: 1500. */
23
+ idleMs: number;
24
+ /** Callback to deliver a completed block. Called with trimmed text. */
25
+ send: (text: string) => Promise<void>;
26
+ }
27
+ export declare class BlockStreamer {
28
+ private buffer;
29
+ private idleTimer;
30
+ private sending;
31
+ private opts;
32
+ /** Number of blocks emitted so far. */
33
+ blockCount: number;
34
+ constructor(opts: BlockStreamerOptions);
35
+ /** Feed a new text chunk from the agent stream. */
36
+ push(chunk: string): void;
37
+ /** Flush all remaining buffered text. Awaits all pending sends. */
38
+ flush(): Promise<void>;
39
+ private checkEmit;
40
+ private onIdle;
41
+ private emitBlock;
42
+ /**
43
+ * Find the last paragraph boundary (\n\n) in the buffer.
44
+ * Returns the position after the boundary, or -1 if no suitable boundary
45
+ * exists at or after minChars.
46
+ */
47
+ private findBlockBoundary;
48
+ /**
49
+ * Find the best break point at or before maxPos.
50
+ * Prefers paragraph break > newline > space > maxPos.
51
+ */
52
+ private findBreakPoint;
53
+ private clearIdleTimer;
54
+ }