@subshell-ai/plugin-oh-my-pi 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,13 +1,12 @@
1
1
  # Oh My Pi plugin for Subshell
2
2
 
3
3
  `@subshell-ai/plugin-oh-my-pi` launches the `omp` CLI from Subshell. It provides
4
- model, provider, and thinking settings, plus automatic per-launch MCP
5
- configuration. Install and authenticate Oh My Pi separately on each node
6
- where you want to run it.
4
+ model, provider, and thinking settings, automatic per-launch MCP
5
+ configuration, and sidebar subagent monitoring. Install and authenticate Oh My
6
+ Pi separately on each node where you want to run it.
7
7
 
8
8
  See the [Oh My Pi guide](https://docs.subshell.sh/agents/oh-my-pi) for setup
9
9
  and supported behavior. The integration is verified with Oh My Pi 18.12.6.
10
- Automatic conversation resumption, attention hooks, and sidebar subagent
11
- monitoring aren't supported.
10
+ Automatic conversation resumption and attention hooks aren't supported.
12
11
 
13
12
  Licensed under Apache-2.0.
package/dist/index.d.ts CHANGED
@@ -500,6 +500,24 @@ interface InstallSpec {
500
500
  */
501
501
  docsUrl: string;
502
502
  }
503
+ /**
504
+ * How this plugin's harness CLI updates itself in place.
505
+ *
506
+ * The installer doubles as the updater for every built-in today (`curl … | sh`
507
+ * and `npm i -g` lines are idempotent), so this is OPTIONAL and falls back to
508
+ * {@link InstallSpec}. Declare it where the vendor ships a dedicated verb or a
509
+ * different channel — `claude update`, say — so pressing Update runs the
510
+ * vendor's own update path rather than reinstalling.
511
+ *
512
+ * Like `install.command`, this is a SHELL LINE the host runs through `sh -c`,
513
+ * on the node that holds the harness, and it may not need privileges.
514
+ */
515
+ interface UpdateSpec {
516
+ /** Shell line that updates the CLI in place. */
517
+ command: string;
518
+ /** Where the vendor documents it; http(s) only, when present. */
519
+ docsUrl?: string;
520
+ }
503
521
  /** One step an operator must run themselves, because the host may not. */
504
522
  interface PrivilegedStep {
505
523
  /** What it does, in a few words: "Install the daemon" */
@@ -643,6 +661,11 @@ interface SubshellManifest {
643
661
  detect?: DetectSpec;
644
662
  /** Install guidance shown when the binary is missing */
645
663
  install?: InstallSpec;
664
+ /**
665
+ * How the CLI updates itself, when the installer is not the updater.
666
+ * Absent means `install.command` is the update path too.
667
+ */
668
+ update?: UpdateSpec;
646
669
  /**
647
670
  * Environment variables this plugin computes against (spec 2026-09-10 §5).
648
671
  *
@@ -677,6 +700,11 @@ declare const manifest: SubshellManifest;
677
700
  * Oh My Pi uses its own executable and native MCP discovery, independently of
678
701
  * pi. An explicit extension directory contributes mcp.json without redirecting
679
702
  * the user's config or credentials. Verified against omp 18.12.6.
703
+ *
704
+ * The same directory carries the subagent bridge. omp keeps its child roster
705
+ * on the session event bus; the extension reports it so the sidebar can hang
706
+ * those children under the parent subshell. omp offers no extension API for
707
+ * moving the parent's terminal onto a child, so navigation stays unavailable.
680
708
  */
681
709
  declare const createPlugin: HarnessPluginFactory;
682
710
  //#endregion
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- //#region ../../plugin-api/dist/manifest-XGvw0ugx.js
1
+ //#region ../../plugin-api/dist/manifest-Cdy9tSLl.js
2
2
  /** Every {@link PluginType}, for anything that has to iterate or validate them. */
3
3
  const PLUGIN_TYPES = [
4
4
  "agent-harness",
@@ -158,6 +158,19 @@ function parseManifest(pkgJson) {
158
158
  docsUrl: i.docsUrl
159
159
  };
160
160
  }
161
+ let update;
162
+ if (block.update !== void 0) {
163
+ const u = block.update;
164
+ if (!isRecord(u) || typeof u.command !== "string" || u.command.trim() === "") return { error: "`subshell.update` needs a non-empty command" };
165
+ if (needsPrivileges(u.command)) return { error: "`subshell.update.command` must not need sudo, doas or pkexec — the host runs it through a shell and has no terminal for a password prompt" };
166
+ if (u.docsUrl !== void 0) {
167
+ if (typeof u.docsUrl !== "string" || !isDocsUrl(u.docsUrl)) return { error: "`subshell.update.docsUrl` must be an http(s) URL — it is rendered as a link on an admin page" };
168
+ update = {
169
+ command: u.command,
170
+ docsUrl: u.docsUrl
171
+ };
172
+ } else update = { command: u.command };
173
+ }
161
174
  let hostEnv;
162
175
  if (block.hostEnv !== void 0) {
163
176
  if (!Array.isArray(block.hostEnv) || !block.hostEnv.every((k) => typeof k === "string" && k.trim() !== "")) return { error: "`subshell.hostEnv` must be an array of environment variable names" };
@@ -176,6 +189,7 @@ function parseManifest(pkgJson) {
176
189
  entry: block.entry,
177
190
  ...detect ? { detect } : {},
178
191
  ...install ? { install } : {},
192
+ ...update ? { update } : {},
179
193
  ...hostEnv ? { hostEnv } : {},
180
194
  ...parsedNetwork ? { network: parsedNetwork } : {},
181
195
  ...block.pickable === false ? { pickable: false } : {}
@@ -273,6 +287,193 @@ function validateGenericPreset(preset) {
273
287
  };
274
288
  }
275
289
  //#endregion
290
+ //#region src/subagents.ts
291
+ /**
292
+ * omp's session event bus already carries the roster the task tool is running:
293
+ * `task:subagent:lifecycle` announces a child's start and settlement, and
294
+ * `task:subagent:progress` streams its status, generated one-line label, and
295
+ * latest intent. The extension below turns those frames into the snapshot
296
+ * reports the control plane reads, so the sidebar shows the children under the
297
+ * subshell that asked for them.
298
+ *
299
+ * Every frame is already bounded by omp; this module bounds again because the
300
+ * report is rendered in the sidebar of the owner and every viewer, and it is
301
+ * the pane's own porcelain that names them (160 characters, like the server).
302
+ * Progress frames arrive at the coalescing rate omp emits (about 150 ms), so
303
+ * they queue one snapshot per second; lifecycle changes report at once.
304
+ *
305
+ * A child session rebinds every extension factory, so this factory runs once
306
+ * per session. Each binding keeps its own roster, and `session_start` tells a
307
+ * child binding apart from the pane's main agent: only the main agent's roster
308
+ * is the set of children the sidebar should hang under the subshell.
309
+ *
310
+ * The roster repeats every two seconds while it is non-empty: the control
311
+ * plane keeps it in memory, so a restart has nothing until the harness reports
312
+ * again, and an idle child produces no other frames.
313
+ */
314
+ const SUBAGENT_BRIDGE = `
315
+ export default function (pi) {
316
+ const LIFECYCLE = 'task:subagent:lifecycle';
317
+ const PROGRESS = 'task:subagent:progress';
318
+ const MAX_AGENTS = 256;
319
+ const MAX_TEXT = 160;
320
+ const REPORT_DELAY_MS = 1000;
321
+ // A live child keeps reporting, so a control-plane restart (which forgets
322
+ // every roster) gets the set back without waiting for the next tool event.
323
+ const HEARTBEAT_MS = 2000;
324
+
325
+ const agents = new Map();
326
+ let role = 'main';
327
+ let timer = null;
328
+ let reporting = false;
329
+ let queued = false;
330
+ let heartbeat = null;
331
+ let warned = false;
332
+ const label = value => {
333
+ if (typeof value !== 'string') return undefined;
334
+ const trimmed = value.trim().slice(0, MAX_TEXT);
335
+ return trimmed || undefined;
336
+ };
337
+ const validId = id => (typeof id === 'string' && id.length > 0 && id.length <= 256 ? id : null);
338
+ const statusFor = status =>
339
+ status === 'pending' ? 'waiting' : status === 'running' ? 'running' : null;
340
+
341
+ const put = (id, status, activity) => {
342
+ const key = validId(id);
343
+ if (!key || role !== 'main') return false;
344
+ if (!agents.has(key) && agents.size >= MAX_AGENTS) return false;
345
+ const previous = agents.get(key);
346
+ // The report schema caps the name field at 160 while the id may run to
347
+ // 256; an over-long name would fail validation for the whole snapshot.
348
+ const next = { id: key, name: key.slice(0, MAX_TEXT), status };
349
+ const words = activity || previous?.activity;
350
+ if (words) next.activity = words;
351
+ agents.set(key, next);
352
+ if (heartbeat === null) heartbeat = setInterval(beat, HEARTBEAT_MS);
353
+ return true;
354
+ };
355
+
356
+ const drop = id => {
357
+ if (role !== 'main') return false;
358
+ const key = validId(id);
359
+ return key ? agents.delete(key) : false;
360
+ };
361
+
362
+ const report = async () => {
363
+ if (reporting) {
364
+ queued = true;
365
+ return;
366
+ }
367
+ const base = process.env.SUBSHELL_BASE_URL;
368
+ const token = process.env.SUBSHELL_API_KEY;
369
+ const id = process.env.SUBSHELL_ID;
370
+ if (!base || !token || !id) {
371
+ if (!warned) {
372
+ warned = true;
373
+ pi.logger?.debug?.('subshell subagent report skipped: the pane identity environment is missing');
374
+ }
375
+ return;
376
+ }
377
+ reporting = true;
378
+ try {
379
+ const response = await fetch(base + '/api/subshells/' + encodeURIComponent(id) + '/subagents/report', {
380
+ method: 'POST',
381
+ headers: { authorization: 'Bearer ' + token, 'content-type': 'application/json' },
382
+ body: JSON.stringify({
383
+ event: 'snapshot',
384
+ agents: [...agents.values()].slice(0, MAX_AGENTS),
385
+ selectedId: null,
386
+ canSelect: false,
387
+ }),
388
+ signal: AbortSignal.timeout(2000),
389
+ });
390
+ if (!response.ok && !warned) {
391
+ warned = true;
392
+ pi.logger?.debug?.('subshell subagent report failed', { status: response.status });
393
+ }
394
+ } catch {
395
+ // A failed report is not fatal: the next frame reports again, and a pane
396
+ // without the identity variables above has no endpoint to reach.
397
+ } finally {
398
+ reporting = false;
399
+ if (queued) {
400
+ queued = false;
401
+ void report();
402
+ }
403
+ }
404
+ };
405
+
406
+ const schedule = () => {
407
+ if (timer !== null) return;
408
+ timer = setTimeout(() => {
409
+ timer = null;
410
+ void report();
411
+ }, REPORT_DELAY_MS);
412
+ };
413
+
414
+ const beat = () => {
415
+ if (role !== 'main' || agents.size === 0) {
416
+ if (heartbeat !== null) {
417
+ clearInterval(heartbeat);
418
+ heartbeat = null;
419
+ }
420
+ return;
421
+ }
422
+ void report();
423
+ };
424
+
425
+ pi.events.on(LIFECYCLE, frame => {
426
+ if (!frame || typeof frame !== 'object' || typeof frame.status !== 'string') return;
427
+ if (frame.status === 'started') {
428
+ if (put(frame.id, 'running', label(frame.description) || label(frame.agent))) schedule();
429
+ } else if (drop(frame.id)) schedule();
430
+ });
431
+
432
+ pi.events.on(PROGRESS, frame => {
433
+ if (!frame || typeof frame !== 'object') return;
434
+ const progress = frame.progress;
435
+ if (!progress || typeof progress !== 'object') return;
436
+ const status = statusFor(progress.status);
437
+ if (status) {
438
+ if (put(progress.id ?? frame.id, status, label(progress.description) || label(progress.lastIntent))) schedule();
439
+ } else if (progress.status === 'completed' || progress.status === 'failed' || progress.status === 'aborted') {
440
+ if (drop(progress.id ?? frame.id)) schedule();
441
+ }
442
+ });
443
+
444
+ const reset = () => {
445
+ agents.clear();
446
+ if (timer !== null) {
447
+ clearTimeout(timer);
448
+ timer = null;
449
+ }
450
+ if (heartbeat !== null) {
451
+ clearInterval(heartbeat);
452
+ heartbeat = null;
453
+ }
454
+ return report();
455
+ };
456
+
457
+ pi.on('session_start', (_event, ctx) => {
458
+ role = ctx && ctx.agent && ctx.agent.kind === 'sub' ? 'sub' : 'main';
459
+ if (role !== 'main') return;
460
+ return reset();
461
+ });
462
+
463
+ // A switch (resume, fork, new) replaces the conversation the roster belonged
464
+ // to; its children are no longer this session's children.
465
+ pi.on('session_switch', () => {
466
+ if (role !== 'main') return;
467
+ return reset();
468
+ });
469
+
470
+ pi.on('session_shutdown', () => {
471
+ if (role !== 'main') return;
472
+ return reset();
473
+ });
474
+ }
475
+ `;
476
+ //#endregion
276
477
  //#region src/manifest.ts
277
478
  /**
278
479
  * This package's own `subshell` block, read from its package.json so the
@@ -285,7 +486,7 @@ function validateGenericPreset(preset) {
285
486
  const parsed = parseManifest({
286
487
  name: "@subshell-ai/plugin-oh-my-pi",
287
488
  description: "Oh My Pi harness plugin for Subshell",
288
- version: "0.2.1",
489
+ version: "0.2.2",
289
490
  license: "Apache-2.0",
290
491
  type: "module",
291
492
  main: "dist/index.js",
@@ -400,9 +601,22 @@ const TITLE_PREFIX = /^π(?:$|\s+(?:[>!:]|[⠁-⣿]|[◔◑◕○●]|[-\\/|])|:
400
601
  * Oh My Pi uses its own executable and native MCP discovery, independently of
401
602
  * pi. An explicit extension directory contributes mcp.json without redirecting
402
603
  * the user's config or credentials. Verified against omp 18.12.6.
604
+ *
605
+ * The same directory carries the subagent bridge. omp keeps its child roster
606
+ * on the session event bus; the extension reports it so the sidebar can hang
607
+ * those children under the parent subshell. omp offers no extension API for
608
+ * moving the parent's terminal onto a child, so navigation stays unavailable.
403
609
  */
404
610
  const createPlugin = () => ({
405
- capabilities: () => ["mcp", "settings"],
611
+ subagents: {
612
+ discovery: "extension",
613
+ navigation: "unavailable"
614
+ },
615
+ capabilities: () => [
616
+ "subagents",
617
+ "mcp",
618
+ "settings"
619
+ ],
406
620
  /** `π <state> <label>`: the label behind the brand and run state is the name. */
407
621
  parsePaneTitle: (raw) => raw.replace(TITLE_PREFIX, "").trim(),
408
622
  buildCommand({ binary, preset, extraFlags, mcp }) {
@@ -432,9 +646,10 @@ const createPlugin = () => ({
432
646
  name: "subshell-mcp",
433
647
  private: true,
434
648
  type: "module",
435
- omp: { extensions: ["bridge.mjs"] }
649
+ omp: { extensions: ["bridge.mjs", "subagents.mjs"] }
436
650
  }),
437
651
  "bridge.mjs": "export default function () {}\n",
652
+ "subagents.mjs": SUBAGENT_BRIDGE,
438
653
  "mcp.json": fileContent
439
654
  },
440
655
  args: ["--extension", `${configPath}.d`]
@@ -443,7 +658,7 @@ const createPlugin = () => ({
443
658
  mcpSetup() {
444
659
  return {
445
660
  mode: "auto",
446
- summary: "Subshell registers its MCP server through a per-launch Oh My Pi extension. Your existing configuration stays in place."
661
+ summary: "Subshell registers its MCP server and reports the pane's subagents through a per-launch Oh My Pi extension. Your existing configuration stays in place."
447
662
  };
448
663
  },
449
664
  validatePreset: validateGenericPreset,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@subshell-ai/plugin-oh-my-pi",
3
3
  "description": "Oh My Pi harness plugin for Subshell",
4
- "version": "0.2.1",
4
+ "version": "0.2.2",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",