agent-manager-template / scripts /am-opencode-repin.js
Agent Manager
Surface native agent input requests
df112f9
Raw
History Blame Contribute Delete
5.21 kB
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 = '';
},
});
};