Skip to content

mcpx: lifecycle hooks on exec — prePrompt/postPrompt/onError as one declared list, script or prompt, with a declared effect #1929

Open

Description

@dezren39

Relocated from dezren39/darwin-nix#112 on 2026-10-28 (placement audit): mcpx's single source lives in developing-today/code (pkgs/mcpx); re-home tracked at #309. Original body below; issue references inside it are historical (dezren39/nix numbering).

Parent: #176

Summary

mcpx exec runs a script and returns what it produced. This asks for the caller to be able to attach natural-language instructions to the edges of that run — before it, after it, on failure, on success — so the deterministic script stays deterministic and the fuzzy parts sit outside it.

That is a better factoring than it first appears. The script is the part that must be reproducible; "make sure these dependencies exist wherever this runs" and "clean up the results and transform them" are the parts that legitimately vary by environment and by caller. Keeping them out of the script means the same script file works everywhere and the variation is in the options, which is exactly the shape ExecOptions was designed for (#40, step 7: "not a new method, an option").

Original notes

  • mcp-owned opencode api support
    • accept prompt/prePrompt/postPrompt/onErrorPrompt/onSuccessPrompt
      • the script interface just runs the scripts, but you can provide a prePrompt saying hey i added a bunch of dependencies please make sure they exist where the job runs, postPrompt to clean up the results and transform, either prompt/pre/post could say to fix error thing but onError gets provided above or below the error and is first seen with the error if theres an error. prompts can use templatevars to put content where they want it or for prompt overall to be stopped/resumed maybe.

Current state (what mcpx does today, with file refs)

  • execsvc.Options (internal/execsvc/execsvc.go:101) is the designated growth point: "Deliberately one flat struct that grows rather than a new endpoint per capability." It carries Runtime, Timeout, Permissions, Placeholders, Session, Cwd, Env, Stdin, Output, Capabilities, Artifacts, Task, NS, Args, Export. There are no prompt fields.
  • Placeholders already exist and already have substitution semantics worth copying. Recipes declare holes in a header comment (// @param repo:string) and reference them as @repo; values are substituted as JSON, never as text, because "a recipe runs with the user's credentials; raw substitution would make every placeholder an injection site" (docs/consumer.md §3). Launchers use the same @placeholder spelling (Bump build/buck/preludes/buck2-prelude from 3a02ea3 to c77fc97 #7). A recipe with an unfilled required hole is an invalid script, which is the right way round.
  • ScriptConfig.Prefix (internal/config/config.go:236-248) already layers code in front of a script from config, resolved across the config chain (docs/proposals.md §"Layered script prefixes"). It is the deterministic sibling of a prePrompt and the precedent for how layering should work.
  • Deterministic edge hooks already ship — for the CLI and config, not for /v1/exec. (Added in the 2026-09-30 issues pass, at 05c78b2; this issue predated them being noticed.) Five settings — script.before, script.prefix, script.onSuccess, script.onError, script.suffix (internal/settings/registry.go:212-242), layered through ScriptConfig (internal/config/config.go:236-257) and rendered as runner.Phases (internal/runner/runner.go:190-205) — inject source into a file script's launcher: onSuccess sees result.value, onError sees result.error and the error is rethrown ("a hook, not a handler"), suffix runs in a finally. They run inside the script's own process, and they are deliberately not on the wire Options: execsvc.RunWith keeps phases CLI-only (internal/execsvc/execsvc.go:358-366). So Approach B below is partly built already, in a different shape; what is missing is per-call hooks over /v1 and MCP, a typed context, and prompt hooks. The shipped names are one of the four hook vocabularies in Phase 3: sync, blob gate, and relays #181 collision 2.
  • The exec stream has an ordering contract: start, then log/emit/stdout interleaved, then artifact, then result or error, then end, with bodies strictly after end (execsvc.Frame, docs/exec.md). Any hook output has to find a place in that order, and that is a real design constraint, not a formality.
  • internal/diagnose produces structured diagnostics that already sit next to an error.
  • Tasks (internal/tasks) provide the pause/resume machinery the note's "stopped/resumed maybe" is reaching for, and 2026-07-28's MRTR pattern gives a caller-facing way to resume a request mid-flight.
  • mcpx has no model; see Bump build/buck/preludes/buck2-prelude from d956689 to 52604b1 #110. Every rung below degrades to a no-op without one.

Approaches

Approach A — least recommended: five new string options, each handed to a model, each running arbitrary code

Options.Prompt, .PrePrompt, .PostPrompt, .OnErrorPrompt, .OnSuccessPrompt, each sent to a model with a general-purpose agent loop that can do whatever it decides is needed.

Why not:

  • Four of the five are the same thing at different times. Five independent fields means five code paths, five sets of semantics, and five places to fix a bug. Lifecycle hooks are a list of (phase, instruction), and modelling them as one list with a phase is strictly better — prompt is then just "the phase is main", which is also what makes "either prompt/pre/post could say to fix error thing" fall out naturally instead of needing to be special-cased.
  • An unbounded prePrompt is arbitrary code execution as a side door. "make sure these dependencies exist where the job runs" resolved by a general agent means installing packages, which is the provisioning problem from Update module github.com/jackc/pgx/v5 to v5.5.4 [SECURITY] - autoclosed #113 and needs its own confinement decisions. An exec option should not quietly be the more powerful interface.
  • It puts the model on the default path. Every run pays a round trip even when the hooks do nothing surprising. docs/consumer.md's rule — "never sample on a path that doesn't need it" — is violated by construction if hooks are evaluated eagerly.
  • Nothing is reproducible. The same script plus the same prompts gives different behaviour run to run, which destroys the one property that makes exec worth having over a chat turn.

Approach B — middle: one hooks list, deterministic hooks only

Add one field:

"options": {
  "hooks": [
    { "phase": "pre",     "script": "ensure-deps.ts" },
    { "phase": "post",    "script": "normalise.ts" },
    { "phase": "onError", "script": "cleanup.ts" }
  ]
}

Phases: pre, main, post, onError, onSuccess. Each hook is a script, run in the same content-addressed workdir with the same runtime and permissions, receiving a typed context (the run id, the result or the error plus its diagnose.Diagnostic, the artifact list). post transforms the result; onError sees the error first and may return a replacement result or re-raise — which differs from the shipped script.onError, where the error always propagates.

The phase names and hook semantics are not this issue's to decide (#181 collision 2). Four vocabularies exist or are proposed — the shipped script.* phases, #81's hook points, #82's runtime hooks and this list — and the one vocabulary is being decided in a decision record SURFACES is writing under pkgs/mcpx/docs/decisions/, carried by #81. The phases here are input to that record; this issue adopts what it decides.

This is genuinely useful on its own — ScriptConfig.Prefix proves the demand for "run this code around my code" and hooks generalise it to the other three edges — and it is the thing the prompt version needs underneath it anyway, because a prompt hook's output has to become something executable. It is also fully deterministic, cacheable, and testable.

What it does not give you is the actual ask: "hey i added a bunch of dependencies please make sure they exist where the job runs" is a sentence, not a script, and the whole point is not having to write the script.

Approach C — recommended: one hooks list where a hook is either a script or a prompt, with a declared effect

Build B's structure, and let each entry be a script or a prompt:

"options": {
  "hooks": [
    { "phase": "pre",     "prompt": "I added @deps — make sure they exist where this runs",
                          "effect": "script", "model": "cheap" },
    { "phase": "post",    "prompt": "flatten the result to {id, title} and drop nulls",
                          "effect": "transform" },
    { "phase": "onError", "prompt": "if this is a rate limit, wait and say so; otherwise explain",
                          "effect": "advise" }
  ]
}

Four decisions make this safe and small:

1. A hook declares its effect, and the effect bounds what it may do.

effect what the hook's output may be runs code?
advise text, attached to the result or error no
transform a replacement for the result value only no
script TypeScript, diagnosed and typechecked, then run in the same sandbox yes
retry a decision: retry / retry-with-changes / give up via the repair ladder

advise is the default. This is the same dial as repair.level in #111 and should use the same vocabulary; a user who understands one understands the other. #181 collision 3: #111 carries that one dial vocabulary, via the SURFACES decision record under pkgs/mcpx/docs/decisions/; the effect values in this table are input to it, and #112 adopts what it decides rather than shipping its own. The ceiling a daemon puts on it is #185 (collision 5). A prompt hook whose effect is script is the only one that can execute anything, and it inherits Options.Permissions unchanged — a hook must never be able to do what the script it wraps could not.

2. Template variables reuse the placeholder machinery that exists, including its safety property. @name is already the spelling in recipes and launchers, and recipe values are JSON-encoded, not text-substituted, precisely so a placeholder cannot become an injection site. A prompt is text and the same discipline applies at a different layer: the substituted value must be delimited and marked as data, and a hook prompt must never be able to redefine the hook's own effect. Beyond user placeholders, offer a small set of built-ins the note is clearly asking for — @result, @error, @diagnostic, @calls, @artifacts, @source — so "onError gets provided above or below the error" is expressible as "Here is what happened:\n@error\n@diagnostic\nWhat should I do?" rather than being a positioning rule mcpx has to guess. Position by template, not by convention; when a prompt contains no built-in reference, mcpx appends the context after it, which is the sensible default the note describes.

3. Hooks are declarable in config, not just per call. ScriptConfig.Prefix and the shipped script.before/onSuccess/onError/suffix already layer across the config chain; script.hooks (or whatever collision 2's record names it) should layer the same way, so "always check dependencies before any run in this project" is written once. Per-call hooks append to config hooks rather than replacing them, with the same nearest-wins resolution ResolveLines already implements.

4. Stop/resume is internal/tasks, not a new mechanism. "for prompt overall to be stopped/resumed maybe" — a hook that needs a decision raises an elicitation through the existing broker (with a default and a deadline, as elicit.Ask enforces), and a run that must wait becomes a task with a handle, collectable from /v1/tasks/{id}. Both already exist and both already work for the recipe path. On 2026-07-28 this is also exactly MRTR: resultType: "input_required", the client answers, the request is retried.

Stream placement, since the Frame order is a contract (docs/exec.md:137). #181 collision 7: #111 owns the rule every new frame kind follows (declared placement relative to result, listed in the documented order, asserted by a test comparing documented and emitted order); the hook frame is added under that rule and the placement below is input to it: a hook emits log/emit/stdout frames tagged with its phase and a hook frame recording phase, effect, model used, duration and whether it changed anything. pre frames come after start; post/onSuccess frames come after the result frame and before end; onError frames come after error and before end. The final result reflects a transform hook's output, with the original preserved in the hook frame — a caller must be able to see what mcpx changed.

Cost and default posture. Hooks are off unless declared. A prompt hook without an available model is a warning on the run, not a failure — the script still ran, and that is the honest degradation. pre hooks with effect: script should be cached by (prompt, placeholder values, catalogue fingerprint) so the same "ensure my dependencies" instruction resolves to the same generated script on every run instead of re-generating it; that caching is also what makes the generated script reviewable and promotable to a real hook script, which is how this feature should be expected to be used — prompt once, keep the script.

Recommended because the structure is one list rather than five fields, because effect-declaration is what keeps a convenience feature from being an execution back door, because everything it needs — placeholders, prefixes, tasks, elicitation, diagnostics, artifacts — already exists and this mostly wires them to four new points in time, and because the deterministic half (Approach B) is independently valuable and ships first.

Open questions

  • Does onSuccess earn its own phase when post exists? post running only on success and onError on failure covers it; a separate onSuccess is only distinct if post is meant to run on both. Pick one and document it — this is the note's ambiguity, not a design difficulty.
  • Ordering when several hooks share a phase: declaration order across config layers, or an explicit order? Declaration order plus nearest-wins layering matches ScriptPrefix.
  • Should a transform hook be allowed to change artifacts, or only the result value? Only the value is far simpler and probably enough.
  • Timeout accounting: does a hook's time count against Options.Timeout or get its own budget? Its own, or a slow model silently eats the script's budget.
  • Is there a case for hooks on a tool call rather than a run (before every call to this server, ...)? That is closer to a server-level middleware feature and may belong with tool views.
  • Should a hook be able to abort the run before it starts (pre returning refuse)? Useful and cheap, but then a pre hook is a policy mechanism and needs the floor/ceiling clamping Phase 5: who may clear a tag claim #185 owns.

Ease · Impact · Related

Ease M (S for the deterministic half — much of it already ships as script.* launcher phases, so what remains is exposing them per call with a typed context; M with prompt hooks and effects; L if stop/resume gets its own machinery rather than reusing tasks) · Impact medium-high — it is what makes one script file portable across environments without the script knowing where it runs, which is the same goal #40 had for artifacts.

Depends on / related: #81 (owns the hook vocabulary via the SURFACES decision record — #181 collision 2), #111 (owns the autonomy dial and the frame-order rule — collisions 3 and 7), #185 (owns ClampedBy — collision 5), #82 (runtime hook registration, the fourth vocabulary), #40 (closed, fixed in #57: ExecOptions is the home; "not a new method, an option" is the governing precedent), #41 (closed, fixed in #58: placeholders, elicitation defaults/deadlines, diagnostics as hook context), #7 (launcher @placeholder spelling), #5 (config chain, for layered hooks), #79 (hook cost must not regress exec latency). Siblings: #110 (where a prompt hook's model comes from), #113 (the dependency-provisioning half of the pre example; also #103/#104 in #175).

Security/auth deferred; see tracker.

Activity

  1. developing-today commented on Oct 28, 2026

    @developing-today
    Contributor
  2. dezren39 commented on Oct 28, 2026

    @dezren39
    MemberAuthor

    History carried over from dezren39/darwin-nix#112 (verbatim, oldest first):


    @dezren39 · 2026-09-30T07:21:51Z

    Integration pass. Placed in the DAG rooted at #181.


    @dezren39 · 2026-09-30T07:27:50Z

    Integration pass -- cross-theme collision. Surfaced only when the themes were put side by side; the authors of these issues could not see each other's work. Recorded at the root in #181.

    Four hook vocabularies, and no issue owns the reconciliation

    script.* (shipped), #81's hook-point table, #82's runtime hook registration, and #112's prePrompt/postPrompt/onError list are four vocabularies for one concept. #112 sits in a different theme (#176) from #81/#82 (#173), so nobody currently owns the overlap. Reconciling them after the fact is a breaking config change -- design them as one.

    Also on: #81 #82. Nobody currently owns this.


    @dezren39 · 2026-09-30T07:27:53Z

    Integration pass -- cross-theme collision. Surfaced only when the themes were put side by side; the authors of these issues could not see each other's work. Recorded at the root in #181.

    Three autonomy dials, independently invented

    #111's repair.level, #112's hook effect, and #113's prompt.mode: plan are the same dial. Settle it in #111, which is the earliest of the three. Otherwise a user learns three vocabularies for one concept.

    Also on: #111 #113. Nobody currently owns this.


    @dezren39 · 2026-09-30T07:28:01Z

    Integration pass -- cross-theme collision. Surfaced only when the themes were put side by side; the authors of these issues could not see each other's work. Recorded at the root in #181.

    ClampedBy is a settings-registry feature with no owner

    #111 proposes floor/ceiling clamping -- a daemon sets a maximum, a caller may lower but not raise -- and #112 and #113 both need it. It currently exists only as a paragraph inside #111. It is an internal/settings capability, not a repair feature; whoever reaches it first will implement it, and the other two will implement it again differently.

    Also on: #111 #113 #172. Nobody currently owns this.


    @dezren39 · 2026-09-30T07:28:09Z

    Integration pass -- cross-theme collision. Surfaced only when the themes were put side by side; the authors of these issues could not see each other's work. Recorded at the root in #181.

    The exec Frame ordering contract is extended by two issues independently

    #111 adds a call frame; #112 adds a hook frame with explicit before/after-result placement rules. docs/exec.md states the frame order as a contract. Neither issue knows about the other's placement rules, and no issue owns updating the contract.

    Also on: #111. Nobody currently owns this.


    @dezren39 · 2026-09-30T11:06:07Z

    Collision ownership (issues pass, 2026-09-30, at 05c78b2). #181 listed these cross-theme collisions with no owner. Each now has one:

    #181 row Collision Owner What the owner decides
    2 four hook vocabularies (script.* shipped, #81, #82, #112) #81, via a decision record SURFACES is writing under pkgs/mcpx/docs/decisions/ (in flight; linked here when it merges) The vocabulary is decided in the decision record; #81 is the issue that carries it. #82 and #112 adopt it rather than defining their own.
    3 three autonomy dials (#111 repair.level, #112 hook effect, #113 prompt.mode: plan) #111, via a decision record SURFACES is writing under pkgs/mcpx/docs/decisions/ (in flight; linked here when it merges) One dial vocabulary; #111 carries it because it is the earliest of the three. The ceiling mechanism is #185.
    5 ClampedBy -- a daemon ceiling a caller may lower but not raise #185 (new) A settings-registry capability, owned by its own design issue under #176. #111, #112 and #113 consume it.
    7 the exec Frame ordering contract, extended by #111 (call) and #112 (hook) #111 #111 extends docs/exec.md's order first and states the rule every new frame kind follows (declared placement relative to result, listed in the documented order, asserted by a test that compares documented and emitted order). #112 adds its kind under that rule.

    Where this issue is the owner, the decision is made here; where it is not, it adopts the owner's decision rather than making its own.


    @dezren39 · 2026-09-30T11:57:45Z

    Issues pass (2026-09-30, at 05c78b2).


    @dezren39 · 2026-09-30T16:58:17Z

    Decided (SURFACES, 0001 and 0002, merged in #228).

    What changes in this issue:

    • pre → before, post → onSuccess; main is not a hook, it is /v1/intent. prePrompt/postPrompt/onErrorPrompt are the prompt action at before/onSuccess/onError.
    • effect takes the autonomy levels: transform → apply; script → run (or propose to hand the script back); retry → run; a pre hook that refuses is ask.
    • script.hooks becomes entries of the one hooks setting with on: script.
    • Frame order: onSuccess/onError hook frames come before the single final result/error; suffix after it.
    • Found while deciding, and to fix before building on the phases: mcpx exec accepts --before/--on-success/--on-error and drops them; a top-level throw skips onError and suffix because the import sits outside the launcher's try; no phase reaches /v1/exec or --remote.

    @dezren39 · 2026-09-30T19:35:58Z

    Follow-up: two of the three phase holes named in the decision comment are closed in #265 -- mcpx exec now runs --before/--on-success/--on-error (and script.before/onSuccess/onError), and a top-level throw runs onError and suffix (the import moved inside the launcher's try). The third, phases on /v1/exec and --remote, is #192. #265 also fixed mcpx exec --remote losing its output.


    @dezren39 · 2026-10-28T22:22:42Z

    Triage verdict (task 2154, repo-health-nix-pilot): PARTIALLY LANDED + CONFIRMED remainder, RELOCATED — deterministic script phases are now on the wire; prompt hooks (the actual ask) are unimplemented. Implementation routes in developing-today/code; no fix packet in this repo.

    Verified against current main on both sides (nix 224c7b738e88c7a8e5d406987a7d88257e229db2, code d2cb60fe8e08cd6fee149b930187b610299c1fa7):

    Route: no small_fix packet in dezren39/nix — this repo holds no mcpx source to fix. The remainder (one declared hook list unifying the script Phases vocabulary with prompt hooks, gated on #110) belongs with the source in developing-today/code, referencing #112, via the #309 relocation pass; per the cross-repo rule, do not re-file it there. #112 stays open on the nix side until implemented there or transferred.

  3. added
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Oct 29, 2026
  4. dezren39 commented on Oct 29, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  5. dezren39 commented on Oct 29, 2026

    @dezren39
    MemberAuthor

    Triage verdict — task 2825 (2026-10-29)

    Scope confirmed against current pkgs/mcpx main (verified via gh api on current main, 2026-10-29):

    • execsvc.Options (pkgs/mcpx/internal/execsvc/execsvc.go, ~:101) still has no hooks or prompt fields — no Hooks, Prompt, PrePrompt, or PostPrompt. The issue's premise holds unchanged.
    • The shipped deterministic phases are still CLI/config-only: script.before, script.prefix, script.onSuccess, script.onError, script.suffix (pkgs/mcpx/internal/settings/registry.go:291-317), layered through ScriptConfig and not on the wire Options.

    Dedupe — not a duplicate. Searched open code issues for mcpx hooks: #1872 (config-declared hooks over the event bus) and #1873 (privileged plugin surface / runtime hook registration) are the sibling scopes #1929 itself names as separate vocabularies — neither covers a per-call hooks list on ExecOptions. Trackers #1885, #1891, #1900 aggregate related work but do not cover this. No closure, no duplicate link needed.

    Split, per the issue's own recommendation ("the deterministic half is independently valuable and ships first"):

    • Approach B (deterministic script hooks) — implementation follow-up enqueued as queue task 3224: one hooks list on execsvc.Options ({phase, script}; phases pre/main/post/onError/onSuccess adopting the shipped script.* semantics), typed hook context (run id, result/error + diagnostic, artifacts), same runtime/permissions as the wrapped script, config layering append/nearest-wins like ScriptConfig.Prefix, and hook frames placed per the docs/exec.md stream-order contract. Verification for the follow-up: pkgs/mcpx: go test ./... via CI, including tests for pre/post/onError execution and frame placement.
    • Approach C (prompt hooks with declared effects) — stays tracked on this issue, not yet enqueued: it is dependency-gated on the SURFACES hook-vocabulary and effect/dial decision records and on a model source for prompt hooks (tracker mcpx: the AI layer and search -- where a model comes from, and everything that would use one (tracker) #1891; mcpx has no model today, so prompt hooks would degrade to no-ops). Enqueue it once those land; the phase/effect names in this issue are input to those records, not decisions this issue makes.

    This issue stays open as the tracker for both halves.

  6. dezren39 commented on Oct 29, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

  7. 62 remaining items

  8. added
    queue/queuedHas a queued task in the Supabase agent queue
    source/creatorFiled by the new-issue-creator job
    task/triageQueue packet kind: verify, reproduce, and route this item
    on Nov 3, 2026
  9. dezren39 commented on Nov 3, 2026

    @dezren39
    MemberAuthor

    agent-note:v1
    state: triaged (coverage pass)
    Coverage pass 2026-11-03: reviewed in the fleet-wide per-issue pass (drift / overlap / already-handled / stale / blockers / ordering). Verdict: still live; queued in repo-health-code-pilot as triage at priority 30 (priority/none). If this is already handled at current main, the triage closes it with evidence; if it needs splitting, the triage files the split.
    Found via: coverage pass 2026-11-03

  10. added
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 4, 2026
  11. dezren39 commented on Nov 4, 2026

    @dezren39
    MemberAuthor

    agent-note:v1
    task: 8728
    queue: repo-health-code-pilot
    worker: repo-worker-code
    lane: openchamber-scheduled-lane
    lane_id: c29174f1-48e0-4b13-b4ff-9b73ab67e9ab
    event: picked_up
    at: 2026-11-04T04:04:22Z

    Picked up triage task 8728 for #1929. Fresh GitHub state: issue OPEN; no open PR found by searching for #1929; no held/user-owned/do-not-merge/do-not-auto-edit labels. Scope is triage only, no implementation. Verification will use a dedicated worktree based on freshly fetched origin/main eb362463a; the primary clone has diverged local history, so it was preserved rather than rewritten.

    Branch: queue/8728-1929-lifecycle-hooks-triage · worktree: /home/user/git/fleet/8728-1929-lifecycle-hooks-triage.

  12. dezren39 commented on Nov 4, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done — triaged and routed (task 8728)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requestsource/creatorFiled by the new-issue-creator jobtask/triageQueue packet kind: verify, reproduce, and route this item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions