answer opencode's permission prompts, because nothing else can

the spinner had a cause and it was none of the four things fixed before it. the
serve was holding a permission request:

  {"action":"skill","resources":["calendar-and-contacts"],"source":{"type":"tool"}}

opencode asks before loading a skill. in its own terminal a person answers. here
it goes to a UI that has never heard of it, and the turn does not fail — it SITS
THERE. no error, no log line, nothing in the transcript. three models were blamed
for it, and a paid provider, and the skill itself.

replying `always` by hand released it instantly: the skill loaded and the agent
went straight to the curl the skill describes. so the whole chain was correct and
one unanswered question was holding all of it.

CONFIG IS NOT ENOUGH, and this is the part worth keeping. the serve's config
carries `permission: {skill: {"*": "allow"}}`, the agent's own ruleset reports
`skill * allow`, and it asked anyway. answering is the only approach that does not
depend on guessing the shape of the next gate correctly — and there will be a next
gate, because `external_directory` was the last one.

not a widening: the claude harness runs `bypassPermissions` deliberately, because
CLAUDE.md is explicit that an unsandboxed agent on the owner's own machine is the
point. this is the same stance for the other harness. `always` rather than `once`
so a repeated tool does not re-ask every turn.

when opencode reaches members this becomes a real decision. it is owner-only today
for larger and unrelated reasons, and it goes on the list with them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-27 13:31:18 +00:00
co-authored by Claude Opus 5
parent c226e8a46d
commit 3082949fac
+56 -2
View File
@@ -100,7 +100,7 @@ function ensureEventStream(config: ServeConfig): void {
for (const line of lines) {
if (!line.startsWith('data:')) continue;
try {
handleServeEvent(JSON.parse(line.slice(5).trim()));
handleServeEvent(JSON.parse(line.slice(5).trim()), config);
} catch {
/* a frame we could not parse is not worth killing the stream over */
}
@@ -114,7 +114,61 @@ function ensureEventStream(config: ServeConfig): void {
})();
}
function handleServeEvent(raw: unknown): void {
/*
Answer OpenCode's permission requests, because nothing else can.
OpenCode asks before loading a skill, reading outside the project, and whatever it decides to gate
next. In its own terminal a person answers. Here the request goes to a UI that has never heard of it,
and the turn does not fail — it SITS THERE. A spinner on `skill calendar-and-contacts`, no error, no
log line, three models blamed and an afternoon gone.
Config was tried first and is not enough: `permission: {skill: {"*": "allow"}}` is in the serve's
config, the agent's own ruleset reports `skill * allow`, and it asked anyway. Answering is the only
thing that does not depend on guessing the shape of the next gate correctly.
This is not a widening. The Claude harness runs `bypassPermissions` deliberately — CLAUDE.md is
explicit that on the owner's own machine an unsandboxed agent is the point — and this is the same
stance for the other harness. `always` rather than `once` so a repeated tool does not re-ask on every
turn.
A member's session would need this to become a real decision. OpenCode is owner-only today for
unrelated and larger reasons (`api/chat/chat.ts`); when that lifts, this is on the list with them.
*/
async function approvePermission(config: ServeConfig, sessionID: string, requestID: string): Promise<void> {
try {
const res = await fetch(`${config.baseUrl}/api/session/${sessionID}/permission/${requestID}/reply`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ reply: 'always' }),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) console.error(`[opencode] permission reply → ${res.status}`);
} catch (err) {
// Losing this leaves the turn hanging, so it is worth a line even though nothing can be done here.
console.error('[opencode] could not answer a permission request', err);
}
}
/** A permission request, whatever the event is called — it is the ids that matter. */
function permissionRequest(raw: unknown): { sessionID: string; requestID: string } | null {
const p = (raw as { properties?: Record<string, unknown> })?.properties ?? (raw as Record<string, unknown>);
const type = String((raw as { type?: string })?.type ?? '');
if (!type.includes('permission')) return null;
const info = ((p as { info?: Record<string, unknown> }).info ?? p) as Record<string, unknown>;
const sessionID = info.sessionID ?? info.sessionId;
const requestID = info.id ?? info.requestID;
if (typeof sessionID !== 'string' || typeof requestID !== 'string') return null;
return { sessionID, requestID };
}
function handleServeEvent(raw: unknown, config?: ServeConfig): void {
const ask = permissionRequest(raw);
if (ask && config) {
console.log(`[opencode] approving ${ask.requestID} on ${ask.sessionID}`);
void approvePermission(config, ask.sessionID, ask.requestID);
return;
}
const mapped = mapServeEvent(raw as never);
if (!mapped?.sessionId) return;