@subshell-ai/plugin-codex 0.2.0 → 1.0.1

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 (3) hide show
  1. package/dist/index.d.ts +328 -29
  2. package/dist/index.js +187 -11
  3. package/package.json +2 -2
package/dist/index.d.ts CHANGED
@@ -1,32 +1,49 @@
1
- //#region ../../plugin-api/dist/types-B_wiApwK.d.ts
2
- //#region src/types.d.ts
1
+ //#region ../../plugin-api/dist/types-CW2dMAMW.d.ts
2
+ /** How the plugin learns about children: harness lifecycle hooks, or a loaded extension. */
3
+ type HarnessSubagentDiscovery = "hooks" | "extension";
4
+ /** Whether the sidebar can move the parent's own view to a child, or the harness keeps that in-app. */
5
+ type HarnessSubagentNavigation = "native" | "unavailable";
3
6
  /**
4
- * The contract a Subshell plugin implements.
5
- *
6
- * A plugin is loaded from disk by a compiled binary, which means it CANNOT
7
- * import anything of ours at runtime. Measured on bun 1.4.2: a bare specifier
8
- * from a plugin file fails to resolve, because there is no `node_modules`
9
- * beside it. Everything a plugin needs therefore arrives through
10
- * {@link PluginHost}, handed to the factory it default-exports.
11
- *
12
- * This package is types plus PURE helpers only. A plugin bundles it in at
13
- * build time, so anything with runtime behaviour would freeze at the version
14
- * the plugin was built against; a filesystem probe that did that would keep
15
- * searching last year's install locations forever. Those belong on the host.
7
+ * What a plugin declares about its harness's child agents. The host uses
8
+ * `discovery`/`navigation` to decide what the sidebar may offer; `setup`, when
9
+ * the harness needs a one-time human act (trusting hooks, installing an
10
+ * extension), is the copy the preset editor shows for that act.
16
11
  */
12
+ interface HarnessSubagentSupport {
13
+ /** How subagent lifecycle events reach Subshell */
14
+ discovery: HarnessSubagentDiscovery;
15
+ /** Whether subshell may navigate the parent's own view to a child */
16
+ navigation: HarnessSubagentNavigation;
17
+ /** One-time user action required for reporting to work, if any */
18
+ setup?: string;
19
+ }
20
+ //#endregion
21
+ //#region src/types.d.ts
17
22
  /**
18
23
  * What kind of thing a plugin provides.
19
24
  *
20
25
  * For humans: it groups and labels in the UI and filters the catalog. Code
21
26
  * branches on {@link PluginCapability} instead, so adding a type here touches
22
27
  * labels while adding a capability touches the launch pipeline.
28
+ *
29
+ * ONE exception, and it is structural rather than a branch on a label: a
30
+ * `network` plugin implements {@link NetworkPlugin} instead of
31
+ * {@link SubshellPlugin}, so the loader picks WHICH members to require by
32
+ * type. A harness drives a thing in a pane; a network plugin connects this
33
+ * host to one network. They share the store, the manifest and the capability
34
+ * vocabulary, and nothing else.
23
35
  */
24
- type PluginType = "agent-harness" | "terminal";
36
+ type PluginType = "agent-harness" | "terminal" | "network";
25
37
  /**
26
38
  * What a plugin can DO. The launch pipeline and the UI read this; neither
27
39
  * branches on {@link PluginType}.
40
+ *
41
+ * The union is shared across types and the applicable SUBSET is not, so
42
+ * {@link capabilityMismatches} takes the type: a harness claiming `publish`
43
+ * and a network plugin claiming `resume` are both refused at load, for the
44
+ * same reason a claimed-but-unimplemented capability is.
28
45
  */
29
- type PluginCapability = "mcp" | "resume" | "attention" | "settings";
46
+ type PluginCapability = "mcp" | "resume" | "attention" | "subagents" | "publish" | "supervise" | "guard" | "settings";
30
47
  /** A harness preset as defined by the user (decoded JSON blobs). */
31
48
  interface PresetDefinition {
32
49
  /** Human-friendly preset name */
@@ -163,14 +180,25 @@ interface McpLaunchSpec {
163
180
  }
164
181
  /** Per-subshell MCP registration rendered in the plugin's own config dialect. */
165
182
  interface McpRegistration {
183
+ /**
184
+ * Companion integration modules written next to the config file, under
185
+ * `<configPath>.d/`. Keys are basenames only, and the host refuses a name
186
+ * outside `[A-Za-z0-9_-]+\.(mjs|py|json)`, more than eight files, or more
187
+ * than 64 KiB of content — by throwing at launch, so the registration must
188
+ * stay inside those rules. Each file ships self-contained: on a pane's
189
+ * machine nothing of ours is importable (see the package doc).
190
+ */
191
+ files?: Record<string, string>;
166
192
  /** File content the harness reads, written to the subshell's config path */
167
193
  fileContent: string;
168
194
  /** argv the harness needs to load the file (e.g. claude's ["--mcp-config", path]) */
169
195
  args?: string[];
170
196
  /**
171
197
  * Extra pane env the harness needs to discover the file (e.g. OPENCODE_CONFIG).
172
- * Consumed by the HOST, which bakes it into the pane env ahead of preset env;
173
- * plugins never read it back.
198
+ * Consumed by the HOST, which layers it AFTER preset env in the pane's
199
+ * environment: the wiring wins on purpose, so the harness always finds the
200
+ * config this launch wrote even when a preset sets the same name. Plugins
201
+ * never read it back.
174
202
  */
175
203
  env?: Record<string, string>;
176
204
  }
@@ -202,10 +230,23 @@ interface SettingsField {
202
230
  label: string;
203
231
  /** Short description for the editor */
204
232
  description?: string;
205
- /** One of: string, boolean, number, select */
206
- type: "string" | "boolean" | "number" | "select";
233
+ /**
234
+ * One of: string, boolean, number, select, secret.
235
+ *
236
+ * `secret` is WRITE-ONLY and network-only: the host stores it through
237
+ * {@link PluginHost.secrets} rather than in the settings object, reads
238
+ * report only whether one is set, and a plugin never receives the value —
239
+ * it names the secret and the host hydrates it into a process it spawns
240
+ * ({@link SupervisedProcessSpec}). The preset editor does not render this
241
+ * type; a harness declaring one is a bug rather than a second secret store.
242
+ */
243
+ type: "string" | "boolean" | "number" | "select" | "secret";
207
244
  /** Choices when type === "select" */
208
245
  choices?: string[];
246
+ /** True when the field must be set before the plugin can act */
247
+ required?: boolean;
248
+ /** Placeholder / example, shown in the editor. Never a real credential. */
249
+ placeholder?: string;
209
250
  /** Default value when unset */
210
251
  default?: string | boolean | number;
211
252
  }
@@ -248,6 +289,86 @@ interface PluginHost {
248
289
  debug(message: string): void;
249
290
  warn(message: string): void;
250
291
  };
292
+ /**
293
+ * Runs a command to completion under the host's bounds and reports it.
294
+ *
295
+ * The one way a plugin executes anything. A plugin does not spawn: the host
296
+ * owns the deadline, the environment allowlist (the process's own
297
+ * environment holds the auth secret and the database path, and is never
298
+ * handed over), the PATH a service-run server would otherwise lack, and the
299
+ * output cap. Two refusals throw rather than returning a result, because
300
+ * both are bugs in the plugin rather than outcomes: `argv[0]` that is not an
301
+ * ABSOLUTE path (resolve it with {@link findBinary} — a bare name would
302
+ * resolve against a PATH the plugin cannot see), and `argv[0]` that is
303
+ * `sudo`. The server has no terminal to answer a password prompt, so a
304
+ * privileged command is a copy-paste instruction for the operator and never
305
+ * something this runs.
306
+ *
307
+ * Long-running processes are NOT this: a plugin that needs one describes it
308
+ * ({@link SupervisedProcessSpec}) and the host supervises it.
309
+ * @since apiVersion 2
310
+ */
311
+ run(argv: string[], opts?: RunOptions): Promise<RunResult>;
312
+ /**
313
+ * Write-only credential storage, scoped to this plugin.
314
+ *
315
+ * There is deliberately NO `get`. A plugin that could read a credential
316
+ * could put it in an argv (visible in `ps`), a log line or a hint string;
317
+ * every legitimate consumer is a process the HOST spawns, so the host
318
+ * hydrates the value itself from the name the plugin gives it. Values are
319
+ * stored 0600 under the host's data directory, never in the database and
320
+ * never in a settings row.
321
+ * @since apiVersion 2
322
+ */
323
+ secrets: PluginSecrets;
324
+ /** The operating system this host runs on. */
325
+ readonly platform: PluginPlatform;
326
+ /** The home directory of the user this host runs as. */
327
+ readonly homeDir: string;
328
+ }
329
+ /** Where a plugin can be driven at all. Mirrors the manifest's `platforms`. */
330
+ type PluginPlatform = "darwin" | "linux";
331
+ /** Options for {@link PluginHost.run}. */
332
+ interface RunOptions {
333
+ /** Deadline in milliseconds. Default 30_000; the host caps it at 10 minutes. */
334
+ timeoutMs?: number;
335
+ /** Called per output line, ANSI already stripped, from both streams interleaved. */
336
+ onLine?: (line: string) => void;
337
+ /**
338
+ * Ends the run early without failing it.
339
+ *
340
+ * What an interactive login needs: `tailscale up` with no key blocks until a
341
+ * human finishes in a browser, having already printed the URL — so the
342
+ * plugin reads the URL off {@link onLine} and aborts, leaving the daemon
343
+ * waiting. An aborted run reports `aborted: true` and whatever was captured.
344
+ */
345
+ signal?: AbortSignal;
346
+ /** Extra environment on top of the host's allowlist. Never overrides PATH. */
347
+ env?: Record<string, string>;
348
+ /** Text to write to stdin. Absent means stdin is /dev/null, so a prompt cannot hang forever. */
349
+ stdin?: string;
350
+ }
351
+ /** What {@link PluginHost.run} reports. Never throws for a non-zero exit. */
352
+ interface RunResult {
353
+ /** Exit code, or null when the process was signalled or never started. */
354
+ code: number | null;
355
+ /** Captured stdout, ANSI stripped, capped. */
356
+ stdout: string;
357
+ /** Captured stderr, ANSI stripped, capped. */
358
+ stderr: string;
359
+ /** True when the deadline ended it. */
360
+ timedOut: boolean;
361
+ /** True when {@link RunOptions.signal} ended it. */
362
+ aborted: boolean;
363
+ }
364
+ /** Write-only, per-plugin credential storage. See {@link PluginHost.secrets}. */
365
+ interface PluginSecrets {
366
+ /** Stores (or replaces) a credential under this name. */
367
+ set(name: string, value: string): Promise<void>;
368
+ /** Whether a credential is stored under this name. The only read there is. */
369
+ has(name: string): Promise<boolean>;
370
+ /** Removes it. Absent is not an error. */
371
+ delete(name: string): Promise<void>;
251
372
  }
252
373
  /**
253
374
  * The plugin itself.
@@ -257,6 +378,8 @@ interface PluginHost {
257
378
  * plugin is a binary and an argv rather than eight stubs.
258
379
  */
259
380
  interface SubshellPlugin {
381
+ /** Native child-agent reporting and optional in-place navigation. */
382
+ subagents?: HarnessSubagentSupport;
260
383
  /** Builds the argv (no shell) used to launch a subshell. */
261
384
  buildCommand(input: BuildCommandInput): string[];
262
385
  /** Validates a preset definition before saving. */
@@ -290,8 +413,15 @@ interface SubshellPlugin {
290
413
  * then surfaces one-time manual setup via {@link mcpSetup} instead.
291
414
  */
292
415
  mcpRegistration?(launch: McpLaunchSpec, configPath: string): McpRegistration;
293
- /** How users obtain the subshell MCP tools here. Declare the `mcp` capability with it. */
294
- mcpSetup?(launch: McpLaunchSpec): McpSetupInfo;
416
+ /**
417
+ * How users obtain the subshell MCP tools here. Declare the `mcp`
418
+ * capability with it. `reporter` is the host-resolved command prefix that
419
+ * re-enters the subshell binary on a pane's machine (same shape as
420
+ * {@link BuildCommandInput.reporter}), present only when the host could
421
+ * resolve one: use it to render hook-configuration steps, and omit those
422
+ * steps when it is absent rather than guessing a binary name.
423
+ */
424
+ mcpSetup?(launch: McpLaunchSpec, reporter?: ReporterSpec): McpSetupInfo;
295
425
  /** Settings rendered in the PRESET editor and stored on the preset. */
296
426
  presetSettings?(): SettingsField[];
297
427
  /** Known extra env var suggestions for the preset editor. */
@@ -305,8 +435,17 @@ interface SubshellPlugin {
305
435
  description: string;
306
436
  }[];
307
437
  }
308
- /** What a plugin module default-exports. */
309
- type PluginFactory = (host: PluginHost) => SubshellPlugin;
438
+ /**
439
+ * A factory that returns a harness, for a plugin that knows which it is.
440
+ *
441
+ * {@link PluginFactory} is the LOADER's view — it must accept either shape,
442
+ * because it decides which to expect from the manifest. A plugin itself has no
443
+ * such ambiguity, and annotating its own factory with the union would widen
444
+ * what its tests can call: `createPlugin(host).buildCommand(...)` stops
445
+ * type-checking on a value that might be a network plugin. So a plugin
446
+ * declares the half it implements and the loader keeps the union.
447
+ */
448
+ type HarnessPluginFactory = (host: PluginHost) => SubshellPlugin;
310
449
  //#endregion
311
450
  //#region ../../plugin-api/dist/index.d.ts
312
451
  /** How a host finds this plugin's binary, as data, so a scan loads no plugin code. */
@@ -315,16 +454,158 @@ interface DetectSpec {
315
454
  binaryName: string;
316
455
  /** Env var that overrides the lookup outright, e.g. "CLAUDE_PATH" */
317
456
  envOverride: string;
318
- /** Well-known locations relative to HOME, tried after PATH */
457
+ /**
458
+ * Well-known locations, tried after PATH and before the version managers.
459
+ *
460
+ * HOME-relative (`.local/bin/mytool`), or ABSOLUTE when the entry starts
461
+ * with `/` — which is how a vendor's GUI install is named, since
462
+ * `/Applications/Tailscale.app/Contents/MacOS/Tailscale` is nobody's HOME.
463
+ * Each is a candidate to search, so an entry that does not resolve costs
464
+ * nothing and the ladder carries on.
465
+ */
319
466
  knownPaths: string[];
320
467
  }
321
468
  /** Where to send someone whose binary is missing. */
322
469
  interface InstallSpec {
323
- /** Copy-pasteable install command for the official installer */
470
+ /**
471
+ * Copy-pasteable install command for the official installer.
472
+ *
473
+ * A command the HOST may run on request, which is why it may not be
474
+ * privileged: `sudo`, `doas` and `pkexec` are refused by the parser at any
475
+ * command boundary (the line is run through a shell — see
476
+ * {@link needsPrivileges}), so an installer that needs root is described
477
+ * under {@link NetworkManifest.privileged} instead and is only ever shown to
478
+ * copy. Without that refusal the two would be one field with two meanings,
479
+ * and the surface offering a button would have to guess.
480
+ */
324
481
  command: string;
325
- /** URL of the installation documentation */
482
+ /**
483
+ * URL of the installation documentation.
484
+ *
485
+ * Rendered as a link on an admin page, so it must be `http:` or `https:`
486
+ * and the parser refuses anything else — see {@link isDocsUrl}.
487
+ */
326
488
  docsUrl: string;
327
489
  }
490
+ /** One step an operator must run themselves, because the host may not. */
491
+ interface PrivilegedStep {
492
+ /** What it does, in a few words: "Install the daemon" */
493
+ label: string;
494
+ /** The command to copy. Usually `sudo …`. */
495
+ command: string;
496
+ /** Where the vendor documents it; `http:`/`https:` only, as {@link isDocsUrl} requires. */
497
+ docsUrl?: string;
498
+ /**
499
+ * Which ALTERNATIVE this step belongs to, and the heading it renders under.
500
+ *
501
+ * Steps sharing a group are one sequence to run in order; different groups
502
+ * are different ways to arrive at the same place, and a surface renders an
503
+ * `or` between them rather than continuing the numbering. Steps with no
504
+ * group are one plain sequence, which is what every step was before this
505
+ * existed.
506
+ *
507
+ * It exists because a platform can genuinely offer two routes: on macOS
508
+ * Tailscale ships both a GUI app and a command-line daemon, and a page that
509
+ * numbered those 1..3 told a person to install both. Non-empty when present
510
+ * — it is a heading, so `""` is refused rather than coerced.
511
+ */
512
+ group?: string;
513
+ }
514
+ /**
515
+ * The `subshell.network` block. Required when `type` is `network`.
516
+ *
517
+ * Everything here is DATA a host reads without loading plugin code: which
518
+ * machines this plugin can run on at all, whether it can produce a login URL,
519
+ * what publishing it exposes, and which steps only a human with root can
520
+ * perform. A page can therefore say "not available on this platform" or print
521
+ * the two sudo commands before any plugin code has been imported — and before
522
+ * the vendor's CLI is anywhere on the machine.
523
+ */
524
+ interface NetworkManifest {
525
+ /**
526
+ * Operating systems this plugin can be driven on.
527
+ *
528
+ * Not a hint: a host refuses every act on a platform absent from this list,
529
+ * and the UI renders the row as unavailable rather than offering a button
530
+ * that would 409. Vendors differ here for real reasons — a CLI that talks to
531
+ * a logged-in desktop session cannot be driven from a launchd service.
532
+ */
533
+ platforms: PluginPlatform[];
534
+ /**
535
+ * True when `join({})` with no credential can yield a URL a human finishes.
536
+ *
537
+ * Data rather than a capability because it describes what a RETURN VALUE may
538
+ * be, and a capability is checked against members. The UI reads it to decide
539
+ * whether to offer "sign in" beside "paste a key".
540
+ */
541
+ interactiveLogin?: boolean;
542
+ /**
543
+ * True when publishing leaves NOTHING the daemon can later be asked about.
544
+ *
545
+ * NetBird is the case that forced the field: a join is the whole publish,
546
+ * so `status()` can only ever see the join, and a plugin that declared
547
+ * itself `published` would claim a thing its own vendor CLI never told it.
548
+ * The HOST's publish record is the one witness of the transition, and this
549
+ * flag is what lets the host (and only the host) upgrade `joined` to
550
+ * `published` from it. A plugin without the flag is never upgraded: for
551
+ * Tailscale a serve reset outside this app is a real state, and `joined`
552
+ * is the honest reading of it.
553
+ */
554
+ publishImplicit?: boolean;
555
+ /**
556
+ * What publishing on this network exposes the server to.
557
+ *
558
+ * `private` is a network only invited machines are on. `public-with-gate`
559
+ * reaches the open internet with an identity check in front — a different
560
+ * security posture, stated here so every surface can say so before the act
561
+ * rather than after it.
562
+ */
563
+ exposure: "private" | "public-with-gate";
564
+ /**
565
+ * Steps the host can never perform, per platform, in the order to run them.
566
+ *
567
+ * Every mesh VPN installs a daemon as root and the server has no terminal to
568
+ * answer a password prompt, so these are printed to copy and never executed.
569
+ * Keeping them out of {@link InstallSpec} is what lets a surface offer a
570
+ * button for one and a copy row for the other without inspecting the string.
571
+ */
572
+ privileged?: Partial<Record<PluginPlatform, PrivilegedStep[]>>;
573
+ /**
574
+ * What this network calls the two things a person acts on.
575
+ *
576
+ * Vendors name the same act differently and a generic word is wrong rather
577
+ * than merely bland: Tailscale takes an "auth key", NetBird a "setup key",
578
+ * Cloudflare a "tunnel token", and a field labelled "Auth key" on the
579
+ * NetBird row asks for something NetBird does not have. Likewise publishing
580
+ * is "Tailscale Serve" on one network and starting a tunnel on another.
581
+ *
582
+ * MANIFEST data rather than something the plugin returns, for the same
583
+ * reason `platforms` is: a surface renders these before any plugin code has
584
+ * been loaded, and before the vendor's CLI is anywhere on the machine. Both
585
+ * are optional and both have a sensible generic default, so a plugin that
586
+ * says nothing is merely plain rather than broken.
587
+ */
588
+ labels?: NetworkLabels;
589
+ }
590
+ /** The vendor's own words for the two acts a person takes. See {@link NetworkManifest.labels}. */
591
+ interface NetworkLabels {
592
+ /** What to call the pasted credential: "Auth key", "Setup key", "Tunnel token". */
593
+ credential?: string;
594
+ /** What to call publishing: "Publish with Tailscale Serve", "Start tunnel". */
595
+ publish?: string;
596
+ /**
597
+ * Where the credential named by `credential` comes from — the vendor page
598
+ * that mints one.
599
+ *
600
+ * The credential box asks for an "Auth key" on a card that otherwise says
601
+ * nothing about what an auth key is or where to get it, and every vendor
602
+ * words that differently enough that the SPA cannot write the sentence
603
+ * itself. So the plugin names its page, and the card renders it as a Docs
604
+ * link on the label row. http(s) only, refused at parse like every other
605
+ * URL this contract carries — it lands in an `href` on an admin page.
606
+ */
607
+ credentialDocsUrl?: string;
608
+ }
328
609
  /** The parsed `subshell` block. */
329
610
  interface SubshellManifest {
330
611
  /** plugin-api version this plugin was built against */
@@ -359,6 +640,20 @@ interface SubshellManifest {
359
640
  * fallback, i.e. a resume that quietly never offers itself (§11).
360
641
  */
361
642
  hostEnv?: string[];
643
+ /** Network facts. Present exactly when `type` is `network`. */
644
+ network?: NetworkManifest;
645
+ /**
646
+ * False means the launch pickers must not offer this plugin, even though
647
+ * it drives a pane. Absent means true.
648
+ *
649
+ * The declaration belongs to the plugin because only it knows whether a
650
+ * bare launch means something: a harness whose useful launch needs inputs
651
+ * no picker carries (SSH needs a destination, which the connect form
652
+ * supplies through its own door) would otherwise be a row that starts a
653
+ * dying pane. This hides the entry, not the route: the plugin stays
654
+ * launchable by id for whatever flow does supply the inputs.
655
+ */
656
+ pickable?: boolean;
362
657
  }
363
658
  //#endregion
364
659
  //#region src/manifest.d.ts
@@ -384,7 +679,11 @@ declare const manifest: SubshellManifest;
384
679
  * launch carries `-c mcp_servers.subshell.command=… -c mcp_servers.subshell.args=…`
385
680
  * config overrides (dotted path, value parsed as TOML — verified against the
386
681
  * real binary), which codex merges over ~/.codex/config.toml for that run
387
- * only. No config file the harness must read, no CODEX_HOME redirect (that
682
+ * only. The subagent monitoring hooks ride the same mechanism as
683
+ * `hooks.SubagentStart/SubagentStop/SessionStart` overrides, so a user's own
684
+ * `hooks.*` table is REPLACED for the duration of a subshell-launched run —
685
+ * the documented limitation, same shape as claude-code's; nothing on disk
686
+ * changes. No config file the harness must read, no CODEX_HOME redirect (that
388
687
  * dir holds the user's auth.json). The spawned `subshell mcp` child inherits
389
688
  * the subshell's baked SUBSHELL_* pane env for its credential, like every
390
689
  * harness.
@@ -396,6 +695,6 @@ declare const manifest: SubshellManifest;
396
695
  *
397
696
  * `host` carries what this module cannot import. See `@subshell-ai/plugin-api`.
398
697
  */
399
- declare const createPlugin: PluginFactory;
698
+ declare const createPlugin: HarnessPluginFactory;
400
699
  //#endregion
401
700
  export { createPlugin as default, manifest };
package/dist/index.js CHANGED
@@ -1,6 +1,12 @@
1
- //#region ../../plugin-api/dist/manifest-Czd9ea1g.js
1
+ //#region ../../plugin-api/dist/manifest-XGvw0ugx.js
2
2
  /** Every {@link PluginType}, for anything that has to iterate or validate them. */
3
- const PLUGIN_TYPES = ["agent-harness", "terminal"];
3
+ const PLUGIN_TYPES = [
4
+ "agent-harness",
5
+ "terminal",
6
+ "network"
7
+ ];
8
+ /** Every {@link PluginPlatform}, for validation. */
9
+ const PLUGIN_PLATFORMS = ["darwin", "linux"];
4
10
  /**
5
11
  * The MCP server name every harness registers the built-in `subshell mcp`
6
12
  * server under: the config object key, and the `hermes mcp add|remove`
@@ -28,6 +34,80 @@ const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
28
34
  function isRecord(value) {
29
35
  return typeof value === "object" && value !== null && !Array.isArray(value);
30
36
  }
37
+ /** The privilege wrappers no install command may ever run, by first-word basename. */
38
+ const PRIVILEGED_COMMANDS = [
39
+ "sudo",
40
+ "doas",
41
+ "pkexec"
42
+ ];
43
+ /** Shell boundaries that introduce a NEW command word: `;` `|` `&` (so `&&` and `||` too) and newline. */
44
+ const COMMAND_BOUNDARY_RE = /[;&|\n]+/;
45
+ /** A leading `NAME=…` env assignment, which `sh` strips before choosing the command. */
46
+ const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=\S*\s*/;
47
+ /**
48
+ * Whether a shell line runs one of {@link PRIVILEGED_COMMANDS} AS A COMMAND
49
+ * anywhere in it — not merely at the start.
50
+ *
51
+ * `install.command` is the one manifest field a host RUNS, and it runs through
52
+ * `sh -c` (`api/network/install-network.route.ts`), which means any boundary
53
+ * in the line can start the privileged command the start of the line hides:
54
+ * `"apt-get update && sudo apt-get install -y x"` is sudo-without-prompt on a
55
+ * NOPASSWD host. A line-start regex was the first gate and it was not one.
56
+ *
57
+ * The split is CONSERVATIVE, not a shell parser: segments are taken at every
58
+ * boundary character quote-blind (so a `;` inside a quoted string still
59
+ * counts, which can only over-refuse), leading env assignments are stripped
60
+ * (`FOO=bar sudo x` runs sudo), and each segment's first word is compared by
61
+ * BASENAME (`/usr/bin/sudo` is sudo, `pkexec-demo` is not). A word merely
62
+ * CONTAINING the token is fine — the comparison is equality on the basename.
63
+ * The accepted limit is a wrapper hidden inside quotes (`sh -c 'sudo …'`),
64
+ * which is the same limit `PluginHost.run`'s first-word check has, and it is
65
+ * accepted for the same reason: a plugin whose code LOADS can already run
66
+ * anything in this process (`docs/security.md` §11.9) — this rule refuses
67
+ * careless manifests deterministically, it is not a sandbox.
68
+ */
69
+ function needsPrivileges(command) {
70
+ for (const segment of command.split(COMMAND_BOUNDARY_RE)) {
71
+ let rest = segment.trim();
72
+ while (ENV_ASSIGNMENT_RE.test(rest)) rest = rest.replace(ENV_ASSIGNMENT_RE, "");
73
+ if (rest === "") continue;
74
+ const ws = rest.search(/\s/);
75
+ const firstWord = ws === -1 ? rest : rest.slice(0, ws);
76
+ const base = firstWord.slice(firstWord.lastIndexOf("/") + 1);
77
+ if (PRIVILEGED_COMMANDS.includes(base)) return true;
78
+ }
79
+ return false;
80
+ }
81
+ /**
82
+ * Whether a string may be rendered as a link in an operator's browser.
83
+ *
84
+ * Every `docsUrl` in this contract ends up as the `href` of an anchor on an
85
+ * admin page, and an `href` is not inert: a `javascript:` URL in one is script
86
+ * running on the control plane's own origin, in the session of the one person
87
+ * who can install plugins. So a URL this contract carries is restricted to the
88
+ * two schemes that navigate, and both the parser below and the host check it.
89
+ *
90
+ * It is checked in two places on purpose. A manifest is static data, so a bad
91
+ * value there is a plugin defect and is refused at load. A URL a plugin
92
+ * REPORTS at runtime is often something it read off a vendor CLI — Tailscale's
93
+ * `AuthURL` comes from whichever control server the operator pointed the
94
+ * daemon at — so the host drops that one instead, because refusing to load a
95
+ * working plugin over a value its control server chose would be the wrong
96
+ * failure.
97
+ *
98
+ * Not a reachability claim: nothing fetches these, and a docs page that 404s
99
+ * is a broken link rather than a hazard.
100
+ * @param value - the candidate URL, as written in a manifest or returned by a plugin
101
+ */
102
+ function isDocsUrl(value) {
103
+ let parsed;
104
+ try {
105
+ parsed = new URL(value);
106
+ } catch {
107
+ return false;
108
+ }
109
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
110
+ }
31
111
  /**
32
112
  * Reads and validates the `subshell` block of a plugin's package.json.
33
113
  *
@@ -43,7 +123,7 @@ function parseManifest(pkgJson) {
43
123
  if (!isRecord(block)) return { error: "package.json has no `subshell` block, so it is not a Subshell plugin" };
44
124
  if (typeof block.apiVersion !== "number" || !Number.isInteger(block.apiVersion)) return { error: "`subshell.apiVersion` must be an integer" };
45
125
  if (block.apiVersion < 1) return { error: `this plugin declares plugin-api ${block.apiVersion}, which is not a version; the lowest is 1 and this host implements 2` };
46
- if (block.apiVersion > 2) return { error: `this plugin needs plugin-api ${block.apiVersion}, but this host implements 2; upgrade the agent` };
126
+ if (block.apiVersion > 2) return { error: `this plugin needs plugin-api ${block.apiVersion}, but this host implements 2; upgrade Subshell` };
47
127
  if (typeof block.id !== "string" || !ID_RE.test(block.id)) return { error: "`subshell.id` must be lowercase letters, digits and hyphens, starting alphanumeric (it becomes a directory name)" };
48
128
  if (typeof block.type !== "string" || !PLUGIN_TYPES.includes(block.type)) return { error: `\`subshell.type\` must be one of: ${PLUGIN_TYPES.join(", ")}` };
49
129
  if (typeof block.name !== "string" || block.name.trim() === "") return { error: "`subshell.name` must be a non-empty string" };
@@ -54,6 +134,7 @@ function parseManifest(pkgJson) {
54
134
  const icon = block.icon;
55
135
  if (!ICON_EXTENSIONS.some((ext) => icon.endsWith(ext))) return { error: `\`subshell.icon\` must end in one of: ${ICON_EXTENSIONS.join(", ")}` };
56
136
  }
137
+ if (block.pickable !== void 0 && typeof block.pickable !== "boolean") return { error: "`subshell.pickable` must be a boolean when present" };
57
138
  if (typeof block.entry !== "string" || block.entry.trim() === "") return { error: "`subshell.entry` must be a non-empty relative path" };
58
139
  if (block.entry.startsWith("/") || block.entry.split("/").includes("..")) return { error: "`subshell.entry` must stay inside the package (no leading `/` and no `..` segment)" };
59
140
  let detect;
@@ -70,6 +151,8 @@ function parseManifest(pkgJson) {
70
151
  if (block.install !== void 0) {
71
152
  const i = block.install;
72
153
  if (!isRecord(i) || typeof i.command !== "string" || typeof i.docsUrl !== "string") return { error: "`subshell.install` needs a command and a docsUrl" };
154
+ if (needsPrivileges(i.command)) return { error: "`subshell.install.command` must not need sudo, doas or pkexec — the host runs it through a shell and has no terminal for a password prompt; put privileged steps in `subshell.network.privileged`" };
155
+ if (!isDocsUrl(i.docsUrl)) return { error: "`subshell.install.docsUrl` must be an http(s) URL — it is rendered as a link on an admin page" };
73
156
  install = {
74
157
  command: i.command,
75
158
  docsUrl: i.docsUrl
@@ -80,17 +163,85 @@ function parseManifest(pkgJson) {
80
163
  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" };
81
164
  hostEnv = [...block.hostEnv];
82
165
  }
166
+ const type = block.type;
167
+ const parsedNetwork = parseNetworkBlock(block.network, type);
168
+ if (parsedNetwork !== void 0 && "error" in parsedNetwork) return parsedNetwork;
83
169
  return {
84
170
  apiVersion: block.apiVersion,
85
171
  id: block.id,
86
- type: block.type,
172
+ type,
87
173
  name: block.name,
88
174
  description: block.description,
89
175
  ...typeof block.icon === "string" ? { icon: block.icon } : {},
90
176
  entry: block.entry,
91
177
  ...detect ? { detect } : {},
92
178
  ...install ? { install } : {},
93
- ...hostEnv ? { hostEnv } : {}
179
+ ...hostEnv ? { hostEnv } : {},
180
+ ...parsedNetwork ? { network: parsedNetwork } : {},
181
+ ...block.pickable === false ? { pickable: false } : {}
182
+ };
183
+ }
184
+ /**
185
+ * Parses `subshell.network`, which is required for and exclusive to `network`.
186
+ *
187
+ * Both directions are refused rather than tolerated. A network plugin without
188
+ * the block would be unrenderable — nothing could say which platforms it runs
189
+ * on or what publishing it exposes, and the safe default for "what does this
190
+ * expose" is not a default anyone should pick silently. A harness WITH one
191
+ * declares facts nothing reads, which is how a manifest starts lying.
192
+ * @returns the parsed block, an error, or undefined when there is none to have
193
+ */
194
+ function parseNetworkBlock(raw, type) {
195
+ if (type !== "network") {
196
+ if (raw !== void 0) return { error: `\`subshell.network\` is only for \`type: "network"\` plugins, and this one is \`${type}\`` };
197
+ return;
198
+ }
199
+ if (!isRecord(raw)) return { error: "`subshell.network` is required for `type: \"network\"` plugins" };
200
+ if (!Array.isArray(raw.platforms) || raw.platforms.length === 0 || !raw.platforms.every((p) => typeof p === "string" && PLUGIN_PLATFORMS.includes(p))) return { error: `\`subshell.network.platforms\` must be a non-empty array of: ${PLUGIN_PLATFORMS.join(", ")}` };
201
+ if (raw.exposure !== "private" && raw.exposure !== "public-with-gate") return { error: "`subshell.network.exposure` must be \"private\" or \"public-with-gate\"" };
202
+ if (raw.interactiveLogin !== void 0 && typeof raw.interactiveLogin !== "boolean") return { error: "`subshell.network.interactiveLogin` must be a boolean" };
203
+ if (raw.publishImplicit !== void 0 && typeof raw.publishImplicit !== "boolean") return { error: "`subshell.network.publishImplicit` must be a boolean" };
204
+ let privileged;
205
+ if (raw.privileged !== void 0) {
206
+ if (!isRecord(raw.privileged)) return { error: "`subshell.network.privileged` must be an object keyed by platform" };
207
+ privileged = {};
208
+ for (const [platform, steps] of Object.entries(raw.privileged)) {
209
+ if (!PLUGIN_PLATFORMS.includes(platform)) return { error: `\`subshell.network.privileged\` has an unknown platform "${platform}"` };
210
+ if (!Array.isArray(steps) || !steps.every((s) => isRecord(s) && typeof s.label === "string" && s.label.trim() !== "" && typeof s.command === "string" && s.command.trim() !== "" && (s.docsUrl === void 0 || typeof s.docsUrl === "string") && (s.group === void 0 || typeof s.group === "string"))) return { error: `\`subshell.network.privileged.${platform}\` must be an array of { label, command, docsUrl?, group? }` };
211
+ const badGroup = steps.find((s) => s.group !== void 0 && s.group.trim() === "");
212
+ if (badGroup) return { error: `\`subshell.network.privileged.${platform}\` has an empty \`group\`, which is a heading with no words: "${badGroup.label}"` };
213
+ const badDocs = steps.find((s) => s.docsUrl !== void 0 && !isDocsUrl(s.docsUrl));
214
+ if (badDocs) return { error: `\`subshell.network.privileged.${platform}\` has a docsUrl that is not an http(s) URL: "${badDocs.label}"` };
215
+ privileged[platform] = steps.map((s) => ({
216
+ label: s.label,
217
+ command: s.command,
218
+ ...s.docsUrl ? { docsUrl: s.docsUrl } : {},
219
+ ...s.group ? { group: s.group } : {}
220
+ }));
221
+ }
222
+ }
223
+ let labels;
224
+ if (raw.labels !== void 0) {
225
+ if (!isRecord(raw.labels)) return { error: "`subshell.network.labels` must be an object" };
226
+ for (const key of ["credential", "publish"]) {
227
+ const value = raw.labels[key];
228
+ if (value !== void 0 && (typeof value !== "string" || value.trim() === "")) return { error: `\`subshell.network.labels.${key}\` must be a non-empty string` };
229
+ }
230
+ const credDocs = raw.labels.credentialDocsUrl;
231
+ if (credDocs !== void 0 && (typeof credDocs !== "string" || !isDocsUrl(credDocs))) return { error: "`subshell.network.labels.credentialDocsUrl` must be an http(s) URL — it is rendered as a link on an admin page" };
232
+ labels = {
233
+ ...typeof raw.labels.credential === "string" ? { credential: raw.labels.credential } : {},
234
+ ...typeof raw.labels.publish === "string" ? { publish: raw.labels.publish } : {},
235
+ ...typeof raw.labels.credentialDocsUrl === "string" ? { credentialDocsUrl: raw.labels.credentialDocsUrl } : {}
236
+ };
237
+ }
238
+ return {
239
+ platforms: [...raw.platforms],
240
+ exposure: raw.exposure,
241
+ ...raw.interactiveLogin === true ? { interactiveLogin: true } : {},
242
+ ...raw.publishImplicit === true ? { publishImplicit: true } : {},
243
+ ...privileged ? { privileged } : {},
244
+ ...labels && Object.keys(labels).length > 0 ? { labels } : {}
94
245
  };
95
246
  }
96
247
  //#endregion
@@ -134,7 +285,7 @@ function validateGenericPreset(preset) {
134
285
  const parsed = parseManifest({
135
286
  name: "@subshell-ai/plugin-codex",
136
287
  description: "Codex harness plugin for Subshell",
137
- version: "0.2.0",
288
+ version: "1.0.1",
138
289
  license: "Apache-2.0",
139
290
  type: "module",
140
291
  main: "dist/index.js",
@@ -157,7 +308,7 @@ const parsed = parseManifest({
157
308
  "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix",
158
309
  "lint:check": "biome check --no-errors-on-unmatched src",
159
310
  "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src",
160
- "test": "bun test",
311
+ "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test",
161
312
  "verify-types": "tsc --project tsconfig.json --noEmit"
162
313
  },
163
314
  devDependencies: {
@@ -269,7 +420,11 @@ function tomlStringArray(values) {
269
420
  * launch carries `-c mcp_servers.subshell.command=… -c mcp_servers.subshell.args=…`
270
421
  * config overrides (dotted path, value parsed as TOML — verified against the
271
422
  * real binary), which codex merges over ~/.codex/config.toml for that run
272
- * only. No config file the harness must read, no CODEX_HOME redirect (that
423
+ * only. The subagent monitoring hooks ride the same mechanism as
424
+ * `hooks.SubagentStart/SubagentStop/SessionStart` overrides, so a user's own
425
+ * `hooks.*` table is REPLACED for the duration of a subshell-launched run —
426
+ * the documented limitation, same shape as claude-code's; nothing on disk
427
+ * changes. No config file the harness must read, no CODEX_HOME redirect (that
273
428
  * dir holds the user's auth.json). The spawned `subshell mcp` child inherits
274
429
  * the subshell's baked SUBSHELL_* pane env for its credential, like every
275
430
  * harness.
@@ -281,11 +436,32 @@ function tomlStringArray(values) {
281
436
  *
282
437
  * `host` carries what this module cannot import. See `@subshell-ai/plugin-api`.
283
438
  */
284
- const createPlugin = (_host) => ({
285
- capabilities: () => ["mcp", "settings"],
439
+ const createPlugin = (host) => ({
440
+ subagents: {
441
+ discovery: "hooks",
442
+ navigation: "unavailable",
443
+ setup: "Review the Subshell subagent monitoring hooks in /hooks and trust them to show active subagents in the sidebar."
444
+ },
445
+ capabilities: () => [
446
+ "subagents",
447
+ "mcp",
448
+ "settings"
449
+ ],
286
450
  buildCommand(input) {
287
451
  const { binary, preset, extraFlags, mcp } = input;
288
452
  const args = [binary];
453
+ if (input.reporter) {
454
+ const command = [
455
+ input.reporter.command,
456
+ ...input.reporter.args,
457
+ "subagents"
458
+ ].map(host.shellQuote).join(" ");
459
+ for (const event of [
460
+ "SubagentStart",
461
+ "SubagentStop",
462
+ "SessionStart"
463
+ ]) args.push("-c", `hooks.${event}=[{hooks=[{type="command",command=${JSON.stringify(command)},statusMessage="Subshell subagent monitoring",timeout=2}]}]`);
464
+ }
289
465
  const s = preset.settings ?? {};
290
466
  if (typeof s.model === "string" && s.model) args.push("-m", s.model);
291
467
  if (typeof s.sandbox === "string" && s.sandbox) args.push("-s", s.sandbox);
@@ -330,7 +506,7 @@ const createPlugin = (_host) => ({
330
506
  mcpSetup(_launch) {
331
507
  return {
332
508
  mode: "auto",
333
- summary: "Subshell registers itself with every Codex subshell automatically (per-invocation -c mcp_servers.subshell.* overrides; your ~/.codex config is never modified)."
509
+ summary: "Subshell registers itself with every Codex subshell automatically (per-invocation -c mcp_servers.subshell.* overrides; your ~/.codex config is never modified). The same overrides carry the subagent monitoring hooks, so a hooks.* you set in ~/.codex is replaced FOR THAT RUN only, and nothing on disk changes."
334
510
  };
335
511
  },
336
512
  validatePreset(preset) {
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": "0.2.0",
4
+ "version": "1.0.1",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -28,7 +28,7 @@
28
28
  "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix",
29
29
  "lint:check": "biome check --no-errors-on-unmatched src",
30
30
  "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src",
31
- "test": "bun test",
31
+ "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test",
32
32
  "verify-types": "tsc --project tsconfig.json --noEmit"
33
33
  },
34
34
  "devDependencies": {