Skip to content

API: expose a server-authorized assignment handoff for an existing OMP session #6899

Description

@castrojo

Part of #2534.

Downstream observation

projectbluefin/review has removed its separate maintainer dashboard and now runs review batches inside one OMP workbench. We have not merged the Hive contributor path into that workbench because the current public contributor contract does not expose a safe handoff for an already-running OMP session.

Verified on 2026-09-14 against Hive v4 commit 8cf12bac205912dc615731bd2dccb6dabd89a791:

  • The WebSocket ready handler clears any prior task, calls selectTask, and sends one server-selected task_assign:
    contributor.mu.Unlock()
    return nil
    })
    go h.heartbeatLoop(contributor)
    case "ready":
    if contributor == nil {
    continue
    }
    contributor.mu.Lock()
    abandoned := contributor.currentTask
    contributor.currentTask = nil
    // #2568: bump the generation on release so a re-`ready` abandon fences any
    // later message echoing the old generation for the just-abandoned task.
    contributor.currentTaskGen = h.nextTaskGen()
    contributor.lastLeaseRenew = time.Time{}
    contributor.tokenMintedAt = time.Time{}
    // #2537: clear any pending/delivered credential state with the task.
    contributor.pendingToken = ""
    contributor.credentialDelivered = false
    contributor.mu.Unlock()
    if abandoned != nil {
    // C4: the relay explicitly gave up this task, so revoke its
    // server-issued lease — a later task_progress for it must not resurrect
    // ownership.
    h.revokeLease(identityOf(contributor), abandoned.TaskID)
    // #5097: same visibility gap as the disconnect path above — the
    // relay giving a task back by asking for new work left no trace in
    // the activity feed either.
    h.addActivity(contributor.profile.GitHubUsername, "released: gave the task back",
    contributor.role, contributor.cliBackend, contributor.model,
    contributor.reasoningEffort, taskDescOf(abandoned))
    h.logger.Warn("[contribute-ws] task abandoned without completion",
    "username", contributor.profile.GitHubUsername,
    "abandoned_task", abandoned.TaskID,
    )
    // kubestellar/hive#2545: a contributor that sends "ready" while
    // still holding a task (e.g. the relay's own MAX_TASK_DURATION_MS
    // watchdog gives up and requeues, or an agent that never actually
    // started work asks for something new) used to leave currentTask
    // set and booked no cooldown at all — worse than the disconnect
    // path immediately above (#2356/#2435), which does both. That left
    // the abandoned issue permanently out of activeIssues circulation
    // for the life of the connection: no PR, no failure record, no
    // re-offer, just a silently held slot. Clear currentTask (above)
    // so selectTask's activeIssues scan releases the issue, and mirror
    // the disconnect/task_failed paths by booking the SAME short
    // non-permanent failure cooldown, so the just-abandoned issue is
    // not instantly handed straight back to the same contributor in
    // the very selectTask call below. Synthetic pr-review tasks carry
    // Number == 0 and must not poison an issue key.
    if abandoned.Number > 0 {
    h.recordTaskFailureForTask(abandoned, false)
    }
    }
    h.logger.Info("[contribute-ws] ready for work",
    "username", contributor.profile.GitHubUsername,
    "role", contributor.role,
    )
    task := h.selectTask(contributor)
    switch {
    case task == nil:
    // Defensive backstop only: after #2436 and #2546 every selectTask
    // path returns an explicit message, so this should not be reached.
    // Kept so an unforeseen nil still fails safe (no send) rather than
    // panicking.
    h.logger.Info("[contribute-ws] no tasks available",
    "username", contributor.profile.GitHubUsername,
    )
    case task.Type == "task_unavailable":
    // An explicit negative-ack rather than silence. #2436 finding 1/2/3
    // covers the enforced refusals (mint failure, disabled tier,
    // concurrency limit); #2546 adds the three formerly-silent
    // no-work-right-now reasons (contribution_suspended, hub_not_ready,
    // no_matching_work). Record the reason on the connection so the ops
    // tab can show WHY this clanker is idle, then send it.
    contributor.mu.Lock()
    contributor.lastIdleReason = task.Reason
    contributor.mu.Unlock()
    if err := contributor.send(*task); err != nil {
    h.logger.Warn("[contribute-ws] failed to send task_unavailable", "error", err)
    return
    }
    h.logger.Info("[contribute-ws] task unavailable",
    "username", contributor.profile.GitHubUsername,
    "reason", task.Reason,
    )
    default:
    if err := contributor.send(*task); err != nil {
    h.logger.Warn("[contribute-ws] failed to send task_assign", "error", err)
    return
    }
    pickupKey := task.TaskKey
    if pickupKey == "" {
    pickupKey = worksource.Ref{Repo: task.Repo, Number: task.Number}.Key()
    }
    taskDesc := assignDesc(task.Kind, pickupKey, task.Title, task.TaskID)
    if task.Role != "" {
    taskDesc = fmt.Sprintf("contributor ran %s task: %s", task.Role, taskDesc)
    }
    h.addActivity(contributor.profile.GitHubUsername, "picked up", contributor.role, contributor.cliBackend, contributor.model, contributor.reasoningEffort, taskDesc)
    h.logger.Info("[contribute-ws] task assigned",
    "username", contributor.profile.GitHubUsername,
    "task", task.TaskID,
    "repo", task.Repo,
    "number", task.Number,
    )
    // #2537: the credential was withheld from the task_assign above and is
    // delivered only AFTER acceptance. In the DEFAULT trusted-source
    // auto-accept mode, the task already cleared admission and the per-tier
    // trust gate in selectTask, so acceptance is automatic HERE — after the
    // assignment is committed and sent — and the scoped credential is
    // delivered immediately. This preserves an unattended fleet's timing
    // (credential arrives right after task_assign) while making the ordering
    // provable: the credential leaves the hub only once acceptance is
    // recorded, never bundled with the metadata. In EXPLICIT-accept mode the
    // hub withholds here and waits for a task_accepted (handled below).
    if !h.requireExplicitAccept() {
    h.deliverTaskCredential(contributor, "auto_accept")
    } else {
    h.logger.Info("[contribute-ws] credential withheld pending explicit acceptance",
    "username", contributor.profile.GitHubUsername, "task", task.TaskID)
    }
    }
  • selectTask chooses one admitted candidate, records the lease/generation, and returns one assignment envelope:
    // #2435: carry any lingering failure history so the ordering below
    // can deprioritise a recently-failed issue within its bucket.
    recentFailures: h.recentFailureCountKey(itemKey),
    })
    }
    }
    if len(candidates) == 0 {
    // #2546: the hub is running and unsuspended but nothing is admissible right
    // now (everything is in cooldown, filtered out, disabled, or already held).
    // Previously a bare nil — indistinguishable on the wire from "suspended" or
    // "hub not ready". Send an explicit no_matching_work negative-ack.
    return h.taskUnavailable(taskUnavailableNoMatchingWork)
    }
    // Operator priority override (#queue-reorder): the ordered list of issue keys
    // the operator dragged to the front of the ready-work queue on the Operations
    // tab. It takes precedence over the default ordering below so a prioritised
    // issue is OFFERED FIRST. It never bypasses admission: every entry in
    // `candidates` already passed the SAME cooldown / failure / disabled-repo /
    // filter / in-flight exclusions above, so a pinned-but-no-longer-actionable key
    // simply never became a candidate (stale keys are skipped). Rank sentinel: a
    // candidate NOT in the override ranks at len(override), so all pinned candidates
    // sort ahead of all unpinned ones while their own relative order is the operator's.
    var queueOrderIdx map[string]int
    if h.server.deps != nil && h.server.deps.Config != nil {
    queueOrderIdx = queueOrderIndex(h.server.deps.Config.Hub.ContributeQueueOrder)
    }
    orderRank := func(c candidate) int {
    if len(queueOrderIdx) == 0 {
    return 0 // no override → every candidate ties, key is a no-op
    }
    if r, ok := queueOrderIdx[fmt.Sprintf("%s#%d", c.repoFull, c.number)]; ok {
    return r
    }
    return len(queueOrderIdx)
    }
    // Order the admissible set with a STABLE sort so the pick is deterministic
    // (easy to reason about and to test — no randomness):
    // 0. operator priority override first (#queue-reorder) — pinned issues in the
    // operator's dragged order; a no-op when no override is set;
    // 1. own-work first (#2390 — preserved unchanged);
    // 2. then fewer recent failures first (#2435 remedy 3 backstop) — an issue
    // whose short failure cooldown has just elapsed but which still carries
    // failure history is deprioritised behind never-failed peers, so a
    // flaky issue can no longer monopolise the head of the queue even if the
    // ledger is imperfect;
    // 3. otherwise the established per-repo / creation scan order is kept.
    // When the contributor has no own work AND nothing has failed AND no override is
    // set, this is a no-op and behaviour is identical to the previous first-eligible pick.
    ownFirst := make([]candidate, len(candidates))
    copy(ownFirst, candidates)
    sort.SliceStable(ownFirst, func(i, j int) bool {
    if ri, rj := orderRank(ownFirst[i]), orderRank(ownFirst[j]); ri != rj {
    return ri < rj // operator-pinned (lower rank) sorts ahead
    }
    if ownFirst[i].isOwn != ownFirst[j].isOwn {
    return ownFirst[i].isOwn // own work sorts ahead of non-own
    }
    if ownFirst[i].interestMatch != ownFirst[j].interestMatch {
    return ownFirst[i].interestMatch // label-affinity matches ahead of non-matches (#2637)
    }
    if ownFirst[i].recentFailures != ownFirst[j].recentFailures {
    return ownFirst[i].recentFailures < ownFirst[j].recentFailures // fewer failures first
    }
    return false // equal keys → SliceStable preserves original scan order
    })
    chosen := ownFirst[0]
    if chosen.isOwn {
    h.logger.Info("[contribute-ws] prioritizing contributor's own work (#2390)",
    "username", ownUsername, "repo", chosen.repoFull, "number", chosen.number)
    }
    // Mint through the shared path so task_assign and the heartbeat token-refresh
    // advertise tokens minted the same way (#2393 item 2). tokenMintedAt below
    // arms the refresh ticker for the token we hand out here. C4: the token is
    // scoped to the chosen issue's REPOSITORY, not the whole installation.
    ghToken, err := h.mintScopedToken(c.profile.TrustTier, chosen.repoFull)
    if err != nil {
    // #2436 finding 1: a mint failure previously returned nil, stranding the
    // contributor with no message (the log even said "skipping task" while
    // abandoning the whole selection). Send an explicit token_mint_failed
    // negative-ack so the failure is diagnosable instead of an indefinite
    // hang. We do not fall through to another candidate: the mint is keyed on
    // the contributor's tier, not the candidate, so every candidate in this
    // pass would fail identically. Preserve the existing Warn log.
    h.logger.Warn("[contribute-ws] failed to mint scoped token — task unavailable",
    "tier", c.profile.TrustTier, "error", err)
    return h.taskUnavailable(taskUnavailableTokenMintFailed)
    }
    // The task id carries the item's own identity segment so two zero-numbered
    // external items cannot mint the same id within one second
    // (kubestellar/hive#4245). GitHub-backed work keeps its historical
    // "ct-<repo>-<number>-<unix>" shape byte for byte.
    taskID := fmt.Sprintf("ct-%s-%s-%d", chosen.repoFull, taskIDSegment(chosen.ref), time.Now().Unix())
    // #2539: build the prompt through the shared, credential-free buildTaskPrompt
    // so the exact text shipped in task_assign below can also be PREVIEWED
    // read-only in the ops tab. The prompt is a pure function of task metadata —
    // the minted github_token is attached to the WSMessage separately (never inside
    // the prompt), so previewing the prompt can never leak the token. buildTaskPrompt
    // itself carries the #2545 workspace-clone instruction (real checkout into
    // $HIVE_WORKSPACE_DIR rather than a fork-only --clone=false).
    canPush := h.contributorCanPush(chosen.repoFull, ownUsername)
    prompt := buildTaskPromptForContributor(chosen.ref, chosen.title, canPush)
    if requestedRole != "" {
    prompt = buildRoleTaskPromptForContributor(chosen.ref, chosen.title, requestedRole, h.roleKickPrompt(requestedRole), canPush)
    }
    // #4105: tell the agent up front — from the hub's own handshake-recorded
    // invocation values — the exact attribution trailer its PR body must end
    // with, so the footer is intentionally produced rather than appended only
    // by the post-merge reconciliation safety net (#4088, unchanged).
    prompt += attributionPromptInstruction(promptInvocationMeta(c))
    // #2568: mint a fresh assignment generation for this task. It is stamped on the
    // connection, shipped in task_assign below, and echoed back by the relay so a
    // later stale-worker completion carrying an older generation is fenced out.
    gen := h.nextTaskGen()
    c.mu.Lock()
    c.currentTask = &WSTaskAssign{
    TaskID: taskID,
    Kind: "issue",
    Role: requestedRole,
    Repo: chosen.repoFull,
    Number: chosen.number,
    Title: chosen.title,
    Key: chosen.ref.Key(),
    SourceType: chosen.ref.SourceType,
    ExternalID: chosen.ref.ExternalID,
    URL: chosen.url,
    }
    c.currentTaskGen = gen
    // #2568: start the hub-owned lease clock. task_progress renews it; cleanupLoop
    // auto-releases the task if it is not renewed within wsTaskTimeout.
    c.lastLeaseRenew = time.Now()
    // Duration anchor for the run log — lastLeaseRenew moves on every
    // progress report, so it cannot serve as the start time.
    c.taskAssignedAt = time.Now()
    // Store the prompt (never the token) so FleetSnapshot can preview it (#2539),
    // and clear any stale idle reason now that this connection has real work.
    c.currentPrompt = prompt
    c.currentLabels = chosen.labels
    c.lastIdleReason = ""
    // #2537: hold the minted scoped token as PENDING rather than shipping it in the
    // task_assign below. It is delivered only AFTER the acceptance decision — see
    // the ready-handler (auto-accept default) and the task_accepted handler
    // (explicit-accept mode) — via deliverTaskCredential. A fresh assignment resets
    // the delivered flag so the new task's credential is (re)delivered post-accept.
    // tokenMintedAt is set here so the #2393 refresh cycle is armed for the token we
    // hand out; deliverTaskCredential re-stamps it on actual delivery to anchor the
    // 50-minute refresh on when the relay truly received the credential.
    c.pendingToken = ghToken
    c.credentialDelivered = false
    c.tokenMintedAt = time.Now()
    c.mu.Unlock()
    // C4: record the SERVER-AUTHORITATIVE lease for this assignment so a later
    // reconnect can be validated against what the hub actually issued — the exact
    // {task, repo, generation, tier} bound here — instead of reconstructing ownership
    // from client-supplied task_progress fields. Revoked on every release path.
    // #5681: record the item's canonical key too, so the double-assignment guard can
    // recognise the lease after a restart — including for external work, whose
    // identity is Key rather than repo#number (#4245).
    h.recordLeaseForKey(identityOf(c), taskID, chosen.repoFull, chosen.number,
    chosen.ref.Key(), c.profile.TrustTier, gen, time.Now())
    // #2566: record this assignment against the identity's rolling hourly/daily
    // windows so the next selectTask enforces tier_limits.max_per_hour /
    // max_per_day. Recorded here — after the task is committed to the connection
    // and we are certain a task_assign will ship — so a refused pass (which returns
    // early above) never consumes a slot. Uses the same identity key as the
    // concurrency gate.
    h.recordAssignment(identityOf(c), time.Now())
    return &WSMessage{
    Type: "task_assign",
    Seq: h.nextSeq(),
    TaskID: taskID,
    TaskGen: gen,
    Kind: "issue",
    Role: requestedRole,
    Repo: chosen.repoFull,
    Number: chosen.number,
    Title: chosen.title,
    URL: chosen.url,
    // Source-aware identity (kubestellar/hive#4245). Additive: a GitHub
    // assignment carries exactly the fields it always did, and these tell a
    // relay working a Linear or Jira item what the item actually IS instead
    // of leaving it to infer one from `number: 0`.
    TaskKey: chosen.ref.Key(),
    SourceType: chosen.ref.SourceType,
    ExternalID: chosen.ref.ExternalID,
    // #2537: NO github_token / token_expires_at here. The scoped credential is
    // split out of task_assign and delivered only after acceptance (see
    // pendingToken / deliverTaskCredential). task_assign now carries exactly the
    // metadata needed to DECIDE — repo/number/title/url/labels/prompt — plus the
    // #2568 TaskGen lease token, and no credential, so nothing an agent could act
    // on is authenticated until the task's source has been accepted under the
    // operator/contributor policy.
    Prompt: prompt,
    // The chosen issue's own labels — the Labels envelope field was declared
  • The shipped relay owns either an interactive tmux launch or a headless child CLI rather than handing an assignment to an existing OMP session:
    // interactive (default) — the legacy path: type the prompt into a live
    // tmux pane with `tmux send-keys` and scrape the pane for readiness,
    // progress and completion. Requires an attached-or-attachable TTY and is
    // unchanged by this feature.
    //
    // headless — the non-interactive path added for #2538: drive the backend
    // CLI in a one-shot / print invocation (`claude -p`, `copilot -p`,
    // `codex exec`, …), capture its stdout/stderr, and report completion or a
    // REAL error back over the same WebSocket channel. No tmux, no pane
    // scraping, no waiting on an invisible prompt — so a K8s Job/Deployment
    // running this mode either runs to completion or fails loudly (the exact
    // "healthy-looking but stalled pod" failure #2538 warns about), and it
    // never needs a human to attach and type `/login`.
    //
    // This is opt-in and additive: absent/any-other value keeps the interactive
    // path exactly as before. K8s manifests (#2549) and the credential boundary
    // (#2537) are the explicit follow-ons and are NOT built here.
    const MODE_INTERACTIVE = 'interactive';
    const MODE_HEADLESS = 'headless';
    const CONTRIBUTOR_MODE = process.env.CONTRIBUTOR_MODE === MODE_HEADLESS
    ? MODE_HEADLESS
    : MODE_INTERACTIVE;
    // contributor-agent.sh creates and exports this before starting the relay. Pin
    // it at process startup just like CONTRIBUTOR_MODE so a later environment
    // mutation cannot make the one-shot CLI run outside the workspace that was
    // granted to Codex with --add-dir.
    const TASK_WORKSPACE_DIR = process.env.HIVE_WORKSPACE_DIR || process.cwd();
    // Where the headless runner records its current lifecycle state as JSON, so a
    // supervising process (or a future K8s liveness/readiness probe reading the
    // file) can distinguish waiting / working / done / failed — instead of a pod
    // that merely looks alive. Best-effort: a write failure never aborts a task.
    const HEADLESS_STATUS_FILE = process.env.HIVE_HEADLESS_STATUS_FILE || '/tmp/contributor-headless-status.json';
    and

    hive/bin/contributor-relay.js

    Lines 1108 to 1253 in 8cf12ba

    function buildLaunchCommand() {
    if (cachedLaunchCommand) return cachedLaunchCommand;
    if (ENTRYPOINT_LAUNCH_CMD) {
    cachedLaunchCommand = ENTRYPOINT_LAUNCH_CMD;
    return cachedLaunchCommand;
    }
    const { cmd, perm } = resolveBackendShell();
    const modelFlag = modelFlagFor();
    const reasoningFlag = BACKEND === 'codex' && REASONING_EFFORT
    ? `-c 'model_reasoning_effort="${REASONING_EFFORT}"'`
    : '';
    // Paired with modelFlag, never on its own: agy without --model needs no
    // --effort, and passing one alone would be a flag agy has no model to apply.
    const agyEffortFlag = BACKEND === 'agy' && modelFlag ? `--effort ${effectiveReasoningEffort()}` : '';
    // muse takes effort on its own, independent of --model (unlike agy), and
    // validates the value itself. Only values muse actually accepts are passed,
    // so effectiveReasoningEffort() never advertises an effort muse rejected.
    const museEffortFlag = BACKEND === 'muse' && effectiveReasoningEffort()
    ? `--reasoning-effort ${effectiveReasoningEffort()}`
    : '';
    cachedLaunchCommand = [cmd, perm, modelFlag, reasoningFlag, agyEffortFlag, museEffortFlag].filter(Boolean).join(' ');
    return cachedLaunchCommand;
    }
    // --- Headless (non-interactive) one-shot dispatch (kubestellar/hive#2538) ---
    //
    // Backends whose CLI supports a one-shot / print invocation that takes the
    // prompt on the command line, runs to completion, and EXITS with a meaningful
    // status — the property the headless mode needs. Each entry says how to turn
    // (binary, perm-flags, prompt) into an argv:
    //
    // flag — the sub-command/flag(s) that select one-shot mode. Either a single
    // token, where the prompt follows as a bare positional
    // (`claude -p "<prompt>"`, `codex exec "<prompt>"`), or an array of
    // leading tokens when a sub-command AND a flag both precede the
    // prompt (`goose run --no-session -t "<prompt>"`). Either way the
    // prompt is appended as the final, distinct argv element.
    //
    // Backends NOT listed here have no known non-interactive entry point (bob /
    // aider drive an interactive TUI), so headless mode refuses them LOUDLY at
    // task time rather than silently stalling. Extending this table is how a
    // future PR adds a backend once its headless invocation is verified.
    const HEADLESS_BACKENDS = {
    // claude -p "<prompt>" — print mode: runs the prompt non-interactively and
    // exits. Same perm flags as the interactive launch (bypass permissions).
    claude: { flag: '-p' },
    // litellm is the claude binary pointed at a LiteLLM proxy, so the same
    // print-mode invocation applies.
    litellm: { flag: '-p' },
    // copilot -p "<prompt>" — non-interactive programmatic mode.
    copilot: { flag: '-p' },
    // codex exec "<prompt>" — Codex's non-interactive execution sub-command.
    // --skip-git-repo-check: exec refuses to run at all in a cwd that is not a
    // git repository ("Not inside a trusted directory..."), and the task
    // workspace root is exactly that — the agent clones INTO it as its first
    // act. Verified live against codex 0.146.0 via bin/test_backend_smoke.sh.
    codex: { flag: ['exec', '--skip-git-repo-check'] },
    // goose run --no-session -t "<prompt>" — goose's one-shot sub-command. The
    // bare `goose` binary drives the interactive TUI, but `goose run` is a
    // documented non-interactive entry point (#2828): `-t` takes the prompt as
    // its VALUE (not a trailing positional), and --no-session skips creating or
    // resuming a session file, which one-shot dispatch never needs. Verified
    // against goose 1.37.0 — the version src/Dockerfile pins via GOOSE_VERSION —
    // that `run`, `-t` and `--no-session` all exist and that a failed run exits
    // non-zero, which is the exit-code contract runHeadlessTask() relies on.
    goose: { flag: ['run', '--no-session', '-t'] },
    // pi --print --mode json <prompt> — Pi's bounded non-interactive entry point.
    // AGENT_MODEL is already the canonical provider/model token, so no separate
    // --provider input is needed (or allowed) and restart/headless stay identical.
    pi: { flag: ['--print', '--mode', 'json'] },
    // agy -p "<prompt>" — Antigravity's print mode ("Run a single prompt
    // non-interactively and print the response", `agy --help`). Verified against
    // agy 1.1.13: a print-mode run answers on stdout and exits 0, which is the
    // exit-code contract runHeadlessTask() relies on. NOTE this makes agy
    // headless-capable on a HOST only — agy's sign-in is an interactive Google
    // OAuth flow (browser URL + pasted code) with no API-key mode, and a fresh
    // container has nothing to inherit it from, which is why agy stays OUT of
    // K8S_HEADLESS_BACKENDS on the /contribute page and out of the contributor
    // image. The capability and the credential are separate questions.
    agy: { flag: '-p' },
    // opencode run "<prompt>" — opencode's one-shot headless invocation
    // (kubestellar/hive#4970). Unlike agy, opencode is the ONLY launch mode
    // this backend gets: there is no interactive-tmux wiring for it (see the
    // getCLIState()/classifyTmuxPane() backend lists below, which opencode
    // deliberately does not join), so it is only ever reached through
    // CONTRIBUTOR_MODE=headless. `opencode run` exits with a real status code
    // on completion, the exit-code contract runHeadlessTask() relies on.
    opencode: { flag: 'run' },
    // Kilo is OpenCode-derived but uses distinct credentials and config.
    kilo: { flag: 'run' },
    // muse exec "<prompt>" — Muse Code's documented non-interactive
    // sub-command ("Run one prompt non-interactively (headless)"). Verified
    // against Muse Code 1.0.3 (1.0.3-R2198.1): a completed run exits 0, a run
    // with no usable credential exits 1 ("missing meta credentials: run `muse
    // login` or set META_API_KEY..."), and a bad flag/value exits 2 — the
    // exit-code contract runHeadlessTask() relies on. muse DOES have an
    // interactive TUI, but hive has no interactive-tmux wiring for it (it
    // deliberately does not join the getCLIState()/classifyTmuxPane() backend
    // lists below), so headless is the only launch mode hive gives it.
    //
    // flagsAfterCommand: muse is a SUB-COMMAND-FIRST CLI. Its options are parsed
    // by `muse exec` itself, not by the `muse` root, so the usual
    // "<perm flags> <one-shot token> <prompt>" order silently breaks: `muse
    // --approval-mode never exec "<prompt>"` prints the root help, exits 0, and
    // never runs the task — a no-op that would look like a passing run. Verified
    // against 1.0.3: flags must follow `exec`.
    muse: { flag: 'exec', flagsAfterCommand: true },
    };
    // headlessSupportsBackend reports whether the configured backend has a known
    // one-shot invocation. Used to fail fast at startup and per task.
    function headlessSupportsBackend() {
    return Object.prototype.hasOwnProperty.call(HEADLESS_BACKENDS, BACKEND);
    }
    // buildHeadlessArgv turns a task prompt into the argv for a one-shot,
    // non-interactive backend invocation: [binary, ...permFlags, ...modelFlag,
    // ...oneShotFlags, prompt]. Returns null for an unsupported backend. Never
    // shell-interpolates the prompt — it is passed as a distinct argv element to
    // execFile, so apostrophes/quotes in the prompt (the exact #2203 wedge on the
    // interactive path) cannot break anything here.
    function buildHeadlessArgv(prompt) {
    const spec = HEADLESS_BACKENDS[BACKEND];
    if (!spec) return null;
    if (BACKEND === 'pi' && !PI_SELECTION.valid) throw new Error(PI_SELECTION.error);
    const { cmd, perm } = resolveBackend();
    const permArgs = perm ? perm.split(/\s+/).filter(Boolean) : [];
    const modelArgs = MODEL && !NO_MODEL_FLAG_BACKENDS.includes(BACKEND) ? ['--model', MODEL] : [];
    const reasoningArgs = BACKEND === 'codex' && REASONING_EFFORT ? ['-c', `model_reasoning_effort="${REASONING_EFFORT}"`] : [];
    // Same --model/--effort pairing the interactive launch enforces, so headless
    // agy honors the configured model instead of silently falling back.
    const agyEffortArgs = BACKEND === 'agy' && modelArgs.length ? ['--effort', agyEffort] : [];
    // spec.flag is a single token for most backends, or an array of leading
    // tokens for backends needing a sub-command plus a flag (goose). Normalize
    // to an array so both shapes spread the same way ahead of the prompt.
    const oneShotArgs = Array.isArray(spec.flag) ? spec.flag : [spec.flag];
    const museEffortArgs = BACKEND === 'muse' && effectiveReasoningEffort()
    ? ['--reasoning-effort', effectiveReasoningEffort()]
    : [];
    const flagArgs = [...permArgs, ...modelArgs, ...reasoningArgs, ...agyEffortArgs, ...museEffortArgs];
    // Sub-command-first CLIs parse their options on the sub-command, not the
    // root binary, so the one-shot token has to lead (see flagsAfterCommand).
    const args = spec.flagsAfterCommand
    ? [...oneShotArgs, ...flagArgs, prompt]
    : [...flagArgs, ...oneShotArgs, prompt];

The downstream consequence is ours: without another upstream seam, one visual OMP workbench cannot consume contributor assignments without either embedding/reimplementing Hive's relay lifecycle or locally selecting work. We are doing neither. review-queue now uses OMP exclusively, while the Hive worker remains a separate foreground path.

Question

Does Hive want to support embedding its contributor lifecycle into an already-running agent host such as OMP?

Possible seams, without prescribing one:

  1. An embeddable relay transport that emits the existing server-selected assignment and accepts progress/completion events while the host owns presentation and agent execution.
  2. A versioned lease API exposing the same server-owned selection, assignment generation, explicit acceptance, post-acceptance credential delivery, heartbeat, release, and completion semantics as the WebSocket protocol.
  3. No new API; downstreams should keep the contributor worker separate from maintainer workbenches.

Any supported seam needs to preserve Hive's ordering and admission decisions, task generation fencing, credential-after-acceptance rule, lease renewal/release behavior, and output capture. It must not let a client request arbitrary issues or synthesize its own batch.

No downstream protocol shim or client-side assignment logic is being added. Please choose the boundary you want; a DESIGN-RESPONSE is sufficient.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    architecture discussionArchitecture discussion neededhelp wantedDenotes an issue that needs help from a contributor. Must meet "help wanted" guidelines.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions