@subshell-ai/plugin-codex 1.0.1 → 1.0.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/dist/index.d.ts +153 -6
- package/dist/index.js +199 -11
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,104 @@
|
|
|
1
|
-
//#region ../../plugin-api/dist/types-
|
|
1
|
+
//#region ../../plugin-api/dist/types-D2qRfuN_.d.ts
|
|
2
|
+
//#region src/previews.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* How a harness reports loopback ports its own tooling started, for the
|
|
5
|
+
* preview picker.
|
|
6
|
+
*
|
|
7
|
+
* Port discovery attributes sockets by walking the pane's process tree. A
|
|
8
|
+
* harness that brokers its tool shells through a shared daemon (oh-my-pi
|
|
9
|
+
* does) leaves that tree, so a server its tools start is reachable but
|
|
10
|
+
* invisible. A plugin that declares the `previews` capability ships a
|
|
11
|
+
* reporter the control plane can list those ports from.
|
|
12
|
+
*
|
|
13
|
+
* The report carries ports only, never a host: the node dials loopback, so a
|
|
14
|
+
* report cannot point the proxy at another machine.
|
|
15
|
+
*/
|
|
16
|
+
type HarnessPreviewReporting = "extension" | "hooks";
|
|
17
|
+
/**
|
|
18
|
+
* What a harness declares about its preview reporting. The rules mirror
|
|
19
|
+
* {@link HarnessSubagentSupport}: `reporting` is checked against the union at
|
|
20
|
+
* load, and `setup` is the copy the preset editor shows when a human act is
|
|
21
|
+
* needed before reports start.
|
|
22
|
+
*/
|
|
23
|
+
interface HarnessPreviews {
|
|
24
|
+
/** How reports reach the plane: a per-launch extension, or harness lifecycle hooks. */
|
|
25
|
+
reporting: HarnessPreviewReporting;
|
|
26
|
+
/** One-time human action required for reporting to work, if any. */
|
|
27
|
+
setup?: string;
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/subagents.d.ts
|
|
31
|
+
/** What a reported agent is doing, as the harness itself says. */
|
|
32
|
+
type HarnessSubagentStatus = "running" | "waiting" | "idle";
|
|
33
|
+
/** A harness agent belongs to an existing terminal, never to a new subshell. */
|
|
34
|
+
interface HarnessSubagent {
|
|
35
|
+
/** Harness-side identifier; unique among this parent's current children */
|
|
36
|
+
id: string;
|
|
37
|
+
/** Parent agent within the harness, when the harness nests */
|
|
38
|
+
parentId?: string;
|
|
39
|
+
/** Display name as the harness reports it; generic ones render numbered */
|
|
40
|
+
name: string;
|
|
41
|
+
/** What the harness says this agent is doing */
|
|
42
|
+
status: HarnessSubagentStatus;
|
|
43
|
+
/**
|
|
44
|
+
* The harness's own one-line label for what this child was asked to do,
|
|
45
|
+
* when the harness has one to give. The host bounds it and normalizes it
|
|
46
|
+
* like a name; it is the harness's summary of the task, never the prompt
|
|
47
|
+
* text or anything read from the child's transcript.
|
|
48
|
+
*/
|
|
49
|
+
activity?: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A reported child as the control plane SERVES it: what the harness said, plus
|
|
53
|
+
* the server's own observation of when the child appeared. Viewers derive
|
|
54
|
+
* elapsed time from that stamp against their own clock, so a roster that
|
|
55
|
+
* stopped being polled ages honestly instead of freezing at its last read.
|
|
56
|
+
*/
|
|
57
|
+
interface HarnessSubagentView extends HarnessSubagent {
|
|
58
|
+
/** Epoch milliseconds of when the server first saw this child; never sent by a harness */
|
|
59
|
+
startedAt: number;
|
|
60
|
+
}
|
|
2
61
|
/** How the plugin learns about children: harness lifecycle hooks, or a loaded extension. */
|
|
3
62
|
type HarnessSubagentDiscovery = "hooks" | "extension";
|
|
4
63
|
/** Whether the sidebar can move the parent's own view to a child, or the harness keeps that in-app. */
|
|
5
64
|
type HarnessSubagentNavigation = "native" | "unavailable";
|
|
65
|
+
/**
|
|
66
|
+
* Pane operations the host hands an integration that drives its harness's
|
|
67
|
+
* own agent picker. The plugin runs in the control plane, so it cannot open a
|
|
68
|
+
* terminal itself; these are the only two acts it needs, and neither submits
|
|
69
|
+
* a prompt on its own.
|
|
70
|
+
*/
|
|
71
|
+
interface HarnessSubagentPane {
|
|
72
|
+
/** The parent pane's current screen with ANSI stripped, exactly as painted. */
|
|
73
|
+
read(): Promise<string>;
|
|
74
|
+
/**
|
|
75
|
+
* Type raw bytes into the pane's own input. Nothing is appended or
|
|
76
|
+
* translated: send `\r` for Enter, `\u001b[1;3D` for Alt+Left, and so on.
|
|
77
|
+
*/
|
|
78
|
+
type(text: string): Promise<void>;
|
|
79
|
+
}
|
|
80
|
+
/** What the host hands an integration's {@link HarnessSubagentSupport.select}. */
|
|
81
|
+
interface HarnessSubagentSelectInput {
|
|
82
|
+
/** The child to show, or null to return the parent itself */
|
|
83
|
+
agentId: string | null;
|
|
84
|
+
/** The roster the child came from, server-stamped and bounded */
|
|
85
|
+
agents: readonly HarnessSubagentView[];
|
|
86
|
+
/** The parent terminal this selection must happen in */
|
|
87
|
+
pane: HarnessSubagentPane;
|
|
88
|
+
}
|
|
89
|
+
/** What the integration must report back after the harness actually switched. */
|
|
90
|
+
interface HarnessSubagentSelectResult {
|
|
91
|
+
/** The view the harness showed when the act finished; null for the parent */
|
|
92
|
+
selectedId: string | null;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* An integration's own way to move its terminal's view. This is for harnesses
|
|
96
|
+
* whose picker or inspector exists only inside their TUI (Codex's `/subagents`,
|
|
97
|
+
* Claude's agent panel, pi's fleet inspector): the integration reads the pane,
|
|
98
|
+
* sends the harness's own keys, and reports the view it verified. Extensions
|
|
99
|
+
* without a pane driver keep using the report-loop command.
|
|
100
|
+
*/
|
|
101
|
+
type HarnessSubagentSelect = (input: HarnessSubagentSelectInput) => Promise<HarnessSubagentSelectResult>;
|
|
6
102
|
/**
|
|
7
103
|
* What a plugin declares about its harness's child agents. The host uses
|
|
8
104
|
* `discovery`/`navigation` to decide what the sidebar may offer; `setup`, when
|
|
@@ -16,6 +112,13 @@ interface HarnessSubagentSupport {
|
|
|
16
112
|
navigation: HarnessSubagentNavigation;
|
|
17
113
|
/** One-time user action required for reporting to work, if any */
|
|
18
114
|
setup?: string;
|
|
115
|
+
/**
|
|
116
|
+
* The integration's own pane driver, when it has one. Requires
|
|
117
|
+
* `navigation: "native"`; the host uses it instead of queueing a command.
|
|
118
|
+
* Works for hook and extension discovery alike: the host only needs the
|
|
119
|
+
* pane's read/type, and the edit gate is the same.
|
|
120
|
+
*/
|
|
121
|
+
select?: HarnessSubagentSelect;
|
|
19
122
|
}
|
|
20
123
|
//#endregion
|
|
21
124
|
//#region src/types.d.ts
|
|
@@ -43,7 +146,7 @@ type PluginType = "agent-harness" | "terminal" | "network";
|
|
|
43
146
|
* and a network plugin claiming `resume` are both refused at load, for the
|
|
44
147
|
* same reason a claimed-but-unimplemented capability is.
|
|
45
148
|
*/
|
|
46
|
-
type PluginCapability = "mcp" | "resume" | "attention" | "subagents" | "publish" | "supervise" | "guard" | "settings";
|
|
149
|
+
type PluginCapability = "mcp" | "resume" | "attention" | "subagents" | "previews" | "publish" | "supervise" | "guard" | "settings";
|
|
47
150
|
/** A harness preset as defined by the user (decoded JSON blobs). */
|
|
48
151
|
interface PresetDefinition {
|
|
49
152
|
/** Human-friendly preset name */
|
|
@@ -380,6 +483,13 @@ interface PluginSecrets {
|
|
|
380
483
|
interface SubshellPlugin {
|
|
381
484
|
/** Native child-agent reporting and optional in-place navigation. */
|
|
382
485
|
subagents?: HarnessSubagentSupport;
|
|
486
|
+
/**
|
|
487
|
+
* Native preview-port reporting: the harness tells the plane which loopback
|
|
488
|
+
* ports its tooling serves, so the picker lists servers discovery cannot
|
|
489
|
+
* attribute to the pane's process tree. Declare the `previews` capability
|
|
490
|
+
* with it.
|
|
491
|
+
*/
|
|
492
|
+
previews?: HarnessPreviews;
|
|
383
493
|
/** Builds the argv (no shell) used to launch a subshell. */
|
|
384
494
|
buildCommand(input: BuildCommandInput): string[];
|
|
385
495
|
/** Validates a preset definition before saving. */
|
|
@@ -397,6 +507,19 @@ interface SubshellPlugin {
|
|
|
397
507
|
* used as-is, which is right for every harness that prints just a version.
|
|
398
508
|
*/
|
|
399
509
|
parseVersion?(raw: string): string | null;
|
|
510
|
+
/**
|
|
511
|
+
* Turns the harness's own terminal title into the subshell name the rail
|
|
512
|
+
* shows.
|
|
513
|
+
*
|
|
514
|
+
* The host removes escape sequences and control bytes and trims the result
|
|
515
|
+
* before calling this, so `raw` is safe text. Interpret what the title adds
|
|
516
|
+
* around the session name — a brand, a run-state separator, a cycling
|
|
517
|
+
* status glyph — and return the name behind it. Return "" when the title
|
|
518
|
+
* names nothing, and the caller keeps the name the pane already had. Omit
|
|
519
|
+
* the member when the harness titles its pane with the name the host
|
|
520
|
+
* supplied, which needs no interpretation.
|
|
521
|
+
*/
|
|
522
|
+
parsePaneTitle?(raw: string): string;
|
|
400
523
|
/** Overrides manifest-driven detection. Almost no plugin needs this. */
|
|
401
524
|
detect?(): Promise<DetectionResult>;
|
|
402
525
|
/** Restart-resume support. Declare the `resume` capability with it. */
|
|
@@ -487,6 +610,24 @@ interface InstallSpec {
|
|
|
487
610
|
*/
|
|
488
611
|
docsUrl: string;
|
|
489
612
|
}
|
|
613
|
+
/**
|
|
614
|
+
* How this plugin's harness CLI updates itself in place.
|
|
615
|
+
*
|
|
616
|
+
* The installer doubles as the updater for every built-in today (`curl … | sh`
|
|
617
|
+
* and `npm i -g` lines are idempotent), so this is OPTIONAL and falls back to
|
|
618
|
+
* {@link InstallSpec}. Declare it where the vendor ships a dedicated verb or a
|
|
619
|
+
* different channel — `claude update`, say — so pressing Update runs the
|
|
620
|
+
* vendor's own update path rather than reinstalling.
|
|
621
|
+
*
|
|
622
|
+
* Like `install.command`, this is a SHELL LINE the host runs through `sh -c`,
|
|
623
|
+
* on the node that holds the harness, and it may not need privileges.
|
|
624
|
+
*/
|
|
625
|
+
interface UpdateSpec {
|
|
626
|
+
/** Shell line that updates the CLI in place. */
|
|
627
|
+
command: string;
|
|
628
|
+
/** Where the vendor documents it; http(s) only, when present. */
|
|
629
|
+
docsUrl?: string;
|
|
630
|
+
}
|
|
490
631
|
/** One step an operator must run themselves, because the host may not. */
|
|
491
632
|
interface PrivilegedStep {
|
|
492
633
|
/** What it does, in a few words: "Install the daemon" */
|
|
@@ -630,6 +771,11 @@ interface SubshellManifest {
|
|
|
630
771
|
detect?: DetectSpec;
|
|
631
772
|
/** Install guidance shown when the binary is missing */
|
|
632
773
|
install?: InstallSpec;
|
|
774
|
+
/**
|
|
775
|
+
* How the CLI updates itself, when the installer is not the updater.
|
|
776
|
+
* Absent means `install.command` is the update path too.
|
|
777
|
+
*/
|
|
778
|
+
update?: UpdateSpec;
|
|
633
779
|
/**
|
|
634
780
|
* Environment variables this plugin computes against (spec 2026-09-10 §5).
|
|
635
781
|
*
|
|
@@ -676,7 +822,8 @@ declare const manifest: SubshellManifest;
|
|
|
676
822
|
* (a user may put it in their own preset flags — that is their call).
|
|
677
823
|
*
|
|
678
824
|
* subshell MCP is wired automatically WITHOUT touching user state: every
|
|
679
|
-
* launch carries `-c mcp_servers.subshell.command
|
|
825
|
+
* launch carries `-c mcp_servers.subshell.command=…`,
|
|
826
|
+
* `-c mcp_servers.subshell.args=…` and `-c mcp_servers.subshell.env_vars=…`
|
|
680
827
|
* config overrides (dotted path, value parsed as TOML — verified against the
|
|
681
828
|
* real binary), which codex merges over ~/.codex/config.toml for that run
|
|
682
829
|
* only. The subagent monitoring hooks ride the same mechanism as
|
|
@@ -684,9 +831,9 @@ declare const manifest: SubshellManifest;
|
|
|
684
831
|
* `hooks.*` table is REPLACED for the duration of a subshell-launched run —
|
|
685
832
|
* the documented limitation, same shape as claude-code's; nothing on disk
|
|
686
833
|
* changes. No config file the harness must read, no CODEX_HOME redirect (that
|
|
687
|
-
* dir holds the user's auth.json). The spawned `subshell mcp` child
|
|
688
|
-
*
|
|
689
|
-
*
|
|
834
|
+
* dir holds the user's auth.json). The spawned `subshell mcp` child needs the
|
|
835
|
+
* pane's SUBSHELL_* identity, and Codex filters a child's environment, so the
|
|
836
|
+
* env_vars override re-forwards those names (never values) at each launch.
|
|
690
837
|
* (Flags/values verified against @openai/codex 2026-09 help output.)
|
|
691
838
|
*
|
|
692
839
|
* Identity, detection and install guidance live in this package's
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region ../../plugin-api/dist/manifest-
|
|
1
|
+
//#region ../../plugin-api/dist/manifest-CrULrf80.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 } : {}
|
|
@@ -285,7 +299,7 @@ function validateGenericPreset(preset) {
|
|
|
285
299
|
const parsed = parseManifest({
|
|
286
300
|
name: "@subshell-ai/plugin-codex",
|
|
287
301
|
description: "Codex harness plugin for Subshell",
|
|
288
|
-
version: "1.0.
|
|
302
|
+
version: "1.0.3",
|
|
289
303
|
license: "Apache-2.0",
|
|
290
304
|
type: "module",
|
|
291
305
|
main: "dist/index.js",
|
|
@@ -315,7 +329,7 @@ const parsed = parseManifest({
|
|
|
315
329
|
"@internal/tsconfig": "workspace:*",
|
|
316
330
|
"@subshell-ai/plugin-api": "workspace:*",
|
|
317
331
|
"@types/bun": "1.3.14",
|
|
318
|
-
"hash-runner": "
|
|
332
|
+
"hash-runner": "5.0.0",
|
|
319
333
|
"tsdown": "0.22.14",
|
|
320
334
|
"typescript": "7.0.2"
|
|
321
335
|
},
|
|
@@ -348,6 +362,14 @@ if ("error" in parsed) throw new Error(`@subshell-ai/plugin-codex has an invalid
|
|
|
348
362
|
const manifest = parsed;
|
|
349
363
|
//#endregion
|
|
350
364
|
//#region src/index.ts
|
|
365
|
+
const MCP_ENV_VARS = [
|
|
366
|
+
"SUBSHELL_API_KEY",
|
|
367
|
+
"SUBSHELL_BASE_URL",
|
|
368
|
+
"SUBSHELL_ID",
|
|
369
|
+
"SUBSHELL_NAME",
|
|
370
|
+
"SUBSHELL_DATA_DIR",
|
|
371
|
+
"SUBSHELL_SERVER_DATA_DIR"
|
|
372
|
+
];
|
|
351
373
|
/** Known Codex settings, applied as per-invocation CLI flags. */
|
|
352
374
|
const CODEX_SETTINGS_FIELDS = [
|
|
353
375
|
{
|
|
@@ -401,6 +423,166 @@ function tomlString(value) {
|
|
|
401
423
|
function tomlStringArray(values) {
|
|
402
424
|
return `[${values.map(tomlString).join(", ")}]`;
|
|
403
425
|
}
|
|
426
|
+
const OVERLAY_MARKER = "Select an agent to watch.";
|
|
427
|
+
const EMPTY_COMPOSER = "Ask Codex to do anything";
|
|
428
|
+
/** ESC [ 1 ; 3 D — Codex's Alt+Left, which returns a subagent view to Main. */
|
|
429
|
+
const ALT_LEFT = "\x1B[1;3D";
|
|
430
|
+
/**
|
|
431
|
+
* The text sitting in Codex's composer, if any. The placeholder is not input;
|
|
432
|
+
* the overlay's numbered lines are not the composer either. Returning a
|
|
433
|
+
* non-empty string means typing `/subagents` would append to a real prompt,
|
|
434
|
+
* so the picker act refuses instead of clobbering it.
|
|
435
|
+
*/
|
|
436
|
+
function composerText(screen) {
|
|
437
|
+
const text = screen.split("\n").reverse().find((row) => /^\s*›\s*/.test(row))?.replace(/^\s*›\s*/, "").trim() ?? "";
|
|
438
|
+
return text === EMPTY_COMPOSER ? "" : text;
|
|
439
|
+
}
|
|
440
|
+
/** One keystroke batch's settle; Codex can fold an Enter sent in the same burst into the paste. */
|
|
441
|
+
const COMMAND_SETTLE_MS = 120;
|
|
442
|
+
/** Main's status line, not a transcript mention of it. */
|
|
443
|
+
function isMainView(screen) {
|
|
444
|
+
const tail = screen.split("\n").filter((line) => line.trim() !== "").slice(-4).join(" ").replace(/\s+/g, " ");
|
|
445
|
+
return tail.includes("Main [default]") && !tail.includes("Viewing sub-agent");
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* A real picker, not the marker text appearing in transcript history. The
|
|
449
|
+
* footer and the first numbered row are what distinguish the overlay.
|
|
450
|
+
*/
|
|
451
|
+
function hasPicker(screen) {
|
|
452
|
+
const overlay = screen.lastIndexOf(OVERLAY_MARKER);
|
|
453
|
+
if (overlay === -1) return false;
|
|
454
|
+
const block = screen.slice(overlay);
|
|
455
|
+
const tail = screen.split("\n").filter((line) => line.trim() !== "").slice(-2).join(" ");
|
|
456
|
+
return /enter select\s*·\s*esc back/.test(tail) && block.includes("enter select") && /^\s*(?:›\s*)?1\.\s+•\s+/m.test(block);
|
|
457
|
+
}
|
|
458
|
+
async function typeCommand(pane, text) {
|
|
459
|
+
await pane.type(text);
|
|
460
|
+
if (await waitForScreen(pane, (screen) => composerText(screen) === text, 1500).catch(() => void 0).then(async (value) => value ?? (composerText(await pane.read()) === text ? "" : void 0)) === void 0) throw new Error("Codex didn't render the command in its composer.");
|
|
461
|
+
await pane.type("\r");
|
|
462
|
+
}
|
|
463
|
+
async function waitForScreen(pane, predicate, timeoutMs) {
|
|
464
|
+
const deadline = Date.now() + timeoutMs;
|
|
465
|
+
let screen = await pane.read();
|
|
466
|
+
while (!predicate(screen)) {
|
|
467
|
+
if (Date.now() >= deadline) throw new Error("Timed out waiting for Codex's agent picker.");
|
|
468
|
+
await new Promise((resolve) => setTimeout(resolve, 75));
|
|
469
|
+
screen = await pane.read();
|
|
470
|
+
}
|
|
471
|
+
return screen;
|
|
472
|
+
}
|
|
473
|
+
/** A closed picker must survive two consecutive reads; a half-painted frame doesn't. */
|
|
474
|
+
async function waitForPickerClosed(pane, timeoutMs) {
|
|
475
|
+
const deadline = Date.now() + timeoutMs;
|
|
476
|
+
let previous;
|
|
477
|
+
while (Date.now() < deadline) {
|
|
478
|
+
const screen = await pane.read();
|
|
479
|
+
if (!hasPicker(screen) && previous !== void 0 && !hasPicker(previous)) return screen;
|
|
480
|
+
previous = screen;
|
|
481
|
+
await new Promise((resolve) => setTimeout(resolve, 75));
|
|
482
|
+
}
|
|
483
|
+
throw new Error("Timed out waiting for Codex's agent picker to close.");
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* The picker's 1-based row for this id, read from the ids it prints. A narrow
|
|
487
|
+
* pane wraps a long id onto the next line, so each numbered row is assembled
|
|
488
|
+
* from its own lines before the id is matched. Reading the ids matters:
|
|
489
|
+
* completed children stay listed in Codex's picker, so a roster position
|
|
490
|
+
* would pick the wrong row as soon as a child finishes.
|
|
491
|
+
*/
|
|
492
|
+
const PICKER_ROW = /^\s*(?:›\s*)?(\d+)\.\s+•\s+(.*)$/;
|
|
493
|
+
const HIGHLIGHTED_ROW = /^\s*›\s*(\d+)\.\s+•\s+(.*)$/;
|
|
494
|
+
const PICKER_ID = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
|
|
495
|
+
function pickerLines(screen) {
|
|
496
|
+
const overlay = screen.lastIndexOf(OVERLAY_MARKER);
|
|
497
|
+
return overlay === -1 ? void 0 : screen.slice(overlay).split("\n");
|
|
498
|
+
}
|
|
499
|
+
/** A row plus up to three indented id fragments; labels and descriptions don't join. */
|
|
500
|
+
function pickerRowText(lines, start, match) {
|
|
501
|
+
let text = match[2] ?? "";
|
|
502
|
+
for (let j = start + 1; j < Math.min(lines.length, start + 4); j++) {
|
|
503
|
+
const next = lines[j] ?? "";
|
|
504
|
+
if (!/^\s+[0-9a-f-]+\s*$/i.test(next)) break;
|
|
505
|
+
text += next;
|
|
506
|
+
}
|
|
507
|
+
return text;
|
|
508
|
+
}
|
|
509
|
+
function pickerRowId(text) {
|
|
510
|
+
return text.replace(/\s+/g, "").match(new RegExp(PICKER_ID.source, "ig"))?.at(-1);
|
|
511
|
+
}
|
|
512
|
+
function pickerIndex(screen, agentId) {
|
|
513
|
+
if (!hasPicker(screen)) return void 0;
|
|
514
|
+
const lines = pickerLines(screen) ?? [];
|
|
515
|
+
let expected = 1;
|
|
516
|
+
let main;
|
|
517
|
+
for (let i = 0; i < lines.length; i++) {
|
|
518
|
+
const match = lines[i]?.match(PICKER_ROW);
|
|
519
|
+
if (!match) continue;
|
|
520
|
+
const index = Number(match[1]);
|
|
521
|
+
if (index !== expected) continue;
|
|
522
|
+
expected = index + 1;
|
|
523
|
+
const text = pickerRowText(lines, i, match);
|
|
524
|
+
if (agentId === null) {
|
|
525
|
+
if (/^\s*Main\s*\[default\]/.test(match[2] ?? "")) main = index;
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
if (pickerRowId(text)?.toLowerCase() === agentId.toLowerCase()) return index;
|
|
529
|
+
}
|
|
530
|
+
return agentId === null ? main : void 0;
|
|
531
|
+
}
|
|
532
|
+
/** The row Codex marks with `›` after a picker keypress, plus its printed id. */
|
|
533
|
+
function highlightedPickerRow(screen) {
|
|
534
|
+
const lines = pickerLines(screen);
|
|
535
|
+
if (!lines) return void 0;
|
|
536
|
+
for (let i = 0; i < lines.length; i++) {
|
|
537
|
+
const match = lines[i]?.match(HIGHLIGHTED_ROW);
|
|
538
|
+
if (match) return {
|
|
539
|
+
index: Number(match[1]),
|
|
540
|
+
text: pickerRowText(lines, i, match),
|
|
541
|
+
label: match[2] ?? ""
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Codex keeps its agent picker entirely inside the TUI. Read the pane, send
|
|
547
|
+
* the picker's own keys, then read it back to confirm the view actually moved.
|
|
548
|
+
* Completed children remain in the list, so the target is chosen by the id
|
|
549
|
+
* printed on its row, never by the server roster's position.
|
|
550
|
+
*/
|
|
551
|
+
async function selectCodexSubagent(input) {
|
|
552
|
+
const { agentId, pane } = input;
|
|
553
|
+
const deadline = Date.now() + 2e4;
|
|
554
|
+
const wait = (ms) => Math.max(0, Math.min(ms, deadline - Date.now()));
|
|
555
|
+
let screen = await pane.read();
|
|
556
|
+
for (let i = 0; i < 64 && Date.now() < deadline && !hasPicker(screen) && !isMainView(screen); i++) {
|
|
557
|
+
await pane.type(ALT_LEFT);
|
|
558
|
+
await new Promise((resolve) => setTimeout(resolve, COMMAND_SETTLE_MS));
|
|
559
|
+
screen = await pane.read();
|
|
560
|
+
}
|
|
561
|
+
if (!hasPicker(screen) && !isMainView(screen)) throw new Error("Codex is showing a subagent and didn't return to Main.");
|
|
562
|
+
if (agentId === null && !hasPicker(screen) && isMainView(screen)) return { selectedId: null };
|
|
563
|
+
if (!hasPicker(screen)) {
|
|
564
|
+
if (composerText(screen)) throw new Error("Finish or clear the prompt before switching agents.");
|
|
565
|
+
await typeCommand(pane, "/subagents");
|
|
566
|
+
screen = await waitForScreen(pane, hasPicker, wait(3e3));
|
|
567
|
+
}
|
|
568
|
+
const index = pickerIndex(screen, agentId);
|
|
569
|
+
if (index === void 0) throw new Error("Codex didn't list that subagent in its picker.");
|
|
570
|
+
await pane.type(String(index));
|
|
571
|
+
screen = await waitForScreen(pane, (next) => !hasPicker(next) || highlightedPickerRow(next)?.index === index, wait(1500));
|
|
572
|
+
if (!hasPicker(screen)) {
|
|
573
|
+
screen = await waitForPickerClosed(pane, wait(1e3));
|
|
574
|
+
if (agentId === null ? !isMainView(screen) : isMainView(screen)) throw new Error("Codex didn't switch to the selected agent.");
|
|
575
|
+
return { selectedId: agentId };
|
|
576
|
+
}
|
|
577
|
+
const row = highlightedPickerRow(screen);
|
|
578
|
+
if (!row) throw new Error("Codex didn't highlight the selected row in its picker.");
|
|
579
|
+
const shownId = agentId === null ? null : pickerRowId(row.text) ?? null;
|
|
580
|
+
if (agentId === null ? !/^\s*Main\s*\[default\]/.test(row.label) : shownId?.toLowerCase() !== agentId.toLowerCase()) throw new Error("Codex highlighted a different row in its picker.");
|
|
581
|
+
await pane.type("\r");
|
|
582
|
+
screen = await waitForPickerClosed(pane, wait(3e3));
|
|
583
|
+
if (agentId === null ? !isMainView(screen) : isMainView(screen)) throw new Error("Codex didn't switch to the selected agent.");
|
|
584
|
+
return { selectedId: agentId };
|
|
585
|
+
}
|
|
404
586
|
/**
|
|
405
587
|
* Built-in harness: Codex (OpenAI's `@openai/codex` CLI).
|
|
406
588
|
*
|
|
@@ -417,7 +599,8 @@ function tomlStringArray(values) {
|
|
|
417
599
|
* (a user may put it in their own preset flags — that is their call).
|
|
418
600
|
*
|
|
419
601
|
* subshell MCP is wired automatically WITHOUT touching user state: every
|
|
420
|
-
* launch carries `-c mcp_servers.subshell.command
|
|
602
|
+
* launch carries `-c mcp_servers.subshell.command=…`,
|
|
603
|
+
* `-c mcp_servers.subshell.args=…` and `-c mcp_servers.subshell.env_vars=…`
|
|
421
604
|
* config overrides (dotted path, value parsed as TOML — verified against the
|
|
422
605
|
* real binary), which codex merges over ~/.codex/config.toml for that run
|
|
423
606
|
* only. The subagent monitoring hooks ride the same mechanism as
|
|
@@ -425,9 +608,9 @@ function tomlStringArray(values) {
|
|
|
425
608
|
* `hooks.*` table is REPLACED for the duration of a subshell-launched run —
|
|
426
609
|
* the documented limitation, same shape as claude-code's; nothing on disk
|
|
427
610
|
* changes. No config file the harness must read, no CODEX_HOME redirect (that
|
|
428
|
-
* dir holds the user's auth.json). The spawned `subshell mcp` child
|
|
429
|
-
*
|
|
430
|
-
*
|
|
611
|
+
* dir holds the user's auth.json). The spawned `subshell mcp` child needs the
|
|
612
|
+
* pane's SUBSHELL_* identity, and Codex filters a child's environment, so the
|
|
613
|
+
* env_vars override re-forwards those names (never values) at each launch.
|
|
431
614
|
* (Flags/values verified against @openai/codex 2026-09 help output.)
|
|
432
615
|
*
|
|
433
616
|
* Identity, detection and install guidance live in this package's
|
|
@@ -439,8 +622,9 @@ function tomlStringArray(values) {
|
|
|
439
622
|
const createPlugin = (host) => ({
|
|
440
623
|
subagents: {
|
|
441
624
|
discovery: "hooks",
|
|
442
|
-
navigation: "
|
|
443
|
-
setup: "Review the Subshell subagent monitoring hooks in /hooks and trust them to show active subagents in the sidebar."
|
|
625
|
+
navigation: "native",
|
|
626
|
+
setup: "Review the Subshell subagent monitoring hooks in /hooks and trust them to show active subagents in the sidebar.",
|
|
627
|
+
select: selectCodexSubagent
|
|
444
628
|
},
|
|
445
629
|
capabilities: () => [
|
|
446
630
|
"subagents",
|
|
@@ -459,7 +643,8 @@ const createPlugin = (host) => ({
|
|
|
459
643
|
for (const event of [
|
|
460
644
|
"SubagentStart",
|
|
461
645
|
"SubagentStop",
|
|
462
|
-
"SessionStart"
|
|
646
|
+
"SessionStart",
|
|
647
|
+
"PostToolUse"
|
|
463
648
|
]) args.push("-c", `hooks.${event}=[{hooks=[{type="command",command=${JSON.stringify(command)},statusMessage="Subshell subagent monitoring",timeout=2}]}]`);
|
|
464
649
|
}
|
|
465
650
|
const s = preset.settings ?? {};
|
|
@@ -491,13 +676,16 @@ const createPlugin = (host) => ({
|
|
|
491
676
|
`[mcp_servers.${MCP_SERVER_NAME}]`,
|
|
492
677
|
`command = ${tomlString(launch.command)}`,
|
|
493
678
|
`args = ${tomlStringArray(launch.args)}`,
|
|
679
|
+
`env_vars = ${tomlStringArray(MCP_ENV_VARS)}`,
|
|
494
680
|
""
|
|
495
681
|
].join("\n"),
|
|
496
682
|
args: [
|
|
497
683
|
"-c",
|
|
498
684
|
`mcp_servers.${MCP_SERVER_NAME}.command=${tomlString(launch.command)}`,
|
|
499
685
|
"-c",
|
|
500
|
-
`mcp_servers.${MCP_SERVER_NAME}.args=${tomlStringArray(launch.args)}
|
|
686
|
+
`mcp_servers.${MCP_SERVER_NAME}.args=${tomlStringArray(launch.args)}`,
|
|
687
|
+
"-c",
|
|
688
|
+
`mcp_servers.${MCP_SERVER_NAME}.env_vars=${tomlStringArray(MCP_ENV_VARS)}`
|
|
501
689
|
],
|
|
502
690
|
env: void 0
|
|
503
691
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@subshell-ai/plugin-codex",
|
|
3
3
|
"description": "Codex harness plugin for Subshell",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.3",
|
|
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": "
|
|
38
|
+
"hash-runner": "5.0.0",
|
|
39
39
|
"tsdown": "0.22.14",
|
|
40
40
|
"typescript": "7.0.2"
|
|
41
41
|
},
|