dad2941971
Entire-Checkpoint: 69bf47d06770
217 lines
7.7 KiB
TypeScript
217 lines
7.7 KiB
TypeScript
// Entire CLI plugin for OpenCode
|
|
// Auto-generated by `entire enable --agent opencode`
|
|
// Do not edit manually — changes will be overwritten on next install.
|
|
// Requires Bun runtime (used by OpenCode's plugin system for loading ESM plugins).
|
|
import type { Plugin } from "@opencode-ai/plugin"
|
|
|
|
export const EntirePlugin: Plugin = async ({ directory }) => {
|
|
const ENTIRE_CMD = 'entire'
|
|
// Track seen user messages to fire turn-start only once per message
|
|
const seenUserMessages = new Set<string>()
|
|
// Track current session ID for message events (which don't include sessionID)
|
|
let currentSessionID: string | null = null
|
|
// Track the model used by the most recent assistant message
|
|
let currentModel: string | null = null
|
|
// In-memory store for message metadata (role, tokens, etc.)
|
|
const messageStore = new Map<string, any>()
|
|
|
|
/**
|
|
* Build the shell command for a hook invocation.
|
|
* Uses sh -c so that shell command substitution in ENTIRE_CMD
|
|
* (e.g., $(git rev-parse --show-toplevel) for local-dev) is interpreted.
|
|
*/
|
|
function hookCmd(hookName: string): string[] {
|
|
if (ENTIRE_CMD !== "entire") {
|
|
return ["sh", "-c", `${ENTIRE_CMD} hooks opencode ${hookName}`]
|
|
}
|
|
return ["sh", "-c", `if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks opencode ${hookName}`]
|
|
}
|
|
|
|
/**
|
|
* Pipe JSON payload to an entire hooks command (async).
|
|
* Errors are logged but never thrown — plugin failures must not crash OpenCode.
|
|
*/
|
|
async function callHook(hookName: string, payload: Record<string, unknown>) {
|
|
try {
|
|
const json = JSON.stringify(payload)
|
|
const proc = Bun.spawn(hookCmd(hookName), {
|
|
cwd: directory,
|
|
stdin: new Blob([json + "\n"]),
|
|
stdout: "ignore",
|
|
stderr: "ignore",
|
|
})
|
|
await proc.exited
|
|
} catch {
|
|
// Silently ignore — plugin failures must not crash OpenCode
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Synchronous variant for hooks that must complete before subsequent agent work
|
|
* or process exit. `turn-start` must finish initializing session state before a
|
|
* fast mid-turn commit can hit git hooks, and `turn-end` / `session-end` must
|
|
* finish before `opencode run` tears down its event loop.
|
|
*/
|
|
function callHookSync(hookName: string, payload: Record<string, unknown>) {
|
|
try {
|
|
const json = JSON.stringify(payload)
|
|
Bun.spawnSync(hookCmd(hookName), {
|
|
cwd: directory,
|
|
stdin: new TextEncoder().encode(json + "\n"),
|
|
stdout: "ignore",
|
|
stderr: "ignore",
|
|
})
|
|
} catch {
|
|
// Silently ignore — plugin failures must not crash OpenCode
|
|
}
|
|
}
|
|
|
|
function resetSessionTracking(sessionID: string) {
|
|
if (currentSessionID === sessionID) {
|
|
return false
|
|
}
|
|
seenUserMessages.clear()
|
|
messageStore.clear()
|
|
currentModel = null
|
|
currentSessionID = sessionID
|
|
return true
|
|
}
|
|
|
|
return {
|
|
event: async ({ event }) => {
|
|
try {
|
|
switch (event.type) {
|
|
case "session.created": {
|
|
const session = (event as any).properties?.info
|
|
if (!session?.id) break
|
|
// Reset per-session tracking state when switching sessions.
|
|
if (resetSessionTracking(session.id)) {
|
|
const json = JSON.stringify({
|
|
session_id: session.id,
|
|
})
|
|
const proc = Bun.spawn(hookCmd("session-start"), {
|
|
cwd: directory,
|
|
stdin: new Blob([json + "\n"]),
|
|
stdout: "ignore",
|
|
stderr: "ignore",
|
|
})
|
|
await proc.exited
|
|
}
|
|
break
|
|
}
|
|
|
|
case "message.updated": {
|
|
const msg = (event as any).properties?.info
|
|
if (!msg) break
|
|
|
|
if (msg.sessionID && resetSessionTracking(msg.sessionID)) {
|
|
callHookSync("session-start", {
|
|
session_id: msg.sessionID,
|
|
})
|
|
}
|
|
|
|
// Store message metadata (role, time, tokens, etc.)
|
|
messageStore.set(msg.id, msg)
|
|
// Track model from assistant messages
|
|
if (msg.role === "assistant" && msg.modelID) {
|
|
currentModel = msg.modelID
|
|
}
|
|
|
|
// Fallback: some opencode run flows commit before any message.part.updated
|
|
// event is delivered for the user's prompt. Start the turn from the
|
|
// user message itself so git hooks see an ACTIVE session in time.
|
|
if (msg.role === "user" && !seenUserMessages.has(msg.id)) {
|
|
seenUserMessages.add(msg.id)
|
|
const sessionID = msg.sessionID ?? currentSessionID
|
|
if (sessionID) {
|
|
callHookSync("turn-start", {
|
|
session_id: sessionID,
|
|
prompt: "",
|
|
model: currentModel ?? "",
|
|
})
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
case "message.part.updated": {
|
|
const part = (event as any).properties?.part
|
|
if (!part?.messageID) break
|
|
|
|
// Fire turn-start on the first text part of a new user message
|
|
const msg = messageStore.get(part.messageID)
|
|
if (msg?.role === "user" && part.type === "text" && !seenUserMessages.has(msg.id)) {
|
|
seenUserMessages.add(msg.id)
|
|
const sessionID = msg.sessionID ?? currentSessionID
|
|
if (sessionID) {
|
|
callHookSync("turn-start", {
|
|
session_id: sessionID,
|
|
prompt: part.text ?? "",
|
|
model: currentModel ?? "",
|
|
})
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
case "session.status": {
|
|
// session.status fires in both TUI and non-interactive (run) mode.
|
|
// session.idle is deprecated and not reliably emitted in run mode.
|
|
const props = (event as any).properties
|
|
if (props?.status?.type !== "idle") break
|
|
const sessionID = props?.sessionID ?? currentSessionID
|
|
if (!sessionID) break
|
|
// Use sync variant: `opencode run` exits on the same idle event,
|
|
// so an async hook would be killed before completing.
|
|
callHookSync("turn-end", {
|
|
session_id: sessionID,
|
|
model: currentModel ?? "",
|
|
})
|
|
break
|
|
}
|
|
|
|
case "session.compacted": {
|
|
const sessionID = (event as any).properties?.sessionID
|
|
if (!sessionID) break
|
|
await callHook("compaction", {
|
|
session_id: sessionID,
|
|
})
|
|
break
|
|
}
|
|
|
|
case "session.deleted": {
|
|
const session = (event as any).properties?.info
|
|
if (!session?.id) break
|
|
seenUserMessages.clear()
|
|
messageStore.clear()
|
|
currentSessionID = null
|
|
// Use sync variant: session-end may fire during shutdown.
|
|
callHookSync("session-end", {
|
|
session_id: session.id,
|
|
})
|
|
break
|
|
}
|
|
|
|
case "server.instance.disposed": {
|
|
// Fires when OpenCode shuts down (TUI close or `opencode run` exit).
|
|
// session.deleted only fires on explicit user deletion, not on quit,
|
|
// so this is the only reliable way to end sessions on exit.
|
|
if (!currentSessionID) break
|
|
const sessionID = currentSessionID
|
|
seenUserMessages.clear()
|
|
messageStore.clear()
|
|
currentSessionID = null
|
|
// Use sync variant: this is the last event before process exit.
|
|
callHookSync("session-end", {
|
|
session_id: sessionID,
|
|
})
|
|
break
|
|
}
|
|
}
|
|
} catch {
|
|
// Silently ignore — plugin failures must not crash OpenCode
|
|
}
|
|
},
|
|
}
|
|
}
|