@bahulam/code 0.1.10 → 0.1.11

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.
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import * as fs from 'node:fs';
9
+ import * as path from 'node:path';
9
10
  import { createRequire } from 'node:module';
10
11
  import { BahulamAuth } from '../auth/bahulam-auth.mjs';
11
12
  import { AgentHistoryTurnBuilder } from '../core/agent-history.mjs';
@@ -23,6 +24,66 @@ import { BrowserApprovalManager } from './approval-bridge.mjs';
23
24
  const __require = createRequire(import.meta.url);
24
25
  const VERSION = __require('../../package.json').version;
25
26
 
27
+ function envList(name) {
28
+ return String(process.env[name] || '')
29
+ .split(',')
30
+ .map(item => item.trim())
31
+ .filter(Boolean);
32
+ }
33
+
34
+ function activePluginNamesFromSession(session = {}) {
35
+ const names = [];
36
+ if (session.plugin?.name) names.push(session.plugin.name);
37
+ if (session.plugin?.metadata_name) names.push(session.plugin.metadata_name);
38
+ const plugins = Array.isArray(session.plugins)
39
+ ? session.plugins
40
+ : (Array.isArray(session.active_plugins) ? session.active_plugins : []);
41
+ for (const plugin of plugins) {
42
+ if (typeof plugin === 'string') names.push(plugin);
43
+ else if (plugin?.name) names.push(plugin.name);
44
+ else if (plugin?.metadata_name) names.push(plugin.metadata_name);
45
+ }
46
+ return [...new Set(names.map(name => String(name).trim()).filter(Boolean))];
47
+ }
48
+
49
+ function pluginScanDirsFromSession(session = {}) {
50
+ const dirs = [];
51
+ const addPluginParent = (plugin) => {
52
+ const pluginDir = String(plugin?.plugin_dir || plugin?.dir || '').trim();
53
+ if (pluginDir) dirs.push(path.dirname(pluginDir));
54
+ };
55
+ if (session.plugin) addPluginParent(session.plugin);
56
+ const plugins = Array.isArray(session.plugins)
57
+ ? session.plugins
58
+ : (Array.isArray(session.active_plugins) ? session.active_plugins : []);
59
+ for (const plugin of plugins) {
60
+ if (plugin && typeof plugin === 'object') addPluginParent(plugin);
61
+ }
62
+ return [...new Set(dirs.filter(Boolean))];
63
+ }
64
+
65
+ /**
66
+ * ${VAR} expansion for MCP server configs — args, env values, headers.
67
+ * Matches how the existing ~/.bahulam/config.json mcpServers block is
68
+ * handled so a plugin author with a working Claude Desktop MCP config
69
+ * gets the same env-var behavior after drop-in.
70
+ */
71
+ function _expandEnvInMcpConfig(config) {
72
+ const sub = (v) => typeof v === 'string'
73
+ ? v.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => process.env[name] ?? '')
74
+ : v;
75
+ const walk = (v) => {
76
+ if (Array.isArray(v)) return v.map(walk);
77
+ if (v && typeof v === 'object') {
78
+ const out = {};
79
+ for (const [k, val] of Object.entries(v)) out[k] = walk(val);
80
+ return out;
81
+ }
82
+ return sub(v);
83
+ };
84
+ return walk(config);
85
+ }
86
+
26
87
  export class LocalAgentRelay {
27
88
  constructor({ session, emit } = {}) {
28
89
  if (!session?.id) throw new Error('LocalAgentRelay requires a session');
@@ -69,17 +130,16 @@ export class LocalAgentRelay {
69
130
  err.code = 'CONFLICT';
70
131
  throw err;
71
132
  }
72
- if (this.turnCount > 0) {
73
- const err = new Error('Start a new local workspace session to switch history after a turn has run');
74
- err.code = 'CONFLICT';
75
- throw err;
76
- }
77
133
  this.resumeLoaded = true;
78
134
  this.resumeSessionId = null;
135
+ this.turnCount = 0;
79
136
  this.displayHistory = [];
80
137
  this.agentHistory = [];
81
138
  this.jsonlWriter = null;
82
- if (this.client) this.client.sessionId = null;
139
+ if (this.client) {
140
+ this.client.sessionId = null;
141
+ this.client.currentTaskId = null;
142
+ }
83
143
  this.emit('agent_history_new', {
84
144
  root_path: this.session.root_path,
85
145
  });
@@ -104,11 +164,6 @@ export class LocalAgentRelay {
104
164
  err.code = 'CONFLICT';
105
165
  throw err;
106
166
  }
107
- if (this.turnCount > 0 && this.resumeSessionId !== requestedSessionId) {
108
- const err = new Error('Start a new local workspace session to switch history after a turn has run');
109
- err.code = 'CONFLICT';
110
- throw err;
111
- }
112
167
  if (this.resumeLoaded && this.resumeSessionId === requestedSessionId) return this._historySnapshot();
113
168
 
114
169
  try {
@@ -131,6 +186,7 @@ export class LocalAgentRelay {
131
186
  this.displayHistory = history.displayHistory || [];
132
187
  this.agentHistory = history.agentHistory || [];
133
188
  this.resumeLoaded = true;
189
+ this.turnCount = 0;
134
190
  if (this.client) this.client.sessionId = this.resumeSessionId;
135
191
  if (this.jsonlWriter?.sessionId && this.jsonlWriter.sessionId !== this.resumeSessionId) {
136
192
  this.jsonlWriter = null;
@@ -344,6 +400,14 @@ export class LocalAgentRelay {
344
400
 
345
401
  async close() {
346
402
  this.approvalManager?.rejectAll?.();
403
+ // Tear down plugin-owned MCP clients — the executor's registered
404
+ // <server>.<tool> entries are removed here so a rerun in the same
405
+ // process picks up any manifest edits cleanly.
406
+ for (const { plugin, name, client } of (this._mcpClients || [])) {
407
+ try { this.toolExecutor?.unregisterMcpServer?.(plugin, name); } catch { /* ignore */ }
408
+ try { await client.disconnect(); } catch { /* ignore */ }
409
+ }
410
+ this._mcpClients = [];
347
411
  try {
348
412
  await this.jsonlWriter?.close?.();
349
413
  } catch {}
@@ -501,6 +565,14 @@ export class LocalAgentRelay {
501
565
  return payload;
502
566
  }
503
567
 
568
+ async executeTool(name, args = {}) {
569
+ await this._ensureReady();
570
+ if (!this.toolExecutor) {
571
+ throw new Error('Tool executor is not initialized');
572
+ }
573
+ return this.toolExecutor.execute(name, args);
574
+ }
575
+
504
576
  async _ensureReady() {
505
577
  if (this.ready) return this.ready;
506
578
  this.ready = this._initialize().catch((err) => {
@@ -526,10 +598,63 @@ export class LocalAgentRelay {
526
598
  process.chdir(this.session.root_path);
527
599
  }
528
600
 
529
- const toolExecutor = createToolExecutor();
601
+ const { PluginRegistry } = await import('../plugins/registry.mjs');
602
+ const activePlugins = activePluginNamesFromSession(this.session);
603
+ const pluginDirs = pluginScanDirsFromSession(this.session);
604
+ const pluginRegistry = new PluginRegistry({
605
+ disabled: envList('BAHULAM_DISABLE_PLUGINS'),
606
+ enabled: activePlugins,
607
+ ...(pluginDirs.length ? { pluginDirs } : {}),
608
+ });
609
+ pluginRegistry.scan();
610
+
611
+ // Reactive pulse: every plugin state write bubbles up here and lands
612
+ // on the shared SSE bus as `plugin_state_changed`. Views subscribed
613
+ // to /api/events re-render as the agent works — the Shared
614
+ // Blackboard's live-update moment. `this.emit` is the same event
615
+ // sink server.mjs uses for tool_call / tool_done / agent events.
616
+ const stateEmit = (evt) => {
617
+ try { this.emit('plugin_state_changed', evt); }
618
+ catch { /* never let SSE failure break a tool call */ }
619
+ };
620
+ const toolExecutor = createToolExecutor({ pluginRegistry, stateEmit });
530
621
  await toolExecutor.waitForAutoRegister?.();
531
622
  await toolExecutor.registerProjectRoots?.([this.session.root_path], { forceRefresh: false });
532
623
 
624
+ // Plugin=MCP+UX: every plugin can declare mcpServers in its
625
+ // plugin.yaml (or a sibling mcp.json). Spawn each, discover its
626
+ // tools, and register them as `<server>.<tool>` in the shared
627
+ // executor. Failure of one server never blocks the workspace —
628
+ // its tools drop out, everything else keeps running.
629
+ this._mcpClients = []; // {plugin, name, client} — for teardown on close()
630
+ for (const entry of pluginRegistry.listMcpServers?.() || []) {
631
+ try {
632
+ const { McpClient } = await import('../mcp/client.mjs');
633
+ const client = new McpClient(_expandEnvInMcpConfig(entry.config));
634
+ await client.connect();
635
+ const tools = await client.listTools();
636
+ let registered = 0;
637
+ for (const t of tools) {
638
+ if (toolExecutor.registerMcpTool?.(entry.plugin, entry.name, t.name, client, t.inputSchema)) {
639
+ registered++;
640
+ }
641
+ }
642
+ this._mcpClients.push({ plugin: entry.plugin, name: entry.name, client });
643
+ this.emit('plugin_mcp_ready', {
644
+ plugin: entry.plugin,
645
+ server: entry.name,
646
+ tools: registered,
647
+ });
648
+ } catch (err) {
649
+ // Never fatal: the plugin's JS tools + views still work.
650
+ this.emit('plugin_mcp_failed', {
651
+ plugin: entry.plugin,
652
+ server: entry.name,
653
+ error: String(err.message || err),
654
+ });
655
+ }
656
+ }
657
+
533
658
  const approval = new BrowserApprovalManager({
534
659
  emit: this.emit,
535
660
  cwd: this.session.root_path,
@@ -545,6 +670,7 @@ export class LocalAgentRelay {
545
670
  toolExecutor,
546
671
  approvalManager: approval,
547
672
  mode: 'remote',
673
+ pluginRegistry,
548
674
  });
549
675
  if (this.resumeSessionId) this.client.sessionId = this.resumeSessionId;
550
676
  this._ensureJsonlWriter();
@@ -553,6 +679,7 @@ export class LocalAgentRelay {
553
679
  backend_url: creds.backendUrl,
554
680
  root_path: this.session.root_path,
555
681
  tools: toolExecutor.listTools?.().length || 0,
682
+ plugins: pluginRegistry.list?.().map(plugin => plugin.metadata?.name).filter(Boolean) || [],
556
683
  backend_session_id: this.client.sessionId || null,
557
684
  approval_mode: this.approvalAutoMode ? 'auto' : 'ask',
558
685
  });
@@ -8,11 +8,13 @@
8
8
  import * as crypto from 'node:crypto';
9
9
  import * as fs from 'node:fs';
10
10
  import * as http from 'node:http';
11
+ import * as os from 'node:os';
11
12
  import * as path from 'node:path';
12
13
  import { fileURLToPath } from 'node:url';
13
14
  import { BahulamAuth } from '../auth/bahulam-auth.mjs';
14
15
  import { resolveWebUrl } from '../core/backend-url.mjs';
15
16
  import { LocalAgentRelay } from './agent-relay.mjs';
17
+ import { PluginRegistry } from '../plugins/registry.mjs';
16
18
  import {
17
19
  DEFAULT_MAX_RAW_BYTES,
18
20
  contentTypeForPath,
@@ -420,18 +422,193 @@ async function routeRequest({ req, res, sessionId, token, events, sseClients, em
420
422
 
421
423
  if (req.method === 'POST' && url.pathname === '/api/tools/execute') {
422
424
  const body = await readJsonBody(req);
423
- emit('tool_execution_requested', { name: body.name || null });
424
- sendJson(res, 501, {
425
- ok: false,
426
- error: 'local_tool_bridge_not_wired',
427
- message: 'Tool execution will be routed through the local service permission layer in the next slice.',
428
- });
425
+ const name = String(body.name || '').trim();
426
+ const args = body.args || {};
427
+ if (!name) {
428
+ sendJson(res, 400, { ok: false, error: 'tool name is required' });
429
+ return;
430
+ }
431
+ emit('tool_execution_requested', { name });
432
+ try {
433
+ const relay = getAgentRelay(session);
434
+ const result = await relay.executeTool(name, args);
435
+ sendJson(res, 200, { ok: true, result });
436
+ } catch (err) {
437
+ sendJson(res, 500, { ok: false, error: err.message || String(err) });
438
+ }
439
+ return;
440
+ }
441
+
442
+ // Plugin workspace views (PRD-101 §4.6): panels declared under
443
+ // spec.workspace.views in plugin.yaml, rendered as sandboxed iframes.
444
+ // Generic infra — ANY installed plugin (project-local or ~/.bahulam)
445
+ // contributes tabs; its whole directory is served statically so views
446
+ // can ship css/js/assets with relative paths.
447
+ if (req.method === 'GET' && url.pathname === '/api/plugin-views') {
448
+ const views = getPluginViews(session).map(v => ({
449
+ plugin: v.plugin,
450
+ name: v.name,
451
+ url: `/plugin-view/${encodeURIComponent(v.plugin)}/${v.source.split('/').map(encodeURIComponent).join('/')}`,
452
+ }));
453
+ sendJson(res, 200, { ok: true, views });
454
+ return;
455
+ }
456
+
457
+ // Shared-blackboard direct-access API for plugin views. The tool
458
+ // executor already injects `options.state` for agent-driven writes;
459
+ // this endpoint gives browser-side views the same surface so a human
460
+ // clicking a button hits the exact same store the agent uses.
461
+ //
462
+ // POST /api/plugin-state/<plugin> body: {op, ...args}
463
+ // ops: get|set|patch|append|list|keys|delete|query
464
+ //
465
+ // Writes emit a `plugin_state_changed` SSE event on the shared bus,
466
+ // so any view watching /api/events re-renders live.
467
+ const pluginStateMatch = req.method === 'POST'
468
+ ? url.pathname.match(/^\/api\/plugin-state\/([^/]+)$/)
469
+ : null;
470
+ if (pluginStateMatch) {
471
+ const pluginName = decodeURIComponent(pluginStateMatch[1]);
472
+ // Scope guard: `bahulam plugin <name>` sessions are pinned to one
473
+ // plugin. Refuse cross-plugin state access so a hostile view can't
474
+ // read another plugin's DB via a hand-crafted URL.
475
+ const scoped = session?.plugin?.name;
476
+ if (scoped && String(scoped).toLowerCase() !== pluginName.toLowerCase()) {
477
+ sendJson(res, 403, { ok: false, error: 'plugin_scope_mismatch' });
478
+ return;
479
+ }
480
+ if (!getPluginDirs(session).has(pluginName)) {
481
+ sendJson(res, 404, { ok: false, error: 'plugin_not_found' });
482
+ return;
483
+ }
484
+ let body;
485
+ try { body = await readJsonBody(req); }
486
+ catch (err) { sendJson(res, 400, { ok: false, error: 'bad_body', message: err.message }); return; }
487
+ const op = String(body.op || '').trim();
488
+ if (!op) { sendJson(res, 400, { ok: false, error: 'op_required' }); return; }
489
+ try {
490
+ const { makePluginState } = await import('../plugins/state.mjs');
491
+ const state = makePluginState(pluginName, {
492
+ emit: (evt) => { try { emit('plugin_state_changed', evt); } catch { /* ignore */ } },
493
+ });
494
+ const result = runStateOp(state, op, body);
495
+ sendJson(res, 200, { ok: true, result });
496
+ } catch (err) {
497
+ sendJson(res, 500, { ok: false, error: 'state_op_failed', message: err.message });
498
+ }
499
+ return;
500
+ }
501
+
502
+ const pluginViewMatch = req.method === 'GET'
503
+ ? url.pathname.match(/^\/plugin-view\/([^/]+)\/(.+)$/)
504
+ : null;
505
+ if (pluginViewMatch) {
506
+ const pluginName = decodeURIComponent(pluginViewMatch[1]);
507
+ const relPath = pluginViewMatch[2].split('/').map(decodeURIComponent).join('/');
508
+ const dir = getPluginDirs(session).get(pluginName);
509
+ if (!dir) {
510
+ sendJson(res, 404, { ok: false, error: 'plugin_not_found' });
511
+ return;
512
+ }
513
+ const root = path.resolve(dir);
514
+ const abs = path.resolve(root, relPath);
515
+ if (abs !== root && !abs.startsWith(root + path.sep)) {
516
+ sendJson(res, 403, { ok: false, error: 'forbidden' });
517
+ return;
518
+ }
519
+ try {
520
+ const content = fs.readFileSync(abs);
521
+ res.writeHead(200, { 'Content-Type': pluginViewContentType(abs) });
522
+ res.end(content);
523
+ } catch {
524
+ sendJson(res, 404, { ok: false, error: 'plugin_view_file_missing' });
525
+ }
429
526
  return;
430
527
  }
431
528
 
432
529
  sendJson(res, 404, { ok: false, error: 'not_found' });
433
530
  }
434
531
 
532
+ // Short-TTL registry cache, keyed by session so `bahulam plugin <name>`
533
+ // only surfaces the scoped plugin's views. A generic `workspace open`
534
+ // session (no session.plugin) sees every installed plugin — that's the
535
+ // developer/multi-plugin experience.
536
+ const _pluginScan = new Map();
537
+ function scanPlugins(session) {
538
+ const scope = session?.plugin?.name ? String(session.plugin.name).toLowerCase() : '__all__';
539
+ const cached = _pluginScan.get(scope);
540
+ if (cached && Date.now() - cached.at < 5000) return cached;
541
+ const views = [];
542
+ const dirs = new Map();
543
+ try {
544
+ const registry = new PluginRegistry({
545
+ pluginDirs: [
546
+ path.join(session.root_path || process.cwd(), '.bahulam', 'plugins'),
547
+ path.join(os.homedir(), '.bahulam', 'plugins'),
548
+ ],
549
+ }).scan();
550
+ for (const manifest of registry.list()) {
551
+ const pluginName = manifest.metadata?.name || '';
552
+ if (!pluginName || !manifest._dir) continue;
553
+ if (scope !== '__all__' && pluginName.toLowerCase() !== scope) continue;
554
+ dirs.set(pluginName, manifest._dir);
555
+ (manifest.spec?.workspace?.views || []).forEach((view) => {
556
+ const source = String(view?.source || '').trim().replace(/^\.\//, '');
557
+ if (!source) return;
558
+ views.push({
559
+ plugin: pluginName,
560
+ name: String(view?.name || '').trim() || pluginName,
561
+ source,
562
+ });
563
+ });
564
+ }
565
+ } catch {
566
+ // plugin scan failures must never break the workspace
567
+ }
568
+ const entry = { at: Date.now(), views, dirs };
569
+ _pluginScan.set(scope, entry);
570
+ return entry;
571
+ }
572
+ function getPluginViews(session) { return scanPlugins(session).views; }
573
+ function getPluginDirs(session) { return scanPlugins(session).dirs; }
574
+
575
+ /**
576
+ * Dispatch a POST /api/plugin-state op onto the plugin's state proxy.
577
+ * Kept as a plain switch so failures are per-op and the router surfaces
578
+ * a clean 500 with the offending op name rather than a stack trace.
579
+ */
580
+ function runStateOp(state, op, body) {
581
+ switch (op) {
582
+ case 'get': return state.get(body.key, body.fallback ?? null);
583
+ case 'set': return state.set(body.key, body.value);
584
+ case 'patch': return state.patch(body.key, body.partial ?? body.value);
585
+ case 'delete': return state.delete(body.key);
586
+ case 'keys': return state.keys();
587
+ case 'append': return state.append(body.stream, body.payload);
588
+ case 'list': return state.list(body.stream, { limit: body.limit, order: body.order });
589
+ case 'query': return state.query(body.sql, body.params || []);
590
+ default: throw new Error(`unknown state op: ${op}`);
591
+ }
592
+ }
593
+
594
+ // Real mime types for plugin views — unlike contentTypeForPath (file
595
+ // viewer), views MUST execute, so html/css/js keep their native types.
596
+ // Plugin code runs client-side by user consent (same trust as shell).
597
+ const PLUGIN_VIEW_MIME = {
598
+ html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8',
599
+ css: 'text/css; charset=utf-8',
600
+ js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8',
601
+ json: 'application/json', map: 'application/json',
602
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
603
+ webp: 'image/webp', svg: 'image/svg+xml', ico: 'image/x-icon',
604
+ woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf',
605
+ wasm: 'application/wasm',
606
+ };
607
+ function pluginViewContentType(filePath) {
608
+ const ext = path.extname(String(filePath || '')).slice(1).toLowerCase();
609
+ return PLUGIN_VIEW_MIME[ext] || 'application/octet-stream';
610
+ }
611
+
435
612
  function requireAuthorizedSession(sessionId, token, req, url) {
436
613
  const session = loadLocalWorkspaceSession(sessionId);
437
614
  if (!session) {
@@ -807,7 +984,7 @@ main.hide-files{grid-template-columns:0 0 minmax(360px,1fr) 6px var(--right-w)}m
807
984
  .tabbar{height:36px;display:flex;align-items:center;border-bottom:1px solid var(--ws-border-subtle);background:var(--ws-tab);flex:0 0 auto;overflow-x:auto}.tab{height:100%;display:flex;align-items:center;gap:7px;border:0;border-right:1px solid var(--ws-border-subtle);padding:0 9px;background:transparent;color:rgba(27,27,27,.45);font-size:11px;cursor:pointer;max-width:220px;min-width:90px}.tab.active{background:#fff;color:var(--ws-foreground);box-shadow:inset 0 -1.5px 0 var(--ws-foreground)}.tab:hover{background:rgba(27,27,27,.03);color:rgba(27,27,27,.75)}.tab-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tab-close{border:0;background:transparent;border-radius:4px;color:rgba(27,27,27,.28);cursor:pointer;padding:0 2px}.tab-close:hover{background:rgba(27,27,27,.07);color:rgba(27,27,27,.70)}.tab-muted{flex:1;height:100%;border-left:1px solid var(--ws-border-subtle);min-width:24px}
808
985
  .viewer{flex:1;min-height:0;overflow:hidden;background:#fff;display:flex;flex-direction:column}.file-header{min-height:34px;flex:0 0 auto;border-bottom:1px solid var(--ws-border-subtle);display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:5px 12px;background:rgba(27,27,27,.015);font:12px var(--mono);color:rgba(27,27,27,.50)}.path{min-width:120px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.file-chip{display:inline-flex;align-items:center;gap:5px;border:1px solid rgba(27,27,27,.08);border-radius:999px;background:#fff;padding:1px 7px;font:10px var(--sans);color:rgba(27,27,27,.48);white-space:nowrap}.file-chip.warn{border-color:#FDE68A;background:#FFFBEB;color:#92400E}.file-actions{margin-left:auto;display:flex;align-items:center;gap:6px;min-width:0;flex-wrap:wrap}.file-action{font:11px var(--sans);color:rgba(27,27,27,.50);text-decoration:none;border:1px solid var(--ws-border);border-radius:6px;padding:2px 7px;background:#fff;cursor:pointer}.file-action:hover{color:var(--ws-foreground);background:var(--ws-surface)}.file-icon-action{width:24px;height:24px;min-width:24px;padding:0;display:inline-flex;align-items:center;justify-content:center}.file-icon-action svg{width:13px;height:13px;display:block}select.file-action{height:23px;max-width:190px;padding:1px 24px 1px 7px}.empty{color:var(--ws-faint);padding:24px;font-size:12px}.error{color:var(--ws-error)}
809
986
  .monaco-host{flex:1;min-height:0}.code-fallback{flex:1;min-height:0;overflow:auto}.code-wrap{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:start;min-height:100%;font:12px/1.55 var(--mono)}.line-nums{user-select:none;text-align:right;padding:14px 10px 14px 14px;color:rgba(27,27,27,.25);background:#FAF9F5;border-right:1px solid var(--ws-border-subtle);white-space:pre}.code-pre{margin:0;padding:14px;white-space:pre;overflow:auto;color:#1F2937;background:#fff;min-height:100%}.markdown-preview{flex:1;min-height:0;overflow:auto;max-width:920px;padding:24px 28px;color:rgba(27,27,27,.86);font-size:14px;line-height:1.65}.markdown-preview h1,.markdown-preview h2,.markdown-preview h3{line-height:1.2;margin:18px 0 8px}.markdown-preview p{margin:0 0 12px}.markdown-preview code,.message-content code{font-family:var(--mono);font-size:.92em;background:rgba(27,27,27,.06);border-radius:4px;padding:1px 4px}.markdown-preview pre,.message-content pre{overflow:auto;background:#0D1117;color:#E5E7EB;border-radius:7px;padding:12px}.image-stage{flex:1;min-height:0;display:flex;flex-direction:column;background:#F8F7F2}.image-toolbar{height:34px;flex:0 0 auto;display:flex;align-items:center;justify-content:flex-end;gap:6px;padding:0 10px;border-bottom:1px solid var(--ws-border-subtle);background:#fff}.image-preview{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:18px;overflow:auto}.image-preview img{max-width:100%;max-height:100%;object-fit:contain;border:1px solid var(--ws-border);background:#fff;box-shadow:0 10px 30px rgba(27,27,27,.10);transform-origin:center center}.frame-preview{flex:1;min-height:0;background:#F8F7F2}.frame-preview iframe{width:100%;height:100%;border:0;background:#fff}.table-preview{flex:1;min-height:0;padding:18px;overflow:auto}.table-preview .table-meta{margin:0 0 10px;color:rgba(27,27,27,.42);font:11px var(--mono)}.table-preview table{border-collapse:collapse;font-size:12px;background:#fff}.table-preview th,.table-preview td{border:1px solid var(--ws-border);padding:5px 7px;max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.table-preview th{background:#F7F5EF;text-align:left;color:rgba(27,27,27,.65);position:sticky;top:0}.table-preview td.formula-cell{background:#F0F9FF;color:#075985}.diagram-preview{flex:1;min-height:0;padding:18px;display:grid;gap:14px;max-width:none;overflow:auto}.mermaid-card{border:1px solid var(--ws-border);border-radius:8px;background:#fff;overflow:hidden;display:flex;flex-direction:column}.mermaid-output{padding:18px;overflow:auto;min-height:220px;display:flex;align-items:center;justify-content:center}.mermaid-output svg{width:100%;max-width:100%;height:auto;display:block}.mermaid-source{margin:0;border-top:1px solid var(--ws-border-subtle);border-radius:0;background:#FAF9F5;color:rgba(27,27,27,.68);font:11px/1.45 var(--mono);max-height:220px;overflow:auto;padding:10px 12px;white-space:pre}.viewer-note{flex:1;min-height:0;overflow:auto;display:flex;align-items:center;justify-content:center;padding:24px;background:#FAF9F5}.viewer-note-card{width:min(520px,100%);max-height:100%;overflow:auto;border:1px solid var(--ws-border);border-radius:8px;background:#fff;padding:18px;box-shadow:0 10px 34px rgba(27,27,27,.05)}.viewer-note-title{font-size:14px;font-weight:750;color:rgba(27,27,27,.84)}.viewer-note-body{margin-top:8px;color:rgba(27,27,27,.56);font-size:12px;line-height:1.55}.viewer-note-actions{margin-top:14px;display:flex;gap:8px;flex-wrap:wrap}.binary-preview{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:24px;color:rgba(27,27,27,.50);text-align:center}
810
- .chat-head{height:36px;border-bottom:1px solid var(--ws-border-subtle);display:flex;align-items:center;gap:8px;padding:0 12px;background:rgba(255,255,255,.55)}.chat-title{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:rgba(27,27,27,.35)}.chat-subtitle{min-width:0;flex:1;font-size:10px;color:rgba(27,27,27,.28);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.agent-tabs{height:32px;display:flex;align-items:center;border-bottom:1px solid var(--ws-border-subtle);background:#F7F5EF;padding:0 8px;gap:3px}.agent-tab{height:24px;border:0;border-radius:6px;background:transparent;color:rgba(27,27,27,.45);font-size:11px;font-weight:650;padding:0 9px;cursor:pointer}.agent-tab:hover{background:var(--ws-hover);color:rgba(27,27,27,.76)}.agent-tab.active{background:#fff;color:var(--ws-foreground);box-shadow:0 0 0 1px rgba(27,27,27,.06)}.chat-body{display:flex;flex-direction:column;flex:1;min-height:0}.chat-pane{display:none;flex-direction:column;flex:1;min-height:0}.chat-pane.active{display:flex}.thread{flex:1;min-height:0;overflow:auto;padding:16px 14px 10px;background:rgba(255,255,255,.40)}.thread-inner{display:flex;flex-direction:column;gap:12px}.empty-chat{display:flex;height:100%;align-items:center;justify-content:center;text-align:center;color:rgba(27,27,27,.35);font-size:12px}.session-list{display:flex;flex-direction:column;gap:2px}.session-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;border-radius:6px;padding:8px}.session-row:hover{background:rgba(27,27,27,.035)}.session-main{min-width:0}.session-prompt{font-size:12px;color:rgba(27,27,27,.82);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.session-meta{margin-top:3px;font:10px/1.4 var(--mono);color:rgba(27,27,27,.38);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.session-tools{margin-top:5px;display:flex;gap:4px;flex-wrap:wrap}.session-chip{border-radius:999px;border:1px solid rgba(27,27,27,.07);background:#F7F5EF;padding:1px 6px;font:10px var(--mono);color:rgba(27,27,27,.48)}.msg{display:flex}.msg.user{justify-content:flex-end}.msg.assistant{justify-content:flex-start}.bubble{max-width:86%;border-radius:16px;padding:10px 12px;font-size:13px;line-height:1.55;word-break:break-word}.user .bubble{background:#1B1B1B;color:#FFFDF7}.message-attachments{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}.message-attachment{max-width:100%;display:inline-flex;align-items:center;gap:5px;border:1px solid rgba(255,255,255,.20);border-radius:999px;background:rgba(255,255,255,.10);color:#FFFDF7;padding:2px 7px;cursor:pointer}.message-attachment span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px}.message-attachment small{font:10px var(--mono);opacity:.68}.message-attachment:hover{background:rgba(255,255,255,.17)}.assistant-stack{width:100%;max-width:960px;display:flex;flex-direction:column;gap:8px}.assistant-bubble{display:none;max-width:94%;border-radius:16px;background:#F7F5EF;padding:11px 13px;color:rgba(27,27,27,.86);font-size:13px;line-height:1.6}.assistant-bubble:not(:empty){display:block}.message-content p{margin:0 0 10px}.message-content p:last-child{margin-bottom:0}.message-content ul{margin:0 0 10px 18px;padding:0}.message-content li{margin:2px 0}
987
+ .chat-head{height:36px;border-bottom:1px solid var(--ws-border-subtle);display:flex;align-items:center;gap:8px;padding:0 12px;background:rgba(255,255,255,.55)}.chat-title{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:rgba(27,27,27,.35)}.chat-subtitle{min-width:0;flex:1;font-size:10px;color:rgba(27,27,27,.28);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.agent-tabs{height:32px;display:flex;align-items:center;border-bottom:1px solid var(--ws-border-subtle);background:#F7F5EF;padding:0 8px;gap:3px}.agent-tab{height:24px;border:0;border-radius:6px;background:transparent;color:rgba(27,27,27,.45);font-size:11px;font-weight:650;padding:0 9px;cursor:pointer}.agent-tab:hover{background:var(--ws-hover);color:rgba(27,27,27,.76)}.agent-tab.active{background:#fff;color:var(--ws-foreground);box-shadow:0 0 0 1px rgba(27,27,27,.06)}.chat-body{display:flex;flex-direction:column;flex:1;min-height:0}.chat-pane{display:none;flex-direction:column;flex:1;min-height:0}.chat-pane.active{display:flex}.plugins-menu-wrap{position:relative}.plugins-menu-wrap[hidden]{display:none}.plugins-dropdown{position:absolute;top:26px;left:0;z-index:60;min-width:210px;background:#fff;border:1px solid var(--ws-border);border-radius:8px;box-shadow:0 12px 34px rgba(27,27,27,.14);padding:4px;display:flex;flex-direction:column}.plugins-dropdown[hidden]{display:none}.plugins-dropdown button{border:0;background:transparent;text-align:left;font:12px var(--sans);color:rgba(27,27,27,.78);padding:6px 9px;border-radius:5px;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:10px}.plugins-dropdown button:hover{background:var(--ws-hover)}.plugins-dropdown .plugin-origin{color:var(--ws-faint);font:10px var(--mono)}.thread{flex:1;min-height:0;overflow:auto;padding:16px 14px 10px;background:rgba(255,255,255,.40)}.thread-inner{display:flex;flex-direction:column;gap:12px}.empty-chat{display:flex;height:100%;align-items:center;justify-content:center;text-align:center;color:rgba(27,27,27,.35);font-size:12px}.session-list{display:flex;flex-direction:column;gap:2px}.session-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;border-radius:6px;padding:8px}.session-row:hover{background:rgba(27,27,27,.035)}.session-main{min-width:0}.session-prompt{font-size:12px;color:rgba(27,27,27,.82);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.session-meta{margin-top:3px;font:10px/1.4 var(--mono);color:rgba(27,27,27,.38);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.session-tools{margin-top:5px;display:flex;gap:4px;flex-wrap:wrap}.session-chip{border-radius:999px;border:1px solid rgba(27,27,27,.07);background:#F7F5EF;padding:1px 6px;font:10px var(--mono);color:rgba(27,27,27,.48)}.msg{display:flex}.msg.user{justify-content:flex-end}.msg.assistant{justify-content:flex-start}.bubble{max-width:86%;border-radius:16px;padding:10px 12px;font-size:13px;line-height:1.55;word-break:break-word}.user .bubble{background:#1B1B1B;color:#FFFDF7}.message-attachments{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}.message-attachment{max-width:100%;display:inline-flex;align-items:center;gap:5px;border:1px solid rgba(255,255,255,.20);border-radius:999px;background:rgba(255,255,255,.10);color:#FFFDF7;padding:2px 7px;cursor:pointer}.message-attachment span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px}.message-attachment small{font:10px var(--mono);opacity:.68}.message-attachment:hover{background:rgba(255,255,255,.17)}.assistant-stack{width:100%;max-width:960px;display:flex;flex-direction:column;gap:8px}.assistant-bubble{display:none;max-width:94%;border-radius:16px;background:#F7F5EF;padding:11px 13px;color:rgba(27,27,27,.86);font-size:13px;line-height:1.6}.assistant-bubble:not(:empty){display:block}.message-content p{margin:0 0 10px}.message-content p:last-child{margin-bottom:0}.message-content ul{margin:0 0 10px 18px;padding:0}.message-content li{margin:2px 0}
811
988
  .activity-card{display:none;overflow:hidden;border:1px solid var(--ws-border);border-radius:8px;background:#FFFDF7;font-size:12px}.activity-card.active{display:block}.activity-head{height:28px;width:100%;border:0;background:transparent;display:flex;align-items:center;gap:8px;padding:0 9px;cursor:pointer;color:rgba(27,27,27,.68)}.activity-head:hover{background:#F7F5EF}.activity-label{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left;font-size:11px;font-weight:650}.activity-rollup{font-size:10px;color:rgba(27,27,27,.38)}.activity-rows{border-top:1px solid rgba(27,27,27,.05);padding:7px 9px;max-height:96px;overflow:auto;display:flex;flex-direction:column;gap:5px}.activity-card.expanded .activity-rows{max-height:360px}.activity-row{display:flex;align-items:flex-start;gap:7px;color:rgba(27,27,27,.65);font-size:11px;line-height:1.35}.activity-row .status-dot{width:8px;height:8px;border-radius:999px;background:rgba(27,27,27,.18);margin-top:4px;flex:0 0 auto}.activity-row.running .status-dot{background:#0891B2}.activity-row.done .status-dot{background:#059669}.activity-row.error .status-dot{background:#DC2626}.activity-row.approval .status-dot{background:#D97706}.activity-row.thinking{font-style:italic;color:rgba(27,27,27,.48)}.activity-main{display:block;min-width:0;flex:1}.activity-message{display:block}.activity-row pre{display:none;margin:4px 0 0;max-height:120px;overflow:auto;border-radius:6px;background:rgba(27,27,27,.04);padding:6px;font:10px/1.4 var(--mono);color:rgba(27,27,27,.62);white-space:pre-wrap}.activity-card.expanded .activity-row pre{display:block}.activity-approval{margin-top:7px;border:1px solid rgba(217,119,6,.20);border-radius:7px;background:#FFFBEB;overflow:hidden;transition:opacity .85s ease,transform .85s ease,max-height .85s ease,margin .85s ease}.activity-approval.fading{opacity:0;transform:translateY(-4px);max-height:0;margin:0;pointer-events:none}.activity-approval-head{display:flex;align-items:center;justify-content:space-between;gap:8px;border-bottom:1px solid rgba(217,119,6,.12);padding:6px 8px}.activity-approval-title{font-size:11px;font-weight:800;color:#92400E}.activity-approval-meta{font:10px var(--mono);color:rgba(146,64,14,.58);white-space:nowrap}.activity-approval-subject{margin:7px 8px 0;border:1px solid rgba(217,119,6,.14);border-radius:5px;background:#fff;padding:7px;font:10px/1.45 var(--mono);color:rgba(27,27,27,.72);white-space:pre-wrap;overflow-wrap:anywhere;max-height:130px;overflow:auto}.activity-approval-reason{padding:6px 8px 8px;color:rgba(27,27,27,.56);font-size:11px;line-height:1.45}.activity-approval-actions{display:flex;justify-content:flex-end;gap:6px;flex-wrap:wrap;border-top:1px solid rgba(217,119,6,.12);background:rgba(255,255,255,.58);padding:7px 8px}.activity-result{margin-top:6px}.trace{display:block;flex:1;min-height:0;overflow:auto;background:#fff;padding:8px}.trace-row{font:11px/1.45 var(--mono);border-bottom:1px solid rgba(27,27,27,.05);padding:7px 4px;color:rgba(27,27,27,.52);white-space:pre-wrap;overflow-wrap:anywhere}.trace-row b{color:var(--ws-primary)}
812
989
  .notebook-preview{flex:1;min-height:0;overflow:auto;background:#F7F5EF;padding:14px 14px 40px;display:flex;flex-direction:column;gap:10px}.notebook-meta{font:11px var(--mono);color:rgba(27,27,27,.42);padding:0 6px}.notebook-cell{border:1px solid var(--ws-border-subtle);border-radius:8px;background:#fff;overflow:hidden;box-shadow:0 1px 0 rgba(27,27,27,.02)}.notebook-cell.code{display:grid;grid-template-columns:64px minmax(0,1fr);padding:0;align-items:stretch}.notebook-cell.markdown{padding:16px 20px}.notebook-cell.raw{background:#F7F5EF;padding:12px 16px}.notebook-cell.raw .notebook-cell-body{padding:0}.notebook-cell.raw pre{margin:0;white-space:pre-wrap;font:12px/1.5 var(--mono);color:rgba(27,27,27,.72)}.notebook-cell-body{padding:0}.notebook-markdown{font-size:14px;line-height:1.65;color:rgba(27,27,27,.86)}.notebook-markdown h1,.notebook-markdown h2,.notebook-markdown h3{line-height:1.2;margin:14px 0 8px;color:rgba(27,27,27,.92)}.notebook-markdown h1{font-size:22px;border-bottom:1px solid var(--ws-border-subtle);padding-bottom:4px}.notebook-markdown p{margin:0 0 10px}.notebook-markdown code{font-family:var(--mono);font-size:.92em;background:rgba(27,27,27,.06);border-radius:4px;padding:1px 4px}.notebook-markdown pre{overflow:auto;background:#0D1117;color:#E5E7EB;border-radius:6px;padding:10px}.notebook-prompt{padding:12px 10px 12px 10px;font:11px/1.4 var(--mono);text-align:right;user-select:none;color:#4B6BFB;background:transparent;border-right:1px solid var(--ws-border-subtle);white-space:nowrap;display:flex;align-items:flex-start;justify-content:flex-end}.notebook-prompt.out{color:#A21818}.notebook-prompt.empty{color:transparent;pointer-events:none}.notebook-prompt.in::before{content:"In "}.notebook-prompt.out::before{content:"Out "}.notebook-cell-monaco{background:#FAFBFC;min-height:52px;position:relative;overflow:hidden;padding:6px 0}.notebook-cell-monaco .monaco-editor,.notebook-cell-monaco .monaco-editor .overflow-guard,.notebook-cell-monaco .monaco-editor-background,.notebook-cell-monaco .margin{background:#FAFBFC!important}.notebook-code-fallback{margin:0;padding:8px 12px;background:transparent;color:#1F2937;font:12px/1.5 var(--mono);white-space:pre;overflow:auto}.notebook-cell-outputs{grid-column:1 / -1;display:grid;grid-template-columns:64px minmax(0,1fr);background:#fff;border-top:1px solid var(--ws-border-subtle)}.notebook-output-item{display:contents}.notebook-output-item+.notebook-output-item>.notebook-prompt,.notebook-output-item+.notebook-output-item>.notebook-output-body{border-top:1px solid var(--ws-border-subtle)}.notebook-output-body{padding:8px 12px;overflow:auto;min-width:0;background:#fff}.notebook-stream{margin:0;font:12px/1.5 var(--mono);color:#1F2937;white-space:pre-wrap;word-break:break-word;background:transparent;padding:0}.notebook-stream.stderr{color:#7F1D1D;background:#FEF2F2;padding:6px 8px;border-radius:5px}.notebook-error{margin:0;font:12px/1.5 var(--mono);color:#7F1D1D;background:#FEF2F2;padding:8px 10px;border-radius:5px;white-space:pre-wrap;word-break:break-word;overflow:auto}.notebook-plain{margin:0;font:12px/1.5 var(--mono);color:#1F2937;white-space:pre-wrap;word-break:break-word;background:transparent;padding:0}.notebook-json{margin:0;font:12px/1.5 var(--mono);color:#0F172A;background:#F8FAFC;padding:8px 10px;border-radius:5px;white-space:pre;overflow:auto}.notebook-output-body img{max-width:100%;height:auto;display:block;background:#fff;border-radius:4px}.notebook-output-body iframe{width:100%;min-height:220px;border:1px solid var(--ws-border-subtle);border-radius:5px;background:#fff}.notebook-latex{font-size:14px;color:rgba(27,27,27,.90);padding:6px 0;overflow:auto}.katex-display{margin:6px 0!important}.viewer.maximized{position:fixed;inset:12px;z-index:70;border:1px solid rgba(27,27,27,.12);border-radius:8px;box-shadow:0 24px 80px rgba(27,27,27,.28);background:#fff}.viewer.maximized .diagram-preview,.viewer.maximized .markdown-preview{max-width:none}.viewer.maximized .mermaid-output{min-height:calc(100vh - 220px)}.composer{border-top:1px solid var(--ws-border-subtle);background:#FFFDF7;padding:10px 12px}.composer-box{border:1px solid var(--ws-border);border-radius:10px;background:#fff;overflow:hidden}.upload-tray{display:flex;gap:6px;flex-wrap:wrap;padding:8px 9px 0}.upload-tray[hidden]{display:none}.upload-chip{height:24px;display:inline-flex;align-items:center;gap:6px;max-width:100%;border:1px solid rgba(8,145,178,.18);border-radius:999px;background:#F0F9FF;color:#075985;padding:0 4px 0 8px;font-size:11px}.upload-chip-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:210px}.upload-chip-meta{font:10px var(--mono);color:rgba(7,89,133,.58)}.upload-chip button{width:18px;height:18px;border:0;border-radius:999px;background:transparent;color:rgba(7,89,133,.52);cursor:pointer;padding:0}.upload-chip button:hover{background:rgba(8,145,178,.12);color:#075985}textarea{display:block;width:100%;min-height:84px;max-height:240px;resize:vertical;border:0;padding:10px 11px;background:#fff;color:var(--ws-foreground);outline:none}.composer-actions{height:34px;border-top:1px solid var(--ws-border-subtle);display:flex;align-items:center;justify-content:space-between;padding:0 8px}.composer-left,.composer-right{display:flex;align-items:center;gap:6px;min-width:0}.composer-right{margin-left:auto}.status{font-size:11px;color:var(--ws-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:8px}.stop-button{height:24px;border:1px solid rgba(185,28,28,.20);border-radius:6px;background:#FEF2F2;color:#B91C1C;font-size:11px;font-weight:750;padding:0 8px;display:inline-flex;align-items:center;gap:5px;cursor:pointer}.stop-button svg{width:12px;height:12px}.stop-button:hover{background:#FEE2E2;border-color:rgba(185,28,28,.30)}.stop-button:disabled{opacity:.55;cursor:not-allowed}.stop-button[hidden]{display:none}button.primary{height:24px;border:0;border-radius:6px;background:var(--ws-foreground);color:#fff;font-size:11px;font-weight:750;padding:0 10px;cursor:pointer}button.primary:hover{background:#2B2B2B}button.primary:disabled{opacity:.45;cursor:not-allowed}
813
990
  .approval-inline{transition:opacity .85s ease,transform .85s ease,max-height .85s ease,margin .85s ease}.approval-inline.fading{opacity:0;transform:translateY(-4px);max-height:0;margin:0;pointer-events:none}.approval-inline-actions .approval-approve{background:#ECFDF5;border-color:#A7F3D0;color:#047857}.approval-inline-actions .approval-approve:hover{background:#D1FAE5;color:#065F46}.approval-inline-actions .approval-reject{background:#FEF2F2;border-color:#FECACA;color:#B91C1C}.approval-inline-actions .approval-reject:hover{background:#FEE2E2;color:#991B1B}.approval-inline-actions .approval-secondary{background:#EFF6FF;border-color:#BFDBFE;color:#1D4ED8}.approval-inline-actions .approval-secondary:hover{background:#DBEAFE;color:#1E40AF}.approval-result-line{display:inline-flex;max-width:100%;align-items:center;gap:7px;border:1px solid rgba(27,27,27,.08);border-radius:999px;background:#F7F5EF;color:rgba(27,27,27,.58);padding:5px 9px;font:11px/1.2 var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.approval-result-line.approved{background:#ECFDF5;border-color:#A7F3D0;color:#047857}.approval-result-line.denied{background:#FEF2F2;border-color:#FECACA;color:#B91C1C}.approval-result-tool{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:420px}
@@ -857,6 +1034,10 @@ main.hide-files{grid-template-columns:0 0 minmax(360px,1fr) 6px var(--right-w)}m
857
1034
  <button class="menu-item" type="button">Edit</button>
858
1035
  <button class="menu-item" type="button">Run</button>
859
1036
  <button class="menu-item" type="button">Tools</button>
1037
+ <div class="plugins-menu-wrap" id="pluginsMenuWrap" hidden>
1038
+ <button class="menu-item" id="pluginsMenuButton" type="button" aria-haspopup="menu" aria-expanded="false">Plugins</button>
1039
+ <div class="plugins-dropdown" id="pluginsDropdown" hidden role="menu"></div>
1040
+ </div>
860
1041
  <div class="menubar-spacer"></div>
861
1042
  <div class="session-menu-wrap">
862
1043
  <button class="menu-item session-menu-button" id="sessionMenuButton" type="button" aria-haspopup="dialog" aria-expanded="false">Chat: New</button>
@@ -1387,6 +1568,7 @@ async function loadDir(path){
1387
1568
  }
1388
1569
  }
1389
1570
  async function openFile(path, knownData){
1571
+ if(path&&path.startsWith('plugin://')){openPluginView(path);return;}
1390
1572
  currentPath=path;
1391
1573
  if(!openTabs.some(t=>t.path===path))openTabs.push({path,name:basename(path)});
1392
1574
  activePath=path;
@@ -2280,6 +2462,46 @@ function setAgentTab(tab){
2280
2462
  btn.setAttribute('aria-selected', String(active));
2281
2463
  });
2282
2464
  }
2465
+ // Plugin views render as CENTRAL panel tabs (like files), keyed plugin://
2466
+ // Discoverable via the top "Plugins" menu; tabs are also opened at boot.
2467
+ const pluginViews=new Map();
2468
+ async function loadPluginViews(){
2469
+ try{
2470
+ const data=await api('/api/plugin-views');
2471
+ const views=data.views||[];
2472
+ if(!views.length)return;
2473
+ const wrap=document.getElementById('pluginsMenuWrap');
2474
+ const dropdown=document.getElementById('pluginsDropdown');
2475
+ const button=document.getElementById('pluginsMenuButton');
2476
+ views.forEach(view=>{
2477
+ const key='plugin://'+view.plugin+'/'+(view.name||'view');
2478
+ pluginViews.set(key,view);
2479
+ if(!openTabs.some(t=>t.path===key))openTabs.push({path:key,name:view.name||view.plugin});
2480
+ const item=document.createElement('button');
2481
+ item.type='button';item.setAttribute('role','menuitem');
2482
+ item.innerHTML='<span>'+esc(view.name||view.plugin)+'</span><span class="plugin-origin">'+esc(view.plugin)+'</span>';
2483
+ item.addEventListener('click',()=>{dropdown.hidden=true;button.setAttribute('aria-expanded','false');openPluginView(key);});
2484
+ dropdown.appendChild(item);
2485
+ });
2486
+ if(wrap)wrap.hidden=false;
2487
+ if(button&&dropdown){
2488
+ button.addEventListener('click',(e)=>{e.stopPropagation();const open=dropdown.hidden;dropdown.hidden=!open;button.setAttribute('aria-expanded',String(open));});
2489
+ document.addEventListener('click',()=>{if(!dropdown.hidden){dropdown.hidden=true;button.setAttribute('aria-expanded','false');}});
2490
+ }
2491
+ renderTabs();
2492
+ }catch{}
2493
+ }
2494
+ function openPluginView(key){
2495
+ const view=pluginViews.get(key);
2496
+ if(!view)return;
2497
+ currentPath=key;
2498
+ activePath=key;
2499
+ if(!openTabs.some(t=>t.path===key))openTabs.push({path:key,name:view.name||view.plugin});
2500
+ renderTabs();
2501
+ renderExplorer();
2502
+ renderViewer('<div class="file-header"><span>⚙</span><span class="path">'+esc(view.plugin)+' · '+esc(view.name)+'</span></div>'+
2503
+ '<div class="frame-preview"><iframe sandbox="allow-scripts allow-same-origin allow-forms" src="'+esc(view.url)+'?token='+encodeURIComponent(token)+'"></iframe></div>');
2504
+ }
2283
2505
  function setTurnRunning(running){
2284
2506
  turnRunning=Boolean(running);
2285
2507
  if(!turnRunning)turnCancelling=false;
@@ -2627,6 +2849,7 @@ renderTabs();
2627
2849
  renderEmptyViewer();
2628
2850
  loadApprovalMode();
2629
2851
  loadSessionChoices();
2852
+ loadPluginViews();
2630
2853
  loadDir('.').then(()=>{if(currentPath&&currentPath!=='.')openFile(currentPath).catch(()=>{});});
2631
2854
  </script>
2632
2855
  </body>