@subshell-ai/plugin-oh-my-pi 0.2.0 → 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
@@ -1,4 +1,4 @@
1
- //#region ../../plugin-api/dist/types-CW2dMAMW.d.ts
1
+ //#region ../../plugin-api/dist/types-DPriUYLa.d.ts
2
2
  /** How the plugin learns about children: harness lifecycle hooks, or a loaded extension. */
3
3
  type HarnessSubagentDiscovery = "hooks" | "extension";
4
4
  /** Whether the sidebar can move the parent's own view to a child, or the harness keeps that in-app. */
@@ -397,6 +397,19 @@ interface SubshellPlugin {
397
397
  * used as-is, which is right for every harness that prints just a version.
398
398
  */
399
399
  parseVersion?(raw: string): string | null;
400
+ /**
401
+ * Turns the harness's own terminal title into the subshell name the rail
402
+ * shows.
403
+ *
404
+ * The host removes escape sequences and control bytes and trims the result
405
+ * before calling this, so `raw` is safe text. Interpret what the title adds
406
+ * around the session name — a brand, a run-state separator, a cycling
407
+ * status glyph — and return the name behind it. Return "" when the title
408
+ * names nothing, and the caller keeps the name the pane already had. Omit
409
+ * the member when the harness titles its pane with the name the host
410
+ * supplied, which needs no interpretation.
411
+ */
412
+ parsePaneTitle?(raw: string): string;
400
413
  /** Overrides manifest-driven detection. Almost no plugin needs this. */
401
414
  detect?(): Promise<DetectionResult>;
402
415
  /** Restart-resume support. Declare the `resume` capability with it. */
@@ -487,6 +500,24 @@ interface InstallSpec {
487
500
  */
488
501
  docsUrl: string;
489
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
+ }
490
521
  /** One step an operator must run themselves, because the host may not. */
491
522
  interface PrivilegedStep {
492
523
  /** What it does, in a few words: "Install the daemon" */
@@ -630,6 +661,11 @@ interface SubshellManifest {
630
661
  detect?: DetectSpec;
631
662
  /** Install guidance shown when the binary is missing */
632
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;
633
669
  /**
634
670
  * Environment variables this plugin computes against (spec 2026-09-10 §5).
635
671
  *
@@ -664,6 +700,11 @@ declare const manifest: SubshellManifest;
664
700
  * Oh My Pi uses its own executable and native MCP discovery, independently of
665
701
  * pi. An explicit extension directory contributes mcp.json without redirecting
666
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.
667
708
  */
668
709
  declare const createPlugin: HarnessPluginFactory;
669
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.0",
489
+ version: "0.2.2",
289
490
  license: "Apache-2.0",
290
491
  type: "module",
291
492
  main: "dist/index.js",
@@ -315,7 +516,7 @@ const parsed = parseManifest({
315
516
  "@internal/tsconfig": "workspace:*",
316
517
  "@subshell-ai/plugin-api": "workspace:*",
317
518
  "@types/bun": "1.3.14",
318
- "hash-runner": "4.0.0",
519
+ "hash-runner": "5.0.0",
319
520
  "tsdown": "0.22.14",
320
521
  "typescript": "7.0.2"
321
522
  },
@@ -375,12 +576,49 @@ const SETTINGS = [
375
576
  }
376
577
  ];
377
578
  /**
579
+ * omp composes its terminal title from a brand, one column of run state, and
580
+ * the session label (title-generator's `buildTerminalTitleWithState`):
581
+ * `π > label` while idle, `π <spinner> label` while working, `π ! label` when
582
+ * blocked, and `π : label` when the working separator is pinned to the static
583
+ * colon (WSL, or a spinner pinned off); with titles disabled the form is
584
+ * `π: label`. The brand and the state separator describe the terminal, not the
585
+ * session, and mirroring the whole string read `π ⠋ 🚌 Fix the thing` in the
586
+ * rail — a stuck-looking spinner in front of every name (operator screenshot,
587
+ * 2026-11-09). The label keeps its emoji: the generator starts the session
588
+ * name with one.
589
+ *
590
+ * The whitespace is required where the generator writes one (brand, space,
591
+ * separator), and the disabled form's colon is followed by a space, so a label
592
+ * that merely starts with the brand — `π-calculus`, `π/2`, `π:notes` — keeps
593
+ * it. A bare brand appears for a moment before the session name lands; it
594
+ * parses to nothing, and the caller keeps the name it already had. The spinner
595
+ * set covers every style omp ships: braille frames (U+2801–U+28FF, which holds
596
+ * the activity and dots sets), the pulse moon, and the plain-ASCII line
597
+ * frames.
598
+ */
599
+ const TITLE_PREFIX = /^π(?:$|\s+(?:[>!:]|[⠁-⣿]|[◔◑◕○●]|[-\\/|])|:(?=\s|$))\s*/u;
600
+ /**
378
601
  * Oh My Pi uses its own executable and native MCP discovery, independently of
379
602
  * pi. An explicit extension directory contributes mcp.json without redirecting
380
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.
381
609
  */
382
610
  const createPlugin = () => ({
383
- capabilities: () => ["mcp", "settings"],
611
+ subagents: {
612
+ discovery: "extension",
613
+ navigation: "unavailable"
614
+ },
615
+ capabilities: () => [
616
+ "subagents",
617
+ "mcp",
618
+ "settings"
619
+ ],
620
+ /** `π <state> <label>`: the label behind the brand and run state is the name. */
621
+ parsePaneTitle: (raw) => raw.replace(TITLE_PREFIX, "").trim(),
384
622
  buildCommand({ binary, preset, extraFlags, mcp }) {
385
623
  const args = [binary, ...mcp?.args ?? []];
386
624
  const settings = preset.settings ?? {};
@@ -408,9 +646,10 @@ const createPlugin = () => ({
408
646
  name: "subshell-mcp",
409
647
  private: true,
410
648
  type: "module",
411
- omp: { extensions: ["bridge.mjs"] }
649
+ omp: { extensions: ["bridge.mjs", "subagents.mjs"] }
412
650
  }),
413
651
  "bridge.mjs": "export default function () {}\n",
652
+ "subagents.mjs": SUBAGENT_BRIDGE,
414
653
  "mcp.json": fileContent
415
654
  },
416
655
  args: ["--extension", `${configPath}.d`]
@@ -419,7 +658,7 @@ const createPlugin = () => ({
419
658
  mcpSetup() {
420
659
  return {
421
660
  mode: "auto",
422
- 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."
423
662
  };
424
663
  },
425
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.0",
4
+ "version": "0.2.2",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -35,7 +35,7 @@
35
35
  "@internal/tsconfig": "workspace:*",
36
36
  "@subshell-ai/plugin-api": "workspace:*",
37
37
  "@types/bun": "1.3.14",
38
- "hash-runner": "4.0.0",
38
+ "hash-runner": "5.0.0",
39
39
  "tsdown": "0.22.14",
40
40
  "typescript": "7.0.2"
41
41
  },