Spaces:
Running
Running
| import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; | |
| import path from 'node:path'; | |
| import os from 'node:os'; | |
| const SAFE = /^[A-Za-z0-9_-]+$/; | |
| function paneIdentity() { | |
| const amId = process.env.AM_ID; | |
| const runId = process.env.AM_RUN_ID; | |
| if (process.env.AM_CLI !== 'opencode' || !SAFE.test(amId || '') || !SAFE.test(runId || '')) return null; | |
| if (String(process.pid) !== process.env.AM_PANE_PID) return null; | |
| return { amId, runId }; | |
| } | |
| function report(sessionID, cwd, source) { | |
| const identity = paneIdentity(); | |
| if (!identity) return; | |
| const { amId, runId } = identity; | |
| if (!/^ses_[A-Za-z0-9_-]+$/.test(sessionID || '') || typeof cwd !== 'string') return; | |
| const dir = process.env.AM_REPIN_DIR || path.join(os.tmpdir(), 'am-repin'); | |
| const file = path.join(dir, `${amId}.opencode.json`); | |
| const tmp = `${file}.${process.pid}.tmp`; | |
| try { | |
| // OpenCode dispatches generic event hooks without awaiting their Promise. | |
| // Keep this tiny local write synchronous so /clear followed immediately by | |
| // quit cannot terminate the process between mkdir/write/rename. | |
| mkdirSync(dir, { recursive: true }); | |
| writeFileSync(tmp, JSON.stringify({ | |
| amId, | |
| runId, | |
| cli: 'opencode', | |
| pluginPid: process.pid, | |
| payload: { session_id: sessionID, cwd, source }, | |
| })); | |
| renameSync(tmp, file); | |
| } catch { /* telemetry must never interfere with the user's prompt */ } | |
| } | |
| function syncInputRequired(pending) { | |
| const identity = paneIdentity(); | |
| if (!identity) return; | |
| const { amId, runId } = identity; | |
| const dir = process.env.AM_INPUT_REQUIRED_DIR || path.join(os.tmpdir(), 'am-input-required'); | |
| const file = path.join(dir, `${amId}.json`); | |
| const item = pending.values().next().value; | |
| if (!item) { | |
| try { | |
| const existing = JSON.parse(readFileSync(file, 'utf8')); | |
| if (existing?.runId === runId && existing?.source === 'opencode-event') unlinkSync(file); | |
| } catch { /* already absent or no longer ours */ } | |
| return; | |
| } | |
| const tmp = `${file}.${process.pid}.tmp`; | |
| try { | |
| mkdirSync(dir, { recursive: true }); | |
| writeFileSync(tmp, JSON.stringify({ | |
| amId, | |
| runId, | |
| cli: 'opencode', | |
| kind: item.kind, | |
| source: 'opencode-event', | |
| requestId: item.id, | |
| at: item.at, | |
| })); | |
| renameSync(tmp, file); | |
| } catch { /* an attention signal must never interfere with the TUI */ } | |
| } | |
| // OpenCode creates a new root session for /new (alias /clear). chat.message | |
| // additionally follows an explicit switch to an existing session; runner.js | |
| // verifies that id against the database and rejects child/subagent sessions. | |
| export const AgentManagerRepin = async ({ directory }) => { | |
| // OpenCode's own TUI uses the same asked/replied event pairs. Keep every | |
| // queued request: resolving one must not hide another waiting behind it. | |
| const pending = new Map(); | |
| const changed = () => syncInputRequired(pending); | |
| const drop = (id) => { | |
| if (!id || !pending.delete(id)) return; | |
| changed(); | |
| }; | |
| return ({ | |
| event: async ({ event }) => { | |
| const props = event?.properties || {}; | |
| if (event?.type === 'session.created') { | |
| const info = props.info; | |
| if (info?.id && !info.parentID) report(info.id, info.directory || directory, 'session.created'); | |
| return; | |
| } | |
| if (event?.type === 'permission.asked' || event?.type === 'question.asked') { | |
| if (!props.id || pending.has(props.id)) return; | |
| pending.set(props.id, { | |
| id: props.id, | |
| sessionID: props.sessionID, | |
| kind: event.type === 'question.asked' ? 'question' : 'permission', | |
| tool: props.tool || null, | |
| at: Date.now(), | |
| }); | |
| changed(); | |
| return; | |
| } | |
| if (event?.type === 'permission.replied' | |
| || event?.type === 'question.replied' | |
| || event?.type === 'question.rejected') { | |
| drop(props.requestID); | |
| return; | |
| } | |
| // A Question tool can complete without question.replied. OpenCode's TUI | |
| // has this same recovery path; mirror it so the marker cannot stick. | |
| if (event?.type === 'message.part.updated') { | |
| const part = props.part; | |
| if (part?.type !== 'tool' || part.tool !== 'question' | |
| || (part.state?.status !== 'completed' && part.state?.status !== 'error')) return; | |
| let dirty = false; | |
| for (const [id, item] of pending) { | |
| if (item.kind !== 'question' || !item.tool) continue; | |
| if (item.tool.messageID === part.messageID && item.tool.callID === part.callID) { | |
| pending.delete(id); | |
| dirty = true; | |
| } | |
| } | |
| if (dirty) changed(); | |
| } | |
| }, | |
| 'chat.message': async ({ sessionID }) => { | |
| report(sessionID, directory, 'chat.message'); | |
| }, | |
| // Tool shells must not pass the pane's private attribution markers to an | |
| // agent launched inside them. Empty values override OpenCode's process.env | |
| // merge and make the nested plugin a no-op. | |
| 'shell.env': async (_input, output) => { | |
| output.env.AM_ID = ''; | |
| output.env.AM_RUN_ID = ''; | |
| output.env.AM_CLI = ''; | |
| output.env.AM_PANE_PID = ''; | |
| }, | |
| }); | |
| }; | |