@subshell-ai/plugin-oh-my-pi 0.2.1 → 0.2.3
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 +4 -5
- package/dist/index.d.ts +28 -0
- package/dist/index.js +383 -6
- package/package.json +1 -1
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,
|
|
5
|
-
configuration. Install and authenticate Oh My
|
|
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
|
|
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-
|
|
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.
|
|
489
|
+
version: "0.2.3",
|
|
289
490
|
license: "Apache-2.0",
|
|
290
491
|
type: "module",
|
|
291
492
|
main: "dist/index.js",
|
|
@@ -397,12 +598,182 @@ const SETTINGS = [
|
|
|
397
598
|
*/
|
|
398
599
|
const TITLE_PREFIX = /^π(?:$|\s+(?:[>!:]|[⠁-⣿]|[◔◑◕○●]|[-\\/|])|:(?=\s|$))\s*/u;
|
|
399
600
|
/**
|
|
601
|
+
* Loads that provider from the environment. The source is fixed: it names the
|
|
602
|
+
* variable and never interpolates its value, so no preset data (and no
|
|
603
|
+
* credential) is written to a node. A launch without the variable loads
|
|
604
|
+
* nothing, which keeps the module inert for every other omp preset.
|
|
605
|
+
*
|
|
606
|
+
* The value is preset-controlled, so malformed or incomplete JSON leaves the
|
|
607
|
+
* module inert too. It must match what `model-cli omp` generates and nothing
|
|
608
|
+
* else: the provider keeps model-cli's namespace, so a preset cannot override
|
|
609
|
+
* a built-in provider; the apiKey is the env-var name rather than a literal
|
|
610
|
+
* secret; and no field may carry omp's `!command` config syntax, which would
|
|
611
|
+
* run shell commands as the pane user. Registration is queued outside the
|
|
612
|
+
* extension factory's error guard, so a value omp would reject there would
|
|
613
|
+
* take the pane down at startup instead of warning. One debug line names the
|
|
614
|
+
* problem, never the value.
|
|
615
|
+
*
|
|
616
|
+
* A preset built from `model-cli omp` used to name its provider by an
|
|
617
|
+
* `--extension` path in the generating host's cache, so it only worked on
|
|
618
|
+
* machines where model-cli had run. This module rides the per-launch
|
|
619
|
+
* extension directory the host already ships to every node instead.
|
|
620
|
+
*/
|
|
621
|
+
const MODEL_CLI_PROVIDER_SHIM = `export default function(pi) {
|
|
622
|
+
const raw = process.env.MODEL_CLI_OMP_CONFIG;
|
|
623
|
+
if (!raw) return;
|
|
624
|
+
let config;
|
|
625
|
+
try {
|
|
626
|
+
config = JSON.parse(raw);
|
|
627
|
+
} catch {
|
|
628
|
+
pi.logger?.debug?.("model-cli provider config ignored: MODEL_CLI_OMP_CONFIG is not JSON");
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const CONNECTION_KEYS = ["baseUrl", "api", "apiKey", "models"];
|
|
633
|
+
const MODEL_KEYS = [
|
|
634
|
+
"id",
|
|
635
|
+
"name",
|
|
636
|
+
"contextWindow",
|
|
637
|
+
"maxTokens",
|
|
638
|
+
"reasoning",
|
|
639
|
+
"input",
|
|
640
|
+
"cost",
|
|
641
|
+
"compat",
|
|
642
|
+
"thinking",
|
|
643
|
+
];
|
|
644
|
+
const COST_KEYS = ["input", "output", "cacheRead", "cacheWrite"];
|
|
645
|
+
const COMPAT_KEYS = [
|
|
646
|
+
"maxTokensField",
|
|
647
|
+
"reasoningContentField",
|
|
648
|
+
"replayReasoningContent",
|
|
649
|
+
"requiresReasoningContentForAllAssistantTurns",
|
|
650
|
+
"supportsDeveloperRole",
|
|
651
|
+
"supportsStore",
|
|
652
|
+
"supportsStrictMode",
|
|
653
|
+
"thinkingFormat",
|
|
654
|
+
];
|
|
655
|
+
const THINKING_KEYS = ["mode", "efforts", "effortMap"];
|
|
656
|
+
|
|
657
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
658
|
+
const only = (value, keys) => Object.keys(value).every((key) => keys.includes(key));
|
|
659
|
+
const text = (value) => typeof value === "string" && value !== "";
|
|
660
|
+
const amount = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
661
|
+
const positive = (value) => amount(value) && value > 0;
|
|
662
|
+
const command = (value) => {
|
|
663
|
+
if (typeof value === "string") return value.startsWith("!");
|
|
664
|
+
if (Array.isArray(value)) return value.some(command);
|
|
665
|
+
if (isObject(value)) return Object.values(value).some(command);
|
|
666
|
+
return false;
|
|
667
|
+
};
|
|
668
|
+
const cost = (value) => isObject(value) && only(value, COST_KEYS) && Object.values(value).every(amount);
|
|
669
|
+
const compat = (value) =>
|
|
670
|
+
isObject(value) &&
|
|
671
|
+
only(value, COMPAT_KEYS) &&
|
|
672
|
+
Object.values(value).every((item) => typeof item === "string" || typeof item === "boolean");
|
|
673
|
+
const thinking = (value) =>
|
|
674
|
+
isObject(value) &&
|
|
675
|
+
only(value, THINKING_KEYS) &&
|
|
676
|
+
(value.mode === "anthropic-adaptive" || value.mode === "effort") &&
|
|
677
|
+
Array.isArray(value.efforts) &&
|
|
678
|
+
value.efforts.every(text) &&
|
|
679
|
+
isObject(value.effortMap) &&
|
|
680
|
+
Object.values(value.effortMap).every(text);
|
|
681
|
+
const model = (value) =>
|
|
682
|
+
isObject(value) &&
|
|
683
|
+
only(value, MODEL_KEYS) &&
|
|
684
|
+
text(value.id) &&
|
|
685
|
+
(value.name === undefined || text(value.name)) &&
|
|
686
|
+
(value.contextWindow === undefined || positive(value.contextWindow)) &&
|
|
687
|
+
(value.maxTokens === undefined || positive(value.maxTokens)) &&
|
|
688
|
+
(value.reasoning === undefined || typeof value.reasoning === "boolean") &&
|
|
689
|
+
(value.input === undefined ||
|
|
690
|
+
(Array.isArray(value.input) && value.input.every((item) => item === "text" || item === "image"))) &&
|
|
691
|
+
(value.cost === undefined || cost(value.cost)) &&
|
|
692
|
+
(value.compat === undefined || compat(value.compat)) &&
|
|
693
|
+
(value.thinking === undefined || thinking(value.thinking));
|
|
694
|
+
|
|
695
|
+
const connection = config?.connection;
|
|
696
|
+
const models = connection?.models;
|
|
697
|
+
const usable =
|
|
698
|
+
typeof config?.provider === "string" &&
|
|
699
|
+
/^model-cli-omp-(?:litellm|direct)-\\S+$/.test(config.provider) &&
|
|
700
|
+
isObject(connection) &&
|
|
701
|
+
only(connection, CONNECTION_KEYS) &&
|
|
702
|
+
typeof connection.baseUrl === "string" &&
|
|
703
|
+
/^https?:\\/\\/.+/.test(connection.baseUrl) &&
|
|
704
|
+
(connection.api === "anthropic-messages" || connection.api === "openai-completions") &&
|
|
705
|
+
connection.apiKey === "MODEL_CLI_OMP_API_KEY" &&
|
|
706
|
+
Array.isArray(models) &&
|
|
707
|
+
models.length > 0 &&
|
|
708
|
+
models.every(model) &&
|
|
709
|
+
!command(config);
|
|
710
|
+
if (!usable) {
|
|
711
|
+
pi.logger?.debug?.("model-cli provider config ignored: MODEL_CLI_OMP_CONFIG is not the model-cli shape");
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
pi.registerProvider(config.provider, connection);
|
|
715
|
+
}
|
|
716
|
+
`;
|
|
717
|
+
/**
|
|
718
|
+
* A preset built from `model-cli omp` used to prove its provider with an
|
|
719
|
+
* extension path in the generating host's cache. That file is writable by
|
|
720
|
+
* anything running as the pane user and persists between launches, so the
|
|
721
|
+
* provider module below is a replacement, not a companion: the legacy flag is
|
|
722
|
+
* dropped when this launch writes its own module. omp accepts the path as
|
|
723
|
+
* `--extension`, `--trusted-extension`, `-e` or `--hook`, so every spelling is
|
|
724
|
+
* matched, and the path is normalized first because `./` and doubled
|
|
725
|
+
* separators name the same file.
|
|
726
|
+
*/
|
|
727
|
+
const LEGACY_PROVIDER_FLAGS = [
|
|
728
|
+
"--extension",
|
|
729
|
+
"--trusted-extension",
|
|
730
|
+
"-e",
|
|
731
|
+
"--hook"
|
|
732
|
+
];
|
|
733
|
+
const LEGACY_PROVIDER_INLINE = [
|
|
734
|
+
"--extension=",
|
|
735
|
+
"--trusted-extension=",
|
|
736
|
+
"-e=",
|
|
737
|
+
"--hook="
|
|
738
|
+
];
|
|
739
|
+
function isLegacyProviderPath(value) {
|
|
740
|
+
const segments = value.trim().replace(/\\/g, "/").split("/").filter((segment) => segment !== "" && segment !== ".");
|
|
741
|
+
return /(?:^|\/)model-cli\/omp-provider\.mjs$/.test(segments.join("/"));
|
|
742
|
+
}
|
|
743
|
+
function withoutLegacyProviderExtension(flags) {
|
|
744
|
+
const kept = [];
|
|
745
|
+
for (let i = 0; i < flags.length; i++) {
|
|
746
|
+
const flag = flags[i];
|
|
747
|
+
if (LEGACY_PROVIDER_FLAGS.includes(flag) && isLegacyProviderPath(flags[i + 1] ?? "")) {
|
|
748
|
+
i += 1;
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
const inline = LEGACY_PROVIDER_INLINE.find((prefix) => flag.startsWith(prefix));
|
|
752
|
+
if (inline && isLegacyProviderPath(flag.slice(inline.length))) continue;
|
|
753
|
+
kept.push(flag);
|
|
754
|
+
}
|
|
755
|
+
return kept;
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
400
758
|
* Oh My Pi uses its own executable and native MCP discovery, independently of
|
|
401
759
|
* pi. An explicit extension directory contributes mcp.json without redirecting
|
|
402
760
|
* the user's config or credentials. Verified against omp 18.12.6.
|
|
761
|
+
*
|
|
762
|
+
* The same directory carries the subagent bridge. omp keeps its child roster
|
|
763
|
+
* on the session event bus; the extension reports it so the sidebar can hang
|
|
764
|
+
* those children under the parent subshell. omp offers no extension API for
|
|
765
|
+
* moving the parent's terminal onto a child, so navigation stays unavailable.
|
|
403
766
|
*/
|
|
404
767
|
const createPlugin = () => ({
|
|
405
|
-
|
|
768
|
+
subagents: {
|
|
769
|
+
discovery: "extension",
|
|
770
|
+
navigation: "unavailable"
|
|
771
|
+
},
|
|
772
|
+
capabilities: () => [
|
|
773
|
+
"subagents",
|
|
774
|
+
"mcp",
|
|
775
|
+
"settings"
|
|
776
|
+
],
|
|
406
777
|
/** `π <state> <label>`: the label behind the brand and run state is the name. */
|
|
407
778
|
parsePaneTitle: (raw) => raw.replace(TITLE_PREFIX, "").trim(),
|
|
408
779
|
buildCommand({ binary, preset, extraFlags, mcp }) {
|
|
@@ -416,7 +787,7 @@ const createPlugin = () => ({
|
|
|
416
787
|
const value = settings[key];
|
|
417
788
|
if (typeof value === "string" && value) args.push(`--${key}`, value);
|
|
418
789
|
}
|
|
419
|
-
args.push(...preset.flags, ...extraFlags ?? []);
|
|
790
|
+
args.push(...mcp ? withoutLegacyProviderExtension(preset.flags) : preset.flags, ...extraFlags ?? []);
|
|
420
791
|
return args;
|
|
421
792
|
},
|
|
422
793
|
mcpRegistration(launch, configPath) {
|
|
@@ -432,9 +803,15 @@ const createPlugin = () => ({
|
|
|
432
803
|
name: "subshell-mcp",
|
|
433
804
|
private: true,
|
|
434
805
|
type: "module",
|
|
435
|
-
omp: { extensions: [
|
|
806
|
+
omp: { extensions: [
|
|
807
|
+
"bridge.mjs",
|
|
808
|
+
"subagents.mjs",
|
|
809
|
+
"provider.mjs"
|
|
810
|
+
] }
|
|
436
811
|
}),
|
|
437
812
|
"bridge.mjs": "export default function () {}\n",
|
|
813
|
+
"subagents.mjs": SUBAGENT_BRIDGE,
|
|
814
|
+
"provider.mjs": MODEL_CLI_PROVIDER_SHIM,
|
|
438
815
|
"mcp.json": fileContent
|
|
439
816
|
},
|
|
440
817
|
args: ["--extension", `${configPath}.d`]
|
|
@@ -443,7 +820,7 @@ const createPlugin = () => ({
|
|
|
443
820
|
mcpSetup() {
|
|
444
821
|
return {
|
|
445
822
|
mode: "auto",
|
|
446
|
-
summary: "Subshell registers its MCP server through a per-launch Oh My Pi extension. Your existing configuration stays in place."
|
|
823
|
+
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
824
|
};
|
|
448
825
|
},
|
|
449
826
|
validatePreset: validateGenericPreset,
|