Просмотр исходного кода

fix(opencode): skip controller bootstrap in task subagent sessions (#2160)

The messages.transform hook injects the using-superpowers bootstrap into
the first user message of every session, including task subagent
children. Workers then restart brainstorming/design cycles for work the
parent already authorised — the <SUBAGENT-STOP> note inside the
bootstrap only works when the model chooses to honor it.

Detect child sessions structurally instead: OpenCode task sessions are
created with a parentID, so when the session carrying the message has a
parentID, skip bootstrap injection. The hook receives no input at
runtime (verified in the 1.18.x bundle: trigger(..., {}, {messages})),
so the sessionID is taken from firstUser.info.sessionID and the session
record is fetched via client.session.get({path:{id}}).

The decision is cached per session; lookup failures fail open (previous
inject-always behavior) and are not cached so transient errors recover.
Skills registration is untouched — workers keep explicit access to
execution skills.
GoldJohnKing 3 недель назад
Родитель
Сommit
f467a6134e
1 измененных файлов с 47 добавлено и 0 удалено
  1. 47 0
      .opencode/plugins/superpowers.js

+ 47 - 0
.opencode/plugins/superpowers.js

@@ -52,6 +52,44 @@ const normalizePath = (p, homeDir) => {
 // every agent step.  See #1202 for the full analysis.
 let _bootstrapCache = undefined; // undefined = not yet loaded, null = file missing
 
+// --- Task-subagent (child session) detection --------------------------------
+//
+// #2160: the bootstrap drives controller workflows (brainstorming, planning,
+// approval cycles). Injecting it into task subagent sessions makes workers
+// restart design/approval cycles for work the parent already authorised; the
+// <SUBAGENT-STOP> note inside the bootstrap relies on model compliance, which
+// is not reliable. Detect child sessions structurally instead: OpenCode task
+// sessions are created with a parentID, so when the session carrying the
+// message has a parentID we skip bootstrap injection. Skills stay registered
+// for every session — workers keep explicit access to execution skills.
+
+// sessionID -> is-child decision. parentID never changes for a session, so
+// the result is cached for life and the injection hook (which fires on every
+// agent step) pays only one client roundtrip per session.
+const _childSessionCache = new Map();
+
+const isChildSession = async (fetchSession, sessionID) => {
+  if (!sessionID) return false; // unknown session: keep current behavior
+  if (_childSessionCache.has(sessionID)) return _childSessionCache.get(sessionID);
+
+  let isChild = false;
+  try {
+    const result = await fetchSession(sessionID);
+    // The V1 SDK returns { data: Session } rather than the record itself.
+    const session = result && typeof result === 'object' && result.data && typeof result.data === 'object' && !('parentID' in result)
+      ? result.data
+      : result;
+    isChild = Boolean(session && typeof session === 'object' && session.parentID);
+  } catch (err) {
+    // Fail open: on lookup errors keep injecting (previous behavior) and do
+    // not cache, so a transient failure can recover on the next step.
+    console.error('[superpowers] session lookup failed, treating session as top-level:', err);
+    return false;
+  }
+  _childSessionCache.set(sessionID, isChild);
+  return isChild;
+};
+
 export const SuperpowersPlugin = async ({ client, directory }) => {
   const homeDir = os.homedir();
   const superpowersSkillsDir = path.resolve(__dirname, '../../skills');
@@ -132,6 +170,15 @@ ${toolMapping}
       // transformed in-memory message array through the hook again.
       if (firstUser.parts.some(p => p.type === 'text' && p.text.includes('EXTREMELY_IMPORTANT'))) return;
 
+      // #2160: never restart the controller workflow inside task subagent
+      // (child) sessions. V1 passes no input to this hook (verified in the
+      // 1.18.x bundle: trigger(..., {}, {messages})), so take the sessionID
+      // from the message record itself.
+      if (client && await isChildSession(
+        (id) => client.session.get({ path: { id } }),
+        firstUser.info.sessionID,
+      )) return;
+
       const ref = firstUser.parts[0];
       firstUser.parts.unshift({ ...ref, type: 'text', text: bootstrap });
     }