Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions GRUNTCODE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Current patches (each tracked in [hivemind #222](https://github.com/grunt-it/hiv
2. **Attach subscribes to all session message updates.** When a wake fires via `/session/<id>/prompt_async`, the attach-client TUI renders the response. (Without this patch, externally-triggered messages land in the server's db but never reach the user's screen.)
3. **`OPENCODE_SERVER_URL` propagation.** The attach binary reads and propagates this env var to all subprocesses (MCP children, etc.) so agents can find their serve daemon without a launch-script shim.
4. **`--peer-id <id>` flag.** Lets launch scripts set the tab's hivemind peer-id at startup instead of via TAKEOFF gymnastics. Powers patch #1.
5. **Turn-end hook into the hivemind loop primitive ([#266](https://github.com/grunt-it/hivemind-mcp/issues/266) Phase 1).** Behind feature flag `OPENCODE_HIVEMIND_LOOP_ENABLED=1`. Every step-finish fires `hivemind_record_turn_end` into the hivemind MCP, which evaluates the three-layer goal-loop contract + decides to auto-wake the same peer (`continue`), wake the parent peer (`escalate` / `await-review`), or no-op. Per-tool-call `hivemind_loop_progress` bumps the progress timestamp. Fire-and-forget via `Effect.forkIn(scope)` — hook failures NEVER break the TUI. Opt-in per-tab via env var in Phase 1; planned default-on in Phase 2 after validating in the wild.

Everything else is opencode upstream. We rebase against `anomalyco/opencode:dev` weekly.

Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/src/effect/runtime-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
/**
* Loop primitive (#266 Phase 1) — when true, the session processor fires
* hivemind_record_turn_end after every step-finish + hivemind_loop_progress after every
* tool-result. The MCP then decides whether to auto-wake the same peer (continue), wake
* the parent (escalate), or no-op. Default off in Phase 1; opt-in per-tab via env var;
* planned default-on in Phase 2 after validating in the wild. Failure of the hook is
* always non-fatal — the TUI is never blocked or thrown into on a hivemind error.
*/
hivemindLoopEnabled: bool("OPENCODE_HIVEMIND_LOOP_ENABLED"),
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
}) {}

Expand Down
164 changes: 164 additions & 0 deletions packages/opencode/src/session/hivemind-loop-hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { Effect, Option, Scope } from "effect"
import { MCP } from "@/mcp"
import { MessageV2 } from "./message-v2"
import * as Log from "@opencode-ai/core/util/log"

type MCPShape = MCP.Interface

/**
* Loop-primitive hook (#266 Phase 1) — bridges gruntcode's per-turn lifecycle to the
* hivemind-mcp loop primitive (hivemind_record_turn_end + hivemind_loop_progress).
*
* The loop primitive shipped in hivemind-mcp v0.8.2 (PR #18) provides the data layer +
* decision logic; THIS module is the event source. Every gruntcode step-finish + every
* tool-result fires into the MCP, which decides whether to continue the same peer (auto-
* wake with continuation prompt), escalate to parent (auto-wake with violation context),
* or no-op (clean exit / rate-limited / no loop_goal set).
*
* Hard correctness rule (#266 spec): hook failures MUST NEVER break the TUI. Every call
* pipes through Effect.ignore + Effect.forkIn(scope) so the TUI never blocks on the MCP
* call and never sees an exception from a slow / failing hivemind connection.
*
* Feature-flagged: opt-in via OPENCODE_HIVEMIND_LOOP_ENABLED=1 (RuntimeFlags.hivemindLoopEnabled).
* Off by default in Phase 1 so users can adopt per-tab + we can flip the global default in
* Phase 2 after validating in the wild.
*/

const log = Log.create({ service: "session.hivemind-loop-hook" })

const EXCERPT_CAP = 500

/**
* Find the MCP client that exposes the loop primitive tools. The hivemind MCP is conventionally
* named "hivemind" in user opencode.json but we don't hard-code the name — we look up the
* client by checking each one's tool list for our target tool.
*
* Takes a pre-yielded MCP.Service instance instead of yielding it itself so callers (which
* yield the service once at layer construction) can pass it through without re-yielding. This
* is what lets the hook satisfy the processor's `never` environment requirement.
*/
const findHivemindClient = Effect.fnUntraced(function* (mcp: MCPShape, toolName: string) {
const clients = yield* mcp.clients()
const clientNames = Object.keys(clients)
if (clientNames.length === 0) return undefined
// Convention: the hivemind MCP is named "hivemind". Most users will have it under that key,
// and that's the fast path. We fall through to a scan only if the conventional name isn't
// present — handles the rare case where someone renamed it (e.g. "hivemind-prod") or set up
// multiple coordination MCPs.
if (clientNames.includes("hivemind")) {
return clients["hivemind"]
}
// Slow path: ask each client for its tool list + pick the one that exposes our tool. We
// accept the cost only when the conventional name isn't present, which should be rare.
const tools = yield* mcp.tools()
// The tools map composes "<clientName>_<toolName>" as the registry key. Find a key matching
// our tool name suffix, then return that client.
const matchingKey = Object.keys(tools).find((k) => k.endsWith(`_${toolName}`) || k === toolName)
if (!matchingKey) return undefined
const owningName = clientNames.find((name) => matchingKey.startsWith(`${name}_`) || matchingKey === toolName)
return owningName ? clients[owningName] : undefined
})

/**
* Build the last-message excerpt + tool-call count for a finished turn. Reads the
* assistant message's parts synchronously via MessageV2.parts (DB read).
*
* Excerpt: concatenate text parts in part-id order, take last 500 chars. Tool count: count
* tool parts whose state.status indicates a completed (non-error, non-pending) call. The
* MCP's evaluateLoop logic uses tool_call_count=0 as a "silent stall" signal, so we want
* the count to reflect actual tool ACTIVITY this turn, not just any tool part.
*/
function buildTurnSummary(messageId: MessageV2.Assistant["id"]) {
const parts = MessageV2.parts(messageId)
const textParts = parts.filter((p): p is MessageV2.TextPart => p.type === "text")
// Concatenate text parts to form the excerpt. Most turns have one or two text parts; join
// with newlines so contiguous text reads naturally.
const fullText = textParts.map((p) => p.text).join("\n").trim()
const excerpt =
fullText.length > EXCERPT_CAP
? fullText.slice(fullText.length - EXCERPT_CAP)
: fullText
const toolCallCount = parts.filter((p) => p.type === "tool").length
return { excerpt, toolCallCount }
}

/**
* Fire hivemind_record_turn_end after a step-finish. Caller invokes this inside the
* processor's step-finish handler. Fire-and-forget via Effect.forkIn(scope) so the
* processor's hot path doesn't block on the MCP roundtrip.
*
* The MCP wraps evaluateLoop + fires the wake/escalation internally. This side just sends
* the turn-end event and forgets.
*
* Off when OPENCODE_HIVEMIND_LOOP_ENABLED is unset/false — silent no-op.
*/
export const recordTurnEnd = Effect.fn("HivemindLoopHook.recordTurnEnd")(function* (input: {
enabled: boolean
mcp: Option.Option<MCPShape>
sessionID: string
messageID: MessageV2.Assistant["id"]
finishReason: string | undefined
scope: Scope.Scope
}) {
if (!input.enabled) return
if (Option.isNone(input.mcp)) return
const client = yield* findHivemindClient(input.mcp.value, "hivemind_record_turn_end")
if (!client) {
yield* Effect.logDebug("no hivemind MCP client found for record_turn_end; flag enabled but MCP unavailable")
return
}
// Build summary AFTER client resolution so we don't pay the DB read when we're going to
// no-op anyway.
const summary = buildTurnSummary(input.messageID)
// Fork the MCP call into the scope so the processor doesn't await it. Effect.ignore swallows
// any error from the call — the loop primitive's correctness rule is that the hook must
// never break the TUI, so we accept silent best-effort delivery here.
yield* Effect.promise(() =>
client.callTool({
name: "hivemind_record_turn_end",
arguments: {
session_id: input.sessionID,
finish_reason: input.finishReason ?? null,
last_msg_excerpt: summary.excerpt,
tool_call_count: summary.toolCallCount,
},
}),
).pipe(
Effect.tapError((err) =>
Effect.sync(() => log.warn("recordTurnEnd MCP call failed (non-fatal)", { err })),
),
Effect.ignore,
Effect.forkIn(input.scope),
)
})

/**
* Fire hivemind_loop_progress after a tool-result. Cheap call — just bumps
* last_loop_progress_at so evaluate_loop sees fresh activity. Same fire-and-forget guarantee.
*
* Off when OPENCODE_HIVEMIND_LOOP_ENABLED is unset/false — silent no-op.
*/
export const loopProgress = Effect.fn("HivemindLoopHook.loopProgress")(function* (input: {
enabled: boolean
mcp: Option.Option<MCPShape>
scope: Scope.Scope
}) {
if (!input.enabled) return
if (Option.isNone(input.mcp)) return
const client = yield* findHivemindClient(input.mcp.value, "hivemind_loop_progress")
if (!client) return
yield* Effect.promise(() =>
client.callTool({
name: "hivemind_loop_progress",
arguments: {},
}),
).pipe(
Effect.tapError((err) =>
Effect.sync(() => log.debug("loopProgress MCP call failed (non-fatal)", { err })),
),
Effect.ignore,
Effect.forkIn(input.scope),
)
})

export * as HivemindLoopHook from "./hivemind-loop-hook"
28 changes: 28 additions & 0 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import * as DateTime from "effect/DateTime"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { MCP } from "@/mcp"
import { HivemindLoopHook } from "./hivemind-loop-hook"
import { Usage, type LLMEvent } from "@opencode-ai/llm"

const DOOM_LOOP_THRESHOLD = 3
Expand Down Expand Up @@ -101,6 +103,12 @@ export const layer = Layer.effect(
const image = yield* Image.Service
const events = yield* EventV2Bridge.Service
const flags = yield* RuntimeFlags.Service
// MCP.Service is yielded as Option (#266 Phase 1 hivemind loop hook). At production
// runtime the prompt layer provides MCP (session/prompt.ts:1649). In test layers MCP is
// typically NOT provided — yielding as Option lets the processor cleanly degrade to
// no-op for the hook when MCP isn't in context, instead of forcing every test layer to
// provide an unused service.
const mcpOption = yield* Effect.serviceOption(MCP.Service)

const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
// Pre-capture snapshot before the LLM stream starts. The AI SDK
Expand Down Expand Up @@ -498,6 +506,14 @@ export const layer = Layer.effect(
})
}
yield* completeToolCall(value.id, output)
// Hivemind loop primitive (#266 Phase 1): bump last_loop_progress_at after every
// successful tool result. Cheap signal that the peer is actively driving its loop —
// evaluateLoop uses this to distinguish "made progress" from "silent stall".
yield* HivemindLoopHook.loopProgress({
enabled: flags.hivemindLoopEnabled,
mcp: mcpOption,
scope,
})
return
}

Expand Down Expand Up @@ -607,6 +623,18 @@ export const layer = Layer.effect(
messageID: ctx.assistantMessage.parentID,
})
.pipe(Effect.ignore, Effect.forkIn(scope))
// Hivemind loop primitive (#266 Phase 1): fire the turn-end event into hivemind-mcp.
// The MCP runs evaluateLoop + decides auto-wake / auto-escalate / no-op. Off by
// default; opt-in via OPENCODE_HIVEMIND_LOOP_ENABLED. Always fire-and-forget —
// hivemind failure must never break the TUI.
yield* HivemindLoopHook.recordTurnEnd({
enabled: flags.hivemindLoopEnabled,
mcp: mcpOption,
sessionID: ctx.sessionID,
messageID: ctx.assistantMessage.id,
finishReason: value.reason,
scope,
})
if (
!ctx.assistantMessage.summary &&
isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model })
Expand Down
125 changes: 125 additions & 0 deletions packages/opencode/test/session/hivemind-loop-hook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Layer, Option, Scope } from "effect"
import { HivemindLoopHook } from "@/session/hivemind-loop-hook"
import { MCP } from "@/mcp"
import { testEffect } from "../lib/effect"
import { MessageID } from "@/session/schema"

/**
* Loop hook unit tests (#266 Phase 1). The hook is intentionally narrow: it gates on a
* feature flag, finds the hivemind MCP client, and fires fire-and-forget Effect.promise
* wrappers. The hard correctness rule is "MUST NEVER break the TUI" — disabled path is a
* silent no-op, missing-MCP path is a silent no-op, and the in-flight wake itself is
* forked via Effect.forkIn(scope) + Effect.ignore so even an MCP exception is swallowed.
*
* These tests cover the gating paths (flag off + MCP-not-provided) without needing a
* full MCP fixture. The wake fire-path is exercised end-to-end via the real coordinator
* dispatch loop in production once Phase 1 is enabled.
*/

const it = testEffect(Layer.empty)

describe("HivemindLoopHook.recordTurnEnd", () => {
it.effect("flag off → silent no-op (no MCP needed)", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
// No MCP layer provided + enabled=false. The hook returns immediately on flag check;
// no error, no environment requirement leaks.
yield* HivemindLoopHook.recordTurnEnd({
enabled: false,
mcp: Option.none(),
sessionID: "ses_x",
messageID: MessageID.make("msg_x"),
finishReason: "stop",
scope,
})
yield* Scope.close(scope, Exit.succeed(undefined))
// If we reached here without throwing, the no-op path is clean.
expect(true).toBe(true)
}),
)

it.effect("flag on but MCP=None → silent no-op (no error)", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
yield* HivemindLoopHook.recordTurnEnd({
enabled: true,
mcp: Option.none(),
sessionID: "ses_x",
messageID: MessageID.make("msg_x"),
finishReason: "stop",
scope,
})
yield* Scope.close(scope, Exit.succeed(undefined))
expect(true).toBe(true)
}),
)
})

describe("HivemindLoopHook.loopProgress", () => {
it.effect("flag off → silent no-op", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
yield* HivemindLoopHook.loopProgress({
enabled: false,
mcp: Option.none(),
scope,
})
yield* Scope.close(scope, Exit.succeed(undefined))
expect(true).toBe(true)
}),
)

it.effect("flag on but MCP=None → silent no-op", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
yield* HivemindLoopHook.loopProgress({
enabled: true,
mcp: Option.none(),
scope,
})
yield* Scope.close(scope, Exit.succeed(undefined))
expect(true).toBe(true)
}),
)
})

describe("HivemindLoopHook with mocked MCP", () => {
it.effect("flag on + MCP Some + no hivemind client → silent no-op (no throw)", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
// Build a minimal MCP service stub: clients() returns empty map → hook can't find
// hivemind, returns silently. Other interface methods aren't called by the hook.
const stubMcp = {
status: () => Effect.succeed({}),
clients: () => Effect.succeed({}),
tools: () => Effect.succeed({}),
prompts: () => Effect.succeed({}),
resources: () => Effect.succeed({}),
add: () => Effect.succeed({ status: {} }),
connect: () => Effect.void,
disconnect: () => Effect.void,
getPrompt: () => Effect.succeed(undefined),
readResource: () => Effect.succeed(undefined),
startAuth: () => Effect.succeed({ authorizationUrl: "", oauthState: "" }),
authenticate: () => Effect.succeed({ status: "connected" as const }),
finishAuth: () => Effect.succeed({ status: "connected" as const }),
removeAuth: () => Effect.void,
supportsOAuth: () => Effect.succeed(false),
hasStoredTokens: () => Effect.succeed(false),
getAuthStatus: () => Effect.succeed({ kind: "none" as const }),
} as unknown as MCP.Interface

yield* HivemindLoopHook.recordTurnEnd({
enabled: true,
mcp: Option.some(stubMcp),
sessionID: "ses_x",
messageID: MessageID.make("msg_x"),
finishReason: "stop",
scope,
})
yield* Scope.close(scope, Exit.succeed(undefined))
expect(true).toBe(true)
}),
)
})
Loading