@bahulam/code 0.1.11 → 0.1.13

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.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/src/auth/tarang-auth.mjs +313 -0
  3. package/src/commands/agent.mjs +7 -7
  4. package/src/commands/install.mjs +295 -0
  5. package/src/commands/plugin-manage.mjs +280 -88
  6. package/src/config/cli-args.mjs +16 -0
  7. package/src/config/settings-loader.mjs +15 -0
  8. package/src/core/background-tasks.mjs +186 -0
  9. package/src/core/headless.mjs +54 -3
  10. package/src/core/local-agent.mjs +10 -1
  11. package/src/core/risk-tier.mjs +1 -0
  12. package/src/core/stream-client.mjs +95 -15
  13. package/src/core/tool-executor.mjs +266 -15
  14. package/src/local-service/agent-relay.mjs +1 -1
  15. package/src/local-service/server.mjs +116 -14
  16. package/src/orchestration/approval.mjs +30 -0
  17. package/src/orchestration/completion-triggers.mjs +40 -0
  18. package/src/orchestration/dispatch.mjs +118 -0
  19. package/src/orchestration/events.mjs +19 -0
  20. package/src/orchestration/graph.mjs +126 -0
  21. package/src/orchestration/node-runner.mjs +193 -0
  22. package/src/orchestration/runner.mjs +200 -0
  23. package/src/plugins/executor.mjs +2 -2
  24. package/src/plugins/manifest.mjs +30 -27
  25. package/src/plugins/pi-compat/loader-hook.mjs +45 -0
  26. package/src/plugins/pi-compat/probe.mjs +294 -0
  27. package/src/plugins/pi-compat/scaffold.mjs +487 -0
  28. package/src/plugins/pi-compat/shim.mjs +134 -0
  29. package/src/plugins/pi-compose.mjs +147 -0
  30. package/src/plugins/preflight.mjs +35 -10
  31. package/src/plugins/registry.mjs +6 -0
  32. package/src/terminal/agents.mjs +8 -3
  33. package/src/terminal/main.mjs +39 -7
  34. package/src/terminal/paste-input.mjs +23 -0
  35. package/src/terminal/repl-render.mjs +65 -10
  36. package/src/terminal/repl-state.mjs +4 -2
  37. package/src/terminal/repl.mjs +624 -103
  38. package/src/tools/agent.mjs +6 -2
  39. package/src/tools/registry.mjs +107 -4
  40. package/src/ui/input-dock.mjs +5 -2
  41. package/src/ui/slash-commands.mjs +1 -1
  42. package/src/ui/sub-agent.mjs +14 -8
@@ -0,0 +1,487 @@
1
+ /**
2
+ * scaffoldPiPack — generate a full Bahulam pack directory from an
3
+ * installed pi ingredient. The generated pack composes the pi package's
4
+ * tools, adds a persistent state layer (append-only records), a single
5
+ * agent that has both toolsets, and a workspace panel that reads the
6
+ * state live via SSE.
7
+ *
8
+ * The pack is written into ~/.bahulam/plugins/<slug>/ (or the target
9
+ * dir the caller provides) so `bahulam install pi:<pkg>` is one command
10
+ * and the pack is immediately installed + preflight-approved.
11
+ *
12
+ * Design tenets:
13
+ * - Zero authoring cost. All content templated from the pi tools cache.
14
+ * - Composed pi tools ARE the surface — no need to wrap each one.
15
+ * - Native tools are limited to the state layer (save/list/drop item).
16
+ * This bridges the pi surface (stateless) to the workspace panel.
17
+ * - Everything is overridable. The user can edit the generated files
18
+ * after the fact — they live in ~/.bahulam/plugins/<slug>/ and won't
19
+ * be regenerated on re-install.
20
+ */
21
+
22
+ import * as fs from 'node:fs';
23
+ import * as path from 'node:path';
24
+ import { COMPOSED_TOOL_SEPARATOR } from '../pi-compose.mjs';
25
+
26
+ /**
27
+ * Derive a pack slug from a pi package name.
28
+ * pi-web-access → web-access-studio
29
+ * pi-redmine → redmine-studio
30
+ * @ffmpeg/transitions → transitions-studio
31
+ * plain-name → plain-name-studio
32
+ */
33
+ export function deriveSlug(packageName) {
34
+ let base = String(packageName || '').trim();
35
+ const scoped = base.match(/^@[^/]+\/(.+)$/);
36
+ if (scoped) base = scoped[1];
37
+ base = base.replace(/^pi-/, '');
38
+ base = base.replace(/[^a-z0-9-]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase();
39
+ if (!base) base = 'pi-pack';
40
+ return `${base}-studio`;
41
+ }
42
+
43
+ /**
44
+ * Derive a short namespace prefix from the pi package name. Used as
45
+ * `as:` in the composes block. Kept short so composed tool names stay
46
+ * readable: `web__web_search`, `fx__add_transitions` (double underscore
47
+ * matches Claude Code / MCP naming and passes Anthropic's tool-name regex).
48
+ */
49
+ export function deriveNamespace(packageName) {
50
+ let base = String(packageName || '').trim();
51
+ const scoped = base.match(/^@([^/]+)\/(.+)$/);
52
+ if (scoped) base = scoped[2];
53
+ base = base.replace(/^pi-/, '');
54
+ const first = base.split(/[-_.]/)[0] || 'pi';
55
+ return first.slice(0, 8).toLowerCase();
56
+ }
57
+
58
+ function yamlString(s) {
59
+ const str = String(s || '');
60
+ if (str === '' || /[:#{}\[\],&*!|>'"%@`\n]/.test(str)) {
61
+ return JSON.stringify(str);
62
+ }
63
+ return str;
64
+ }
65
+
66
+ function yamlBlock(text, indent) {
67
+ const pad = ' '.repeat(indent);
68
+ const clean = String(text || '').trim().replace(/\r\n/g, '\n');
69
+ if (!clean) return '""';
70
+ const lines = clean.split('\n').map(l => l.trimEnd());
71
+ return '|\n' + lines.map(l => pad + l).join('\n');
72
+ }
73
+
74
+ function truncate(s, n) {
75
+ const str = String(s || '').replace(/\s+/g, ' ').trim();
76
+ if (str.length <= n) return str;
77
+ return str.slice(0, n - 1) + '…';
78
+ }
79
+
80
+ /**
81
+ * Compose an agent system prompt from the pi package + its tools.
82
+ * Focused on WHAT the agent should do, not step-by-step recipes — the
83
+ * generic template can't know the pack's domain. Users are expected to
84
+ * edit the prompt after generation.
85
+ */
86
+ function generatePrompt(packageName, namespace, toolNames, hasState) {
87
+ const composed = toolNames.map(t => `${namespace}${COMPOSED_TOOL_SEPARATOR}${t}`);
88
+ const stateLines = hasState
89
+ ? [
90
+ '',
91
+ 'Persistence:',
92
+ '- Call `list_items` first when the user references prior work — the notebook',
93
+ ' survives across turns and populates the workspace panel.',
94
+ '- After any meaningful tool call, `save_item` with a title, source, and short note',
95
+ ' so the finding is durable and visible in the panel.',
96
+ '- `drop_item` removes an entry by id.',
97
+ ]
98
+ : [];
99
+ const lines = [
100
+ `You are the ${packageName} specialist.`,
101
+ `You have direct access to ${toolNames.length} composed tool${toolNames.length === 1 ? '' : 's'} from the pi package \`${packageName}\`.`,
102
+ '',
103
+ 'Available composed tools:',
104
+ ...composed.map(t => `- \`${t}\``),
105
+ ...stateLines,
106
+ '',
107
+ 'Rules:',
108
+ '- Use the composed tools directly — do not describe what you would do, DO it.',
109
+ '- If a tool fails, report the exact error message. Do NOT fall back to general',
110
+ ' knowledge for tasks the tool was meant to answer.',
111
+ '- Cite sources or IDs from tool responses whenever you make a claim.',
112
+ ];
113
+ return lines.join('\n');
114
+ }
115
+
116
+ /**
117
+ * Emit plugin.yaml as a hand-crafted string. YAML libraries add noise
118
+ * (quoted keys, over-escaping); a small emitter here yields a diff-
119
+ * friendly manifest the user can edit.
120
+ */
121
+ function renderManifest({ slug, packageName, versionRange, namespace, exposeTools, agentSlug, agentDescription, hasState, hasWorkspace, systemPrompt }) {
122
+ const versionSpec = versionRange ? `${packageName}@${versionRange}` : packageName;
123
+ const tools = hasState ? [
124
+ ' tools:',
125
+ ' - name: save_item',
126
+ ' description: >',
127
+ ` Persist an item to the ${slug} notebook — a title, an optional source URL,`,
128
+ ' and freeform notes. The workspace panel and future turns see it immediately.',
129
+ ` tool: ./tools/save-item.mjs`,
130
+ ' parameters:',
131
+ ' type: object',
132
+ ' properties:',
133
+ ' title: { type: string, description: "Short headline for the item" }',
134
+ ' source: { type: string, description: "Source URL or identifier (optional)" }',
135
+ ' notes: { type: string, description: "Freeform notes (optional)" }',
136
+ ' topic: { type: string, description: "Topic tag for filtering (optional)" }',
137
+ ' required: [title]',
138
+ '',
139
+ ' - name: list_items',
140
+ ' description: >',
141
+ ' Read persisted items from the notebook. Use this first when the user',
142
+ ' references prior work, before re-running composed tools.',
143
+ ` tool: ./tools/list-items.mjs`,
144
+ ' parameters:',
145
+ ' type: object',
146
+ ' properties:',
147
+ ' topic: { type: string, description: "Filter by topic tag (exact match). Omit for all." }',
148
+ ' limit: { type: integer, description: "Max rows, default 50" }',
149
+ '',
150
+ ' - name: drop_item',
151
+ ' description: Remove a persisted item by its id.',
152
+ ` tool: ./tools/drop-item.mjs`,
153
+ ' parameters:',
154
+ ' type: object',
155
+ ' properties:',
156
+ ' id: { type: integer, description: "Item id (from list_items)" }',
157
+ ' required: [id]',
158
+ '',
159
+ ] : [' tools: []', ''];
160
+
161
+ const composesBlock = [
162
+ ' composes:',
163
+ ` - source: pi:${versionSpec}`,
164
+ ` as: ${namespace}`,
165
+ ' expose:',
166
+ ...exposeTools.map(t => ` - ${t}`),
167
+ ' verified: true',
168
+ '',
169
+ ];
170
+
171
+ const agentToolRefs = [
172
+ ...(hasState ? ['save_item', 'list_items', 'drop_item'] : []),
173
+ ...exposeTools.map(t => `${namespace}${COMPOSED_TOOL_SEPARATOR}${t}`),
174
+ ];
175
+
176
+ const agentBlock = [
177
+ ' agents:',
178
+ ` - slug: ${agentSlug}`,
179
+ ` name: ${yamlString(agentSlug.replace(/-/g, ' '))}`,
180
+ ' role: specialist',
181
+ ' description: >',
182
+ ` ${agentDescription}`,
183
+ ' tools:',
184
+ ...agentToolRefs.map(t => ` - ${t}`),
185
+ ` system_prompt: ${yamlBlock(systemPrompt, 8)}`,
186
+ '',
187
+ ];
188
+
189
+ const workspaceBlock = hasWorkspace ? [
190
+ ' workspace:',
191
+ ' views:',
192
+ ' - type: panel',
193
+ ` name: ${yamlString(slug.replace(/-/g, ' '))}`,
194
+ ' source: ./workspace/panel.html',
195
+ '',
196
+ ] : [];
197
+
198
+ return [
199
+ 'apiVersion: bahulam.plugin/1',
200
+ 'kind: Plugin',
201
+ 'metadata:',
202
+ ` name: ${slug}`,
203
+ ' version: 0.1.0',
204
+ ' description: >',
205
+ ` Auto-scaffolded pack composing pi:${packageName}.`,
206
+ ` Edit tools/, workspace/, and this manifest to customize.`,
207
+ '',
208
+ 'spec:',
209
+ ...tools,
210
+ ...composesBlock,
211
+ ...agentBlock,
212
+ ...workspaceBlock,
213
+ ].join('\n');
214
+ }
215
+
216
+ const SAVE_ITEM_TOOL = `/**
217
+ * save_item — persist a single item to the pack's notebook.
218
+ * Append-style records so a topic can accumulate many entries over time.
219
+ * The workspace panel binds to this stream.
220
+ */
221
+ export async function call(args = {}, options = {}) {
222
+ const title = String(args.title || '').trim();
223
+ if (!title) return { success: false, output: 'title is required' };
224
+
225
+ const state = options.state ? await options.state : null;
226
+ if (!state) return { success: false, output: 'Shared blackboard unavailable' };
227
+
228
+ const item = {
229
+ title,
230
+ source: String(args.source || '').trim(),
231
+ notes: String(args.notes || '').slice(0, 1000),
232
+ topic: String(args.topic || '').trim(),
233
+ at: new Date().toISOString(),
234
+ };
235
+ const record = state.append('items', item);
236
+ return {
237
+ success: true,
238
+ output: \`Saved item #\${record?.id || ''}: \${title}\`,
239
+ item: { ...item, id: record?.id },
240
+ };
241
+ }
242
+ `;
243
+
244
+ const LIST_ITEMS_TOOL = `/**
245
+ * list_items — read persisted items from the pack notebook, most recent first.
246
+ */
247
+ export async function call(args = {}, options = {}) {
248
+ const state = options.state ? await options.state : null;
249
+ if (!state) return { success: false, output: 'Shared blackboard unavailable' };
250
+
251
+ const topic = String(args.topic || '').trim().toLowerCase();
252
+ const limit = Math.max(1, Math.min(500, Number(args.limit) || 50));
253
+ const rows = state.list('items', { limit, order: 'desc' }) || [];
254
+ const filtered = topic
255
+ ? rows.filter(r => String(r.payload?.topic || '').toLowerCase() === topic)
256
+ : rows;
257
+ const summary = filtered.map(r => \`#\${r.id} · \${r.payload?.title || ''}\${r.payload?.topic ? ' (' + r.payload.topic + ')' : ''}\`).join('\\n');
258
+ return {
259
+ success: true,
260
+ output: filtered.length ? summary : (topic ? \`No items for topic '\${topic}'\` : 'No items yet'),
261
+ items: filtered.map(r => ({ id: r.id, ...(r.payload || {}), created_at: r.created_at })),
262
+ };
263
+ }
264
+ `;
265
+
266
+ const DROP_ITEM_TOOL = `/**
267
+ * drop_item — remove a persisted item by id.
268
+ */
269
+ export async function call(args = {}, options = {}) {
270
+ const id = Number(args.id);
271
+ if (!Number.isInteger(id) || id <= 0) {
272
+ return { success: false, output: 'id must be a positive integer' };
273
+ }
274
+ const state = options.state ? await options.state : null;
275
+ if (!state) return { success: false, output: 'Shared blackboard unavailable' };
276
+
277
+ const info = state.db.prepare('DELETE FROM records WHERE stream = ? AND id = ?').run('items', id);
278
+ return {
279
+ success: info.changes > 0,
280
+ output: info.changes > 0 ? \`Dropped item #\${id}\` : \`No item with id \${id}\`,
281
+ };
282
+ }
283
+ `;
284
+
285
+ function renderPanel(slug, packageName, composedToolNames) {
286
+ const example = composedToolNames[0] || 'example_tool';
287
+ return `<!doctype html>
288
+ <html>
289
+ <head>
290
+ <meta charset="utf-8">
291
+ <title>${slug}</title>
292
+ <style>
293
+ :root { --bg:#FBFAF7; --fg:#1F2328; --muted:#8A8F98; --ok:#1A7F37; --err:#C0392B; --brand:#0891B2; --mono:ui-monospace,Menlo,monospace; }
294
+ body { margin:0; padding:20px 24px; background:var(--bg); color:var(--fg); font:14px/1.55 -apple-system,system-ui,sans-serif; }
295
+ h1 { font-size:17px; margin:0 0 2px; } .lede { color:var(--muted); font-size:12px; margin:0 0 14px; max-width:900px; }
296
+ .row { display:flex; gap:8px; align-items:center; margin:12px 0 12px; flex-wrap:wrap; }
297
+ input, button, select { font:inherit; padding:6px 10px; border:1px solid #D5D3CB; border-radius:4px; background:#fff; }
298
+ button { cursor:pointer; } button.danger { color:var(--err); }
299
+ #live { color:var(--muted); font-size:11px; }
300
+ #status { color:var(--muted); font-size:11px; min-height:16px; }
301
+ #status.err { color:var(--err); } #status.ok { color:var(--ok); }
302
+ .item { border-bottom:1px solid #F0EFEA; padding:10px 0; }
303
+ .idPill { display:inline-block; padding:1px 6px; border-radius:10px; font-size:10px; background:#F0EFEA; color:var(--muted); font-family:var(--mono); margin-right:6px; }
304
+ .topicPill { display:inline-block; padding:1px 7px; border-radius:10px; font-size:10px; background:#E0F2FE; color:var(--brand); margin-right:6px; }
305
+ .title { font-weight:600; }
306
+ .src { color:var(--muted); font-size:12px; word-break:break-all; }
307
+ .notes { color:#4a4d52; font-size:13px; margin-top:4px; }
308
+ .meta { color:var(--muted); font-size:11px; font-family:var(--mono); margin-top:2px; }
309
+ .btnDrop { float:right; font-size:11px; }
310
+ </style>
311
+ </head>
312
+ <body>
313
+ <h1>${slug} <span id="live">· connecting…</span></h1>
314
+ <p class="lede">Items the agent saves via <code>save_item</code> land here — live. Composed from <code>pi:${packageName}</code>. Cross-process pulse updates the panel even when the agent runs in another terminal.</p>
315
+
316
+ <div class="row">
317
+ <input id="topic" placeholder="Filter by topic…" />
318
+ <span id="count" class="muted"></span>
319
+ <span id="status" style="margin-left:auto"></span>
320
+ </div>
321
+
322
+ <div id="items"><p class="muted" style="padding:14px">loading…</p></div>
323
+
324
+ <script>
325
+ const token = new URLSearchParams(location.search).get('token') || '';
326
+ const PLUGIN = ${JSON.stringify(slug)};
327
+
328
+ function setStatus(msg, tone) {
329
+ const el = document.getElementById('status');
330
+ el.textContent = msg || ''; el.className = tone || '';
331
+ }
332
+ async function state(op, extra = {}) {
333
+ const res = await fetch('/api/plugin-state/' + PLUGIN, {
334
+ method: 'POST',
335
+ headers: { 'Content-Type': 'application/json', 'X-Bahulam-Local-Token': token },
336
+ body: JSON.stringify({ op, ...extra }),
337
+ });
338
+ const body = await res.json();
339
+ if (!res.ok || body.ok === false) throw new Error(body.error || body.message || 'state op failed');
340
+ return body.result;
341
+ }
342
+ async function tool(name, args = {}) {
343
+ const res = await fetch('/api/tools/execute', {
344
+ method: 'POST',
345
+ headers: { 'Content-Type': 'application/json', 'X-Bahulam-Local-Token': token },
346
+ body: JSON.stringify({ name, args }),
347
+ });
348
+ const body = await res.json();
349
+ if (!res.ok || body.ok === false) throw new Error(body.error || 'tool call failed');
350
+ if (body.result?.success === false) throw new Error(String(body.result.output));
351
+ return body.result;
352
+ }
353
+ function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
354
+ function fmtTime(iso) { return (iso || '').replace('T', ' ').slice(0, 19); }
355
+
356
+ async function reload() {
357
+ const container = document.getElementById('items');
358
+ try {
359
+ const filter = document.getElementById('topic').value.trim().toLowerCase();
360
+ const rows = await state('list', { stream: 'items', limit: 500, order: 'desc' });
361
+ const items = (rows || []).filter(r => !filter || String(r.payload?.topic || '').toLowerCase() === filter);
362
+ document.getElementById('count').textContent = \`\${items.length} item\${items.length === 1 ? '' : 's'}\`;
363
+ if (!items.length) {
364
+ container.innerHTML = '<p class="muted" style="padding:14px">no items yet — ask the ${slug} agent</p>';
365
+ return;
366
+ }
367
+ container.innerHTML = items.map(r => {
368
+ const p = r.payload || {};
369
+ return \`<div class="item">
370
+ <button class="btnDrop danger" data-drop="\${r.id}">Drop</button>
371
+ <span class="idPill">#\${r.id}</span>\${p.topic ? \`<span class="topicPill">\${escapeHtml(p.topic)}</span>\` : ''}
372
+ <span class="title">\${escapeHtml(p.title || '')}</span>
373
+ \${p.source ? \`<div class="src"><a href="\${escapeHtml(p.source)}" target="_blank" rel="noreferrer">\${escapeHtml(p.source)}</a></div>\` : ''}
374
+ \${p.notes ? \`<div class="notes">\${escapeHtml(p.notes)}</div>\` : ''}
375
+ <div class="meta">\${fmtTime(r.created_at || p.at)}</div>
376
+ </div>\`;
377
+ }).join('');
378
+ container.querySelectorAll('button[data-drop]').forEach(btn => {
379
+ btn.addEventListener('click', async () => {
380
+ try { await tool('drop_item', { id: Number(btn.getAttribute('data-drop')) }); setStatus('dropped', 'ok'); }
381
+ catch (err) { setStatus(err.message, 'err'); }
382
+ });
383
+ });
384
+ } catch (err) {
385
+ container.innerHTML = \`<p style="padding:14px;color:var(--err)">\${escapeHtml(err.message)}</p>\`;
386
+ }
387
+ }
388
+
389
+ let _t = null;
390
+ const scheduleReload = () => { clearTimeout(_t); _t = setTimeout(reload, 200); };
391
+ document.getElementById('topic').addEventListener('input', scheduleReload);
392
+
393
+ try {
394
+ const es = new EventSource('/api/events?token=' + encodeURIComponent(token));
395
+ es.addEventListener('open', () => { document.getElementById('live').textContent = '· live'; });
396
+ es.addEventListener('error', () => { document.getElementById('live').textContent = '· reconnecting…'; });
397
+ es.addEventListener('plugin_state_changed', (ev) => {
398
+ let evt = {}; try { evt = JSON.parse(ev.data || '{}'); } catch {}
399
+ if (evt.plugin !== PLUGIN) return;
400
+ scheduleReload();
401
+ });
402
+ } catch { /* SSE unavailable; still load-only */ }
403
+
404
+ reload();
405
+ </script>
406
+ </body>
407
+ </html>
408
+ `;
409
+ }
410
+
411
+ /**
412
+ * Scaffold a full pack directory from an installed pi ingredient.
413
+ *
414
+ * @param {Object} opts
415
+ * @param {string} opts.packageName — pi package name (e.g. "pi-web-access")
416
+ * @param {string} [opts.versionRange] — pi package version range
417
+ * @param {string} opts.piDir — dir where pi package is installed (~/.bahulam/plugins-pi/<safe>/)
418
+ * @param {string} opts.targetDir — parent dir for the generated pack
419
+ * @param {Object} opts.discoveredTools — parsed .bahulam-tools.json
420
+ * @param {boolean} [opts.state=true] — include persistent state layer + native tools
421
+ * @param {boolean} [opts.workspace=true] — include reactive panel
422
+ * @param {string} [opts.slug] — override the derived slug
423
+ * @param {boolean} [opts.force=false] — overwrite an existing pack
424
+ * @returns {{ dest: string, slug: string, namespace: string, exposeTools: string[] }}
425
+ */
426
+ export function scaffoldPiPack({
427
+ packageName,
428
+ versionRange,
429
+ piDir,
430
+ targetDir,
431
+ discoveredTools,
432
+ state = true,
433
+ workspace = true,
434
+ slug: slugOverride,
435
+ force = false,
436
+ }) {
437
+ const slug = slugOverride || deriveSlug(packageName);
438
+ const namespace = deriveNamespace(packageName);
439
+ const toolNames = (discoveredTools?.tools || []).map(t => t.name).filter(Boolean);
440
+ if (!toolNames.length) {
441
+ throw new Error(`pi package ${packageName} has no discoverable tools — cannot scaffold`);
442
+ }
443
+
444
+ const dest = path.join(targetDir, slug);
445
+ if (fs.existsSync(dest)) {
446
+ if (!force) throw new Error(`pack already exists: ${dest} (use --force to overwrite)`);
447
+ fs.rmSync(dest, { recursive: true, force: true });
448
+ }
449
+ fs.mkdirSync(dest, { recursive: true });
450
+
451
+ const agentSlug = `${namespace}-specialist`;
452
+ const agentDescription = truncate(
453
+ `Specialist agent for ${packageName}. Composes ${toolNames.length} tool${toolNames.length === 1 ? '' : 's'} exposed as ${namespace}${COMPOSED_TOOL_SEPARATOR}*.`,
454
+ 240,
455
+ );
456
+ const systemPrompt = generatePrompt(packageName, namespace, toolNames, state);
457
+
458
+ const manifest = renderManifest({
459
+ slug,
460
+ packageName,
461
+ versionRange,
462
+ namespace,
463
+ exposeTools: toolNames,
464
+ agentSlug,
465
+ agentDescription,
466
+ hasState: state,
467
+ hasWorkspace: workspace,
468
+ systemPrompt,
469
+ });
470
+ fs.writeFileSync(path.join(dest, 'plugin.yaml'), manifest);
471
+
472
+ if (state) {
473
+ const toolsDir = path.join(dest, 'tools');
474
+ fs.mkdirSync(toolsDir, { recursive: true });
475
+ fs.writeFileSync(path.join(toolsDir, 'save-item.mjs'), SAVE_ITEM_TOOL);
476
+ fs.writeFileSync(path.join(toolsDir, 'list-items.mjs'), LIST_ITEMS_TOOL);
477
+ fs.writeFileSync(path.join(toolsDir, 'drop-item.mjs'), DROP_ITEM_TOOL);
478
+ }
479
+
480
+ if (workspace) {
481
+ const wsDir = path.join(dest, 'workspace');
482
+ fs.mkdirSync(wsDir, { recursive: true });
483
+ fs.writeFileSync(path.join(wsDir, 'panel.html'), renderPanel(slug, packageName, toolNames));
484
+ }
485
+
486
+ return { dest, slug, namespace, exposeTools: toolNames, agentSlug };
487
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Pi runtime shim — the synthetic `pi` module a composed pi package
3
+ * imports at load time.
4
+ *
5
+ * Pi's extension API is imperative: extensions do `import { pi } from
6
+ * 'pi'` and call `pi.registerTool(name, schema, handler)` /
7
+ * `pi.registerCommand(cmd, handler)` / `pi.ctx.ui.setWidget(...)`. We
8
+ * intercept the module resolution via a Node ESM loader hook
9
+ * (`loader-hook.mjs`) that returns a virtual module which imports THIS
10
+ * shim and instantiates it against a shared capture object.
11
+ *
12
+ * v1 scope:
13
+ * - registerTool: captured, exposed to our loop as a pluginToolMap entry
14
+ * - registerCommand: captured but not surfaced (no REPL command bridge)
15
+ * - pi.events.on/emit: no-op (cross-extension event bus, deferred)
16
+ * - pi.ctx.ui.setWidget/custom: no-op with debug warning (TUI widgets
17
+ * don't translate to our workspace canvas; author dedicated panels)
18
+ * - pi.ctx.log: forwards to stderr with plugin prefix
19
+ */
20
+
21
+ export function createPiShim({ pluginName = 'pi', captured }) {
22
+ if (!captured || typeof captured !== 'object') {
23
+ throw new Error('createPiShim: captured object is required');
24
+ }
25
+ captured.tools ||= [];
26
+ captured.commands ||= [];
27
+
28
+ const pi = {
29
+ // Pi's canonical shape is registerTool({name, description, parameters,
30
+ // execute}) — a single descriptor with an `execute` function. Older
31
+ // examples use registerTool(name, schema, handler) with positional args.
32
+ // Accept both.
33
+ registerTool(arg1, arg2, arg3) {
34
+ if (arg1 && typeof arg1 === 'object' && !Array.isArray(arg1)) {
35
+ const desc = arg1;
36
+ const name = desc.name;
37
+ if (!name || typeof name !== 'string') return;
38
+ // Descriptor form: pi's canonical `{name, description, parameters,
39
+ // execute}`. Handler is called as execute(id, params).
40
+ captured.tools.push({
41
+ name,
42
+ description: desc.description || '',
43
+ schema: desc.parameters || desc.input_schema || desc.schema || { type: 'object', properties: {} },
44
+ handler: typeof desc.execute === 'function' ? desc.execute
45
+ : typeof desc.handler === 'function' ? desc.handler
46
+ : typeof desc.call === 'function' ? desc.call
47
+ : null,
48
+ _form: 'descriptor',
49
+ });
50
+ return;
51
+ }
52
+ // Positional legacy form: (name, schema, handler). Handler is
53
+ // called as handler(args).
54
+ if (!arg1 || typeof arg1 !== 'string') return;
55
+ let name = arg1, schema = arg2, handler = arg3;
56
+ if (typeof handler !== 'function' && typeof schema === 'function') {
57
+ handler = schema;
58
+ schema = { type: 'object', properties: {} };
59
+ }
60
+ captured.tools.push({
61
+ name,
62
+ description: '',
63
+ schema: schema || { type: 'object', properties: {} },
64
+ handler,
65
+ _form: 'positional',
66
+ });
67
+ },
68
+
69
+ // registerCommand(cmd, descriptor) in real pi; descriptor has
70
+ // {description, execute}. Older form: registerCommand(cmd, handler).
71
+ registerCommand(cmd, arg2) {
72
+ if (!cmd || typeof cmd !== 'string') return;
73
+ if (arg2 && typeof arg2 === 'object' && !Array.isArray(arg2)) {
74
+ const desc = arg2;
75
+ captured.commands.push({
76
+ cmd,
77
+ description: desc.description || '',
78
+ handler: typeof desc.execute === 'function' ? desc.execute
79
+ : typeof desc.handler === 'function' ? desc.handler
80
+ : null,
81
+ });
82
+ } else if (typeof arg2 === 'function') {
83
+ captured.commands.push({ cmd, handler: arg2 });
84
+ }
85
+ },
86
+
87
+ events: {
88
+ on() { /* no-op in v1 */ },
89
+ emit() { /* no-op in v1 */ },
90
+ off() { /* no-op in v1 */ },
91
+ },
92
+
93
+ // Pi packages call pi.on(...) directly for lifecycle events (session
94
+ // start/end etc.) — no-op them so activation reaches registerTool.
95
+ // Same treatment for pi.off, pi.emit, pi.once.
96
+ on() { /* no-op */ },
97
+ off() { /* no-op */ },
98
+ emit() { /* no-op */ },
99
+ once() { /* no-op */ },
100
+
101
+ // Additional pi surfaces called at activation-time by real packages
102
+ // (pi-web-access, etc.). Stub them so activation completes and tools
103
+ // register; runtime callers that rely on these still throw at call
104
+ // time, which is the correct signal that a feature isn't supported.
105
+ registerShortcut() { /* no-op */ },
106
+ appendEntry() { /* no-op */ },
107
+ sendMessage() { /* no-op */ },
108
+ exec() {
109
+ throw new Error(`[pi:${pluginName}] pi.exec is not supported in Bahulam compat`);
110
+ },
111
+ // pi.fetch is pi's authenticated fetch. Delegate to global fetch —
112
+ // that's what the extension expects: an HTTP client. Auth headers
113
+ // are typically added by the extension itself using env credentials.
114
+ fetch(...args) { return globalThis.fetch(...args); },
115
+
116
+ ctx: {
117
+ ui: {
118
+ setWidget(widget) {
119
+ if (process.env.DEBUG) {
120
+ const title = widget?.title || widget?.name || 'untitled';
121
+ process.stderr.write(`[pi:${pluginName}] widget ignored: ${title}\n`);
122
+ }
123
+ },
124
+ custom() { /* no-op */ },
125
+ clear() { /* no-op */ },
126
+ },
127
+ log(...args) {
128
+ process.stderr.write(`[pi:${pluginName}] ${args.map(String).join(' ')}\n`);
129
+ },
130
+ },
131
+ };
132
+
133
+ return pi;
134
+ }