@plurnk/plurnk-execs 1.3.12 → 1.4.0

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 (50) hide show
  1. package/.env.defaults +4 -0
  2. package/README.md +78 -36
  3. package/SPEC.md +379 -260
  4. package/dist/BaseExecutor.d.ts.map +1 -1
  5. package/dist/BaseExecutor.js +12 -13
  6. package/dist/BaseExecutor.js.map +1 -1
  7. package/dist/ErrorDetail.d.ts +8 -0
  8. package/dist/ErrorDetail.d.ts.map +1 -0
  9. package/dist/ErrorDetail.js +25 -0
  10. package/dist/ErrorDetail.js.map +1 -0
  11. package/dist/Notice.d.ts +3 -0
  12. package/dist/Notice.d.ts.map +1 -0
  13. package/dist/Notice.js +2 -0
  14. package/dist/Notice.js.map +1 -0
  15. package/dist/RuntimeTag.d.ts +5 -0
  16. package/dist/RuntimeTag.d.ts.map +1 -0
  17. package/dist/RuntimeTag.js +28 -0
  18. package/dist/RuntimeTag.js.map +1 -0
  19. package/dist/SubprocessExecutor.d.ts +1 -1
  20. package/dist/SubprocessExecutor.d.ts.map +1 -1
  21. package/dist/SubprocessExecutor.js +67 -20
  22. package/dist/SubprocessExecutor.js.map +1 -1
  23. package/dist/advertise.d.ts +4 -0
  24. package/dist/advertise.d.ts.map +1 -1
  25. package/dist/advertise.js +4 -2
  26. package/dist/advertise.js.map +1 -1
  27. package/dist/discover.d.ts.map +1 -1
  28. package/dist/discover.js +52 -84
  29. package/dist/discover.js.map +1 -1
  30. package/dist/index.d.ts +7 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +10 -6
  33. package/dist/index.js.map +1 -1
  34. package/dist/policy.d.ts +1 -0
  35. package/dist/policy.d.ts.map +1 -1
  36. package/dist/policy.js +21 -20
  37. package/dist/policy.js.map +1 -1
  38. package/dist/runtime.js +1 -1
  39. package/dist/runtime.js.map +1 -1
  40. package/dist/tokenizeArgv.d.ts +5 -0
  41. package/dist/tokenizeArgv.d.ts.map +1 -0
  42. package/dist/tokenizeArgv.js +69 -0
  43. package/dist/tokenizeArgv.js.map +1 -0
  44. package/dist/types.d.ts +6 -4
  45. package/dist/types.d.ts.map +1 -1
  46. package/package.json +11 -15
  47. package/dist/TelemetryEvent.d.ts +0 -18
  48. package/dist/TelemetryEvent.d.ts.map +0 -1
  49. package/dist/TelemetryEvent.js +0 -2
  50. package/dist/TelemetryEvent.js.map +0 -1
package/SPEC.md CHANGED
@@ -1,292 +1,411 @@
1
1
  # plurnk-execs — Specification
2
2
 
3
- Contract for `@plurnk/plurnk-execs-*` sibling packages — runtime executors that plurnk-service's `exec://` scheme dispatches to. Audience: implementer of a runtime executor. Consumer: [plurnk-service](https://github.com/plurnk/plurnk-service) (SPEC.md §6.8, §10). Contract shape settled in plurnk-service#174.
4
-
5
- ## §1 Role
6
-
7
- A runtime executor handles one or more EXEC `runtime` slot values (`sh`, `node`, `python`, `search`, `news`, …). It is a `BaseExecutor` subclass that declares its output channels and implements `run()`; the framework discovers it from its `package.json` `plurnk` block. The consuming scheme owns all I/O and lifecycle machinery (db, channels, subscriptions, AbortController bridging, wake-on-completion) and hands the executor sinks — the executor stays stateless across runs beyond its construction metadata.
3
+ Author contract for `@plurnk/plurnk-execs-*` runtime packages. Core consumes
4
+ the framework; executor leaves implement it.
5
+
6
+ ## §executor-role Role and ownership
7
+
8
+ An executor owns the runtime-specific mapping from an EXEC body and optional
9
+ target to work, declared output channels, environment availability, and effect
10
+ classification. The consumer owns dispatch, proposal policy, storage,
11
+ subscriptions, deadlines, polling, cancellation delivery, packet projection,
12
+ and all reads of stored output.
13
+
14
+ ```mermaid
15
+ flowchart LR
16
+ Manifest["package runtime declarations"] --> Discover["framework discovery"]
17
+ Discover --> Registry["runtime-tag registry"]
18
+ Registry --> Probe["consumer instantiates and probes each tag"]
19
+ Request["EXEC[tag]"] --> Effect["executor effect fact"]
20
+ Effect --> Admission["consumer proposal policy"]
21
+ Admission --> Stream["consumer creates tag-addressed stream"]
22
+ Stream --> Run["executor run with consumer sinks"]
23
+ Run --> Channels["stored output channels"]
24
+ Channels --> Packet["consumer observation and READ/FIND"]
25
+ ```
8
26
 
9
- The framework ships `SubprocessExecutor` (§4), a concrete `BaseExecutor` for
10
- subprocess runtimes. Runtime packages override its `spawnArgs()` hook and
11
- inherit process execution, streaming, cancellation, and telemetry.
27
+ `SubprocessExecutor` is the concrete base for command runtimes. It owns child
28
+ process spawning, stdout/stderr streaming, environment handoff, exit status,
29
+ and process-group cancellation. A subprocess leaf normally overrides only its
30
+ spawn recipe and availability binary.
12
31
 
13
- ## §2 Executor contract
32
+ ## §executor-contract Author surface
14
33
 
15
34
  ```ts
16
35
  abstract class BaseExecutor {
17
- readonly runtime: string; // matched tag — "sh" / "search" / "news" / …
36
+ readonly runtime: string;
18
37
  readonly glyph: string;
19
- constructor(metadata: { runtime: string; glyph: string });
20
38
 
21
- // Channels this executor writes to; the scheme seeds the exec entry from
22
- // these (§2.1). Subprocess runtimes declare { stdout, stderr }; search
23
- // declares { results }.
24
39
  abstract get channels(): Readonly<Record<string, ChannelDecl>>;
25
-
40
+ get defaultChannel(): string;
26
41
  abstract run(args: ExecArgs): Promise<ExecResult>;
42
+ probe(signal?: AbortSignal): Promise<RuntimeAvailability>;
43
+ effect(target: string | null): Effect;
27
44
  }
28
45
 
29
- interface ChannelDecl { mimetype: string; defaultState?: ChannelState; }
30
- type ChannelState = "active" | "closed" | "errored";
46
+ interface ChannelDecl {
47
+ mimetype: string;
48
+ defaultState?: "active" | "closed" | "errored";
49
+ }
31
50
 
32
51
  interface ExecArgs {
33
- runtime: string; // matched tag; multi-tag executors branch on it
34
- command: string; // EXEC body: shell line / source / search query
35
- cwd: string | null; // process working dir (workspace workspace); null for logical runtimes
36
- target: string | null; // parsed EXEC (target) slot, interpreted per-runtime (data: input+body=program / executable: program+body=stdin); resolved vs cwd; null if none
37
- env?: NodeJS.ProcessEnv; // consumer-scoped child env — drops plurnk's own secrets (plurnk-execs#8); host env inherited when omitted
38
- signal: AbortSignal; // cancellation — executors must honor it
39
- write: (channel: string, chunk: string, mimetype?: string) => void; // write a chunk; optional mimetype stamps the channel's real per-call output type
40
- setState: (channel: string, state: ChannelState) => void; // drive a declared channel's lifecycle
41
- emit: (event: TelemetryEvent) => void; // emit telemetry/error (§2.2)
42
- entry?: (path: string, content: string | null, opts: { tags: string[]; mimetype?: string }) => Promise<void>; // request materialization/prefetch — content null ⇒ consumer-sourced (§2.6)
52
+ runtime: string;
53
+ command: string;
54
+ cwd: string | null;
55
+ target: string | null;
56
+ env?: NodeJS.ProcessEnv;
57
+ signal: AbortSignal;
58
+ write(channel: string, chunk: string, mimetype?: string): void;
59
+ setState(channel: string, state: ChannelState): void;
60
+ emit(notice: Notice): void;
61
+ entry?(
62
+ path: string,
63
+ content: string | null,
64
+ opts: { tags: string[]; mimetype?: string },
65
+ ): Promise<string>;
43
66
  }
44
67
 
45
- interface ExecResult {
46
- status: number; // 200 ok / 499 aborted / 500 error
47
- exitCode?: number; // subprocess family only
68
+ interface RuntimeAvailability {
69
+ available: boolean;
70
+ detail?: string;
48
71
  }
49
- ```
50
-
51
- `run()` must not throw for an expected runtime failure: surface it through `emit` + an `errored` channel state + a non-200 `status`.
52
-
53
- ### §2.1 Channel topology is executor-declared
54
-
55
- The executor declares its channels; the consuming scheme seeds the exec entry from `executor.channels` rather than from a static scheme-level manifest (plurnk-service#174 Q1). This keeps channel names honest — `search` exposes `{ results: { mimetype: "application/json" } }`, and the model reads `exec://<coord>/EXEC#results` instead of an overloaded `#stdout`. `write` / `setState` are generic over channel name; writing to an undeclared channel is a contract violation.
56
-
57
- ### §2.2 Availability probe
58
-
59
- ```ts
60
- interface RuntimeAvailability { available: boolean; detail?: string; }
61
72
 
62
- abstract class BaseExecutor {
63
- async probe(signal?: AbortSignal): Promise<RuntimeAvailability> { return { available: true }; } // default: available
64
- }
65
- ```
66
-
67
- `probe()` reports whether the runtime's *environment* is usable here — distinct from whether the *package* is installed (`discover()`). Pure / in-process runtimes (node, sqlite) inherit the available default; runtimes depending on an external binary (`python` → `python3 --version`) or config (`search` → `SEARXNG_URL`) override. `SubprocessExecutor` probes its `binary` getter (`null` = always available).
68
-
69
- Consumer contract (plurnk-service#181): probe **once at boot, per package** (not per tag — stamp all of a package's tags with the one result), **concurrently under a per-probe timeout**, and **cache**. The per-probe deadline/cancellation is delivered as the optional **`signal`** — a probe that spawns a child or opens a connection MUST hand `signal` to it, so a resolved or timed-out probe **reaps** it immediately (else an in-flight `--version` write can `EPIPE` after host teardown, plurnk-execs#16). `signal` is ignore-safe: a probe that spawns nothing needs nothing. `probe()` MAY reject; the consumer treats rejection as `{ available: false, detail: <error> }`, so a buggy probe degrades only its runtime. The model is offered a positive list of available runtimes; an attempt at an unavailable one returns **501 carrying `detail`** (so `detail` is model-facing — terse and actionable). A configured default runtime that probes unavailable is a **fail-hard boot error**.
70
-
71
- ### §2.3 Effect (proposal gating)
72
-
73
- ```ts
74
73
  type Effect = "pure" | "read" | "host";
75
-
76
- abstract class BaseExecutor {
77
- effect(target: string | null): Effect { return "host"; } // default: conservative
78
- }
79
74
  ```
80
75
 
81
- `effect()` classifies an invocation's side effect so the consumer can gate the proposal lifecycle per runtime (service#182). The executor declares the **fact**; the consumer owns the **policy** (an `effect → propose/auto` map, deployment-tunable: default `host → propose`, `read`/`pure → auto`).
82
-
83
- - **`host`** — runs code / mutates the host (subprocess; file-backed sqlite) → propose.
84
- - **`read`** — observes external state, no host mutation (search) → auto.
85
- - **`pure`** — no observable side effect (sqlite `:memory:`, transforms) → auto.
86
-
87
- `effect()` MUST be **pure, synchronous, and cheap** — it runs on the dispatch hot path at propose time. It classifies the **target only** (known pre-`run()`); it MUST NOT inspect the command (parsing SQL/shell to judge intent is a sandbox-escape footgun) and MUST NOT do I/O. Default `host` is conservative — anything unclassifiable is proposed (fail-safe, the mirror of `probe()`'s fail-open default).
88
-
89
- For `exec`, per-runtime `effect()` supersedes the static `Exec.manifest.flags.proposes`. Auto-run (`read`/`pure`) runtimes may run **inline** — synchronous return rather than entry-then-read-next-turn — while still landing the result as a re-readable entry.
90
-
91
- ### §2.4 Telemetry
92
-
93
- Runtime failures are emitted as a grammar `TelemetryEvent` via the `emit` sink (plurnk-service#174 Q3); the scheme routes it to the engine's telemetry buffer — the same path grammar's `parse_error` takes. Events are not encoded into `stderr` (that pollutes program output) nor returned on `ExecResult` (that loses mid-run events).
94
-
95
- - `source`: `"exec:<runtime>"` (e.g. `"exec:search"`) or `"scheme:exec"`.
96
- - `kind`: producer-minted, open vocabulary. What the shipped executors actually emit: `SubprocessExecutor` → `spawn_failed` (a failed *start*; a nonzero exit is the program's own result and stays on `stderr`, **not** telemetered); search → `searxng_not_configured` / `searxng_unreachable` / `searxng_timeout` / `searxng_http_<n>` / `external_bang_refused`; sqlite → `sqlite_open_failed` / `sqlite_error`. A cancellation (`signal` abort) is normal flow — **not** emitted.
97
- - `message`: terse, factual. `position`: typically null at the runtime layer.
98
-
99
- The envelope is mirrored locally (`TelemetryEvent`, `ContentOffset`, `LogCoordinate`) so the framework needs no `@plurnk/plurnk-grammar` dependency; grammar's `dist/schema/TelemetryEvent.json` is the source of truth.
100
-
101
- ### §2.5 Deadlines & polling — the executor stays oblivious
102
-
103
- An EXEC op may carry a `<L>` line-marker slot read as `[TIMEOUT_SECONDS, POLL_SECONDS?]` (plurnk-grammar#39, plurnk-service#277): `<1800>` hard-kills at 1800s, `<-1>` declines a per-exec deadline (still loop-life bounded), `<1800,300>` fixes a 300s loop-poll. **None of this touches the executor contract.** `run()` stays stream-only and abort-driven:
104
-
105
- - The **timeout** is the consumer's: at the deadline it fires `args.signal` — arriving as an ordinary abort the executor already honors (the §4 process-group SIGHUP→grace→KILL path). No deadline awareness in the executor.
106
- - The **poll** is purely the consumer's loop: it wakes on a cadence while the channel is `active`, reading the partial output the executor already streams incrementally via `write`. The executor never sees a tick; it's non-destructive.
107
-
108
- **Default poll — exponential backoff, not park-blind (owner ruling, #519).** An absent poll (`<T>`, `<T,-1>`, or no slot) does **not** mean "never wake" — the consumer applies a default **exponential backoff, base 60s, 8 turns** (both operator-tunable: `PLURNK_SERVICE_EXEC_POLL_SEC` / `PLURNK_SERVICE_EXEC_POLL_TURNS`): the loop wakes at 60s, 120, 240 … doubling for 8 turns then holding at the cap. This reverses the earlier park-blind default, under which a hung exec (a deadlock, a lock-wait, a bare interpreter on stdin) held its worker for the whole loop life with the model never regaining a turn. The wake is **non-destructive and model-driven**: each tick hands the model a turn to observe the partial output and decide — re-park a legitimately slow long-runner, or **KILL** a stuck one. There is deliberately **no auto-kill**: only the model can tell a silent deadlock from a silent `cargo build`, so disposal is its judgment, not a timer's. Overrides: `<,P>` (P>0) fixes the cadence; `<,0>` opts out to a blind park for an exec a model truly wants unwatched. Mechanism + knobs live in the consumer (plurnk-service).
109
-
110
- So the feature is contract-free for executors and applies to all of them uniformly: a long-runner is watched by the backoff (or the op slot), a fast/logical executor (search) terminates before the first tick and the slot is a no-op.
111
-
112
- ### §2.6 Output addressing — the executor produces, the consumer reads
113
-
114
- An executor is a **producer, not a reader.** Its whole output contract is `run()` writing channels (`write` / `setState` / `emit`); the consumer streams those into an ordinary log entry addressed at **`<tag>://<coord>`** (the worker's `<runtime>:///<loop>/<turn>/<seq>`), and every READ/FIND over that output is the consumer's uniform entry machinery — identical for `sh`, `search`, `sqlite`, and MCP alike. The executor never reads, slices, summarizes, or orients its own output.
115
-
116
- The executor's only scheme-facing contribution is a **derived manifest** — `OutputScheme.manifestFromRuntime({ name, glyph, channels, defaultChannel })`, built from the runtime declaration, no separate authoring — so the consumer can mint a per-tag output scheme (name = the tag) and the model can address `<tag>://`. `defaultChannel` (first declared channel; subprocess → `stdout`) is where a bare `READ <tag>://<coord>` lands. `discover()` is unchanged (static install/config truth, §3); activation (§3.2) is the consumer's runtime overlay.
117
-
118
- **No executor read-face; no per-runtime read, no orientation receipt.** `BaseExecutor` implements no `read`/`find`, and none is wired downstream — not one sibling in the family overrides them (the "rich" `sqlite`/MCP included), and the consumer's per-tag scheme serves every READ through its single `DefaultRead` resolver (which holds the `Mimetypes` instance + output store the executor never sees) over the stored entry. Orientation is **model-pulled**: the model probes an output's shape by querying the entry (FIND / READ with jsonpath / glob / line-range) against the neutral `{ mimetype, tokens, lines }` every entry carries, pulling only the slice it wants. An engine-pushed shape digest *is* the catalog-as-index the paradigm forbids ("the instant it did, it would be an index again") — so there is none, and the answer to service#240's *"where is the OrientIndex receipt built?"* is **nowhere; nothing builds one.** A `<tag>://` entry is folded by default, so output never floods context regardless; the one real risk — a chatty runtime filling sqlite — is a daemon storage-cap concern, not an executor one.
119
-
120
- MCP is the proof, not the exception: `plurnk-execs-mcp` is a plain producer — `run()` calls the tool and writes its JSON result to a `results` channel, exactly as `search` writes `results` — with no `read`, no `find`, no summary. Bring a need and it maps onto the log; the log paradigm already orients, and the executor never curates.
121
-
122
- **Entry materialization is a sink, not a substrate breach (execs#18 / service#340).** `ExecArgs.entry?(path, content, { tags, mimetype }) → Promise<void>` lets an executor *request* that the consumer materialize a substrate entry — the same shape as `write`/`emit` (consumer-implemented callbacks; the executor never touches storage, tagging, or announcement machinery). The consumer creates the entry, applies the tags, and announces it through its ambience as a **folded** row (the meta line carries path + tokens; page-scale bodies never ride a wake packet). Consumer collision semantics: upsert content, union tags, bump freshness. A rejected `entry()` means only *not materialized*; it does not invalidate upstream discovery metadata. The sink is optional: absent, producers degrade gracefully without a materialization verdict. This closed the gap that §2.6's earlier absolutism forced on the search flow (a double-fetch dance routed around the missing sink); the principle — executors own zero substrate machinery — is unchanged.
123
-
124
- **The search prefetch seam resolves here (service#596).** The consumer implements `entry()` as auto-prefetch: acquire, MIME-project, and materialize the `https://` entry. A guarded browser render is a fallback only when the HTTP response has no useful model-facing projection. Resolve/reject becomes the candidate's admission verdict: failed materializations are omitted while survivors retain SearXNG rank. Plurnk does not otherwise rerank or judge the results. There is no executor pre-validation (that would fetch — forbidden) or double fetch. A URL prefetch carries no page body for the executor to supply, so `content` is **consumer-sourced** — the executor contributes the candidate `url` as `path` and learns only the materialization verdict, never the body.
125
-
126
- Search acquisition emits aggregate `search_progress` telemetry through `emit`: a start, at most approximately ten intermediate milestones, and a terminal `complete`, carrying `completed`, `total`, `materialized`, `rejected`, and `percent`. It never emits candidate URLs or one event per result. This is compact live client state, not the ranked digest and not a substitute for its durable per-result verdicts.
127
-
128
- ## §3 Discovery
129
-
130
- `discover(options?) → { registry, skipped, disabled }`. Scans **every installed package** under the nearest `node_modules` (walking up from `cwd` for workspace hoisting) — scope-agnostic (scoped and unscoped) — for those declaring `plurnk.kind === "exec"`, and registers each runtime tag from `plurnk.runtimes[]`. The scan is deliberately not limited to `@plurnk/*`: a **third party** can publish an executor under their own scope (`@acme/acme-execs-foo`) and have it discovered with no involvement from this project. (No aggregator package: the default bundle is the daemon's own dependency list, not a metapackage.)
76
+ The framework constructs one executor per matched runtime tag with
77
+ `{ runtime, glyph }`. A package that declares several tags may branch on
78
+ `this.runtime`; it must not retain per-run state on the executor instance. The
79
+ derived addressable runtime scheme retains `glyph` for client discovery
80
+ ({§manifest-client-display}); runtime aliases remain excluded from scheme
81
+ model-teaching because EXEC examples own that hot-path surface.
82
+
83
+ ### §executor-sinks Inputs and sinks
84
+
85
+ | Field | Contract |
86
+ | ---------- | ------------------------------------------------------------------------------------------------------- |
87
+ | `runtime` | The matched tag. |
88
+ | `command` | The EXEC body: source, query, filter, or command according to the leaf contract. |
89
+ | `cwd` | Consumer-selected project working directory, or `null` for a runtime that has none. |
90
+ | `target` | Consumer-resolved optional EXEC target. The leaf defines how target and body map to its tool. |
91
+ | `env` | Exact child environment when supplied. A subprocess must use it instead of reconstructing host policy. |
92
+ | `signal` | Consumer cancellation. Every executor must honor it at each cancellable boundary. |
93
+ | `write` | Append to a declared channel. An optional mimetype replaces that channel's per-call output type. |
94
+ | `setState` | Move a declared channel from `active` to terminal `closed` or `errored`. |
95
+ | `emit` | Publish a transient, nonterminal Notice. |
96
+ | `entry` | Optionally request consumer-owned entry materialization and receive its canonical model-facing address. |
97
+
98
+ The executor receives callbacks, never a database, subscription registry,
99
+ packet builder, or wake mechanism. Related lifecycle behavior remains on the
100
+ consumer side of {§executor-role}.
101
+
102
+ ### §executor-channels Channels
103
+
104
+ The executor declares every channel it may write. The consumer seeds a stream
105
+ entry from that declaration and rejects writes or state transitions for an
106
+ undeclared channel. `defaultChannel` is the fragmentless READ channel and
107
+ defaults to the first declared channel. Subprocess executors declare
108
+ `stdout` and `stderr`, with `stdout` first; logical runtimes commonly declare
109
+ `results`.
110
+
111
+ ### §executor-results Results, failures, and notices
112
+
113
+ `run()` resolves one universal `SchemeResult`; `ExecResult` adds an optional
114
+ subprocess `exitCode`. A result with `status >= 400` carries exactly one RFC
115
+ 9457 Problem whose status agrees with the result. Expected runtime failures
116
+ resolve as failure results and leave affected channels `errored`; they do not
117
+ throw. The consumer validates the boundary and converts a throw or invalid
118
+ result into its own durable failure before closing the stream.
119
+
120
+ A nonzero subprocess exit directs the caller to inspect both stdout and stderr
121
+ because either may contain the useful diagnostic. Third-party diagnostic text
122
+ entering a Problem is bounded with `ErrorDetail` and the required
123
+ `PLURNK_EXECS_ERROR_DETAIL_LIMIT`. Missing or invalid configuration is itself
124
+ an `invalid-configuration` failure. Structured diagnostic facts remain
125
+ separate Problem extensions.
126
+
127
+ `emit` is only for transient progress or other nonterminal observations. It
128
+ cannot own failure truth, change the terminal result, or drive the scheduler.
129
+ The runtime-neutral Notice types are re-exported from
130
+ `@plurnk/plurnk-contracts`; they are not redefined by this package.
131
+
132
+ ### §executor-effect Effect is an admission fact
133
+
134
+ `effect(target)` is pure, synchronous, cheap, and target-classified. The
135
+ consumer calls it exactly once for an admitted invocation, before proposal
136
+ policy, then preserves that exact fact through application and effect-sensitive
137
+ bookkeeping. An installed executor does not parse authored code or commands to
138
+ infer safety. Unknown or unclassifiable work remains `host` through the
139
+ conservative default.
140
+
141
+ `target` is the consumer-canonical logical target. It is normally the same
142
+ local value later supplied to `run()`. If the consumer will materialize an
143
+ addressed source only after acceptance, it may classify against a stable opaque
144
+ target-present identity; the executor neither resolves that address nor
145
+ reclassifies the materialized path.
146
+
147
+ | Effect | Executor fact | Default consumer admission |
148
+ | ------ | -------------------------------------------- | -------------------------- |
149
+ | `host` | Runs host code or may mutate host state. | Human proposal gate. |
150
+ | `read` | Observes external state without mutating it. | Automatically accepted. |
151
+ | `pure` | Has no externally observable side effect. | Automatically accepted. |
152
+
153
+ The classification controls admission policy and may be reused as operational
154
+ metadata; it never changes within the invocation. After acceptance, every EXEC
155
+ uses the same background stream path: output is not returned in the dispatching
156
+ turn. Core owns that composed behavior in {§exec-host-proposes},
157
+ {§exec-readpure-ungated}, and {§exec-stream}.
158
+
159
+ ### §executor-probe Availability is per runtime tag
160
+
161
+ `probe(signal?)` reports whether this runtime tag can work in the current
162
+ environment. The default is `{ available: true }`; leaves override it for
163
+ external binaries or required configuration. `detail` must be terse and
164
+ actionable because the consumer may return it with a 501 failure.
165
+
166
+ The consumer constructs and probes one executor per tag at boot, concurrently
167
+ under a per-probe timeout, then caches each verdict. This is intentionally
168
+ per-tag: a multi-tag package can expose only the interpreters actually present.
169
+ A probe that spawns or opens a connection passes the supplied signal through so
170
+ completion or timeout reaps its work. Probe rejection or timeout makes only
171
+ that tag unavailable; a configured default runtime that is absent or
172
+ unavailable is a fail-hard boot error.
173
+
174
+ ## §executor-output-address Tag-addressed output
175
+
176
+ `exec` is the consumer's internal EXEC dispatcher; it is not a model-facing
177
+ output namespace. Every available runtime receives one derived read-only
178
+ scheme face from its tag, channels, and default channel. The executor authors
179
+ no second scheme manifest.
180
+
181
+ | Output | Model-facing address |
182
+ | ------------------------- | ------------------------------------------------------------------ |
183
+ | Calling worker's stream | `<tag>:///<loop>/<turn>/<sequence>#<channel>` |
184
+ | Fragmentless read | `<tag>:///<loop>/<turn>/<sequence>` → the declared default channel |
185
+ | Example subprocess stdout | `sh:///1/2/3#stdout` |
186
+ | Example structured result | `sqlite:///1/2/3#results` |
187
+
188
+ The executor only produces through `write` and `setState`. The consumer stores
189
+ the entry and serves every later READ/FIND through uniform entry machinery.
190
+ An executor does not implement a private read face, orientation receipt,
191
+ slicer, or index. Owner-qualified cross-worker addresses and packet projection
192
+ belong to core's {§stream-owner-scoped} and {§exec-stream} contracts.
193
+
194
+ ### §executor-entry-sink Optional materialization
195
+
196
+ `entry(path, content, { tags, mimetype? })` is a consumer-implemented sink, not
197
+ executor access to storage. The consumer materializes or updates an entry,
198
+ unions tags, announces it through its ordinary ambience, and resolves the
199
+ canonical model-facing address. `content === null` requests consumer-sourced
200
+ acquisition. Rejection means only that this materialization failed; it does not
201
+ invalidate the executor's upstream result. When the sink is absent, the
202
+ executor preserves its result without inventing a materialization verdict.
203
+
204
+ ## §executor-discovery Discovery and registration
205
+
206
+ `discover(options?)` scans every scoped and unscoped package under the nearest
207
+ `node_modules` for the exact declaration `plurnk.kind === "exec"`. It returns
208
+ `{ registry, packageAttributions, skipped, disabled }`, where the registry maps
209
+ each flat runtime tag to its declaration and package owner and the package map
210
+ carries the canonical attribution fact from {§plugin-attribution}.
131
211
 
132
212
  ```json
133
213
  {
134
- "name": "@plurnk/plurnk-execs-search",
135
- "plurnk": {
136
- "kind": "exec",
137
- "runtimes": [
138
- { "name": "search", "glyph": "🔎", "example": "<<EXEC[search]:france population:EXEC", "documentation": "# search\n\n`!bang` / `:lang` ride the query…" },
139
- { "name": "news", "glyph": "📰" }
140
- ]
141
- }
214
+ "name": "@acme/acme-execs-cobol",
215
+ "plurnk": {
216
+ "kind": "exec",
217
+ "runtimes": [
218
+ {
219
+ "name": "cobol",
220
+ "glyph": "🗄",
221
+ "example": "<<EXEC[cobol]:DISPLAY 'HI'.:EXEC"
222
+ }
223
+ ]
224
+ }
142
225
  }
143
226
  ```
144
227
 
145
- A package may claim multiple tags backed by one handler. Tags form a **flat global namespace**; `registry` maps tag → `{ runtime, glyph, example, documentation, packageName, attribution? }`. Unlike plurnk-mimetypes (last-loaded wins), a tag **collision is fail-hard**: two packages claiming the same runtime is an unresolvable install ambiguity the operator must fix.
146
-
147
- Each entry's optional **`attribution`** is the package's raw `plurnk.attribution` (`string | string[]`) — credit a consumer unions onto the model call when the package's tags are active (plurnk-service#249). It's **package-level** (every tag of a package carries the same value) and surfaced **raw**: the consumer owns the reservation policy (e.g. `@plurnk/`-scoped attribution only from `@plurnk/`-scoped packages). `undefined` when the package omits it.
148
-
149
- Each entry's optional **`example`** is a one-line, self-documenting usage example surfaced **verbatim** by the consumer in its `# Plurnk System Tools` capability sheet so the model learns the tag's syntax + purpose in one line instead of a separate prose description (plurnk-execs#7). It MUST be the **complete canonical op, `<<`-delimited** — `<<EXEC[tag]:body:EXEC` (or `<<EXEC[tag](target):body:EXEC`) — because the consumer renders it verbatim into the sheet; an example missing the `<<` opener teaches the model a malformed op. Defaults to `""` when omitted. Kept to a single line on purpose — the sheet is hot-path and token-sensitive; the generic `(target)` slot is documented once at the op level, not repeated per tag.
150
-
151
- Each entry's optional **`documentation`** is full markdown — the flags, modes, and gotchas the one-liner can't carry. It is the depth a consumer can serve on demand, separate from the always-on `example` (progressive disclosure). The **source of truth is a `docs/<tag>.md` file** in the package (the docs convention; ship it via `files`), which `discover()` reads into `documentation`; the inline `documentation` manifest field is the fallback when no file ships. Defaults to `""`. The execs contract is the two fields; **how** the consumer surfaces them to the model — an in-context one-liner, the full doc fetched when the model wants it — is the consumer's (plurnk-service's) concern, not specified here.
152
-
153
- ### §3.1 Dynamic runtimes (per-deployment tags)
154
-
155
- A package whose tags are not known at publish time — the MCP bridge is the motivating case, where each tag is a per-deployment MCP **server** an operator configures in the environment (plurnk-execs#10) — declares **`plurnk.runtimesModule`** (an export subpath, resolved through the package's export map) **instead of** a static `plurnk.runtimes[]`:
156
-
157
- ```json
158
- {
159
- "name": "@plurnk/plurnk-execs-mcp",
160
- "plurnk": { "kind": "exec", "runtimesModule": "./runtimes" }
161
- }
162
- ```
163
-
164
- The module's **`runtimes`** export (or its default export) is a hook `() => RuntimeDecl[] | Promise<RuntimeDecl[]>` returning the same decls a static manifest would (`{ name, glyph?, example?, documentation? }`). `discover()` imports and calls it at scan time and registers the result exactly as if the decls were static (a `docs/<tag>.md` file still wins over an inline `documentation`, though dynamic tags rarely ship one). The hook reads its own config from the environment — it must be cheap and **must not** depend on network reachability (that is `probe()`'s job, per server, at boot).
165
-
166
- Two guarantees frame the hook:
167
-
168
- - **Trust-gated execution.** The hook is imported **only for trusted packages** — the `PLURNK_PLUGINS_TRUSTED_ONLY` gate (below) runs *before* the import, so an untrusted package's code is never executed. This is the same in-process-trust posture executors already carry (their `run()` is trusted host code); dynamic discovery just extends it to scan time.
169
- - **Fail-hard on a broken hook.** An unloadable module, a missing/non-function export, or a non-array return is a **contract violation by a trusted package** (its own packaging or config) and throws with the cause attached — surfaced, not swallowed. This is deliberately stricter than a malformed third-party `package.json`, which `discover()` silently skips: a package that *declares* a hook owns making it work.
170
-
171
- `runtimes[]` and `runtimesModule` are mutually exclusive; if both are present the **static array wins** and the hook is never loaded.
172
-
173
- **Trust gate.** `discover()` honors the host's **`PLURNK_PLUGINS_TRUSTED_ONLY`** env var (plurnk-service#229) — the one posture decided once and enforced across every scope-agnostic discovery surface (schemes/mimetypes/providers/execs). Unset/`""`/`0` → off: every installed package registers (no regression). Any value → on: `@plurnk/*` is always trusted, plus a comma-separated allowlist of additionally-trusted package names (`1` = on with zero third-party). An untrusted package is **discovered but not registered** — never a crash — and returned in **`Discovery.skipped`** (package names) so the consumer can emit a telemetry note (`discover()` has no sink of its own). The trust predicate is the single shared implementation in **`@plurnk/plurnk-meta`** (`Meta.isTrusted`), consumed identically by the daemon and every family-head scanner — trust is defined once for the whole family, never duplicated.
174
-
175
- Each runtime package's **default export** is its `BaseExecutor` subclass (also a named export — `export { default as Sh }` / `export { default }`); the consumer instantiates it per matched tag with the tag + glyph from the registry.
176
-
177
- ### §3.2 Activation (Active / Available)
178
-
179
- Discovery answers *what is installed/configured*; **activation** answers *what is offered to the model right now*. They are distinct axes and must not be conflated — `discover()` stays static truth, activation is a runtime overlay the consumer owns on top of it (plurnk-execs#10). This generalizes across the **shared exec/scheme namespace**: a registered capability is a tag claimed once that is both `EXEC[tag]` and `tag://`, and activation operates on it whichever family it came from. MCP is not a category here — it is one *route* for registering a capability (alongside package discovery and env), so its tags activate by the same rules as any other.
180
-
181
- **Two states, no third.** A registered capability is **Active** (in the `# Plurnk System Tools` sheet, dispatchable) or **Available** (registered, inert). "Disabled" is the *verb* (Active→Available), not a state. The consumer's capability sheet surfaces two buckets:
182
-
183
- - **Tools Active** — the full one-line `example` each (the hot-path teaching cost).
184
- - **Tools Available** — name + glyph + `attribution` each (a word, not a line).
185
-
186
- This split *is* the progressive disclosure that bounds the sheet: N available servers cost N words, not N lines.
187
-
188
- **Default-activation rule** (the consumer applies it; execs supplies the signals — `packageName` scope, source route, `attribution`):
189
-
190
- | Source route | Trust | Default |
191
- |---|---|---|
192
- | Installed package (boot discovery) | first-party (`@plurnk/*`) | **Active** |
193
- | Installed package (boot discovery) | third-party | **Available** |
194
- | Env-declared (e.g. `PLURNK_EXECS_MCP_<server>`, model-alias style) | — | **Available** |
195
- | Runtime hotload (`/mcp`, gated — below) | — | **Active** on add |
196
-
197
- The principle: *Active = the operator unambiguously committed this capability ON* (installed a first-party package; explicitly hotloaded). Configuring connection details (env) or installing a third-party package is the lighter act → Available, opt-in. Mirrors `PLURNK_MODEL_*` — declare many aliases, activate a subset.
198
-
199
- **Reachability is orthogonal.** Whether a capability is Active/Available (activation) is independent of whether it is reachable/unreachable (the §2.2 `probe()` health flag). A configured MCP server that is down is *Available but unreachable*. Do not overload "available" across the two axes.
200
-
201
- **Capability vs substrate.** enable/disable targets **capability** tags only (execs and executor-backed schemes). Core **substrate** schemes (`file://`, the addressing/ops ground) are always-active and never toggleable — `/disable file` must not be able to brick the address space. For an executor-backed scheme, `disable` gates *new production*; existing `tag://` entries stay READable (reads are pure).
202
-
203
- **Security — one gate, on introduction not activation.** `enable` / `disable` are **not** trust-gated: a client (and the model itself) may freely activate any *registered* capability. This is safe because the registered set is operator-bounded — only env-declared or package-installed capabilities exist — *unless* the single daemon gate **`PLURNK_EXECS_MCP_INSTALL`** (default off) opens the runtime-hotload route, permitting *arbitrary* tooling to be **added**. Gate the introduction of capabilities, never their activation. The trust gate (§3) still bounds *registration*; activation rides on top of an already-trusted set.
204
-
205
- Execs owns none of the overlay machinery — the live Active/Available state, the `/enable` `/disable` `/mcp` commands, and the gate enforcement are the consumer's (plurnk-service#240). Execs' contribution is the static signals above and (for the hotload route) an MCP executor that accepts a runtime-injected server config and re-checks `PLURNK_EXECS_MCP_INSTALL` at its connect path (defense in depth).
206
-
207
- ### §3.3 Runtime policy (the enable/disable cascade)
208
-
209
- A **registration** bound — a sibling of the §3 trust gate, **not** the §3.2 activation overlay. The trust gate bounds registration by *package*; the runtime policy bounds it by *tag*; `discover()` enforces both (the two operator gates on "what is registered for this daemon"). A policy-disabled tag is **absent** — not Active, not Available — so §3.2's "a client may freely activate any registered capability" still holds: the registered set is simply bounded per layer.
210
-
211
- **Grammar** (the `PLURNK_EXECS_*` namespace, identical at every tier):
212
-
213
- | Var | Effect |
214
- |---|---|
215
- | `PLURNK_EXECS_<TAG>=0` (or `false`) | surgical kill-switch for one tag |
216
- | `PLURNK_EXECS_ONLY=a,b,c` | allowlist — a tag not listed is disabled |
217
-
218
- The two compose within a layer (an allowlisted tag can still be individually killed). `ONLY` is reserved: a runtime tag may not be named `only`. **Keys are matched case-insensitively** — `PLURNK_EXECS_sh=0` ≡ `PLURNK_EXECS_SH=0`, the way the mcp config folds server names, so the natural lowercase-tag spelling doesn't silently no-op. Purely **subtractive** — there is no force-enable verb, by design.
219
-
220
- **The cascade is an intersection.** A tag is live iff enabled in *every* layer: `enabled(tag) = service ∧ client ∧ …`. Because each layer only subtracts, order is irrelevant and no downstream layer can undo an upstream disable — **the client can never re-enable what the service disabled**, structurally, not by a policed rule.
221
-
222
- - `discover()` applies the **daemon boot layer** (`process.env`) at registration, uniformly across every plugin (framework-guaranteed — a plugin cannot escape it, the way `-common`'s old local kill-switch let `sqlite`/`jq`/`git` slip through). Disabled tags are returned in `Discovery.disabled`.
223
- - The consumer applies the **per-workspace client layer** with the *same parser* — `Policy.enabledAcross(tag, [serviceEnv, clientLayer])`, or `Policy.isEnabled(tag, clientLayer)` over the already-registered set. The client declares its policy in the identical `PLURNK_EXECS_*` format; the daemon intersects. The client has no enforcement surface of its own — executors run in the daemon — so this is a structural ceiling, not a trust boundary.
224
-
225
- Framework surface: **`Policy.isEnabled(tag, env?)`** (one layer, defaults to `process.env`) and **`Policy.enabledAcross(tag, layers)`** (the intersection). To disable all standard execs except search: `PLURNK_EXECS_ONLY=search`.
226
-
227
- ### §3.4 The empty-set notice (the legible no)
228
-
229
- When a loop leaves **zero** EXEC runtimes in the §3.2 *Active* bucket, the capability sheet must carry a positive statement — **"No EXEC operations permitted"** — not silent absence. The grammar still teaches `EXEC` as a valid op (the GBNF permits it); with no availability signal either way, the model reads the bare op mention as *unknown* and confabulates runtimes, burning its strikes on a gate that correctly refuses them (execs#24). The negative line closes that window: a legible *no* where silence invited a guess.
230
-
231
- **One contributor, both cases.** `Advertise.contribute(registry, isPermitted)` returns the permitted runtimes **and** the notice from the *same* filter — a non-empty result never carries a notice, so the N-runtime sheet and the 0-runtime line cannot drift out of sync.
232
-
233
- **Cause-agnostic.** `isPermitted` resolves whether a registered tag survives *this* loop's gates: execs supplies the §3.3 policy cascade as the baseline predicate, and the consumer composes stricter gates into it — the effect-typed **host bar** by which a loop mode (ask-mode) bars host-effecting ops. But *why* the set is empty — policy zeroing every tag, or the host bar catching them all — is never execs' business. Execs owns the §2.3 `effect()` classification and the count; the consumer owns the loop-mode decision and reduces it to the effect-typed bar it composes into the predicate. Nothing about the mode reaches execs. The line is short and **EXEC-scoped** — it names the op, so the model reads a bounded "no EXEC here", not a general rule — and cause-agnostic. Exact wording is the consumer's ask-mode probe to validate, not asserted here.
234
-
235
- Execs owns the notice content and the tally. It does **not** own the mode, the sheet's rendering, or the decision of when a loop bars host effects — only the single line spoken when its own count of surviving runtimes reaches zero.
236
-
237
- ## §4 Subprocess executors
238
-
239
- `SubprocessExecutor` translates a runtime tag and command into
240
- `node:child_process.spawn` arguments:
228
+ A tag collision is a fail-hard installation error naming both claiming
229
+ packages. A package may declare multiple tags backed by the same default
230
+ export. Discovery is scope-agnostic; third-party packages participate through
231
+ the same contract.
232
+
233
+ ### §executor-installation Installed capability lifecycle
234
+
235
+ Configuration cannot enable a package that is absent from the consumer-visible
236
+ module graph. An executor leaf must be installed under the `node_modules` found
237
+ from the running service before discovery can inspect its declaration. The
238
+ service assembles every installed member's package-owned `.env.defaults` under
239
+ operator configuration {§operator-config-env-defaults}; a leaf may therefore
240
+ ship default-disabled without transferring ownership of its key to the host.
241
+
242
+ | Package state | Effective policy and probe | Runtime result |
243
+ | ------------- | -------------------------------------- | ------------------------------------------------------------------- |
244
+ | Absent | Any configuration | Not discovered, registered, advertised, or dispatchable. |
245
+ | Installed | Tag disabled in any policy layer | Listed in `Discovery.disabled`; not registered or advertised. |
246
+ | Installed | Tag admitted; probe unavailable | Registered as unavailable; not advertised or dispatchable. |
247
+ | Installed | Tag admitted; probe available | Registered, advertised through `availableRuntimes()`, dispatchable. |
248
+
249
+ An operator value of `PLURNK_EXECS_<TAG>=1` can outrank a package-owned `0`
250
+ floor. It does not restore a tag removed by `PLURNK_EXECS_ONLY`, another
251
+ consumer layer, or a service hard ceiling. Discovery and policy are evaluated
252
+ at boot; changing package membership or configuration requires a restart.
253
+
254
+ ### §executor-runtime-declaration Runtime metadata
255
+
256
+ | Field | Meaning |
257
+ | --------------- | -------------------------------------------------------------------------------------------------- |
258
+ | `name` | Canonical runtime tag and derived output-scheme name, admitted below. |
259
+ | `glyph` | Optional presentation glyph. |
260
+ | `example` | Optional compact, verbatim `plurnk` snippet. Each line is a complete `<<`-delimited operation. |
261
+ | `documentation` | Optional full Markdown reference. `docs/<tag>.md` wins over the inline manifest field. |
262
+ | `attribution` | Published per-tag projection of the validated package declaration ({§plugin-attribution}). |
263
+ | `packageName` | Package that owns and default-exports the executor implementation. |
264
+
265
+ The framework carries example and documentation content unchanged. The
266
+ consumer decides when and how to present either surface. A multi-tag package
267
+ appears at most once in `Discovery.packageAttributions`, and only when at least
268
+ one of its tags survives discovery policy. An instantiated executor may add
269
+ attempt-time tags through the shared runtime hook ({§plugin-attribution}).
270
+
271
+ Runtime-name admission is one identity contract:
272
+
273
+ | Constraint | Contract |
274
+ | ----------- | ------------------------------------------------------------------------------------------------------------- |
275
+ | Syntax | `[a-z][a-z0-9+.-]*`: canonical lowercase RFC-scheme syntax that is also an admitted EXEC identifier. |
276
+ | Identity | The exact name is the EXEC selector, registry key, `docs/<tag>.md` basename, and output URI-scheme name. |
277
+ | Reservation | `only` is unavailable because `PLURNK_EXECS_ONLY` owns that case-insensitive configuration key. |
278
+
279
+ Installed static declarations, trusted dynamic-hook declarations, and
280
+ module-owned declarations use the same validator before documentation lookup,
281
+ policy filtering, or registry mutation. A malformed claimed declaration fails
282
+ hard and names its package or module boundary; discovery still ignores an
283
+ unreadable package that never forms an executor manifest.
284
+
285
+ ### §executor-dynamic-runtimes Dynamic declarations
286
+
287
+ A trusted package whose tags depend on deployment configuration may declare
288
+ `plurnk.runtimesModule` as an exported subpath instead of a static
289
+ `plurnk.runtimes[]` array. Its named `runtimes` export, or default export,
290
+ returns `RuntimeDecl[] | Promise<RuntimeDecl[]>`. Static declarations win when
291
+ both fields are present.
292
+
293
+ Discovery resolves the subpath through the package export map and imports it
294
+ only after the trust gate. An unloadable hook, missing function export, thrown
295
+ hook, or non-array result is a fail-hard contract violation by the admitted
296
+ package. The hook enumerates configuration; reachability remains the per-tag
297
+ probe's job.
298
+
299
+ ### §executor-trust Trust precedes executable discovery hooks
300
+
301
+ The shared plugin trust predicate runs before a dynamic runtime module is
302
+ imported. A withheld package executes no hook and is returned in
303
+ `Discovery.skipped` for consumer presentation. Discovery silently ignores a
304
+ package that does not form a readable executor manifest; once an admitted
305
+ package declares executable hook code, its broken hook is surfaced rather than
306
+ swallowed.
307
+
308
+ ### §executor-policy Subtractive runtime policy
309
+
310
+ Discovery applies the daemon's registration policy to every tag. The exported
311
+ `Policy` parser can apply the same grammar to additional consumer-owned layers.
312
+
313
+ | Variable | Enforced effect |
314
+ | ---------------------------------------- | ----------------------------------------------------------- |
315
+ | `PLURNK_EXECS_<TAG>=0` or `false` | Remove that tag. Keys are matched case-insensitively. |
316
+ | `PLURNK_EXECS_ONLY=<list>` | When present, remove every unlisted tag; empty removes all. |
317
+ | `Policy.isKey(key)` | Admit only `ONLY` or a canonical runtime-tag suffix. |
318
+ | `Policy.enabledAcross(tag, [a, b, ...])` | Keep the tag only when every supplied layer enables it. |
319
+
320
+ Policy is purely subtractive: no layer can re-enable a tag removed by another.
321
+ Boot-disabled tags are absent from the registry and returned in
322
+ `Discovery.disabled`. Workspace and loop admission remain consumer concerns;
323
+ the framework does not define a second Active/Available state machine.
324
+
325
+ ### §executor-advertise-compat Frozen `Advertise` compatibility
326
+
327
+ | Surface | 1.x contract |
328
+ |--------------------------------------|-------------------------------------------------------------------------------------------------------------|
329
+ | `Advertise.contribute(registry, fn)` | Filters the supplied registry; returns `NO_EXECS_NOTICE` only when the filtered result is empty. |
330
+ | Architectural ownership | None. It does not discover, probe, admit, dispatch, or render; the composed service never calls it. |
331
+ | Evolution | Frozen. New consumers compose discovery and `Policy` with their own presentation. Removal is SemVer-major. |
332
+
333
+ ## §executor-default-inventory Current installed set
334
+
335
+ The default `@plurnk/plurnk-service` composition installs the following
336
+ `@plurnk/plurnk-execs-<leaf>` packages. The lean framework owns no leaf
337
+ dependency edges. A probe and consumer policy still determine which declared
338
+ tags are offered in a particular workspace ({§bundled-set}).
339
+
340
+ | Leaf | Declared tags | Effect by target | Channels / mimetype |
341
+ | -------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------- | ---------------------------------- |
342
+ | `common` | `sh`, `bash`, `node`, `python`, `python3`, `perl`, `ruby`, `php`, `lua`, `deno`, `bun`, `tcl`, `bc`, `awk` | `host` | `stdout`, `stderr` / `text/stream` |
343
+ | `git` | `git` | `host` | `stdout`, `stderr` / `text/stream` |
344
+ | `search` | `search`, `images`, `videos`, `news`, `map`, `music`, `it`, `science`, `social`, `downloadable` | `read` | `results` / `application/json` |
345
+ | `jq` | `jq` | no target `pure`; target `read` | `results` / `application/jsonl` |
346
+ | `sqlite` | `sqlite` | memory `pure`; file `host` | `results` / `application/json` |
347
+ | `wasm` | `wat`, `wasm` | no target `pure`; file `read` | `results` / `application/json` |
348
+
349
+ Optional executor packages are not part of this installed set merely because
350
+ they exist in the workspace.
351
+
352
+ ## §executor-subprocess Subprocess contract
353
+
354
+ `SubprocessExecutor` translates a runtime, body, and optional target into:
241
355
 
242
356
  ```ts
243
- interface SpawnArgs { cmd: string; args: string[]; useShell: boolean; stdin?: string; }
357
+ interface SpawnArgs {
358
+ cmd: string;
359
+ args: string[];
360
+ useShell: boolean;
361
+ stdin?: string;
362
+ }
244
363
  ```
245
364
 
246
- | Runtime | Spawn |
247
- |---|---|
248
- | `""` / `"sh"` / `"bash"` | `{ cmd: command, args: [], useShell: true }` |
249
- | `"node"` | `{ cmd: "node", args: ["-e", command], useShell: false }` |
250
- | `"python"` / `"python3"` | `{ cmd: "python3", args: ["-c", command], useShell: false }` |
251
- | any other | `{ cmd: runtime, args: ["-c", command], useShell: false }` (conservative fallback) |
252
-
253
- The executor registry rejects an unknown runtime before an executor runs.
254
-
255
- With a target, a subprocess executor runs the target as the program and passes
256
- the body as stdin: a shell runs `sh -c "<target>"`; another interpreter runs
257
- `<interpreter> <target>`. With no target, the body is the inline program. Data
258
- runtimes such as jq, sqlite, and wasm define their own mapping.
259
-
260
- The consumer stat-routes a subprocess target: a directory becomes the command's
261
- working directory; a file is the program and the body is its stdin. With no
262
- target, the body runs from the project-root working directory.
263
-
264
- `SubprocessExecutor extends BaseExecutor`, declares stdout and stderr channels,
265
- and implements `run()`. Subclasses override
266
- `spawnArgs(runtime, command, target) → SpawnArgs` and optionally `binary`.
267
- They inherit streaming, scoped environment handling, availability probing, exit
268
- status, and process-group cancellation. Abort sends SIGHUP by default; an
269
- explicit kill reason supplies its signal; loop-end housekeeping may escalate to
270
- SIGKILL after the consumer-provided grace period.
271
-
272
- ## §5 Consumer surface (plurnk-service)
273
-
274
- Per plurnk-service#174/#181/#182, realized in service `0.9.0`, the exec scheme:
275
-
276
- 1. Boot-discovers executors (`discover()`), `probe()`s each per-package, and offers the model the positive available-runtimes list; an unavailable runtime returns 501 carrying `probe()` `detail`.
277
- 2. Resolves the runtime tag to its executor and runs it through `run()`, seeding the exec entry's channels from `executor.channels`.
278
- 3. Provides the `write` / `setState` / `emit` sinks bound to its channel-write, channel-state, and engine-telemetry machinery; bridges its AbortController to `args.signal`; maps the `ExecResult` to close-status + wake summary.
279
- 4. Gates the proposal lifecycle by `effect(target)` (`EffectPolicy`: `host → propose`, `read`/`pure → auto`), running auto-run runtimes inline (synchronous return) while still landing the result as a re-readable entry.
280
-
281
- ## §6 Forbidden (for siblings)
282
-
283
- | ❌ |
284
- |---|
285
- | Database access |
286
- | Imports from `@plurnk/plurnk-service/*` |
287
- | Mutating `ExecArgs` |
288
- | Holding state across `worker` calls beyond construction metadata |
289
- | Reading runtime output via `console.*` |
290
- | Ignoring `args.signal` |
291
- | Writing to an undeclared channel |
292
- | Spawning processes outside the runtime's domain (e.g. an HTTP/search runtime spawning subprocesses) |
365
+ ### §executor-subprocess-routing Default routing
366
+
367
+ | Runtime | No-target spawn |
368
+ | -------------------------- | -------------------------------------------------------------------- |
369
+ | `sh` / `bash` | Shell command line. |
370
+ | `node` | `node -e <command>`. |
371
+ | `python` / `python3` | `python3 -c <command>`. |
372
+ | Other default-base runtime | `<runtime> -c <command>`; specialized leaves override this fallback. |
373
+
374
+ With a target, the target is the program and the body is its stdin. Core
375
+ stat-routes a directory target to `cwd`; a file remains the executor target.
376
+ Data runtimes define their own target mapping.
377
+
378
+ Subprocess leaves inherit stdout/stderr streaming, scoped-environment handoff,
379
+ availability probing, operation results, exit code, and process-group
380
+ cancellation. `CommandSyntaxError` during spawn translation becomes a durable
381
+ 400 `invalid-command`; other translation exceptions remain plugin contract
382
+ violations for the consumer to contain.
383
+
384
+ ### §executor-cancellation Cancellation and consumer timing
385
+
386
+ Executors know only the supplied `AbortSignal`. Core owns EXEC timeout and poll
387
+ syntax, timers, wakes, and loop lifetime in {§exec-timeout} and {§exec-poll}.
388
+ Subprocess cancellation signals the process group: a caller-supplied kill code
389
+ is delivered once; ordinary cancellation uses SIGHUP; loop-end housekeeping
390
+ may escalate after the consumer-provided grace period.
391
+
392
+ ## §executor-consumer-boundary Required composition
393
+
394
+ The consumer:
395
+
396
+ 1. discovers declared tags, instantiates and probes each tag, and caches the verdict;
397
+ 2. registers a derived output scheme for every admitted, available tag;
398
+ 3. resolves EXEC to exactly one tag and obtains its effect fact;
399
+ 4. applies proposal policy, creates the stream entry, and supplies sinks plus cancellation;
400
+ 5. validates the terminal result and closes every declared channel and subscription coherently; and
401
+ 6. projects stream observations and later reads without calling back into the executor.
402
+
403
+ ## §executor-forbidden Forbidden in executor leaves
404
+
405
+ - Direct database, subscription, packet, or wake access.
406
+ - Imports from `@plurnk/plurnk-service/*`.
407
+ - Mutation of `ExecArgs` or state retained across runs.
408
+ - Output through `console.*` instead of declared channels.
409
+ - Writes or state transitions for undeclared channels.
410
+ - Ignoring `args.signal` at a cancellable boundary.
411
+ - A process or network mechanism unrelated to the leaf's declared runtime domain.