@bahulam/code 0.1.21 → 0.1.22

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.
@@ -22,7 +22,8 @@ import * as path from 'node:path';
22
22
  import { pathToFileURL } from 'node:url';
23
23
  import { parsePluginManifestFile, validatePluginManifest } from './manifest.mjs';
24
24
  import { composedToolName, validateCompose } from './pi-compose.mjs';
25
- import { bahulamHome } from '../core/paths.mjs';
25
+ import { expandStateContextTools } from './state-tools.mjs';
26
+ import { pluginDirs } from '../core/paths.mjs';
26
27
 
27
28
  const TOOL_NAME_RE = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
28
29
  const AGENT_SLUG_RE = /^[a-z][a-z0-9-]{0,63}$/;
@@ -182,6 +183,25 @@ export async function preflightPlugin(pluginDir, opts = {}) {
182
183
  }
183
184
  }
184
185
 
186
+ // 4b. Generated state tools. A plugin that declares config.state.context_tools
187
+ // gets read-only tools synthesized at load time (state-tools.mjs), so an
188
+ // agent may legitimately allowlist them. Without this, a plugin referencing
189
+ // its own generated tool fails preflight as "not defined by this plugin".
190
+ const stateToolNames = new Set();
191
+ for (const generated of expandStateContextTools(name, pluginDir, manifest.config?.state)) {
192
+ const gname = generated.name;
193
+ if (!gname) continue;
194
+ if (toolNames.has(gname) || composedToolNames.has(gname)) {
195
+ errors.push(`Generated state tool "${gname}" collides with an existing tool of the same name`);
196
+ continue;
197
+ }
198
+ if (RESERVED_TOOL_NAMES.has(gname)) {
199
+ errors.push(`Generated state tool "${gname}" shadows a built-in tool — rename it in config.state.context_tools`);
200
+ continue;
201
+ }
202
+ stateToolNames.add(gname);
203
+ }
204
+
185
205
  // 5. Sub-agent checks
186
206
  const agentSlugs = new Set();
187
207
  for (const [i, agent] of agents.entries()) {
@@ -207,7 +227,8 @@ export async function preflightPlugin(pluginDir, opts = {}) {
207
227
  }
208
228
  continue;
209
229
  }
210
- if (!toolNames.has(toolRef) && !composedToolNames.has(toolRef) && !RESERVED_TOOL_NAMES.has(toolRef)) {
230
+ if (!toolNames.has(toolRef) && !stateToolNames.has(toolRef)
231
+ && !composedToolNames.has(toolRef) && !RESERVED_TOOL_NAMES.has(toolRef)) {
211
232
  errors.push(`Agent "${slug}": tool "${toolRef}" is not defined by this plugin and is not a built-in`);
212
233
  }
213
234
  }
@@ -245,12 +266,12 @@ export async function preflightPlugin(pluginDir, opts = {}) {
245
266
  * Convenience — collect installed plugin names from both search paths.
246
267
  * Used by the installer to detect collisions.
247
268
  */
248
- export function existingInstalledNames(cwd = process.cwd()) {
269
+ export function existingInstalledNames(_cwd = process.cwd()) {
249
270
  const names = [];
250
- for (const dir of [
251
- path.join(cwd, '.bahulam', 'plugins'),
252
- path.join(bahulamHome(), 'plugins'),
253
- ]) {
271
+ // Plugins live in one global root. `cwd` is kept only so existing call
272
+ // sites keep working; it must never influence discovery, since that
273
+ // cwd-dependence is the thing this removed.
274
+ for (const dir of pluginDirs()) {
254
275
  if (!fs.existsSync(dir)) continue;
255
276
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
256
277
  if (!entry.isDirectory() || entry.name.endsWith('.disabled')) continue;
@@ -9,24 +9,20 @@ import fs from 'fs';
9
9
  import path from 'path';
10
10
  import { parsePluginManifestFile, validatePluginManifest } from './manifest.mjs';
11
11
  import { expandComposedTools } from './pi-compose.mjs';
12
- import { bahulamHome } from '../core/paths.mjs';
13
-
14
- const DEFAULT_PLUGIN_DIRS = () => [
15
- path.join(process.cwd(), '.bahulam', 'plugins'),
16
- path.join(bahulamHome(), 'plugins'),
17
- ];
12
+ import { expandStateContextTools } from './state-tools.mjs';
13
+ import { pluginDirs as defaultPluginDirs } from '../core/paths.mjs';
18
14
 
19
15
  export class PluginRegistry {
20
16
  /**
21
17
  * @param {Object} [options]
22
- * @param {string[]} [options.pluginDirs] - Directories to scan (default: project .bahulam/plugins + ~/.bahulam/plugins)
18
+ * @param {string[]} [options.pluginDirs] - Directories to scan (default: ~/.bahulam/plugins)
23
19
  * @param {string[]} [options.disabled] - Plugin names to skip
24
20
  * @param {string[]} [options.enabled] - If provided, only these plugin names are loaded
25
21
  * @param {string[]} [options.active] - Alias for enabled
26
22
  * @param {string} [options.pluginDir] - Legacy single plugin dir (mapped to pluginDirs[0])
27
23
  */
28
24
  constructor({ pluginDirs, disabled = [], enabled = null, active = null, pluginDir } = {}) {
29
- this.pluginDirs = pluginDirs || (pluginDir ? [pluginDir] : DEFAULT_PLUGIN_DIRS());
25
+ this.pluginDirs = pluginDirs || (pluginDir ? [pluginDir] : defaultPluginDirs());
30
26
  this.disabled = new Set(
31
27
  (Array.isArray(disabled) ? disabled : [])
32
28
  .map(s => String(s).trim().toLowerCase())
@@ -113,7 +109,7 @@ export class PluginRegistry {
113
109
  return false;
114
110
  }
115
111
 
116
- // Check for existing (first wins project overrides global)
112
+ // Duplicates are skipped the first manifest scanned wins.
117
113
  if (this.plugins.has(lowerName)) {
118
114
  return false; // silently skip duplicates
119
115
  }
@@ -165,6 +161,13 @@ export class PluginRegistry {
165
161
  plugin._dir,
166
162
  plugin.config?.composes || [],
167
163
  ));
164
+ // Manifest-declared state query tools. Synthesized rather than
165
+ // imported, so they carry `_state_tool` instead of a module path.
166
+ tools.push(...expandStateContextTools(
167
+ plugin.metadata?.name || '',
168
+ plugin._dir,
169
+ plugin.config?.state,
170
+ ));
168
171
  }
169
172
  return tools;
170
173
  }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Declared-state tool expansion.
3
+ *
4
+ * A plugin that declares tables in `config.state.tables` can ask the CLI
5
+ * to generate read-only query tools for the agent, instead of hand-writing
6
+ * a `tools/*.mjs` handler for every "let me look at my data" case. The
7
+ * manifest block looks like:
8
+ *
9
+ * state:
10
+ * tables:
11
+ * - name: questions
12
+ * columns: [...]
13
+ * context_tools:
14
+ * - name: list_questions
15
+ * table: questions
16
+ * description: List exam questions, optionally filtered by topic
17
+ * parameters:
18
+ * type: object
19
+ * properties:
20
+ * topic: { type: string }
21
+ * where: "topic = ?"
22
+ * params: [topic]
23
+ *
24
+ * `expandStateContextTools` turns that into registry tool entries. The
25
+ * execution side (tool-executor) recognizes the `_state_tool` marker and
26
+ * runs the query against the plugin's own state DB — so the same tool
27
+ * flows through every existing surface: `listPluginToolSchemas()`,
28
+ * the client_tools map, sub-agent tool allowlists, and `/tools`.
29
+ *
30
+ * This mirrors `pi-compose.mjs`: one expansion per composition kind, both
31
+ * feeding the same `PluginRegistry.listTools()` contract.
32
+ */
33
+
34
+ /** Tool names are passed to the model, so keep them in the same charset the rest of the CLI uses. */
35
+ function sanitizeToolName(value) {
36
+ return String(value || '').trim().replace(/[^A-Za-z0-9_]/g, '_');
37
+ }
38
+
39
+ /**
40
+ * Expand a plugin's declared `config.state.context_tools` into tool entries.
41
+ *
42
+ * Entries carry no `tool:` module path — they are synthesized, and
43
+ * `_state_tool` tells the executor to query the plugin's state DB rather
44
+ * than import a handler file.
45
+ *
46
+ * @param {string} pluginName
47
+ * @param {string} pluginDir
48
+ * @param {object|null|undefined} state normalized `config.state`
49
+ * @returns {object[]}
50
+ */
51
+ export function expandStateContextTools(pluginName, pluginDir, state) {
52
+ const tools = [];
53
+ const declared = Array.isArray(state?.context_tools) ? state.context_tools : [];
54
+ if (!pluginName || declared.length === 0) return tools;
55
+
56
+ const tableNames = new Set((state?.tables || []).map(t => t.name));
57
+
58
+ for (const tool of declared) {
59
+ const name = sanitizeToolName(tool?.name);
60
+ if (!name) continue;
61
+ // A generated tool must never reach past the plugin's own declared
62
+ // tables — that is the whole safety story for author-supplied `where`.
63
+ if (!tableNames.has(tool.table)) continue;
64
+
65
+ tools.push({
66
+ name,
67
+ description: tool.description || `List rows from ${tool.table}`,
68
+ input_schema: tool.parameters || { type: 'object', properties: {} },
69
+ // No module to load — signals "synthesized" to any consumer that
70
+ // would otherwise try to resolve a handler file.
71
+ tool: null,
72
+ plugin_name: pluginName,
73
+ _plugin_name: pluginName,
74
+ _plugin_dir: pluginDir,
75
+ _state_tool: {
76
+ plugin: pluginName,
77
+ table: tool.table,
78
+ where: tool.where || '',
79
+ params: Array.isArray(tool.params) ? tool.params : [],
80
+ limit: tool.limit || 50,
81
+ },
82
+ });
83
+ }
84
+
85
+ return tools;
86
+ }
@@ -206,17 +206,18 @@ function pluginDataDir(pluginName) {
206
206
  return dir;
207
207
  }
208
208
 
209
- function openDb(pluginName) {
210
- if (_handles.has(pluginName)) return _handles.get(pluginName);
211
- const dir = pluginDataDir(pluginName);
212
- const dbPath = path.join(dir, 'state.db');
213
- const db = DatabaseSync ? new DatabaseSync(dbPath) : new JsonStateDb(dbPath);
214
- // WAL: multiple readers, one writer; robust against concurrent view+agent.
215
- db.exec('PRAGMA journal_mode = WAL');
216
- db.exec('PRAGMA synchronous = NORMAL');
217
- db.exec('PRAGMA foreign_keys = ON');
218
- // Bootstrap schema — idempotent so evolving plugins never crash on start.
219
- db.exec(`
209
+ function openDb(pluginName, { tables = [] } = {}) {
210
+ let handle = _handles.get(pluginName);
211
+ if (!handle) {
212
+ const dir = pluginDataDir(pluginName);
213
+ const dbPath = path.join(dir, 'state.db');
214
+ const db = DatabaseSync ? new DatabaseSync(dbPath) : new JsonStateDb(dbPath);
215
+ // WAL: multiple readers, one writer; robust against concurrent view+agent.
216
+ db.exec('PRAGMA journal_mode = WAL');
217
+ db.exec('PRAGMA synchronous = NORMAL');
218
+ db.exec('PRAGMA foreign_keys = ON');
219
+ // Bootstrap schema — idempotent so evolving plugins never crash on start.
220
+ db.exec(`
220
221
  CREATE TABLE IF NOT EXISTS kv (
221
222
  key TEXT PRIMARY KEY,
222
223
  value TEXT NOT NULL,
@@ -231,11 +232,99 @@ function openDb(pluginName) {
231
232
  CREATE INDEX IF NOT EXISTS records_stream_idx
232
233
  ON records(stream, id DESC);
233
234
  `);
234
- const handle = { db, dir, path: dbPath };
235
- _handles.set(pluginName, handle);
235
+ handle = { db, dir, path: dbPath, schemaSig: null, declaredTables: new Set() };
236
+ _handles.set(pluginName, handle);
237
+ }
238
+ // Declared tables are applied on every open call, not just the first —
239
+ // a plugin installed mid-session (or a version bump that adds columns)
240
+ // must still converge the DB. The signature check makes the repeat
241
+ // calls free once the schema already matches.
242
+ applyDeclaredSchema(handle, tables);
236
243
  return handle;
237
244
  }
238
245
 
246
+ function quoteIdent(name) {
247
+ return `"${String(name).replace(/"/g, '""')}"`;
248
+ }
249
+
250
+ /** Render one normalized column as the DDL fragment it contributes. */
251
+ function columnToSql(col) {
252
+ const parts = [quoteIdent(col.name), col.type];
253
+ if (col.primary) parts.push('PRIMARY KEY');
254
+ if (col.autoincrement) parts.push('AUTOINCREMENT');
255
+ if (col.not_null) parts.push('NOT NULL');
256
+ // `default` is author-supplied SQL text (e.g. "'medium'", "0"), which is
257
+ // why normalizeState stringifies it and why it is never parameterized.
258
+ if (col.default != null) parts.push(`DEFAULT ${col.default}`);
259
+ if (col.references) parts.push(`REFERENCES ${col.references}`);
260
+ return parts.join(' ');
261
+ }
262
+
263
+ /**
264
+ * Create/evolve the tables a plugin declared in `config.state.tables`.
265
+ *
266
+ * Additive by contract: a table that exists is left alone apart from
267
+ * genuinely new columns, which are appended with ALTER TABLE. Nothing is
268
+ * ever dropped or retyped, so bumping a plugin version can't cost the
269
+ * user their data. Re-running with an unchanged declaration is a no-op.
270
+ *
271
+ * Silently does nothing on the JSON fallback backend, where arbitrary SQL
272
+ * isn't available — plugins that need declared tables need node:sqlite.
273
+ */
274
+ function applyDeclaredSchema(handle, tables) {
275
+ if (!DatabaseSync) return;
276
+ if (!Array.isArray(tables) || tables.length === 0) {
277
+ handle.declaredTables = new Set();
278
+ handle.schemaSig = null;
279
+ return;
280
+ }
281
+ const sig = JSON.stringify(tables);
282
+ if (handle.schemaSig === sig) return;
283
+
284
+ const { db } = handle;
285
+ for (const table of tables) {
286
+ let existing = new Set();
287
+ try {
288
+ existing = new Set(db.prepare(`PRAGMA table_info(${quoteIdent(table.name)})`).all().map(r => String(r.name)));
289
+ } catch { /* table doesn't exist yet — created below */ }
290
+
291
+ try {
292
+ if (existing.size === 0) {
293
+ const defs = table.columns.map(columnToSql).join(', ');
294
+ db.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)} (${defs});`);
295
+ } else {
296
+ for (const col of table.columns) {
297
+ if (existing.has(col.name)) continue;
298
+ // SQLite refuses PRIMARY KEY / NOT NULL-without-default on ALTER
299
+ // TABLE ADD COLUMN. Strip those rather than fail the migration.
300
+ const addable = {
301
+ ...col,
302
+ primary: false,
303
+ autoincrement: false,
304
+ not_null: col.not_null && col.default != null,
305
+ };
306
+ db.exec(`ALTER TABLE ${quoteIdent(table.name)} ADD COLUMN ${columnToSql(addable)};`);
307
+ }
308
+ }
309
+ for (const index of (table.indexes || [])) {
310
+ const cols = index.columns.map(quoteIdent).join(', ');
311
+ const idxName = `${table.name}_${index.columns.join('_')}_idx`;
312
+ db.exec(
313
+ `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX IF NOT EXISTS ${quoteIdent(idxName)} `
314
+ + `ON ${quoteIdent(table.name)} (${cols});`,
315
+ );
316
+ }
317
+ } catch (err) {
318
+ if (process.env.DEBUG) {
319
+ console.error(`Failed to apply declared schema for table ${table.name}: ${err.message}`);
320
+ }
321
+ }
322
+ }
323
+
324
+ handle.declaredTables = new Set(tables.map(t => t.name));
325
+ handle.schemaSig = sig;
326
+ }
327
+
239
328
  function now() { return new Date().toISOString(); }
240
329
 
241
330
  function deepMerge(base, patch) {
@@ -261,10 +350,15 @@ function deepMerge(base, patch) {
261
350
  * @param {(evt: {plugin: string, op: string, kind: 'kv'|'records', target: string, at: string}) => void} [opts.emit]
262
351
  * Called (debounced) after every write commits. The workspace server
263
352
  * turns this into an SSE `plugin_state_changed` event for the browser.
264
- * @returns proxy with { get, set, patch, append, list, query, delete, close, db, path }
353
+ * @param {object[]} [opts.tables]
354
+ * Tables declared in the plugin manifest's `config.state.tables`. They
355
+ * are created (and additively migrated) when the DB opens, so a plugin
356
+ * can own real domain tables — questions, documents, answers — without
357
+ * shipping its own migration logic.
358
+ * @returns proxy with { get, set, patch, append, list, query, delete, summary, readTable, close, db, path }
265
359
  */
266
- export function makePluginState(pluginName, { emit = null } = {}) {
267
- const { db, path: dbPath } = openDb(pluginName);
360
+ export function makePluginState(pluginName, { emit = null, tables = [] } = {}) {
361
+ const { db, path: dbPath, declaredTables } = openDb(pluginName, { tables });
268
362
 
269
363
  // One debounce timer per (kind, target). Fast writes coalesce into
270
364
  // exactly one plugin_state_changed event. Pending entry is stored so
@@ -398,6 +492,64 @@ export function makePluginState(pluginName, { emit = null } = {}) {
398
492
  return { changes: info.changes, lastInsertRowid: Number(info.lastInsertRowid) };
399
493
  },
400
494
 
495
+ /**
496
+ * Read the slices a manifest flagged as `context_always` so the agent
497
+ * can orient itself at session start without spending a tool call.
498
+ *
499
+ * Returns `{ kv: { <key>: value }, streams: { <stream>: [rows] } }`.
500
+ * Anything the declaration doesn't name is deliberately omitted —
501
+ * this is the "small and high-signal" tier, and its whole value is
502
+ * that it stays cheap.
503
+ *
504
+ * @param {object[]} decl normalized `config.state.context_always`
505
+ */
506
+ summary(decl = []) {
507
+ const out = { kv: {}, streams: {} };
508
+ for (const item of (Array.isArray(decl) ? decl : [])) {
509
+ if (!item || typeof item !== 'object') continue;
510
+ try {
511
+ if (item.kind === 'records' && item.stream) {
512
+ out.streams[item.stream] = this.list(item.stream, {
513
+ limit: item.limit || 5,
514
+ order: 'desc',
515
+ });
516
+ } else if (item.kind === 'kv' && item.key) {
517
+ out.kv[item.key] = this.get(item.key, null);
518
+ }
519
+ } catch { /* one unreadable slice must not blank the whole summary */ }
520
+ }
521
+ return out;
522
+ },
523
+
524
+ /**
525
+ * Read rows from a table the plugin declared in `config.state.tables`.
526
+ * Backs the auto-generated `context_tools`.
527
+ *
528
+ * Only declared tables are reachable, so a manifest typo can't turn a
529
+ * generated tool into arbitrary table access. `where` is plugin-authored
530
+ * SQL (same trust model as `query()`); values are always bound.
531
+ *
532
+ * @param {string} table
533
+ * @param {object} [opts]
534
+ * @param {string} [opts.where] e.g. "topic = ?"
535
+ * @param {any[]} [opts.params] bound positionally against `where`
536
+ * @param {number} [opts.limit]
537
+ * @returns {object[]}
538
+ */
539
+ readTable(table, { where = '', params = [], limit = 50 } = {}) {
540
+ if (!DatabaseSync) {
541
+ throw new Error('Declared state tables require the native node:sqlite backend');
542
+ }
543
+ const name = String(table || '');
544
+ if (!declaredTables.has(name)) {
545
+ throw new Error(`unknown declared state table: ${name || '(none)'}`);
546
+ }
547
+ const args = Array.isArray(params) ? params : [params];
548
+ const cap = Math.max(1, Math.min(1000, Math.floor(limit) || 50));
549
+ const clause = where ? ` WHERE ${where}` : '';
550
+ return db.prepare(`SELECT * FROM ${quoteIdent(name)}${clause} LIMIT ?`).all(...args, cap);
551
+ },
552
+
401
553
  /** Direct DatabaseSync handle for callers that know what they need. */
402
554
  get db() { return db; },
403
555
  /** Absolute path to the DB file on disk. */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Interactive per-role model form for /model (PRD-076 W7).
3
3
  *
4
- * ↑↓ picks a role row, ←→ cycles through [backend default] + the available
4
+ * ↑↓ picks a role row, ←→ cycles through [backend default] + the curated
5
5
  * platform catalog for that role, Enter applies to session overrides,
6
6
  * c resets every row to default, Esc cancels. Same raw-stdin overlay
7
7
  * pattern as the resume picker (repl-resume.mjs): pause readline, raw
@@ -35,19 +35,19 @@ function formatTokenLimit(value, label) {
35
35
  return `${Math.round(n)} ${label}`;
36
36
  }
37
37
 
38
- export function optionRowsForRole(catalog, row) {
39
- const available = (catalog || []).filter(m => m?.id);
38
+ function optionRowsForRole(catalog, row) {
39
+ const curated = (catalog || []).filter(m => m?.harness_validated && m?.id);
40
40
  const group = String(row?.optionGroup || 'text').toLowerCase();
41
41
  if (group === 'image_analysis') {
42
- return available.filter(m => (
42
+ return curated.filter(m => (
43
43
  ['image', 'multimodal'].includes(modelCategory(m))
44
44
  && !isImageGenerationModel(m)
45
45
  ));
46
46
  }
47
47
  if (group === 'image_generation') {
48
- return available.filter(m => modelCategory(m) === 'image' && isImageGenerationModel(m));
48
+ return curated.filter(m => modelCategory(m) === 'image' && isImageGenerationModel(m));
49
49
  }
50
- return available.filter(m => ['text', 'chat', 'multimodal'].includes(modelCategory(m)));
50
+ return curated.filter(m => ['text', 'chat'].includes(modelCategory(m)));
51
51
  }
52
52
 
53
53
  /**
@@ -63,11 +63,11 @@ export async function pickModelOverridesForm({ rl, roles, catalog, fallbackIds,
63
63
  if (!process.stdin.isTTY) return null;
64
64
  if (rl) rl.pause();
65
65
 
66
- const catalogRows = (catalog || []).filter(m => m?.id);
66
+ const catalogRows = (catalog || []).filter(m => m?.harness_validated && m?.id);
67
67
  const usingFallback = catalogRows.length === 0;
68
68
  const byId = new Map(catalogRows.map(m => [m.id, m]));
69
69
 
70
- // Per-row option list; a current override that isn't in the catalog list
70
+ // Per-row option list; a current override that isn't in the curated list
71
71
  // is appended so it stays visible and selectable.
72
72
  const rows = roles.map(r => {
73
73
  const optionIds = usingFallback
@@ -99,7 +99,9 @@ export async function pickModelOverridesForm({ rl, roles, catalog, fallbackIds,
99
99
  }
100
100
  const meta = byId.get(value);
101
101
  const badge = meta ? creditBadge(meta) : '';
102
- const flag = meta && meta.harness_validated === false ? c.yellow(' (uncurated)') : '';
102
+ // Only flag uncurated picks when a curated catalog actually loaded
103
+ // in fallback mode every option is a known backend model, not a stray.
104
+ const flag = meta || usingFallback ? '' : c.yellow(' (uncurated)');
103
105
  return `${c.brand(value)}${badge ? ` ${c.dim(badge)}` : ''}${flag}`;
104
106
  };
105
107
 
@@ -111,7 +111,13 @@ export function createToolRegistry({
111
111
  if (!pluginName) return null;
112
112
  if (pluginStateHandles.has(pluginName)) return pluginStateHandles.get(pluginName);
113
113
  const { makePluginState } = await import('../plugins/state.mjs');
114
- const state = makePluginState(pluginName, { emit: stateEmit });
114
+ // Pass the plugin's declared tables through, otherwise this path
115
+ // opens the shared handle without them — and `applyDeclaredSchema`
116
+ // treats an empty list as "this plugin declares nothing", which
117
+ // would *un-apply* a schema the executor path had already applied.
118
+ const plugin = pluginRegistry?.get?.(pluginName) || null;
119
+ const tables = plugin?.config?.state?.tables || [];
120
+ const state = makePluginState(pluginName, { emit: stateEmit, tables });
115
121
  pluginStateHandles.set(pluginName, state);
116
122
  return state;
117
123
  }