Selaa lähdekoodia

Merge pull request #435 from alvinunreal/divoom-2

Harden Divoom prompt display states
Alvin 3 kuukautta sitten
vanhempi
sitoutus
ca5616d77e

+ 19 - 5
docs/divoom.md

@@ -14,15 +14,18 @@ When enabled, the plugin sends bundled GIFs as OpenCode changes state:
 
 | OpenCode state | Divoom display |
 |----------------|----------------|
-| Plugin loaded / orchestrator waiting for user input | `intro.gif` |
+| Plugin loaded | `intro.gif` |
 | Orchestrator is busy planning or working directly | `orchestrator.gif` |
 | A delegated agent starts | that agent's GIF |
 | Multiple agents run in parallel | first delegated agent keeps the display |
 | Delegated agents finish but orchestrator is still working | `orchestrator.gif` |
 | Orchestrator becomes idle again | `intro.gif` |
+| Permission prompt or question needs a reply | `input.gif` |
 
 Bundled GIFs currently cover `orchestrator`, `explorer`, `librarian`, `oracle`,
-`designer`, `fixer`, and `council`.
+`designer`, `fixer`, `council`, `input`, and `intro`. You can configure
+`divoom.gifs.input` to customize user-input waits; if `input.gif` is not present
+yet, the plugin falls back to `intro.gif`.
 
 ## Prerequisites
 
@@ -74,11 +77,21 @@ Before blaming OpenCode, verify the Divoom sender works directly:
   --fps 8 \
   --speed 125 \
   --max-frames 24 \
-  --posterize-bits 3
+  --posterize-bits 3 \
+  --out-dir ~/.local/share/opencode/storage/oh-my-opencode-slim/divoom/captures
 ```
 
+**Note:** The sender must support the `--out-dir` flag. This requires a recent
+version of the Divoom MiniToo sender (the plugin uses this for temporary
+processing files).
+
 If that updates the display, the OpenCode integration should work once enabled.
 
+**Output directory path:** The plugin writes temporary processing files to
+`$XDG_DATA_HOME/opencode/storage/oh-my-opencode-slim/divoom/captures` when
+`XDG_DATA_HOME` is set to a non-empty absolute path. Otherwise it falls back to
+`~/.local/share/opencode/storage/oh-my-opencode-slim/divoom/captures`.
+
 ## Enable in oh-my-opencode-slim
 
 Open your plugin config:
@@ -105,8 +118,9 @@ For one-off runs, you can enable Divoom without changing your config:
 OH_MY_OPENCODE_SLIM_DIVOOM=1 opencode
 ```
 
-Accepted truthy values are `1`, `true`, `yes`, and `on`. If `divoom.enabled`
-is explicitly set in config, the config value wins over the environment variable.
+Accepted truthy values are `1`, `true`, `yes`, and `on`. The environment
+variable force-enables Divoom for that run, even if `divoom.enabled` is `false`
+in config.
 
 ## Tunable settings
 

+ 1 - 0
scripts/verify-release-artifact.ts

@@ -32,6 +32,7 @@ const packagedRequiredFiles = [
   'dist/divoom/designer.gif',
   'dist/divoom/explorer.gif',
   'dist/divoom/fixer.gif',
+  'dist/divoom/input.gif',
   'dist/divoom/intro.gif',
   'dist/divoom/librarian.gif',
   'dist/divoom/oracle.gif',

+ 42 - 42
src/agents/custom.test.ts

@@ -1,15 +1,15 @@
-import { describe, expect, spyOn, test } from "bun:test";
-import type { PluginConfig } from "../config";
-import { createAgents, getAgentConfigs } from "./index";
+import { describe, expect, spyOn, test } from 'bun:test';
+import type { PluginConfig } from '../config';
+import { createAgents, getAgentConfigs } from './index';
 
-describe("custom-agent creation", () => {
-  test("infers custom agents from unknown keys", () => {
+describe('custom-agent creation', () => {
+  test('infers custom agents from unknown keys', () => {
     const config: PluginConfig = {
       agents: {
-        explorer: { model: "openai/gpt-5.4-mini" },
+        explorer: { model: 'openai/gpt-5.4-mini' },
         reviewer: {
-          model: "openai/gpt-5.5",
-          prompt: "You are the custom reviewer agent.",
+          model: 'openai/gpt-5.5',
+          prompt: 'You are the custom reviewer agent.',
         },
       },
     };
@@ -17,91 +17,91 @@ describe("custom-agent creation", () => {
     const agents = createAgents(config);
     const names = agents.map((agent) => agent.name);
 
-    expect(names).toContain("reviewer");
+    expect(names).toContain('reviewer');
 
-    const customAgent = agents.find((agent) => agent.name === "reviewer");
+    const customAgent = agents.find((agent) => agent.name === 'reviewer');
     expect(customAgent).toBeDefined();
-    expect(customAgent?.config.model).toBe("openai/gpt-5.5");
+    expect(customAgent?.config.model).toBe('openai/gpt-5.5');
     expect(customAgent?.config.prompt).toBe(
-      "You are the custom reviewer agent."
+      'You are the custom reviewer agent.',
     );
   });
 
-  test("supports prompt and orchestratorPrompt for custom agents", () => {
+  test('supports prompt and orchestratorPrompt for custom agents', () => {
     const config: PluginConfig = {
       agents: {
-        "test-auditor": {
-          model: "openai/gpt-5.4-mini",
-          prompt: "You are a custom subagent for auditing.",
+        'test-auditor': {
+          model: 'openai/gpt-5.4-mini',
+          prompt: 'You are a custom subagent for auditing.',
           orchestratorPrompt:
-            "@test-auditor\n- Role: Compliance audit specialist",
+            '@test-auditor\n- Role: Compliance audit specialist',
         },
       },
     };
 
     const agents = createAgents(config);
-    const customAgent = agents.find((agent) => agent.name === "test-auditor");
+    const customAgent = agents.find((agent) => agent.name === 'test-auditor');
 
     expect(customAgent).toBeDefined();
     expect(customAgent?.config.prompt).toBe(
-      "You are a custom subagent for auditing."
+      'You are a custom subagent for auditing.',
     );
 
-    const orchestrator = agents.find((agent) => agent.name === "orchestrator");
+    const orchestrator = agents.find((agent) => agent.name === 'orchestrator');
     expect(orchestrator?.config.prompt).toContain(
-      "@test-auditor\n- Role: Compliance audit specialist"
+      '@test-auditor\n- Role: Compliance audit specialist',
     );
   });
 
-  test("skips custom agents without a model", () => {
-    const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
+  test('skips custom agents without a model', () => {
+    const warnSpy = spyOn(console, 'warn').mockImplementation(() => {});
 
     try {
       const config: PluginConfig = {
         agents: {
           janitor: {
-            prompt: "You are Janitor.",
-            orchestratorPrompt: "@janitor\n- Role: Cleanup specialist",
+            prompt: 'You are Janitor.',
+            orchestratorPrompt: '@janitor\n- Role: Cleanup specialist',
           },
         },
       };
 
       const agentDefs = createAgents(config);
       expect(
-        agentDefs.find((agent) => agent.name === "janitor")
+        agentDefs.find((agent) => agent.name === 'janitor'),
       ).toBeUndefined();
       expect(warnSpy).toHaveBeenCalledWith(
-        "[oh-my-opencode] Custom agent 'janitor' skipped: 'model' is required"
+        "[oh-my-opencode] Custom agent 'janitor' skipped: 'model' is required",
       );
     } finally {
       warnSpy.mockRestore();
     }
   });
 
-  test("does not create or inject disabled custom agents", () => {
+  test('does not create or inject disabled custom agents', () => {
     const config: PluginConfig = {
-      disabled_agents: ["test-auditor", "designer"],
+      disabled_agents: ['test-auditor', 'designer'],
       agents: {
-        "test-auditor": {
-          model: "openai/gpt-5.4-mini",
-          prompt: "You are a disabled custom agent.",
+        'test-auditor': {
+          model: 'openai/gpt-5.4-mini',
+          prompt: 'You are a disabled custom agent.',
         },
       },
     };
 
     const agentDefs = createAgents(config);
     const names = agentDefs.map((agent) => agent.name);
-    expect(names).not.toContain("test-auditor");
+    expect(names).not.toContain('test-auditor');
 
     const sdkConfigs = getAgentConfigs(config);
-    expect(sdkConfigs["test-auditor"]).toBeUndefined();
+    expect(sdkConfigs['test-auditor']).toBeUndefined();
   });
 
-  test("rejects unsafe custom agent names", () => {
+  test('rejects unsafe custom agent names', () => {
     const config: PluginConfig = {
       agents: {
-        "unsafe/name": {
-          model: "openai/gpt-5.4-mini",
+        'unsafe/name': {
+          model: 'openai/gpt-5.4-mini',
         },
       },
     };
@@ -109,20 +109,20 @@ describe("custom-agent creation", () => {
     expect(() => createAgents(config)).toThrow();
   });
 
-  test("accepts arbitrary orchestratorPrompt text for custom agents", () => {
+  test('accepts arbitrary orchestratorPrompt text for custom agents', () => {
     const config: PluginConfig = {
       agents: {
         janitor: {
-          model: "openai/gpt-5.4-mini",
-          orchestratorPrompt: "@cleanup\n- Role: Cleanup specialist",
+          model: 'openai/gpt-5.4-mini',
+          orchestratorPrompt: '@cleanup\n- Role: Cleanup specialist',
         },
       },
     };
 
     const agents = createAgents(config);
-    const orchestrator = agents.find((agent) => agent.name === "orchestrator");
+    const orchestrator = agents.find((agent) => agent.name === 'orchestrator');
     expect(orchestrator?.config.prompt).toContain(
-      "@cleanup\n- Role: Cleanup specialist"
+      '@cleanup\n- Role: Cleanup specialist',
     );
   });
 });

+ 301 - 301
src/agents/index.test.ts

@@ -1,5 +1,5 @@
-import { describe, expect, test } from "bun:test";
-import type { PluginConfig } from "../config";
+import { describe, expect, test } from 'bun:test';
+import type { PluginConfig } from '../config';
 import {
   AgentOverrideConfigSchema,
   CouncilConfigSchema,
@@ -7,390 +7,390 @@ import {
   DEFAULT_MODELS,
   PluginConfigSchema,
   SUBAGENT_NAMES,
-} from "../config";
+} from '../config';
 import {
   createAgents,
   getAgentConfigs,
   getDisabledAgents,
   getEnabledAgentNames,
   isSubagent,
-} from "./index";
+} from './index';
 
 function councilConfig() {
   const parsed = CouncilConfigSchema.parse({
-    presets: { default: { alpha: { model: "test/councillor" } } },
+    presets: { default: { alpha: { model: 'test/councillor' } } },
   });
   return parsed;
 }
 
-describe("agent alias backward compatibility", () => {
+describe('agent alias backward compatibility', () => {
   test("applies 'explore' config to 'explorer' agent", () => {
     const config: PluginConfig = {
       agents: {
-        explore: { model: "test/old-explore-model" },
+        explore: { model: 'test/old-explore-model' },
       },
     };
     const agents = createAgents(config);
-    const explorer = agents.find((a) => a.name === "explorer");
+    const explorer = agents.find((a) => a.name === 'explorer');
     expect(explorer).toBeDefined();
-    expect(explorer?.config.model).toBe("test/old-explore-model");
+    expect(explorer?.config.model).toBe('test/old-explore-model');
   });
 
   test("applies 'frontend-ui-ux-engineer' config to 'designer' agent", () => {
     const config: PluginConfig = {
       agents: {
-        "frontend-ui-ux-engineer": { model: "test/old-frontend-model" },
+        'frontend-ui-ux-engineer': { model: 'test/old-frontend-model' },
       },
     };
     const agents = createAgents(config);
-    const designer = agents.find((a) => a.name === "designer");
+    const designer = agents.find((a) => a.name === 'designer');
     expect(designer).toBeDefined();
-    expect(designer?.config.model).toBe("test/old-frontend-model");
+    expect(designer?.config.model).toBe('test/old-frontend-model');
   });
 
-  test("new name takes priority over old alias", () => {
+  test('new name takes priority over old alias', () => {
     const config: PluginConfig = {
       agents: {
-        explore: { model: "old-model" },
-        explorer: { model: "new-model" },
+        explore: { model: 'old-model' },
+        explorer: { model: 'new-model' },
       },
     };
     const agents = createAgents(config);
-    const explorer = agents.find((a) => a.name === "explorer");
-    expect(explorer?.config.model).toBe("new-model");
+    const explorer = agents.find((a) => a.name === 'explorer');
+    expect(explorer?.config.model).toBe('new-model');
   });
 
-  test("new agent names work directly", () => {
+  test('new agent names work directly', () => {
     const config: PluginConfig = {
       agents: {
-        explorer: { model: "direct-explorer" },
-        designer: { model: "direct-designer" },
+        explorer: { model: 'direct-explorer' },
+        designer: { model: 'direct-designer' },
       },
     };
     const agents = createAgents(config);
-    expect(agents.find((a) => a.name === "explorer")?.config.model).toBe(
-      "direct-explorer"
+    expect(agents.find((a) => a.name === 'explorer')?.config.model).toBe(
+      'direct-explorer',
     );
-    expect(agents.find((a) => a.name === "designer")?.config.model).toBe(
-      "direct-designer"
+    expect(agents.find((a) => a.name === 'designer')?.config.model).toBe(
+      'direct-designer',
     );
   });
 
-  test("temperature override via old alias", () => {
+  test('temperature override via old alias', () => {
     const config: PluginConfig = {
       agents: {
         explore: { temperature: 0.5 },
       },
     };
     const agents = createAgents(config);
-    const explorer = agents.find((a) => a.name === "explorer");
+    const explorer = agents.find((a) => a.name === 'explorer');
     expect(explorer?.config.temperature).toBe(0.5);
   });
 
-  test("variant override via old alias", () => {
+  test('variant override via old alias', () => {
     const config: PluginConfig = {
       agents: {
-        explore: { variant: "low" },
+        explore: { variant: 'low' },
       },
     };
     const agents = createAgents(config);
-    const explorer = agents.find((a) => a.name === "explorer");
-    expect(explorer?.config.variant).toBe("low");
+    const explorer = agents.find((a) => a.name === 'explorer');
+    expect(explorer?.config.variant).toBe('low');
   });
 });
 
-describe("fixer agent fallback", () => {
-  test("fixer inherits librarian model when no fixer config provided", () => {
+describe('fixer agent fallback', () => {
+  test('fixer inherits librarian model when no fixer config provided', () => {
     const config: PluginConfig = {
       agents: {
-        librarian: { model: "librarian-custom-model" },
+        librarian: { model: 'librarian-custom-model' },
       },
     };
     const agents = createAgents(config);
-    const fixer = agents.find((a) => a.name === "fixer");
-    const librarian = agents.find((a) => a.name === "librarian");
+    const fixer = agents.find((a) => a.name === 'fixer');
+    const librarian = agents.find((a) => a.name === 'librarian');
     expect(fixer?.config.model).toBe(librarian?.config.model);
   });
 
-  test("fixer uses its own model when explicitly configured", () => {
+  test('fixer uses its own model when explicitly configured', () => {
     const config: PluginConfig = {
       agents: {
-        librarian: { model: "librarian-model" },
-        fixer: { model: "fixer-specific-model" },
+        librarian: { model: 'librarian-model' },
+        fixer: { model: 'fixer-specific-model' },
       },
     };
     const agents = createAgents(config);
-    const fixer = agents.find((a) => a.name === "fixer");
-    expect(fixer?.config.model).toBe("fixer-specific-model");
+    const fixer = agents.find((a) => a.name === 'fixer');
+    expect(fixer?.config.model).toBe('fixer-specific-model');
   });
 });
 
-describe("orchestrator agent", () => {
-  test("orchestrator is first in agents array", () => {
+describe('orchestrator agent', () => {
+  test('orchestrator is first in agents array', () => {
     const agents = createAgents();
-    expect(agents[0].name).toBe("orchestrator");
+    expect(agents[0].name).toBe('orchestrator');
   });
 
-  test("orchestrator has question permission set to allow", () => {
+  test('orchestrator has question permission set to allow', () => {
     const agents = createAgents();
-    const orchestrator = agents.find((a) => a.name === "orchestrator");
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
     expect(orchestrator?.config.permission).toBeDefined();
-    expect((orchestrator?.config.permission as any).question).toBe("allow");
+    expect((orchestrator?.config.permission as any).question).toBe('allow');
   });
 
-  test("orchestrator is denied access to council_session", () => {
+  test('orchestrator is denied access to council_session', () => {
     const agents = createAgents();
-    const orchestrator = agents.find((a) => a.name === "orchestrator");
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
     expect((orchestrator?.config.permission as any).council_session).toBe(
-      "deny"
+      'deny',
     );
   });
 
-  test("orchestrator accepts overrides", () => {
+  test('orchestrator accepts overrides', () => {
     const config: PluginConfig = {
       agents: {
-        orchestrator: { model: "custom-orchestrator-model", temperature: 0.3 },
+        orchestrator: { model: 'custom-orchestrator-model', temperature: 0.3 },
       },
     };
     const agents = createAgents(config);
-    const orchestrator = agents.find((a) => a.name === "orchestrator");
-    expect(orchestrator?.config.model).toBe("custom-orchestrator-model");
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
+    expect(orchestrator?.config.model).toBe('custom-orchestrator-model');
     expect(orchestrator?.config.temperature).toBe(0.3);
   });
 
-  test("orchestrator accepts variant override", () => {
+  test('orchestrator accepts variant override', () => {
     const config: PluginConfig = {
       agents: {
-        orchestrator: { variant: "high" },
+        orchestrator: { variant: 'high' },
       },
     };
     const agents = createAgents(config);
-    const orchestrator = agents.find((a) => a.name === "orchestrator");
-    expect(orchestrator?.config.variant).toBe("high");
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
+    expect(orchestrator?.config.variant).toBe('high');
   });
 
-  test("orchestrator stores model array with per-model variants in _modelArray", () => {
+  test('orchestrator stores model array with per-model variants in _modelArray', () => {
     const config: PluginConfig = {
       agents: {
         orchestrator: {
           model: [
-            { id: "google/gemini-3-pro", variant: "high" },
-            { id: "github-copilot/claude-3.5-haiku" },
-            "openai/gpt-4",
+            { id: 'google/gemini-3-pro', variant: 'high' },
+            { id: 'github-copilot/claude-3.5-haiku' },
+            'openai/gpt-4',
           ],
         },
       },
     };
     const agents = createAgents(config);
-    const orchestrator = agents.find((a) => a.name === "orchestrator");
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
     expect(orchestrator?._modelArray).toEqual([
-      { id: "google/gemini-3-pro", variant: "high" },
-      { id: "github-copilot/claude-3.5-haiku" },
-      { id: "openai/gpt-4" },
+      { id: 'google/gemini-3-pro', variant: 'high' },
+      { id: 'github-copilot/claude-3.5-haiku' },
+      { id: 'openai/gpt-4' },
     ]);
     expect(orchestrator?.config.model).toBeUndefined();
   });
 });
 
-describe("per-model variant in array config", () => {
-  test("subagent stores model array with per-model variants", () => {
+describe('per-model variant in array config', () => {
+  test('subagent stores model array with per-model variants', () => {
     const config: PluginConfig = {
       agents: {
         explorer: {
           model: [
-            { id: "google/gemini-3-flash", variant: "low" },
-            "openai/gpt-4o-mini",
+            { id: 'google/gemini-3-flash', variant: 'low' },
+            'openai/gpt-4o-mini',
           ],
         },
       },
     };
     const agents = createAgents(config);
-    const explorer = agents.find((a) => a.name === "explorer");
+    const explorer = agents.find((a) => a.name === 'explorer');
     expect(explorer?._modelArray).toEqual([
-      { id: "google/gemini-3-flash", variant: "low" },
-      { id: "openai/gpt-4o-mini" },
+      { id: 'google/gemini-3-flash', variant: 'low' },
+      { id: 'openai/gpt-4o-mini' },
     ]);
     expect(explorer?.config.model).toBeUndefined();
   });
 
-  test("top-level variant preserved alongside per-model variants", () => {
+  test('top-level variant preserved alongside per-model variants', () => {
     const config: PluginConfig = {
       agents: {
         orchestrator: {
           model: [
-            { id: "google/gemini-3-pro", variant: "high" },
-            "openai/gpt-4",
+            { id: 'google/gemini-3-pro', variant: 'high' },
+            'openai/gpt-4',
           ],
-          variant: "low",
+          variant: 'low',
         },
       },
     };
     const agents = createAgents(config);
-    const orchestrator = agents.find((a) => a.name === "orchestrator");
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
     // top-level variant still set as default
-    expect(orchestrator?.config.variant).toBe("low");
+    expect(orchestrator?.config.variant).toBe('low');
     // per-model variants stored in _modelArray
-    expect(orchestrator?._modelArray?.[0]?.variant).toBe("high");
+    expect(orchestrator?._modelArray?.[0]?.variant).toBe('high');
     expect(orchestrator?._modelArray?.[1]?.variant).toBeUndefined();
   });
 });
 
-describe("skill permissions", () => {
-  test("orchestrator gets codemap skill allowed by default", () => {
+describe('skill permissions', () => {
+  test('orchestrator gets codemap skill allowed by default', () => {
     const agents = createAgents();
-    const orchestrator = agents.find((a) => a.name === "orchestrator");
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
     expect(orchestrator).toBeDefined();
     const skillPerm = (
       orchestrator?.config.permission as Record<string, unknown>
     )?.skill as Record<string, string>;
     // orchestrator gets wildcard allow (from RECOMMENDED_SKILLS wildcard entry)
-    expect(skillPerm?.["*"]).toBe("allow");
+    expect(skillPerm?.['*']).toBe('allow');
     // CUSTOM_SKILLS loop must also add a named codemap entry for orchestrator
-    expect(skillPerm?.codemap).toBe("allow");
+    expect(skillPerm?.codemap).toBe('allow');
   });
 
-  test("fixer does not get codemap skill allowed by default", () => {
+  test('fixer does not get codemap skill allowed by default', () => {
     const agents = createAgents();
-    const fixer = agents.find((a) => a.name === "fixer");
+    const fixer = agents.find((a) => a.name === 'fixer');
     expect(fixer).toBeDefined();
     const skillPerm = (fixer?.config.permission as Record<string, unknown>)
       ?.skill as Record<string, string>;
-    expect(skillPerm?.codemap).not.toBe("allow");
+    expect(skillPerm?.codemap).not.toBe('allow');
   });
 
-  test("oracle gets requesting-code-review skill allowed by default", () => {
+  test('oracle gets requesting-code-review skill allowed by default', () => {
     const agents = createAgents();
-    const oracle = agents.find((a) => a.name === "oracle");
+    const oracle = agents.find((a) => a.name === 'oracle');
     expect(oracle).toBeDefined();
     const skillPerm = (oracle?.config.permission as Record<string, unknown>)
       ?.skill as Record<string, string>;
-    expect(skillPerm?.["requesting-code-review"]).toBe("allow");
+    expect(skillPerm?.['requesting-code-review']).toBe('allow');
   });
 
-  test("oracle gets simplify skill allowed by default", () => {
+  test('oracle gets simplify skill allowed by default', () => {
     const agents = createAgents();
-    const oracle = agents.find((a) => a.name === "oracle");
+    const oracle = agents.find((a) => a.name === 'oracle');
     expect(oracle).toBeDefined();
     const skillPerm = (oracle?.config.permission as Record<string, unknown>)
       ?.skill as Record<string, string>;
-    expect(skillPerm?.simplify).toBe("allow");
+    expect(skillPerm?.simplify).toBe('allow');
   });
 });
 
-describe("tool permissions", () => {
-  test("council agent is allowed to invoke council_session", () => {
+describe('tool permissions', () => {
+  test('council agent is allowed to invoke council_session', () => {
     const agents = createAgents({
       council: councilConfig(),
     });
-    const council = agents.find((a) => a.name === "council");
-    expect((council?.config.permission as any).council_session).toBe("allow");
+    const council = agents.find((a) => a.name === 'council');
+    expect((council?.config.permission as any).council_session).toBe('allow');
   });
 
-  test("oracle is denied access to council_session", () => {
+  test('oracle is denied access to council_session', () => {
     const agents = createAgents();
-    const oracle = agents.find((a) => a.name === "oracle");
-    expect((oracle?.config.permission as any).council_session).toBe("deny");
+    const oracle = agents.find((a) => a.name === 'oracle');
+    expect((oracle?.config.permission as any).council_session).toBe('deny');
   });
 
-  test("explorer is denied access to council_session", () => {
+  test('explorer is denied access to council_session', () => {
     const agents = createAgents();
-    const explorer = agents.find((a) => a.name === "explorer");
-    expect((explorer?.config.permission as any).council_session).toBe("deny");
+    const explorer = agents.find((a) => a.name === 'explorer');
+    expect((explorer?.config.permission as any).council_session).toBe('deny');
   });
 
-  test("councillor is denied access to council_session", () => {
+  test('councillor is denied access to council_session', () => {
     const agents = createAgents();
-    const councillor = agents.find((a) => a.name === "councillor");
-    expect((councillor?.config.permission as any).council_session).toBe("deny");
+    const councillor = agents.find((a) => a.name === 'councillor');
+    expect((councillor?.config.permission as any).council_session).toBe('deny');
   });
 });
 
-describe("isSubagent type guard", () => {
-  test("returns true for valid subagent names", () => {
-    expect(isSubagent("explorer")).toBe(true);
-    expect(isSubagent("librarian")).toBe(true);
-    expect(isSubagent("oracle")).toBe(true);
-    expect(isSubagent("designer")).toBe(true);
-    expect(isSubagent("fixer")).toBe(true);
+describe('isSubagent type guard', () => {
+  test('returns true for valid subagent names', () => {
+    expect(isSubagent('explorer')).toBe(true);
+    expect(isSubagent('librarian')).toBe(true);
+    expect(isSubagent('oracle')).toBe(true);
+    expect(isSubagent('designer')).toBe(true);
+    expect(isSubagent('fixer')).toBe(true);
   });
 
-  test("returns false for orchestrator", () => {
-    expect(isSubagent("orchestrator")).toBe(false);
+  test('returns false for orchestrator', () => {
+    expect(isSubagent('orchestrator')).toBe(false);
   });
 
-  test("returns false for invalid agent names", () => {
-    expect(isSubagent("invalid-agent")).toBe(false);
-    expect(isSubagent("")).toBe(false);
-    expect(isSubagent("explore")).toBe(false); // old alias, not actual agent name
+  test('returns false for invalid agent names', () => {
+    expect(isSubagent('invalid-agent')).toBe(false);
+    expect(isSubagent('')).toBe(false);
+    expect(isSubagent('explore')).toBe(false); // old alias, not actual agent name
   });
 });
 
-describe("agent classification", () => {
-  test("SUBAGENT_NAMES excludes orchestrator", () => {
-    expect(SUBAGENT_NAMES).not.toContain("orchestrator");
-    expect(SUBAGENT_NAMES).toContain("explorer");
-    expect(SUBAGENT_NAMES).toContain("fixer");
+describe('agent classification', () => {
+  test('SUBAGENT_NAMES excludes orchestrator', () => {
+    expect(SUBAGENT_NAMES).not.toContain('orchestrator');
+    expect(SUBAGENT_NAMES).toContain('explorer');
+    expect(SUBAGENT_NAMES).toContain('fixer');
   });
 
-  test("getAgentConfigs applies correct classification visibility and mode", () => {
+  test('getAgentConfigs applies correct classification visibility and mode', () => {
     // Enable all agents (including observer) for classification testing
     const configs = getAgentConfigs({ disabled_agents: [] });
 
     // Primary agent
-    expect(configs.orchestrator.mode).toBe("primary");
+    expect(configs.orchestrator.mode).toBe('primary');
 
     // Subagents
     for (const name of SUBAGENT_NAMES) {
       // Council is a dual-mode agent ("all"), rest are subagents
-      if (name === "council") {
+      if (name === 'council') {
         expect(configs[name]).toBeUndefined();
       } else {
-        expect(configs[name].mode).toBe("subagent");
+        expect(configs[name].mode).toBe('subagent');
       }
     }
   });
 });
 
-describe("createAgents", () => {
-  test("creates all agents without config", () => {
+describe('createAgents', () => {
+  test('creates all agents without config', () => {
     const agents = createAgents();
     const names = agents.map((a) => a.name);
-    expect(names).toContain("orchestrator");
-    expect(names).toContain("explorer");
-    expect(names).toContain("designer");
-    expect(names).toContain("oracle");
-    expect(names).toContain("librarian");
-    expect(names).toContain("fixer");
+    expect(names).toContain('orchestrator');
+    expect(names).toContain('explorer');
+    expect(names).toContain('designer');
+    expect(names).toContain('oracle');
+    expect(names).toContain('librarian');
+    expect(names).toContain('fixer');
   });
 
-  test("creates exactly 7 agents by default (observer disabled, council unconfigured)", () => {
+  test('creates exactly 7 agents by default (observer disabled, council unconfigured)', () => {
     const agents = createAgents();
     expect(agents.length).toBe(7);
   });
 
-  test("does not create council when council is not configured", () => {
+  test('does not create council when council is not configured', () => {
     const agents = createAgents();
     const names = agents.map((a) => a.name);
-    const orchestrator = agents.find((a) => a.name === "orchestrator");
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
 
-    expect(names).not.toContain("council");
-    expect(orchestrator?.config.prompt).not.toContain("@council");
+    expect(names).not.toContain('council');
+    expect(orchestrator?.config.prompt).not.toContain('@council');
   });
 
-  test("creates council when council is configured", () => {
+  test('creates council when council is configured', () => {
     const agents = createAgents({
       council: councilConfig(),
     });
     const names = agents.map((a) => a.name);
-    const orchestrator = agents.find((a) => a.name === "orchestrator");
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
 
-    expect(names).toContain("council");
-    expect(orchestrator?.config.prompt).toContain("@council");
+    expect(names).toContain('council');
+    expect(orchestrator?.config.prompt).toContain('@council');
   });
 });
 
-describe("getAgentConfigs", () => {
-  test("returns config record keyed by agent name", () => {
+describe('getAgentConfigs', () => {
+  test('returns config record keyed by agent name', () => {
     const configs = getAgentConfigs();
     expect(configs.orchestrator).toBeDefined();
     expect(configs.explorer).toBeDefined();
@@ -399,80 +399,80 @@ describe("getAgentConfigs", () => {
     expect(configs.explorer.model).toBeDefined();
   });
 
-  test("includes description in SDK config", () => {
+  test('includes description in SDK config', () => {
     const configs = getAgentConfigs();
     expect(configs.orchestrator.description).toBeDefined();
     expect(configs.explorer.description).toBeDefined();
   });
 });
 
-describe("council agent model resolution", () => {
-  test("council agent uses default model", () => {
+describe('council agent model resolution', () => {
+  test('council agent uses default model', () => {
     const agents = createAgents({
       council: councilConfig(),
     });
-    const council = agents.find((a) => a.name === "council");
+    const council = agents.find((a) => a.name === 'council');
     expect(council?.config.model).toBe(DEFAULT_MODELS.council);
   });
 
-  test("councillor agent uses default model", () => {
+  test('councillor agent uses default model', () => {
     const agents = createAgents();
-    const councillor = agents.find((a) => a.name === "councillor");
+    const councillor = agents.find((a) => a.name === 'councillor');
     expect(councillor?.config.model).toBe(DEFAULT_MODELS.councillor);
   });
 
-  test("council falls back to legacy master.model when no preset override", () => {
+  test('council falls back to legacy master.model when no preset override', () => {
     // Simulates a pre-1.0.0 config with council.master.model but no council
     // entry in the agent preset — the exact scenario from issue #369.
     const config: PluginConfig = {
       agents: {
-        oracle: { model: "openai/gpt-5.5" },
+        oracle: { model: 'openai/gpt-5.5' },
       },
       council: {
         ...councilConfig(),
-        _legacyMasterModel: "anthropic/claude-opus-4-6",
+        _legacyMasterModel: 'anthropic/claude-opus-4-6',
       },
     };
     const agents = createAgents(config);
-    const council = agents.find((a) => a.name === "council");
-    expect(council?.config.model).toBe("anthropic/claude-opus-4-6");
+    const council = agents.find((a) => a.name === 'council');
+    expect(council?.config.model).toBe('anthropic/claude-opus-4-6');
   });
 
-  test("council preset override takes precedence over legacy master.model", () => {
+  test('council preset override takes precedence over legacy master.model', () => {
     // If user has explicit council in preset, that wins — legacy is ignored.
     const config: PluginConfig = {
       agents: {
-        council: { model: "google/gemini-3-pro" },
+        council: { model: 'google/gemini-3-pro' },
       },
       council: {
         ...councilConfig(),
-        _legacyMasterModel: "anthropic/claude-opus-4-6",
+        _legacyMasterModel: 'anthropic/claude-opus-4-6',
       },
     };
     const agents = createAgents(config);
-    const council = agents.find((a) => a.name === "council");
-    expect(council?.config.model).toBe("google/gemini-3-pro");
+    const council = agents.find((a) => a.name === 'council');
+    expect(council?.config.model).toBe('google/gemini-3-pro');
   });
 
-  test("council uses default when no legacy master and no preset override", () => {
+  test('council uses default when no legacy master and no preset override', () => {
     // No legacy master, no preset override → standard default
     const config: PluginConfig = {
       council: councilConfig(),
     };
     const agents = createAgents(config);
-    const council = agents.find((a) => a.name === "council");
+    const council = agents.find((a) => a.name === 'council');
     expect(council?.config.model).toBe(DEFAULT_MODELS.council);
   });
 
-  test("end-to-end: raw master.model config flows through schema to council agent", () => {
+  test('end-to-end: raw master.model config flows through schema to council agent', () => {
     // Integration test: start from raw user config with deprecated master.model,
     // parse through CouncilConfigSchema, then pass to createAgents.
     // This validates the full seam between schema transform and agent resolution.
     const rawCouncilConfig = {
-      master: { model: "anthropic/claude-opus-4-6" },
+      master: { model: 'anthropic/claude-opus-4-6' },
       presets: {
         default: {
-          alpha: { model: "openai/gpt-5.4-mini" },
+          alpha: { model: 'openai/gpt-5.4-mini' },
         },
       },
     };
@@ -485,147 +485,147 @@ describe("council agent model resolution", () => {
         council: parsed.data,
       };
       const agents = createAgents(config);
-      const council = agents.find((a) => a.name === "council");
+      const council = agents.find((a) => a.name === 'council');
       // Legacy master.model should flow through schema → agent
-      expect(council?.config.model).toBe("anthropic/claude-opus-4-6");
+      expect(council?.config.model).toBe('anthropic/claude-opus-4-6');
     }
   });
 });
 
-describe("options passthrough", () => {
-  test("options are applied to agent config via overrides", () => {
+describe('options passthrough', () => {
+  test('options are applied to agent config via overrides', () => {
     const config: PluginConfig = {
       agents: {
         oracle: {
-          model: "openai/gpt-5.5",
-          options: { textVerbosity: "low" },
+          model: 'openai/gpt-5.5',
+          options: { textVerbosity: 'low' },
         },
       },
     };
     const agents = createAgents(config);
-    const oracle = agents.find((a) => a.name === "oracle");
-    expect(oracle?.config.options).toEqual({ textVerbosity: "low" });
+    const oracle = agents.find((a) => a.name === 'oracle');
+    expect(oracle?.config.options).toEqual({ textVerbosity: 'low' });
   });
 
-  test("options with nested objects are passed through", () => {
+  test('options with nested objects are passed through', () => {
     const config: PluginConfig = {
       agents: {
         oracle: {
-          model: "anthropic/claude-sonnet-4-6",
+          model: 'anthropic/claude-sonnet-4-6',
           options: {
-            thinking: { type: "enabled", budgetTokens: 16000 },
+            thinking: { type: 'enabled', budgetTokens: 16000 },
           },
         },
       },
     };
     const agents = createAgents(config);
-    const oracle = agents.find((a) => a.name === "oracle");
+    const oracle = agents.find((a) => a.name === 'oracle');
     expect(oracle?.config.options).toEqual({
-      thinking: { type: "enabled", budgetTokens: 16000 },
+      thinking: { type: 'enabled', budgetTokens: 16000 },
     });
   });
 
-  test("options work with other overrides", () => {
+  test('options work with other overrides', () => {
     const config: PluginConfig = {
       agents: {
         oracle: {
-          model: "openai/gpt-5.5",
-          variant: "high",
+          model: 'openai/gpt-5.5',
+          variant: 'high',
           temperature: 0.7,
-          options: { textVerbosity: "low", reasoningEffort: "medium" },
+          options: { textVerbosity: 'low', reasoningEffort: 'medium' },
         },
       },
     };
     const agents = createAgents(config);
-    const oracle = agents.find((a) => a.name === "oracle");
-    expect(oracle?.config.model).toBe("openai/gpt-5.5");
-    expect(oracle?.config.variant).toBe("high");
+    const oracle = agents.find((a) => a.name === 'oracle');
+    expect(oracle?.config.model).toBe('openai/gpt-5.5');
+    expect(oracle?.config.variant).toBe('high');
     expect(oracle?.config.temperature).toBe(0.7);
     expect(oracle?.config.options).toEqual({
-      textVerbosity: "low",
-      reasoningEffort: "medium",
+      textVerbosity: 'low',
+      reasoningEffort: 'medium',
     });
   });
 
-  test("options are absent when not configured", () => {
+  test('options are absent when not configured', () => {
     const config: PluginConfig = {
       agents: {
-        oracle: { model: "openai/gpt-5.5" },
+        oracle: { model: 'openai/gpt-5.5' },
       },
     };
     const agents = createAgents(config);
-    const oracle = agents.find((a) => a.name === "oracle");
+    const oracle = agents.find((a) => a.name === 'oracle');
     expect(oracle?.config.options).toBeUndefined();
   });
 
-  test("options flow through getAgentConfigs to SDK output", () => {
+  test('options flow through getAgentConfigs to SDK output', () => {
     const config: PluginConfig = {
       agents: {
         oracle: {
-          model: "openai/gpt-5.5",
-          options: { textVerbosity: "low" },
+          model: 'openai/gpt-5.5',
+          options: { textVerbosity: 'low' },
         },
       },
     };
     const configs = getAgentConfigs(config);
-    expect(configs.oracle.options).toEqual({ textVerbosity: "low" });
+    expect(configs.oracle.options).toEqual({ textVerbosity: 'low' });
   });
 
-  test("options are shallow-merged with existing agent config options", () => {
+  test('options are shallow-merged with existing agent config options', () => {
     // Simulate an agent factory setting default options
     const config: PluginConfig = {
       agents: {
         oracle: {
-          model: "openai/gpt-5.5",
-          options: { reasoningEffort: "medium" },
+          model: 'openai/gpt-5.5',
+          options: { reasoningEffort: 'medium' },
         },
       },
     };
     const agents = createAgents(config);
-    const oracle = agents.find((a) => a.name === "oracle");
+    const oracle = agents.find((a) => a.name === 'oracle');
     // Override options should merge with (not replace) any factory defaults
-    expect(oracle?.config.options).toEqual({ reasoningEffort: "medium" });
+    expect(oracle?.config.options).toEqual({ reasoningEffort: 'medium' });
   });
 });
 
-describe("AgentOverrideConfigSchema options validation", () => {
-  test("accepts valid options object", () => {
+describe('AgentOverrideConfigSchema options validation', () => {
+  test('accepts valid options object', () => {
     const result = AgentOverrideConfigSchema.safeParse({
-      options: { textVerbosity: "low" },
+      options: { textVerbosity: 'low' },
     });
     expect(result.success).toBe(true);
   });
 
-  test("accepts empty options object", () => {
+  test('accepts empty options object', () => {
     const result = AgentOverrideConfigSchema.safeParse({ options: {} });
     expect(result.success).toBe(true);
   });
 
-  test("accepts nested values in options", () => {
+  test('accepts nested values in options', () => {
     const result = AgentOverrideConfigSchema.safeParse({
       options: {
-        thinking: { type: "enabled", budgetTokens: 16000 },
+        thinking: { type: 'enabled', budgetTokens: 16000 },
       },
     });
     expect(result.success).toBe(true);
   });
 
-  test("accepts options alongside other fields", () => {
+  test('accepts options alongside other fields', () => {
     const result = AgentOverrideConfigSchema.safeParse({
-      model: "openai/gpt-5.5",
-      variant: "high",
+      model: 'openai/gpt-5.5',
+      variant: 'high',
       temperature: 0.7,
-      options: { textVerbosity: "low" },
+      options: { textVerbosity: 'low' },
     });
     expect(result.success).toBe(true);
     if (result.success) {
-      expect(result.data.options).toEqual({ textVerbosity: "low" });
+      expect(result.data.options).toEqual({ textVerbosity: 'low' });
     }
   });
 
-  test("config without options is valid", () => {
+  test('config without options is valid', () => {
     const result = AgentOverrideConfigSchema.safeParse({
-      model: "openai/gpt-5.5",
+      model: 'openai/gpt-5.5',
     });
     expect(result.success).toBe(true);
     if (result.success) {
@@ -633,67 +633,67 @@ describe("AgentOverrideConfigSchema options validation", () => {
     }
   });
 
-  test("rejects non-object options", () => {
+  test('rejects non-object options', () => {
     const result = AgentOverrideConfigSchema.safeParse({
-      options: "not-an-object",
+      options: 'not-an-object',
     });
     expect(result.success).toBe(false);
   });
 
-  test("rejects empty model arrays", () => {
+  test('rejects empty model arrays', () => {
     const result = AgentOverrideConfigSchema.safeParse({
       model: [],
     });
     expect(result.success).toBe(false);
   });
 
-  test("accepts prompt and orchestratorPrompt override fields", () => {
+  test('accepts prompt and orchestratorPrompt override fields', () => {
     const result = AgentOverrideConfigSchema.safeParse({
-      model: "openai/gpt-5.5",
-      prompt: "You are a specialized reviewer.",
-      orchestratorPrompt: "@reviewer\n- Role: Specialized reviewer",
+      model: 'openai/gpt-5.5',
+      prompt: 'You are a specialized reviewer.',
+      orchestratorPrompt: '@reviewer\n- Role: Specialized reviewer',
     });
     expect(result.success).toBe(true);
     if (result.success) {
-      expect(result.data.prompt).toBe("You are a specialized reviewer.");
+      expect(result.data.prompt).toBe('You are a specialized reviewer.');
       expect(result.data.orchestratorPrompt).toBe(
-        "@reviewer\n- Role: Specialized reviewer"
+        '@reviewer\n- Role: Specialized reviewer',
       );
     }
   });
 
-  test("rejects empty prompt fields", () => {
+  test('rejects empty prompt fields', () => {
     const result = AgentOverrideConfigSchema.safeParse({
-      model: "openai/gpt-5.5",
-      prompt: "",
+      model: 'openai/gpt-5.5',
+      prompt: '',
     });
     expect(result.success).toBe(false);
   });
 
-  test("rejects empty orchestratorPrompt fields", () => {
+  test('rejects empty orchestratorPrompt fields', () => {
     const result = AgentOverrideConfigSchema.safeParse({
-      model: "openai/gpt-5.5",
-      orchestratorPrompt: "",
+      model: 'openai/gpt-5.5',
+      orchestratorPrompt: '',
     });
     expect(result.success).toBe(false);
   });
 
-  test("rejects description field on overrides", () => {
+  test('rejects description field on overrides', () => {
     const result = AgentOverrideConfigSchema.safeParse({
-      model: "openai/gpt-5.5",
-      description: "not supported for custom agents",
+      model: 'openai/gpt-5.5',
+      description: 'not supported for custom agents',
     } as Record<string, unknown>);
     expect(result.success).toBe(false);
   });
 });
 
-describe("PluginConfigSchema custom-agent-only prompt fields", () => {
-  test("rejects prompt on built-in top-level agent overrides", () => {
+describe('PluginConfigSchema custom-agent-only prompt fields', () => {
+  test('rejects prompt on built-in top-level agent overrides', () => {
     const result = PluginConfigSchema.safeParse({
       agents: {
         oracle: {
-          model: "openai/gpt-5.5",
-          prompt: "ignored built-in prompt override",
+          model: 'openai/gpt-5.5',
+          prompt: 'ignored built-in prompt override',
         },
       },
     });
@@ -701,12 +701,12 @@ describe("PluginConfigSchema custom-agent-only prompt fields", () => {
     expect(result.success).toBe(false);
   });
 
-  test("rejects orchestratorPrompt on built-in top-level agent overrides", () => {
+  test('rejects orchestratorPrompt on built-in top-level agent overrides', () => {
     const result = PluginConfigSchema.safeParse({
       agents: {
         explorer: {
-          model: "openai/gpt-5.4-mini",
-          orchestratorPrompt: "@explorer\n- Role: should be invalid here",
+          model: 'openai/gpt-5.4-mini',
+          orchestratorPrompt: '@explorer\n- Role: should be invalid here',
         },
       },
     });
@@ -714,13 +714,13 @@ describe("PluginConfigSchema custom-agent-only prompt fields", () => {
     expect(result.success).toBe(false);
   });
 
-  test("rejects custom-only prompt fields on built-in preset agents", () => {
+  test('rejects custom-only prompt fields on built-in preset agents', () => {
     const result = PluginConfigSchema.safeParse({
       presets: {
         openai: {
           oracle: {
-            model: "openai/gpt-5.5",
-            prompt: "ignored preset built-in prompt override",
+            model: 'openai/gpt-5.5',
+            prompt: 'ignored preset built-in prompt override',
           },
         },
       },
@@ -729,13 +729,13 @@ describe("PluginConfigSchema custom-agent-only prompt fields", () => {
     expect(result.success).toBe(false);
   });
 
-  test("allows prompt fields on custom agents", () => {
+  test('allows prompt fields on custom agents', () => {
     const result = PluginConfigSchema.safeParse({
       agents: {
         janitor: {
-          model: "openai/gpt-5.4-mini",
-          prompt: "You are Janitor.",
-          orchestratorPrompt: "@janitor\n- Role: Cleanup specialist",
+          model: 'openai/gpt-5.4-mini',
+          prompt: 'You are Janitor.',
+          orchestratorPrompt: '@janitor\n- Role: Cleanup specialist',
         },
       },
     });
@@ -743,7 +743,7 @@ describe("PluginConfigSchema custom-agent-only prompt fields", () => {
     expect(result.success).toBe(true);
   });
 
-  test("accepts sessionManager config", () => {
+  test('accepts sessionManager config', () => {
     const result = PluginConfigSchema.safeParse({
       sessionManager: {
         maxSessionsPerAgent: 2,
@@ -756,136 +756,136 @@ describe("PluginConfigSchema custom-agent-only prompt fields", () => {
   });
 });
 
-describe("disabled_agents", () => {
-  test("disabled agents are not created", () => {
+describe('disabled_agents', () => {
+  test('disabled agents are not created', () => {
     const config: PluginConfig = {
-      disabled_agents: ["designer", "fixer"],
+      disabled_agents: ['designer', 'fixer'],
     };
     const agents = createAgents(config);
     const names = agents.map((a) => a.name);
-    expect(names).not.toContain("designer");
-    expect(names).not.toContain("fixer");
-    expect(names).toContain("orchestrator");
-    expect(names).toContain("explorer");
-    expect(names).toContain("oracle");
-    expect(names).toContain("librarian");
+    expect(names).not.toContain('designer');
+    expect(names).not.toContain('fixer');
+    expect(names).toContain('orchestrator');
+    expect(names).toContain('explorer');
+    expect(names).toContain('oracle');
+    expect(names).toContain('librarian');
   });
 
-  test("protected agents cannot be disabled", () => {
+  test('protected agents cannot be disabled', () => {
     const config: PluginConfig = {
-      disabled_agents: ["orchestrator", "councillor"],
+      disabled_agents: ['orchestrator', 'councillor'],
     };
     const agents = createAgents(config);
     const names = agents.map((a) => a.name);
-    expect(names).toContain("orchestrator");
-    expect(names).toContain("councillor");
+    expect(names).toContain('orchestrator');
+    expect(names).toContain('councillor');
   });
 
-  test("disabling council disables council agent", () => {
+  test('disabling council disables council agent', () => {
     const config: PluginConfig = {
-      disabled_agents: ["council"],
+      disabled_agents: ['council'],
     };
     const agents = createAgents(config);
     const names = agents.map((a) => a.name);
-    expect(names).not.toContain("council");
+    expect(names).not.toContain('council');
     // councillor is protected, it stays
-    expect(names).toContain("councillor");
+    expect(names).toContain('councillor');
   });
 
-  test("agent count decreases when agents are disabled", () => {
+  test('agent count decreases when agents are disabled', () => {
     const agents = createAgents();
     expect(agents.length).toBe(7); // observer disabled, council unconfigured
 
     const disabledConfig: PluginConfig = {
-      disabled_agents: ["observer", "designer"],
+      disabled_agents: ['observer', 'designer'],
     };
     const disabledAgents = createAgents(disabledConfig);
     expect(disabledAgents.length).toBe(6);
   });
 
-  test("getDisabledAgents respects protection rules", () => {
+  test('getDisabledAgents respects protection rules', () => {
     const config: PluginConfig = {
-      disabled_agents: ["orchestrator", "designer", "councillor"],
+      disabled_agents: ['orchestrator', 'designer', 'councillor'],
     };
     const disabled = getDisabledAgents(config);
-    expect(disabled.has("designer")).toBe(true);
-    expect(disabled.has("orchestrator")).toBe(false);
-    expect(disabled.has("councillor")).toBe(false);
+    expect(disabled.has('designer')).toBe(true);
+    expect(disabled.has('orchestrator')).toBe(false);
+    expect(disabled.has('councillor')).toBe(false);
   });
 
-  test("getEnabledAgentNames filters correctly", () => {
+  test('getEnabledAgentNames filters correctly', () => {
     const config: PluginConfig = {
-      disabled_agents: ["designer", "fixer"],
+      disabled_agents: ['designer', 'fixer'],
     };
     const enabled = getEnabledAgentNames(config);
-    expect(enabled).not.toContain("designer");
-    expect(enabled).not.toContain("fixer");
-    expect(enabled).toContain("orchestrator");
-    expect(enabled).toContain("explorer");
+    expect(enabled).not.toContain('designer');
+    expect(enabled).not.toContain('fixer');
+    expect(enabled).toContain('orchestrator');
+    expect(enabled).toContain('explorer');
   });
 
-  test("getEnabledAgentNames includes enabled custom agents", () => {
+  test('getEnabledAgentNames includes enabled custom agents', () => {
     const config: PluginConfig = {
-      disabled_agents: ["janitor"],
+      disabled_agents: ['janitor'],
       agents: {
-        janitor: { model: "openai/gpt-5.4-mini" },
-        reviewer: { model: "openai/gpt-5.4-mini" },
+        janitor: { model: 'openai/gpt-5.4-mini' },
+        reviewer: { model: 'openai/gpt-5.4-mini' },
       },
     };
 
     const enabled = getEnabledAgentNames(config);
-    expect(enabled).toContain("reviewer");
-    expect(enabled).not.toContain("janitor");
+    expect(enabled).toContain('reviewer');
+    expect(enabled).not.toContain('janitor');
   });
 
-  test("empty disabled_agents creates observer but not unconfigured council", () => {
+  test('empty disabled_agents creates observer but not unconfigured council', () => {
     const config: PluginConfig = {
       disabled_agents: [],
     };
     const agents = createAgents(config);
     const names = agents.map((a) => a.name);
     expect(agents.length).toBe(8);
-    expect(names).toContain("observer");
-    expect(names).not.toContain("council");
+    expect(names).toContain('observer');
+    expect(names).not.toContain('council');
   });
 });
 
-describe("observer agent", () => {
-  test("observer is disabled by default", () => {
+describe('observer agent', () => {
+  test('observer is disabled by default', () => {
     const agents = createAgents();
     const names = agents.map((a) => a.name);
-    expect(names).not.toContain("observer");
+    expect(names).not.toContain('observer');
   });
 
-  test("observer is enabled when removed from disabled_agents", () => {
+  test('observer is enabled when removed from disabled_agents', () => {
     const config: PluginConfig = {
       disabled_agents: [],
     };
     const agents = createAgents(config);
     const names = agents.map((a) => a.name);
-    expect(names).toContain("observer");
+    expect(names).toContain('observer');
   });
 
-  test("observer is disabled when explicitly listed", () => {
+  test('observer is disabled when explicitly listed', () => {
     const config: PluginConfig = {
-      disabled_agents: ["observer"],
+      disabled_agents: ['observer'],
     };
     const agents = createAgents(config);
     const names = agents.map((a) => a.name);
-    expect(names).not.toContain("observer");
+    expect(names).not.toContain('observer');
   });
 
-  test("observer can be enabled alongside other disabled agents", () => {
+  test('observer can be enabled alongside other disabled agents', () => {
     const config: PluginConfig = {
-      disabled_agents: ["designer"],
+      disabled_agents: ['designer'],
     };
     const agents = createAgents(config);
     const names = agents.map((a) => a.name);
-    expect(names).toContain("observer");
-    expect(names).not.toContain("designer");
+    expect(names).toContain('observer');
+    expect(names).not.toContain('designer');
   });
 
-  test("DEFAULT_DISABLED_AGENTS contains observer", () => {
-    expect(DEFAULT_DISABLED_AGENTS).toContain("observer");
+  test('DEFAULT_DISABLED_AGENTS contains observer', () => {
+    expect(DEFAULT_DISABLED_AGENTS).toContain('observer');
   });
 });

+ 29 - 29
src/config/constants.ts

@@ -1,21 +1,21 @@
 // Agent names
 export const AGENT_ALIASES: Record<string, string> = {
-  explore: "explorer",
-  "frontend-ui-ux-engineer": "designer",
+  explore: 'explorer',
+  'frontend-ui-ux-engineer': 'designer',
 };
 
 export const SUBAGENT_NAMES = [
-  "explorer",
-  "librarian",
-  "oracle",
-  "designer",
-  "fixer",
-  "observer",
-  "council",
-  "councillor",
+  'explorer',
+  'librarian',
+  'oracle',
+  'designer',
+  'fixer',
+  'observer',
+  'council',
+  'councillor',
 ] as const;
 
-export const ORCHESTRATOR_NAME = "orchestrator" as const;
+export const ORCHESTRATOR_NAME = 'orchestrator' as const;
 
 export const ALL_AGENT_NAMES = [ORCHESTRATOR_NAME, ...SUBAGENT_NAMES] as const;
 
@@ -31,24 +31,24 @@ export type AgentName = (typeof ALL_AGENT_NAMES)[number];
 // Which agents each agent type can spawn via delegation.
 // councillor is internal — only CouncilManager spawns it.
 export const ORCHESTRATABLE_AGENTS = [
-  "explorer",
-  "librarian",
-  "oracle",
-  "designer",
-  "fixer",
-  "observer",
-  "council",
+  'explorer',
+  'librarian',
+  'oracle',
+  'designer',
+  'fixer',
+  'observer',
+  'council',
 ] as const;
 
 /** Agents that cannot be disabled even if listed in disabled_agents config. */
-export const PROTECTED_AGENTS = new Set(["orchestrator", "councillor"]);
+export const PROTECTED_AGENTS = new Set(['orchestrator', 'councillor']);
 
 /**
  * Get the list of orchestratable agents, excluding any disabled agents.
  * This is used for delegation validation at runtime.
  */
 export function getOrchestratableAgents(
-  disabledAgents?: Set<string>
+  disabledAgents?: Set<string>,
 ): string[] {
   return ORCHESTRATABLE_AGENTS.filter((name) => !disabledAgents?.has(name));
 }
@@ -69,14 +69,14 @@ export const SUBAGENT_DELEGATION_RULES: Record<AgentName, readonly string[]> = {
 // orchestrator is undefined so its model is fully resolved at runtime via priority fallback
 export const DEFAULT_MODELS: Record<AgentName, string | undefined> = {
   orchestrator: undefined,
-  oracle: "openai/gpt-5.5",
-  librarian: "openai/gpt-5.4-mini",
-  explorer: "openai/gpt-5.4-mini",
-  designer: "openai/gpt-5.4-mini",
-  fixer: "openai/gpt-5.4-mini",
-  observer: "openai/gpt-5.4-mini",
-  council: "openai/gpt-5.4-mini",
-  councillor: "openai/gpt-5.4-mini",
+  oracle: 'openai/gpt-5.5',
+  librarian: 'openai/gpt-5.4-mini',
+  explorer: 'openai/gpt-5.4-mini',
+  designer: 'openai/gpt-5.4-mini',
+  fixer: 'openai/gpt-5.4-mini',
+  observer: 'openai/gpt-5.4-mini',
+  council: 'openai/gpt-5.4-mini',
+  councillor: 'openai/gpt-5.4-mini',
 };
 
 // Polling configuration
@@ -108,4 +108,4 @@ export const STABLE_POLLS_THRESHOLD = 3;
 
 /** Agents that are disabled by default. Users must explicitly enable them
  *  by removing from disabled_agents and configuring an appropriate model. */
-export const DEFAULT_DISABLED_AGENTS: string[] = ["observer"];
+export const DEFAULT_DISABLED_AGENTS: string[] = ['observer'];

+ 86 - 86
src/config/council-schema.test.ts

@@ -1,16 +1,16 @@
-import { describe, expect, test } from "bun:test";
+import { describe, expect, test } from 'bun:test';
 import {
   CouncilConfigSchema,
   type CouncillorConfig,
   CouncillorConfigSchema,
   CouncilPresetSchema,
-} from "./council-schema";
+} from './council-schema';
 
-describe("CouncillorConfigSchema", () => {
-  test("validates config with model and optional variant", () => {
+describe('CouncillorConfigSchema', () => {
+  test('validates config with model and optional variant', () => {
     const goodConfig: CouncillorConfig = {
-      model: "openai/gpt-5.4-mini",
-      variant: "low",
+      model: 'openai/gpt-5.4-mini',
+      variant: 'low',
     };
 
     const result = CouncillorConfigSchema.safeParse(goodConfig);
@@ -20,14 +20,14 @@ describe("CouncillorConfigSchema", () => {
     }
   });
 
-  test("accepts deprecated master fields and reports them", () => {
+  test('accepts deprecated master fields and reports them', () => {
     const config = {
-      master: { model: "anthropic/claude-opus-4-6" },
+      master: { model: 'anthropic/claude-opus-4-6' },
       master_timeout: 300000,
-      master_fallback: ["openai/gpt-5.5"],
+      master_fallback: ['openai/gpt-5.5'],
       presets: {
         default: {
-          alpha: { model: "openai/gpt-5.4-mini" },
+          alpha: { model: 'openai/gpt-5.4-mini' },
         },
       },
     };
@@ -38,23 +38,23 @@ describe("CouncillorConfigSchema", () => {
     if (result.success) {
       // Deprecated fields are stripped but reported via _deprecated
       expect(result.data._deprecated).toEqual([
-        "master",
-        "master_timeout",
-        "master_fallback",
+        'master',
+        'master_timeout',
+        'master_fallback',
       ]);
       // Core fields still work normally
       expect(result.data.timeout).toBe(180000);
-      expect(Object.keys(result.data.presets.default)).toEqual(["alpha"]);
+      expect(Object.keys(result.data.presets.default)).toEqual(['alpha']);
       // Legacy master.model is extracted for backward-compat fallback
-      expect(result.data._legacyMasterModel).toBe("anthropic/claude-opus-4-6");
+      expect(result.data._legacyMasterModel).toBe('anthropic/claude-opus-4-6');
     }
   });
 
-  test("no _deprecated when config has no deprecated fields", () => {
+  test('no _deprecated when config has no deprecated fields', () => {
     const config = {
       presets: {
         default: {
-          alpha: { model: "openai/gpt-5.4-mini" },
+          alpha: { model: 'openai/gpt-5.4-mini' },
         },
       },
     };
@@ -72,8 +72,8 @@ describe("CouncillorConfigSchema", () => {
 test('preset with only legacy "master" key results in empty councillors', () => {
   const config = {
     presets: {
-      "master-only": {
-        master: { model: "anthropic/claude-opus-4-6" },
+      'master-only': {
+        master: { model: 'anthropic/claude-opus-4-6' },
       },
     },
   };
@@ -82,7 +82,7 @@ test('preset with only legacy "master" key results in empty councillors', () =>
   expect(result.success).toBe(true);
 
   if (result.success) {
-    const preset = result.data.presets["master-only"];
+    const preset = result.data.presets['master-only'];
     expect(Object.keys(preset)).toEqual([]);
   }
 });
@@ -92,8 +92,8 @@ test('unwraps legacy nested "councillors" key in preset', () => {
     presets: {
       default: {
         councillors: {
-          alpha: { model: "openai/gpt-5.4-mini" },
-          beta: { model: "openai/gpt-5.3-codex" },
+          alpha: { model: 'openai/gpt-5.4-mini' },
+          beta: { model: 'openai/gpt-5.3-codex' },
         },
       },
     },
@@ -104,9 +104,9 @@ test('unwraps legacy nested "councillors" key in preset', () => {
 
   if (result.success) {
     const preset = result.data.presets.default;
-    expect(Object.keys(preset)).toEqual(["alpha", "beta"]);
-    expect(preset.alpha.model).toBe("openai/gpt-5.4-mini");
-    expect(preset.beta.model).toBe("openai/gpt-5.3-codex");
+    expect(Object.keys(preset)).toEqual(['alpha', 'beta']);
+    expect(preset.alpha.model).toBe('openai/gpt-5.4-mini');
+    expect(preset.beta.model).toBe('openai/gpt-5.3-codex');
   }
 });
 
@@ -115,9 +115,9 @@ test('mixed legacy "councillors" and flat keys in same preset', () => {
     presets: {
       mixed: {
         councillors: {
-          alpha: { model: "openai/gpt-5.4-mini" },
+          alpha: { model: 'openai/gpt-5.4-mini' },
         },
-        beta: { model: "google/gemini-3-pro" },
+        beta: { model: 'google/gemini-3-pro' },
       },
     },
   };
@@ -127,18 +127,18 @@ test('mixed legacy "councillors" and flat keys in same preset', () => {
 
   if (result.success) {
     const preset = result.data.presets.mixed;
-    expect(Object.keys(preset).sort()).toEqual(["alpha", "beta"]);
+    expect(Object.keys(preset).sort()).toEqual(['alpha', 'beta']);
   }
 });
 
-test("deprecated master with non-standard model ID still parses", () => {
+test('deprecated master with non-standard model ID still parses', () => {
   const config = {
-    master: { model: "claude-opus-4-6" }, // no provider/ prefix
-    master_timeout: "fast", // not a number
-    master_fallback: "all", // not an array
+    master: { model: 'claude-opus-4-6' }, // no provider/ prefix
+    master_timeout: 'fast', // not a number
+    master_fallback: 'all', // not an array
     presets: {
       default: {
-        alpha: { model: "openai/gpt-5.4-mini" },
+        alpha: { model: 'openai/gpt-5.4-mini' },
       },
     },
   };
@@ -148,21 +148,21 @@ test("deprecated master with non-standard model ID still parses", () => {
 
   if (result.success) {
     expect(result.data._deprecated).toEqual([
-      "master",
-      "master_timeout",
-      "master_fallback",
+      'master',
+      'master_timeout',
+      'master_fallback',
     ]);
     // Even non-standard model IDs are extracted as-is for backward compat
-    expect(result.data._legacyMasterModel).toBe("claude-opus-4-6");
+    expect(result.data._legacyMasterModel).toBe('claude-opus-4-6');
   }
 });
 
-test("legacyMasterModel undefined when master.model is not a string", () => {
+test('legacyMasterModel undefined when master.model is not a string', () => {
   const config = {
     master: { model: 42 }, // not a string
     presets: {
       default: {
-        alpha: { model: "openai/gpt-5.4-mini" },
+        alpha: { model: 'openai/gpt-5.4-mini' },
       },
     },
   };
@@ -175,12 +175,12 @@ test("legacyMasterModel undefined when master.model is not a string", () => {
   }
 });
 
-test("legacyMasterModel undefined when master is not an object", () => {
+test('legacyMasterModel undefined when master is not an object', () => {
   const config = {
-    master: "oops", // not an object
+    master: 'oops', // not an object
     presets: {
       default: {
-        alpha: { model: "openai/gpt-5.4-mini" },
+        alpha: { model: 'openai/gpt-5.4-mini' },
       },
     },
   };
@@ -193,33 +193,33 @@ test("legacyMasterModel undefined when master is not an object", () => {
   }
 });
 
-test("rejects empty model string", () => {
+test('rejects empty model string', () => {
   const config = {
-    model: "",
+    model: '',
   };
 
   const result = CouncillorConfigSchema.safeParse(config);
   expect(result.success).toBe(false);
 });
 
-test("accepts optional prompt field", () => {
+test('accepts optional prompt field', () => {
   const config: CouncillorConfig = {
-    model: "openai/gpt-5.4-mini",
-    prompt: "Focus on security implications and edge cases.",
+    model: 'openai/gpt-5.4-mini',
+    prompt: 'Focus on security implications and edge cases.',
   };
 
   const result = CouncillorConfigSchema.safeParse(config);
   expect(result.success).toBe(true);
   if (result.success) {
     expect(result.data.prompt).toBe(
-      "Focus on security implications and edge cases."
+      'Focus on security implications and edge cases.',
     );
   }
 });
 
-test("prompt is optional and defaults to undefined", () => {
+test('prompt is optional and defaults to undefined', () => {
   const config: CouncillorConfig = {
-    model: "openai/gpt-5.4-mini",
+    model: 'openai/gpt-5.4-mini',
   };
 
   const result = CouncillorConfigSchema.safeParse(config);
@@ -229,43 +229,43 @@ test("prompt is optional and defaults to undefined", () => {
   }
 });
 
-describe("CouncilPresetSchema", () => {
-  test("validates a named preset with multiple councillors", () => {
+describe('CouncilPresetSchema', () => {
+  test('validates a named preset with multiple councillors', () => {
     const raw = {
       alpha: {
-        model: "openai/gpt-5.4-mini",
+        model: 'openai/gpt-5.4-mini',
       },
       beta: {
-        model: "openai/gpt-5.3-codex",
-        variant: "low",
+        model: 'openai/gpt-5.3-codex',
+        variant: 'low',
       },
       gamma: {
-        model: "google/gemini-3-pro",
+        model: 'google/gemini-3-pro',
       },
     };
 
     const result = CouncilPresetSchema.safeParse(raw);
     expect(result.success).toBe(true);
     if (result.success) {
-      expect(Object.keys(result.data)).toEqual(["alpha", "beta", "gamma"]);
+      expect(Object.keys(result.data)).toEqual(['alpha', 'beta', 'gamma']);
     }
   });
 
-  test("accepts preset with single councillor", () => {
+  test('accepts preset with single councillor', () => {
     const raw = {
       solo: {
-        model: "openai/gpt-5.4-mini",
+        model: 'openai/gpt-5.4-mini',
       },
     };
 
     const result = CouncilPresetSchema.safeParse(raw);
     expect(result.success).toBe(true);
     if (result.success) {
-      expect(Object.keys(result.data)).toEqual(["solo"]);
+      expect(Object.keys(result.data)).toEqual(['solo']);
     }
   });
 
-  test("accepts empty preset (no councillors)", () => {
+  test('accepts empty preset (no councillors)', () => {
     const raw = {};
 
     const result = CouncilPresetSchema.safeParse(raw);
@@ -276,14 +276,14 @@ describe("CouncilPresetSchema", () => {
   });
 });
 
-describe("CouncilConfigSchema", () => {
-  test("validates complete config with defaults", () => {
+describe('CouncilConfigSchema', () => {
+  test('validates complete config with defaults', () => {
     const config = {
       presets: {
         default: {
-          alpha: { model: "openai/gpt-5.4-mini" },
-          beta: { model: "openai/gpt-5.3-codex" },
-          gamma: { model: "google/gemini-3-pro" },
+          alpha: { model: 'openai/gpt-5.4-mini' },
+          beta: { model: 'openai/gpt-5.3-codex' },
+          gamma: { model: 'google/gemini-3-pro' },
         },
       },
     };
@@ -294,18 +294,18 @@ describe("CouncilConfigSchema", () => {
     if (result.success) {
       // Check defaults are filled in
       expect(result.data.timeout).toBe(180000);
-      expect(result.data.default_preset).toBe("default");
+      expect(result.data.default_preset).toBe('default');
     }
   });
 
-  test("fills in defaults for optional fields", () => {
+  test('fills in defaults for optional fields', () => {
     const config = {
       presets: {
         custom: {
-          alpha: { model: "openai/gpt-5.4-mini" },
+          alpha: { model: 'openai/gpt-5.4-mini' },
         },
       },
-      default_preset: "custom",
+      default_preset: 'custom',
     };
 
     const result = CouncilConfigSchema.safeParse(config);
@@ -313,22 +313,22 @@ describe("CouncilConfigSchema", () => {
 
     if (result.success) {
       expect(result.data.timeout).toBe(180000);
-      expect(result.data.default_preset).toBe("custom");
+      expect(result.data.default_preset).toBe('custom');
     }
   });
 
-  test("rejects missing presets", () => {
+  test('rejects missing presets', () => {
     const badConfig = {};
 
     const result = CouncilConfigSchema.safeParse(badConfig);
     expect(result.success).toBe(false);
   });
 
-  test("rejects invalid timeout (negative)", () => {
+  test('rejects invalid timeout (negative)', () => {
     const badConfig = {
       presets: {
         default: {
-          alpha: { model: "openai/gpt-5.4-mini" },
+          alpha: { model: 'openai/gpt-5.4-mini' },
         },
       },
       timeout: -1000,
@@ -338,11 +338,11 @@ describe("CouncilConfigSchema", () => {
     expect(result.success).toBe(false);
   });
 
-  test("accepts zero timeout values (no timeout)", () => {
+  test('accepts zero timeout values (no timeout)', () => {
     const config = {
       presets: {
         default: {
-          alpha: { model: "openai/gpt-5.4-mini" },
+          alpha: { model: 'openai/gpt-5.4-mini' },
         },
       },
       timeout: 0,
@@ -356,10 +356,10 @@ describe("CouncilConfigSchema", () => {
     }
   });
 
-  test("rejects missing presets", () => {
+  test('rejects missing presets', () => {
     const badConfig = {
       master: {
-        model: "anthropic/claude-opus-4-6",
+        model: 'anthropic/claude-opus-4-6',
       },
     };
 
@@ -367,22 +367,22 @@ describe("CouncilConfigSchema", () => {
     expect(result.success).toBe(false);
   });
 
-  test("accepts multiple presets", () => {
+  test('accepts multiple presets', () => {
     const config = {
       presets: {
         default: {
-          alpha: { model: "openai/gpt-5.4-mini" },
-          beta: { model: "openai/gpt-5.3-codex" },
+          alpha: { model: 'openai/gpt-5.4-mini' },
+          beta: { model: 'openai/gpt-5.3-codex' },
         },
         fast: {
-          quick: { model: "openai/gpt-5.4-mini", variant: "low" },
+          quick: { model: 'openai/gpt-5.4-mini', variant: 'low' },
         },
         thorough: {
           detailed1: {
-            model: "anthropic/claude-opus-4-6",
-            prompt: "Provide detailed analysis with citations.",
+            model: 'anthropic/claude-opus-4-6',
+            prompt: 'Provide detailed analysis with citations.',
           },
-          detailed2: { model: "openai/gpt-5.5" },
+          detailed2: { model: 'openai/gpt-5.5' },
         },
       },
     };
@@ -394,7 +394,7 @@ describe("CouncilConfigSchema", () => {
       // Verify prompt is preserved (not silently stripped)
       const thoroughPreset = result.data.presets.thorough;
       expect(thoroughPreset.detailed1.prompt).toBe(
-        "Provide detailed analysis with citations."
+        'Provide detailed analysis with citations.',
       );
       // Verify prompt is undefined when not set
       expect(thoroughPreset.detailed2.prompt).toBeUndefined();

+ 28 - 28
src/config/council-schema.ts

@@ -1,4 +1,4 @@
-import { z } from "zod";
+import { z } from 'zod';
 
 /**
  * Validates model IDs in "provider/model" format.
@@ -8,7 +8,7 @@ const ModelIdSchema = z
   .string()
   .regex(
     /^[^/\s]+\/[^\s]+$/,
-    'Expected provider/model format (e.g. "openai/gpt-5.4-mini")'
+    'Expected provider/model format (e.g. "openai/gpt-5.4-mini")',
   );
 
 /**
@@ -21,14 +21,14 @@ const ModelIdSchema = z
  */
 export const CouncillorConfigSchema = z.object({
   model: ModelIdSchema.describe(
-    'Model ID in provider/model format (e.g. "openai/gpt-5.4-mini")'
+    'Model ID in provider/model format (e.g. "openai/gpt-5.4-mini")',
   ),
   variant: z.string().optional(),
   prompt: z
     .string()
     .optional()
     .describe(
-      "Optional role/guidance injected into the councillor user prompt"
+      'Optional role/guidance injected into the councillor user prompt',
     ),
 });
 
@@ -50,14 +50,14 @@ export const CouncilPresetSchema = z
       // Silently skip the legacy "master" key — no longer parsed as a
       // councillor. Old configs with per-preset master overrides won't
       // error, but the override has no effect.
-      if (key === "master") continue;
+      if (key === 'master') continue;
 
       // Legacy nested format: old configs wrapped councillors in a
       // "councillors" key inside each preset. Unwrap them into the
       // parent so the config still works without migration.
-      if (key === "councillors" && typeof raw === "object" && raw !== null) {
+      if (key === 'councillors' && typeof raw === 'object' && raw !== null) {
         for (const [innerKey, innerRaw] of Object.entries(
-          raw as Record<string, unknown>
+          raw as Record<string, unknown>,
         )) {
           const innerParsed = CouncillorConfigSchema.safeParse(innerRaw);
           if (!innerParsed.success) {
@@ -65,7 +65,7 @@ export const CouncilPresetSchema = z
               code: z.ZodIssueCode.custom,
               message: `Invalid councillor "${innerKey}" (nested under legacy "councillors" key): ${innerParsed.error.issues
                 .map((i) => i.message)
-                .join(", ")}`,
+                .join(', ')}`,
             });
             return z.NEVER;
           }
@@ -80,7 +80,7 @@ export const CouncilPresetSchema = z
           code: z.ZodIssueCode.custom,
           message: `Invalid councillor "${key}": ${parsed.error.issues
             .map((i) => i.message)
-            .join(", ")}`,
+            .join(', ')}`,
         });
         return z.NEVER;
       }
@@ -98,11 +98,11 @@ export type CouncilPreset = z.infer<typeof CouncilPresetSchema>;
  * - serial: Run councillors one at a time (required for single-model systems to avoid conflicts)
  */
 export const CouncillorExecutionModeSchema = z
-  .enum(["parallel", "serial"])
-  .default("parallel")
+  .enum(['parallel', 'serial'])
+  .default('parallel')
   .describe(
     'Execution mode for councillors. Use "serial" for single-model systems to avoid conflicts. ' +
-      'Use "parallel" for multi-model systems for faster execution.'
+      'Use "parallel" for multi-model systems for faster execution.',
   );
 
 /**
@@ -129,9 +129,9 @@ export const CouncilConfigSchema = z
   .object({
     presets: z.record(z.string(), CouncilPresetSchema),
     timeout: z.number().min(0).default(180000),
-    default_preset: z.string().default("default"),
+    default_preset: z.string().default('default'),
     councillor_execution_mode: CouncillorExecutionModeSchema.describe(
-      'Execution mode for councillors. "serial" runs them one at a time (required for single-model systems). "parallel" runs them concurrently (default, faster for multi-model systems).'
+      'Execution mode for councillors. "serial" runs them one at a time (required for single-model systems). "parallel" runs them concurrently (default, faster for multi-model systems).',
     ),
     councillor_retries: z
       .number()
@@ -140,8 +140,8 @@ export const CouncilConfigSchema = z
       .max(5)
       .default(3)
       .describe(
-        "Number of retry attempts for councillors that return empty responses " +
-          "(e.g. due to provider rate limiting). Default: 3 retries."
+        'Number of retry attempts for councillors that return empty responses ' +
+          '(e.g. due to provider rate limiting). Default: 3 retries.',
       ),
     // Deprecated fields — accepted for backward compatibility but ignored.
     // The council agent now synthesizes directly; no separate master session.
@@ -150,7 +150,7 @@ export const CouncilConfigSchema = z
     master: z
       .unknown()
       .optional()
-      .describe("DEPRECATED — ignored. Council agent synthesizes directly."),
+      .describe('DEPRECATED — ignored. Council agent synthesizes directly.'),
     master_timeout: z
       .unknown()
       .optional()
@@ -158,23 +158,23 @@ export const CouncilConfigSchema = z
     master_fallback: z
       .unknown()
       .optional()
-      .describe("DEPRECATED — ignored. No separate master session."),
+      .describe('DEPRECATED — ignored. No separate master session.'),
   })
   .transform((data) => {
     // Detect deprecated fields and attach warning for consumers
     const deprecated: string[] = [];
-    if (data.master !== undefined) deprecated.push("master");
-    if (data.master_timeout !== undefined) deprecated.push("master_timeout");
-    if (data.master_fallback !== undefined) deprecated.push("master_fallback");
+    if (data.master !== undefined) deprecated.push('master');
+    if (data.master_timeout !== undefined) deprecated.push('master_timeout');
+    if (data.master_fallback !== undefined) deprecated.push('master_fallback');
 
     // Backward compat: extract master.model so the council agent can use it
     // as a fallback when no explicit council entry exists in the active preset.
     // See https://github.com/alvinunreal/oh-my-opencode-slim/issues/369
     const legacyMasterModel: string | undefined =
-      typeof data.master === "object" &&
+      typeof data.master === 'object' &&
       data.master !== null &&
-      "model" in data.master &&
-      typeof (data.master as { model: unknown }).model === "string"
+      'model' in data.master &&
+      typeof (data.master as { model: unknown }).model === 'string'
         ? (data.master as { model: string }).model
         : undefined;
 
@@ -207,9 +207,9 @@ export type CouncillorExecutionMode = z.infer<
 export const DEFAULT_COUNCIL_CONFIG: z.input<typeof CouncilConfigSchema> = {
   presets: {
     default: {
-      alpha: { model: "openai/gpt-5.4-mini" },
-      beta: { model: "openai/gpt-5.3-codex" },
-      gamma: { model: "google/gemini-3-pro" },
+      alpha: { model: 'openai/gpt-5.4-mini' },
+      beta: { model: 'openai/gpt-5.3-codex' },
+      gamma: { model: 'google/gemini-3-pro' },
     },
   },
 };
@@ -224,7 +224,7 @@ export interface CouncilResult {
   councillorResults: Array<{
     name: string;
     model: string;
-    status: "completed" | "failed" | "timed_out";
+    status: 'completed' | 'failed' | 'timed_out';
     result?: string;
     error?: string;
   }>;

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 282 - 282
src/config/loader.test.ts


+ 24 - 24
src/config/utils.test.ts

@@ -1,63 +1,63 @@
-import { describe, expect, test } from "bun:test";
-import type { PluginConfig } from "./schema";
-import { getAgentOverride, getCustomAgentNames } from "./utils";
+import { describe, expect, test } from 'bun:test';
+import type { PluginConfig } from './schema';
+import { getAgentOverride, getCustomAgentNames } from './utils';
 
-describe("getAgentOverride", () => {
-  test("reads override by explicit custom agent key", () => {
+describe('getAgentOverride', () => {
+  test('reads override by explicit custom agent key', () => {
     const config = {
       agents: {
-        "custom-reviewer": { model: "openai/gpt-5.4-mini" },
+        'custom-reviewer': { model: 'openai/gpt-5.4-mini' },
       },
     } as PluginConfig;
 
-    const override = getAgentOverride(config, "custom-reviewer");
+    const override = getAgentOverride(config, 'custom-reviewer');
 
     expect(override).toBeDefined();
-    expect(override?.model).toBe("openai/gpt-5.4-mini");
+    expect(override?.model).toBe('openai/gpt-5.4-mini');
   });
 
-  test("reads override from legacy alias when mapped", () => {
+  test('reads override from legacy alias when mapped', () => {
     const config = {
       agents: {
-        explore: { model: "openai/gpt-5.4-mini" },
+        explore: { model: 'openai/gpt-5.4-mini' },
       },
     } as PluginConfig;
 
-    const override = getAgentOverride(config, "explorer");
+    const override = getAgentOverride(config, 'explorer');
 
     expect(override).toBeDefined();
-    expect(override?.model).toBe("openai/gpt-5.4-mini");
+    expect(override?.model).toBe('openai/gpt-5.4-mini');
   });
 
-  test("returns undefined when no override exists", () => {
+  test('returns undefined when no override exists', () => {
     const config = {
       agents: {
-        explorer: { model: "openai/gpt-5.4-mini" },
+        explorer: { model: 'openai/gpt-5.4-mini' },
       },
     } as PluginConfig;
 
-    expect(getAgentOverride(config, "no-such-agent")).toBeUndefined();
+    expect(getAgentOverride(config, 'no-such-agent')).toBeUndefined();
   });
 });
 
-describe("getCustomAgentNames", () => {
-  test("returns only unknown non-alias agent keys", () => {
+describe('getCustomAgentNames', () => {
+  test('returns only unknown non-alias agent keys', () => {
     const config = {
       agents: {
-        explorer: { model: "openai/gpt-5.4-mini" },
-        explore: { model: "openai/gpt-5.4-mini" },
-        janitor: { model: "openai/gpt-5.4-mini" },
+        explorer: { model: 'openai/gpt-5.4-mini' },
+        explore: { model: 'openai/gpt-5.4-mini' },
+        janitor: { model: 'openai/gpt-5.4-mini' },
       },
     } as PluginConfig;
 
-    expect(getCustomAgentNames(config)).toEqual(["janitor"]);
+    expect(getCustomAgentNames(config)).toEqual(['janitor']);
   });
 
-  test("returns an empty list when no custom agents exist", () => {
+  test('returns an empty list when no custom agents exist', () => {
     const config = {
       agents: {
-        explorer: { model: "openai/gpt-5.4-mini" },
-        oracle: { model: "openai/gpt-5.5" },
+        explorer: { model: 'openai/gpt-5.4-mini' },
+        oracle: { model: 'openai/gpt-5.5' },
       },
     } as PluginConfig;
 

+ 191 - 191
src/council/council-manager.test.ts

@@ -1,8 +1,8 @@
-import { describe, expect, mock, test } from "bun:test";
-import type { PluginConfig } from "../config";
-import { CouncilConfigSchema } from "../config/council-schema";
-import { SubagentDepthTracker } from "../utils/subagent-depth";
-import { CouncilManager } from "./council-manager";
+import { describe, expect, mock, test } from 'bun:test';
+import type { PluginConfig } from '../config';
+import { CouncilConfigSchema } from '../config/council-schema';
+import { SubagentDepthTracker } from '../utils/subagent-depth';
+import { CouncilManager } from './council-manager';
 
 function createMockContext(overrides?: {
   sessionCreateResult?:
@@ -25,7 +25,7 @@ function createMockContext(overrides?: {
         create: mock(async () => {
           callCount++;
           const overrideResult = overrides?.sessionCreateResult;
-          if (typeof overrideResult === "function") {
+          if (typeof overrideResult === 'function') {
             return overrideResult();
           }
           return (
@@ -35,7 +35,7 @@ function createMockContext(overrides?: {
           );
         }),
         messages: mock(
-          async () => overrides?.sessionMessagesResult ?? { data: [] }
+          async () => overrides?.sessionMessagesResult ?? { data: [] },
         ),
         prompt: mock(async (args: unknown) => {
           if (overrides?.promptImpl) {
@@ -46,7 +46,7 @@ function createMockContext(overrides?: {
         abort: mock(async () => ({})),
       },
     },
-    directory: "/tmp/test",
+    directory: '/tmp/test',
   } as any;
 }
 
@@ -58,8 +58,8 @@ function createTestCouncilConfig(overrides?: {
   const councilConfig = CouncilConfigSchema.parse({
     presets: overrides?.presets ?? {
       default: {
-        alpha: { model: "openai/gpt-5.4-mini" },
-        beta: { model: "openai/gpt-5.3-codex" },
+        alpha: { model: 'openai/gpt-5.4-mini' },
+        beta: { model: 'openai/gpt-5.3-codex' },
       },
     },
     default_preset: overrides?.default_preset,
@@ -69,21 +69,21 @@ function createTestCouncilConfig(overrides?: {
   return { council: councilConfig } as any;
 }
 
-describe("CouncilManager", () => {
-  describe("constructor", () => {
-    test("creates manager without config", () => {
+describe('CouncilManager', () => {
+  describe('constructor', () => {
+    test('creates manager without config', () => {
       const ctx = createMockContext();
       const manager = new CouncilManager(ctx, undefined);
       expect(manager).toBeDefined();
     });
 
-    test("creates manager with plugin config", async () => {
+    test('creates manager with plugin config', async () => {
       const ctx = createMockContext({
         sessionMessagesResult: {
           data: [
             {
-              info: { role: "assistant" },
-              parts: [{ type: "text", text: "Councillor response" }],
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Councillor response' }],
             },
           ],
         },
@@ -92,9 +92,9 @@ describe("CouncilManager", () => {
       const manager = new CouncilManager(ctx, config, undefined);
 
       const result = await manager.runCouncil(
-        "test prompt",
+        'test prompt',
         undefined,
-        "parent-session-id"
+        'parent-session-id',
       );
 
       expect(result.success).toBe(true);
@@ -103,11 +103,11 @@ describe("CouncilManager", () => {
 
       // Check all councillors completed
       expect(
-        result.councillorResults.every((r) => r.status === "completed")
+        result.councillorResults.every((r) => r.status === 'completed'),
       ).toBe(true);
     });
 
-    test("returns error when all councillors fail", async () => {
+    test('returns error when all councillors fail', async () => {
       const ctx = createMockContext({
         sessionCreateResult: () => ({ data: {} }), // Missing ID triggers failure
       });
@@ -115,26 +115,26 @@ describe("CouncilManager", () => {
       const manager = new CouncilManager(ctx, config, undefined);
 
       const result = await manager.runCouncil(
-        "test prompt",
+        'test prompt',
         undefined,
-        "parent-session-id"
+        'parent-session-id',
       );
 
       expect(result.success).toBe(false);
-      expect(result.error).toBe("All councillors failed or timed out");
+      expect(result.error).toBe('All councillors failed or timed out');
       expect(result.councillorResults).toHaveLength(2);
-      expect(result.councillorResults.every((r) => r.status === "failed")).toBe(
-        true
+      expect(result.councillorResults.every((r) => r.status === 'failed')).toBe(
+        true,
       );
     });
 
-    test("uses default_preset when presetName is undefined", async () => {
+    test('uses default_preset when presetName is undefined', async () => {
       const ctx = createMockContext({
         sessionMessagesResult: {
           data: [
             {
-              info: { role: "assistant" },
-              parts: [{ type: "text", text: "Councillor response" }],
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Councillor response' }],
             },
           ],
         },
@@ -142,35 +142,35 @@ describe("CouncilManager", () => {
       const config = createTestCouncilConfig({
         presets: {
           default: {
-            alpha: { model: "openai/gpt-5.4-mini" },
+            alpha: { model: 'openai/gpt-5.4-mini' },
           },
           custom: {
-            beta: { model: "openai/gpt-5.3-codex" },
+            beta: { model: 'openai/gpt-5.3-codex' },
           },
         },
-        default_preset: "custom",
+        default_preset: 'custom',
       });
       const manager = new CouncilManager(ctx, config, undefined);
 
       const result = await manager.runCouncil(
-        "test prompt",
+        'test prompt',
         undefined,
-        "parent-session-id"
+        'parent-session-id',
       );
 
       expect(result.success).toBe(true);
       expect(result.councillorResults).toHaveLength(1);
-      expect(result.councillorResults[0].name).toBe("beta");
+      expect(result.councillorResults[0].name).toBe('beta');
     });
 
-    test("handles mixed councillor success/failure", async () => {
+    test('handles mixed councillor success/failure', async () => {
       let createCallCount = 0;
       const ctx = createMockContext({
         sessionCreateResult: () => {
           createCallCount++;
           // First councillor succeeds, second fails
           if (createCallCount === 1) {
-            return { data: { id: "councillor-success" } };
+            return { data: { id: 'councillor-success' } };
           }
           if (createCallCount === 2) {
             return { data: {} }; // Missing ID = failure
@@ -180,8 +180,8 @@ describe("CouncilManager", () => {
         sessionMessagesResult: {
           data: [
             {
-              info: { role: "assistant" },
-              parts: [{ type: "text", text: "Successful response" }],
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Successful response' }],
             },
           ],
         },
@@ -190,8 +190,8 @@ describe("CouncilManager", () => {
         council: {
           presets: {
             default: {
-              councillor1: { model: "openai/gpt-5.4-mini" },
-              councillor2: { model: "openai/gpt-5.3-codex" },
+              councillor1: { model: 'openai/gpt-5.4-mini' },
+              councillor2: { model: 'openai/gpt-5.3-codex' },
             },
           },
         },
@@ -199,9 +199,9 @@ describe("CouncilManager", () => {
       const manager = new CouncilManager(ctx, config, undefined);
 
       const result = await manager.runCouncil(
-        "test prompt",
+        'test prompt',
         undefined,
-        "parent-session-id"
+        'parent-session-id',
       );
 
       expect(result.success).toBe(true);
@@ -209,23 +209,23 @@ describe("CouncilManager", () => {
 
       // Check that one completed and one failed (order not guaranteed)
       const completedCount = result.councillorResults.filter(
-        (r) => r.status === "completed"
+        (r) => r.status === 'completed',
       ).length;
       const failedCount = result.councillorResults.filter(
-        (r) => r.status === "failed"
+        (r) => r.status === 'failed',
       ).length;
 
       expect(completedCount).toBe(1);
       expect(failedCount).toBe(1);
     });
 
-    test("uses custom timeouts from config", async () => {
+    test('uses custom timeouts from config', async () => {
       const ctx = createMockContext({
         sessionMessagesResult: {
           data: [
             {
-              info: { role: "assistant" },
-              parts: [{ type: "text", text: "Response" }],
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Response' }],
             },
           ],
         },
@@ -234,27 +234,27 @@ describe("CouncilManager", () => {
         council: {
           presets: {
             default: {
-              alpha: { model: "openai/gpt-5.4-mini" },
+              alpha: { model: 'openai/gpt-5.4-mini' },
             },
             custom: {
-              beta: { model: "openai/gpt-5.3-codex" },
+              beta: { model: 'openai/gpt-5.3-codex' },
             },
           },
-          default_preset: "custom",
+          default_preset: 'custom',
         },
       } as any;
       const manager = new CouncilManager(ctx, config, undefined);
 
       const result = await manager.runCouncil(
-        "test prompt",
+        'test prompt',
         undefined,
-        "parent-session-id"
+        'parent-session-id',
       );
 
       expect(result.success).toBe(true);
     });
 
-    test("handles councillor timeout", async () => {
+    test('handles councillor timeout', async () => {
       let sessionCount = 0;
       const ctx = createMockContext({
         sessionCreateResult: () => {
@@ -264,17 +264,17 @@ describe("CouncilManager", () => {
         promptImpl: async (args: any) => {
           // First councillor times out, second succeeds
           const sessionId = args.path?.id;
-          if (sessionId === "session-1") {
+          if (sessionId === 'session-1') {
             // Simulate timeout
-            throw new Error("Prompt timed out after 180000ms");
+            throw new Error('Prompt timed out after 180000ms');
           }
           return {};
         },
         sessionMessagesResult: {
           data: [
             {
-              info: { role: "assistant" },
-              parts: [{ type: "text", text: "Success" }],
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Success' }],
             },
           ],
         },
@@ -283,8 +283,8 @@ describe("CouncilManager", () => {
         council: {
           presets: {
             default: {
-              timeout: { model: "openai/gpt-5.4-mini" },
-              success: { model: "openai/gpt-5.3-codex" },
+              timeout: { model: 'openai/gpt-5.4-mini' },
+              success: { model: 'openai/gpt-5.3-codex' },
             },
           },
         },
@@ -292,33 +292,33 @@ describe("CouncilManager", () => {
       const manager = new CouncilManager(ctx, config, undefined);
 
       const result = await manager.runCouncil(
-        "test prompt",
+        'test prompt',
         undefined,
-        "parent-session-id"
+        'parent-session-id',
       );
 
       expect(result.success).toBe(true);
       expect(result.councillorResults).toHaveLength(2);
 
       const timeoutResult = result.councillorResults.find(
-        (r) => r.name === "timeout"
+        (r) => r.name === 'timeout',
       );
       const successResult = result.councillorResults.find(
-        (r) => r.name === "success"
+        (r) => r.name === 'success',
       );
 
-      expect(timeoutResult?.status).toBe("timed_out");
-      expect(timeoutResult?.error).toContain("timed out");
-      expect(successResult?.status).toBe("completed");
+      expect(timeoutResult?.status).toBe('timed_out');
+      expect(timeoutResult?.error).toContain('timed out');
+      expect(successResult?.status).toBe('completed');
     });
 
-    test("passes variant to councillor sessions", async () => {
+    test('passes variant to councillor sessions', async () => {
       const ctx = createMockContext({
         sessionMessagesResult: {
           data: [
             {
-              info: { role: "assistant" },
-              parts: [{ type: "text", text: "Response" }],
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Response' }],
             },
           ],
         },
@@ -327,33 +327,33 @@ describe("CouncilManager", () => {
         council: {
           presets: {
             default: {
-              alpha: { model: "openai/gpt-5.4-mini", variant: "low" },
+              alpha: { model: 'openai/gpt-5.4-mini', variant: 'low' },
             },
           },
         },
       } as any;
       const manager = new CouncilManager(ctx, config, undefined);
 
-      await manager.runCouncil("test prompt", undefined, "parent-session-id");
+      await manager.runCouncil('test prompt', undefined, 'parent-session-id');
 
       const promptCalls = ctx.client.session.prompt.mock.calls as Array<
         [{ body?: { variant?: string; agent?: string } }]
       >;
       // Find the councillor call by agent field (notification may be at [0])
       const councillorCall = promptCalls.find(
-        (c) => c[0].body?.agent === "councillor"
+        (c) => c[0].body?.agent === 'councillor',
       );
       expect(councillorCall).toBeDefined();
-      expect(councillorCall?.[0].body?.variant).toBe("low");
+      expect(councillorCall?.[0].body?.variant).toBe('low');
     });
 
-    test("always aborts councillor sessions after completion", async () => {
+    test('always aborts councillor sessions after completion', async () => {
       const ctx = createMockContext({
         sessionMessagesResult: {
           data: [
             {
-              info: { role: "assistant" },
-              parts: [{ type: "text", text: "Response" }],
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Response' }],
             },
           ],
         },
@@ -362,27 +362,27 @@ describe("CouncilManager", () => {
         council: {
           presets: {
             default: {
-              alpha: { model: "openai/gpt-5.4-mini" },
-              beta: { model: "openai/gpt-5.3-codex" },
+              alpha: { model: 'openai/gpt-5.4-mini' },
+              beta: { model: 'openai/gpt-5.3-codex' },
             },
           },
         },
       } as any;
       const manager = new CouncilManager(ctx, config, undefined);
 
-      await manager.runCouncil("test prompt", undefined, "parent-session-id");
+      await manager.runCouncil('test prompt', undefined, 'parent-session-id');
 
       // Should abort 2 councillors
       expect(ctx.client.session.abort).toHaveBeenCalledTimes(2);
     });
 
-    test("handles councillor with invalid model format", async () => {
+    test('handles councillor with invalid model format', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {
         council: {
           presets: {
             default: {
-              badmodel: { model: "invalid-model-no-slash" },
+              badmodel: { model: 'invalid-model-no-slash' },
             },
           },
         },
@@ -390,29 +390,29 @@ describe("CouncilManager", () => {
       const manager = new CouncilManager(ctx, config, undefined);
 
       const result = await manager.runCouncil(
-        "test prompt",
+        'test prompt',
         undefined,
-        "parent-session-id"
+        'parent-session-id',
       );
 
       expect(result.success).toBe(false);
-      expect(result.error).toBe("All councillors failed or timed out");
+      expect(result.error).toBe('All councillors failed or timed out');
       expect(result.councillorResults).toHaveLength(1);
-      expect(result.councillorResults[0].status).toBe("failed");
+      expect(result.councillorResults[0].status).toBe('failed');
       expect(result.councillorResults[0].error).toContain(
-        "Invalid model format"
+        'Invalid model format',
       );
     });
 
-    test("extracts text and reasoning content from councillor responses", async () => {
+    test('extracts text and reasoning content from councillor responses', async () => {
       const ctx = createMockContext({
         sessionMessagesResult: {
           data: [
             {
-              info: { role: "assistant" },
+              info: { role: 'assistant' },
               parts: [
-                { type: "reasoning", text: "I am thinking..." },
-                { type: "text", text: "Final answer." },
+                { type: 'reasoning', text: 'I am thinking...' },
+                { type: 'text', text: 'Final answer.' },
               ],
             },
           ],
@@ -422,7 +422,7 @@ describe("CouncilManager", () => {
         council: {
           presets: {
             default: {
-              alpha: { model: "openai/gpt-5.4-mini" },
+              alpha: { model: 'openai/gpt-5.4-mini' },
             },
           },
         },
@@ -430,27 +430,27 @@ describe("CouncilManager", () => {
       const manager = new CouncilManager(ctx, config, undefined);
 
       const result = await manager.runCouncil(
-        "test prompt",
+        'test prompt',
         undefined,
-        "parent-session-id"
+        'parent-session-id',
       );
 
       expect(result.success).toBe(true);
       // Councillors filter out reasoning parts to avoid bloating the synthesis
       expect(result.councillorResults[0].result).not.toContain(
-        "I am thinking..."
+        'I am thinking...',
       );
-      expect(result.councillorResults[0].result).toContain("Final answer.");
+      expect(result.councillorResults[0].result).toContain('Final answer.');
     });
 
-    test("handles concurrent council sessions with different presets", async () => {
+    test('handles concurrent council sessions with different presets', async () => {
       const ctx = createMockContext({
-        sessionCreateResult: () => ({ data: { id: "session-1" } }),
+        sessionCreateResult: () => ({ data: { id: 'session-1' } }),
         sessionMessagesResult: {
           data: [
             {
-              info: { role: "assistant" },
-              parts: [{ type: "text", text: "Response" }],
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Response' }],
             },
           ],
         },
@@ -458,10 +458,10 @@ describe("CouncilManager", () => {
       const defaultConfig = createTestCouncilConfig({
         presets: {
           default: {
-            alpha: { model: "openai/gpt-5.4-mini" },
+            alpha: { model: 'openai/gpt-5.4-mini' },
           },
           fast: {
-            beta: { model: "openai/gpt-5.3-codex" },
+            beta: { model: 'openai/gpt-5.3-codex' },
           },
         },
       });
@@ -469,17 +469,17 @@ describe("CouncilManager", () => {
       const manager2 = new CouncilManager(ctx, defaultConfig, undefined);
 
       const [result1, result2] = await Promise.all([
-        manager1.runCouncil("test prompt 1", "default", "parent-1"),
-        manager2.runCouncil("test prompt 2", "fast", "parent-2"),
+        manager1.runCouncil('test prompt 1', 'default', 'parent-1'),
+        manager2.runCouncil('test prompt 2', 'fast', 'parent-2'),
       ]);
 
       expect(result1.success).toBe(true);
       expect(result2.success).toBe(true);
-      expect(result1.councillorResults[0].name).toBe("alpha");
-      expect(result2.councillorResults[0].name).toBe("beta");
+      expect(result1.councillorResults[0].name).toBe('alpha');
+      expect(result2.councillorResults[0].name).toBe('beta');
     });
 
-    test("handles empty preset gracefully", async () => {
+    test('handles empty preset gracefully', async () => {
       const ctx = createMockContext();
       const config = createTestCouncilConfig({
         presets: {
@@ -489,75 +489,75 @@ describe("CouncilManager", () => {
       const manager = new CouncilManager(ctx, config, undefined);
 
       const result = await manager.runCouncil(
-        "test prompt",
-        "empty",
-        "parent-id"
+        'test prompt',
+        'empty',
+        'parent-id',
       );
 
       expect(result.success).toBe(false);
       expect(result.error).toContain(
-        'Preset "empty" has no councillors configured'
+        'Preset "empty" has no councillors configured',
       );
       expect(result.councillorResults).toHaveLength(0);
     });
 
-    test("returns available presets when invalid preset name given", async () => {
+    test('returns available presets when invalid preset name given', async () => {
       const ctx = createMockContext();
       const config = createTestCouncilConfig({
         presets: {
           default: {
-            alpha: { model: "openai/gpt-5.4-mini" },
+            alpha: { model: 'openai/gpt-5.4-mini' },
           },
           roled: {
-            beta: { model: "openai/gpt-5.3-codex" },
+            beta: { model: 'openai/gpt-5.3-codex' },
           },
         },
       });
       const manager = new CouncilManager(ctx, config, undefined);
 
       const result = await manager.runCouncil(
-        "test prompt",
-        "architect",
-        "parent-id"
+        'test prompt',
+        'architect',
+        'parent-id',
       );
 
       expect(result.success).toBe(false);
       expect(result.error).toContain('Preset "architect" does not exist');
-      expect(result.error).toContain("Omit the preset parameter");
-      expect(result.error).toContain("default, roled");
+      expect(result.error).toContain('Omit the preset parameter');
+      expect(result.error).toContain('default, roled');
       expect(result.councillorResults).toHaveLength(0);
     });
 
-    test("returns error when depth exceeded", async () => {
+    test('returns error when depth exceeded', async () => {
       const ctx = createMockContext();
       const config = createTestCouncilConfig();
       const tracker = new SubagentDepthTracker(3);
 
       // Simulate depth: root (0) → child1 (1) → child2 (2) → child3 (3)
-      tracker.registerChild("root", "child1"); // depth 1
-      tracker.registerChild("child1", "child2"); // depth 2
-      tracker.registerChild("child2", "child3"); // depth 3 (max)
+      tracker.registerChild('root', 'child1'); // depth 1
+      tracker.registerChild('child1', 'child2'); // depth 2
+      tracker.registerChild('child2', 'child3'); // depth 3 (max)
 
       const manager = new CouncilManager(ctx, config, tracker);
 
       const result = await manager.runCouncil(
-        "test prompt",
+        'test prompt',
         undefined,
-        "child3" // parent at max depth, next spawn would exceed limit
+        'child3', // parent at max depth, next spawn would exceed limit
       );
 
       expect(result.success).toBe(false);
-      expect(result.error).toBe("Subagent depth exceeded");
+      expect(result.error).toBe('Subagent depth exceeded');
       expect(result.councillorResults).toHaveLength(0);
     });
 
-    test("passes agent field in councillor prompt body", async () => {
+    test('passes agent field in councillor prompt body', async () => {
       const ctx = createMockContext({
         sessionMessagesResult: {
           data: [
             {
-              info: { role: "assistant" },
-              parts: [{ type: "text", text: "Response" }],
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Response' }],
             },
           ],
         },
@@ -565,25 +565,25 @@ describe("CouncilManager", () => {
       const config = createTestCouncilConfig();
       const manager = new CouncilManager(ctx, config, undefined);
 
-      await manager.runCouncil("test prompt", undefined, "parent-id");
+      await manager.runCouncil('test prompt', undefined, 'parent-id');
 
       const promptCalls = ctx.client.session.prompt.mock.calls as Array<
         [{ body?: { agent?: string } }]
       >;
       // Find councillor call by agent (notification may interleave)
       const councillorCall = promptCalls.find(
-        (c) => c[0].body?.agent === "councillor"
+        (c) => c[0].body?.agent === 'councillor',
       );
       expect(councillorCall).toBeDefined();
     });
 
-    test("creates session with model label in title", async () => {
+    test('creates session with model label in title', async () => {
       const ctx = createMockContext({
         sessionMessagesResult: {
           data: [
             {
-              info: { role: "assistant" },
-              parts: [{ type: "text", text: "Response" }],
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Response' }],
             },
           ],
         },
@@ -592,31 +592,31 @@ describe("CouncilManager", () => {
         council: {
           presets: {
             default: {
-              alpha: { model: "openai/gpt-5.4-mini" },
+              alpha: { model: 'openai/gpt-5.4-mini' },
             },
           },
         },
       } as any;
       const manager = new CouncilManager(ctx, config, undefined);
 
-      await manager.runCouncil("test prompt", undefined, "parent-id");
+      await manager.runCouncil('test prompt', undefined, 'parent-id');
 
       const createCalls = ctx.client.session.create.mock.calls as Array<
         [{ body?: { title?: string } }]
       >;
       // Councillor title: "Council alpha (gpt-5.4-mini)"
       expect(createCalls[0][0].body?.title).toBe(
-        "Council alpha (gpt-5.4-mini)"
+        'Council alpha (gpt-5.4-mini)',
       );
     });
 
-    test("passes councillor prompt to councillor session", async () => {
+    test('passes councillor prompt to councillor session', async () => {
       const ctx = createMockContext({
         sessionMessagesResult: {
           data: [
             {
-              info: { role: "assistant" },
-              parts: [{ type: "text", text: "Response with role guidance" }],
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Response with role guidance' }],
             },
           ],
         },
@@ -626,8 +626,8 @@ describe("CouncilManager", () => {
           presets: {
             default: {
               alpha: {
-                model: "openai/gpt-5.4-mini",
-                prompt: "You are a meticulous reviewer focused on edge cases.",
+                model: 'openai/gpt-5.4-mini',
+                prompt: 'You are a meticulous reviewer focused on edge cases.',
               },
             },
           },
@@ -635,7 +635,7 @@ describe("CouncilManager", () => {
       } as any;
       const manager = new CouncilManager(ctx, config, undefined);
 
-      await manager.runCouncil("test prompt", undefined, "parent-id");
+      await manager.runCouncil('test prompt', undefined, 'parent-id');
 
       const promptCalls = ctx.client.session.prompt.mock.calls as Array<
         [
@@ -644,27 +644,27 @@ describe("CouncilManager", () => {
               parts?: Array<{ type: string; text?: string }>;
               agent?: string;
             };
-          }
+          },
         ]
       >;
       const councillorCall = promptCalls.find(
-        (c) => c[0].body?.agent === "councillor"
+        (c) => c[0].body?.agent === 'councillor',
       );
       expect(councillorCall).toBeDefined();
       const promptText = councillorCall?.[0]?.body?.parts?.[0]?.text;
-      expect(promptText).toContain("test prompt");
+      expect(promptText).toContain('test prompt');
       expect(promptText).toContain(
-        "You are a meticulous reviewer focused on edge cases."
+        'You are a meticulous reviewer focused on edge cases.',
       );
     });
 
-    test("works without any prompt overrides (backward compatible)", async () => {
+    test('works without any prompt overrides (backward compatible)', async () => {
       const ctx = createMockContext({
         sessionMessagesResult: {
           data: [
             {
-              info: { role: "assistant" },
-              parts: [{ type: "text", text: "Response" }],
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Response' }],
             },
           ],
         },
@@ -673,7 +673,7 @@ describe("CouncilManager", () => {
         council: {
           presets: {
             default: {
-              alpha: { model: "openai/gpt-5.4-mini" },
+              alpha: { model: 'openai/gpt-5.4-mini' },
             },
           },
         },
@@ -681,9 +681,9 @@ describe("CouncilManager", () => {
       const manager = new CouncilManager(ctx, config, undefined);
 
       const result = await manager.runCouncil(
-        "test prompt",
+        'test prompt',
         undefined,
-        "parent-id"
+        'parent-id',
       );
 
       expect(result.success).toBe(true);
@@ -695,17 +695,17 @@ describe("CouncilManager", () => {
               parts?: Array<{ type: string; text?: string }>;
               agent?: string;
             };
-          }
+          },
         ]
       >;
       const councillorCall = promptCalls.find(
-        (c) => c[0].body?.agent === "councillor"
+        (c) => c[0].body?.agent === 'councillor',
       );
       // Without prompt override, councillor gets just the raw user prompt
-      expect(councillorCall?.[0]?.body?.parts?.[0]?.text).toBe("test prompt");
+      expect(councillorCall?.[0]?.body?.parts?.[0]?.text).toBe('test prompt');
     });
 
-    test("retries councillor on empty response", async () => {
+    test('retries councillor on empty response', async () => {
       const ctx = createMockContext({
         promptImpl: async () => ({}),
       });
@@ -721,8 +721,8 @@ describe("CouncilManager", () => {
           return {
             data: [
               {
-                info: { role: "assistant" },
-                parts: [{ type: "text", text: "" }],
+                info: { role: 'assistant' },
+                parts: [{ type: 'text', text: '' }],
               },
             ],
           };
@@ -731,8 +731,8 @@ describe("CouncilManager", () => {
           return {
             data: [
               {
-                info: { role: "assistant" },
-                parts: [{ type: "text", text: "Success" }],
+                info: { role: 'assistant' },
+                parts: [{ type: 'text', text: 'Success' }],
               },
             ],
           };
@@ -746,7 +746,7 @@ describe("CouncilManager", () => {
           councillor_retries: 1,
           presets: {
             default: {
-              alpha: { model: "openai/gpt-5.4-mini" },
+              alpha: { model: 'openai/gpt-5.4-mini' },
             },
           },
         },
@@ -754,25 +754,25 @@ describe("CouncilManager", () => {
       const manager = new CouncilManager(ctx, config, undefined);
 
       const result = await manager.runCouncil(
-        "test prompt",
+        'test prompt',
         undefined,
-        "parent-id"
+        'parent-id',
       );
 
       expect(result.success).toBe(true);
       // First two messages calls are for councillor (empty + success)
       expect(councillorMessagesCallCount).toBeGreaterThanOrEqual(2);
       expect(result.councillorResults).toHaveLength(1);
-      expect(result.councillorResults[0].status).toBe("completed");
-      expect(result.councillorResults[0].result).toBe("Success");
+      expect(result.councillorResults[0].status).toBe('completed');
+      expect(result.councillorResults[0].result).toBe('Success');
     });
 
-    test("does not retry councillor on non-empty failure (timeout)", async () => {
+    test('does not retry councillor on non-empty failure (timeout)', async () => {
       let messagesCallCount = 0;
       const ctx = createMockContext({
         promptImpl: async () => {
           // Simulate timeout error
-          throw new Error("Prompt timed out after 180000ms");
+          throw new Error('Prompt timed out after 180000ms');
         },
       });
 
@@ -782,8 +782,8 @@ describe("CouncilManager", () => {
         return {
           data: [
             {
-              info: { role: "assistant" },
-              parts: [{ type: "text", text: "Success" }],
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Success' }],
             },
           ],
         };
@@ -794,7 +794,7 @@ describe("CouncilManager", () => {
           councillor_retries: 2,
           presets: {
             default: {
-              alpha: { model: "openai/gpt-5.4-mini" },
+              alpha: { model: 'openai/gpt-5.4-mini' },
             },
           },
         },
@@ -802,20 +802,20 @@ describe("CouncilManager", () => {
       const manager = new CouncilManager(ctx, config, undefined);
 
       const result = await manager.runCouncil(
-        "test prompt",
+        'test prompt',
         undefined,
-        "parent-id"
+        'parent-id',
       );
 
       expect(result.success).toBe(false);
       // No retry on timeout — messages should not be called
       expect(messagesCallCount).toBe(0);
       expect(result.councillorResults).toHaveLength(1);
-      expect(result.councillorResults[0].status).toBe("timed_out");
-      expect(result.councillorResults[0].error).toContain("timed out");
+      expect(result.councillorResults[0].status).toBe('timed_out');
+      expect(result.councillorResults[0].error).toContain('timed out');
     });
 
-    test("exhausts councillor retries and returns failure", async () => {
+    test('exhausts councillor retries and returns failure', async () => {
       const ctx = createMockContext({
         promptImpl: async () => ({}),
       });
@@ -825,7 +825,7 @@ describe("CouncilManager", () => {
           councillor_retries: 1,
           presets: {
             default: {
-              alpha: { model: "openai/gpt-5.4-mini" },
+              alpha: { model: 'openai/gpt-5.4-mini' },
             },
           },
         },
@@ -833,21 +833,21 @@ describe("CouncilManager", () => {
       const manager = new CouncilManager(ctx, config, undefined);
 
       const result = await manager.runCouncil(
-        "test prompt",
+        'test prompt',
         undefined,
-        "parent-id"
+        'parent-id',
       );
 
       expect(result.success).toBe(false);
-      expect(result.error).toBe("All councillors failed or timed out");
+      expect(result.error).toBe('All councillors failed or timed out');
       expect(result.councillorResults).toHaveLength(1);
-      expect(result.councillorResults[0].status).toBe("failed");
+      expect(result.councillorResults[0].status).toBe('failed');
       expect(result.councillorResults[0].error).toContain(
-        "Empty response from provider"
+        'Empty response from provider',
       );
     });
 
-    test("returns empty councillor result when retry_on_empty is false", async () => {
+    test('returns empty councillor result when retry_on_empty is false', async () => {
       const ctx = createMockContext({
         promptImpl: async () => ({}),
       });
@@ -856,8 +856,8 @@ describe("CouncilManager", () => {
       ctx.client.session.messages = mock(async () => ({
         data: [
           {
-            info: { role: "assistant" },
-            parts: [{ type: "text", text: "" }],
+            info: { role: 'assistant' },
+            parts: [{ type: 'text', text: '' }],
           },
         ],
       }));
@@ -867,7 +867,7 @@ describe("CouncilManager", () => {
           councillor_retries: 1,
           presets: {
             default: {
-              alpha: { model: "openai/gpt-5.4-mini" },
+              alpha: { model: 'openai/gpt-5.4-mini' },
             },
           },
         },
@@ -878,22 +878,22 @@ describe("CouncilManager", () => {
       const manager = new CouncilManager(ctx, config, undefined);
 
       const result = await manager.runCouncil(
-        "test prompt",
+        'test prompt',
         undefined,
-        "parent-id"
+        'parent-id',
       );
 
       // With retry_on_empty: false, empty response is accepted as completed
       expect(result.councillorResults).toHaveLength(1);
-      expect(result.councillorResults[0].status).toBe("completed");
-      expect(result.councillorResults[0].result).toBe("");
+      expect(result.councillorResults[0].status).toBe('completed');
+      expect(result.councillorResults[0].result).toBe('');
       // Council succeeds because empty is accepted as valid response
       // The formatted result contains the message about all councillors failing
       expect(result.success).toBe(true);
       expect(result.result).toContain(
-        "All councillors failed to produce output"
+        'All councillors failed to produce output',
       );
-      expect(result.result).toContain("test prompt");
+      expect(result.result).toContain('test prompt');
     });
   });
 });

BIN
src/divoom/council.gif


BIN
src/divoom/designer.gif


BIN
src/divoom/explorer.gif


BIN
src/divoom/fixer.gif


BIN
src/divoom/input.gif


BIN
src/divoom/intro.gif


BIN
src/divoom/librarian.gif


+ 221 - 20
src/divoom/manager.test.ts

@@ -2,7 +2,11 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
 import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
 import { tmpdir } from 'node:os';
 import path from 'node:path';
-import { DivoomManager, type DivoomSenderCall } from './manager';
+import {
+  DivoomManager,
+  type DivoomSenderCall,
+  getDivoomOutDir,
+} from './manager';
 
 function createGifAssets(dir: string, names: string[]): void {
   for (const name of names) {
@@ -16,11 +20,15 @@ describe('DivoomManager', () => {
   let pythonPath: string;
   let scriptPath: string;
   let originalDivoomEnv: string | undefined;
+  let originalXdgDataHome: string | undefined;
 
   beforeEach(() => {
     originalDivoomEnv = process.env.OH_MY_OPENCODE_SLIM_DIVOOM;
+    originalXdgDataHome = process.env.XDG_DATA_HOME;
     delete process.env.OH_MY_OPENCODE_SLIM_DIVOOM;
     tempDir = mkdtempSync(path.join(tmpdir(), 'divoom-test-'));
+    // Set XDG_DATA_HOME to a temp path to avoid writing to real user data directory
+    process.env.XDG_DATA_HOME = path.join(tempDir, 'xdg-data');
     calls = [];
     pythonPath = path.join(tempDir, 'python');
     scriptPath = path.join(tempDir, 'divoom_send.py');
@@ -31,6 +39,7 @@ describe('DivoomManager', () => {
       'orchestrator.gif',
       'explorer.gif',
       'fixer.gif',
+      'input.gif',
       'oracle.gif',
     ]);
   });
@@ -41,6 +50,11 @@ describe('DivoomManager', () => {
     } else {
       process.env.OH_MY_OPENCODE_SLIM_DIVOOM = originalDivoomEnv;
     }
+    if (originalXdgDataHome === undefined) {
+      delete process.env.XDG_DATA_HOME;
+    } else {
+      process.env.XDG_DATA_HOME = originalXdgDataHome;
+    }
     rmSync(tempDir, { recursive: true, force: true });
   });
 
@@ -104,14 +118,16 @@ describe('DivoomManager', () => {
     ]);
   });
 
-  test('explicit config disabled wins over env var', async () => {
+  test('env var force-enables even when config disables', async () => {
     process.env.OH_MY_OPENCODE_SLIM_DIVOOM = 'true';
     const manager = createManager({ enabled: false });
 
     manager.onPluginLoad();
     await manager.flush();
 
-    expect(calls).toHaveLength(0);
+    expect(calls.map((call) => call.args[1])).toEqual([
+      path.join(tempDir, 'intro.gif'),
+    ]);
   });
 
   test('shows task agent then orchestrator after a single task', async () => {
@@ -190,6 +206,122 @@ describe('DivoomManager', () => {
     ]);
   });
 
+  test('explicit question requests show input until work resumes', async () => {
+    const manager = createManager();
+
+    manager.onOrchestratorStatus({
+      sessionId: 'parent',
+      status: 'busy',
+      isOrchestrator: true,
+    });
+    await manager.flush();
+    manager.onUserInputRequired({ sessionId: 'parent', requestId: 'q-1' });
+    await manager.flush();
+    manager.onOrchestratorStatus({
+      sessionId: 'parent',
+      status: 'busy',
+      isOrchestrator: true,
+    });
+    await manager.flush();
+    manager.onUserInputResolved({ sessionId: 'parent', requestId: 'q-1' });
+    await manager.flush();
+    manager.onOrchestratorStatus({
+      sessionId: 'parent',
+      status: 'busy',
+      isOrchestrator: true,
+    });
+    await manager.flush();
+
+    expect(calls.map((call) => call.args[1])).toEqual([
+      path.join(tempDir, 'orchestrator.gif'),
+      path.join(tempDir, 'input.gif'),
+      path.join(tempDir, 'orchestrator.gif'),
+    ]);
+  });
+
+  test('child prompt restores delegated agent after reply', async () => {
+    const manager = createManager();
+
+    manager.onTaskStart({
+      parentSessionId: 'parent',
+      callId: 'call-1',
+      args: { subagent_type: 'explorer' },
+    });
+    await manager.flush();
+    manager.onUserInputRequired({ sessionId: 'child', requestId: 'p-1' });
+    await manager.flush();
+    manager.onUserInputResolved({ sessionId: 'child', requestId: 'p-1' });
+    await manager.flush();
+
+    expect(calls.map((call) => call.args[1])).toEqual([
+      path.join(tempDir, 'explorer.gif'),
+      path.join(tempDir, 'input.gif'),
+      path.join(tempDir, 'explorer.gif'),
+    ]);
+  });
+
+  test('overlapping prompts keep input until all resolve', async () => {
+    const manager = createManager();
+
+    manager.onUserInputRequired({ sessionId: 'one', requestId: 'p-1' });
+    await manager.flush();
+    manager.onUserInputRequired({ sessionId: 'two', requestId: 'p-2' });
+    await manager.flush();
+    manager.onUserInputResolved({ sessionId: 'one', requestId: 'p-1' });
+    await manager.flush();
+    manager.onUserInputResolved({ sessionId: 'two', requestId: 'p-2' });
+    await manager.flush();
+
+    expect(calls.map((call) => call.args[1])).toEqual([
+      path.join(tempDir, 'input.gif'),
+      path.join(tempDir, 'intro.gif'),
+    ]);
+  });
+
+  test('session deletion clears pending input and rerenders', async () => {
+    const manager = createManager();
+
+    manager.onUserInputRequired({ sessionId: 'child', requestId: 'p-1' });
+    await manager.flush();
+    manager.onSessionDeleted({ sessionId: 'child' });
+    await manager.flush();
+
+    expect(calls.map((call) => call.args[1])).toEqual([
+      path.join(tempDir, 'input.gif'),
+      path.join(tempDir, 'intro.gif'),
+    ]);
+  });
+
+  test('orchestrator deletion clears busy display', async () => {
+    const manager = createManager();
+
+    manager.onOrchestratorStatus({
+      sessionId: 'parent',
+      status: 'busy',
+      isOrchestrator: true,
+    });
+    await manager.flush();
+    manager.onSessionDeleted({ sessionId: 'parent', isOrchestrator: true });
+    await manager.flush();
+
+    expect(calls.map((call) => call.args[1])).toEqual([
+      path.join(tempDir, 'orchestrator.gif'),
+      path.join(tempDir, 'intro.gif'),
+    ]);
+  });
+
+  test('pending user input falls back to intro when input gif is absent', async () => {
+    rmSync(path.join(tempDir, 'input.gif'));
+    const manager = createManager();
+
+    manager.onUserInputRequired({ sessionId: 'parent', requestId: 'q-1' });
+    await manager.flush();
+
+    expect(calls.map((call) => call.args[1])).toEqual([
+      path.join(tempDir, 'intro.gif'),
+    ]);
+  });
+
   test('keeps first agent visible for parallel tasks', async () => {
     const manager = createManager();
 
@@ -263,23 +395,25 @@ describe('DivoomManager', () => {
     });
     await manager.flush();
 
-    expect(calls[0]).toEqual({
-      command: customPython,
-      args: [
-        customScript,
-        customGif,
-        '--size',
-        '64',
-        '--fps',
-        '12',
-        '--speed',
-        '250',
-        '--max-frames',
-        '10',
-        '--posterize-bits',
-        '4',
-      ],
-    });
+    expect(calls[0].command).toBe(customPython);
+    expect(calls[0].args).toHaveLength(14);
+    expect(calls[0].args[0]).toBe(customScript);
+    expect(calls[0].args[1]).toBe(customGif);
+    expect(calls[0].args.slice(2, 13)).toEqual([
+      '--size',
+      '64',
+      '--fps',
+      '12',
+      '--speed',
+      '250',
+      '--max-frames',
+      '10',
+      '--posterize-bits',
+      '4',
+      '--out-dir',
+    ]);
+    // Verify out-dir is absolute (last arg)
+    expect(path.isAbsolute(calls[0].args[13])).toBe(true);
   });
 
   test('drops stale queued sends and keeps latest requested gif', async () => {
@@ -323,4 +457,71 @@ describe('DivoomManager', () => {
 
     expect(calls).toHaveLength(0);
   });
+
+  test('out-dir is absolute and independent of process.cwd', async () => {
+    const originalCwd = process.cwd();
+    const tempCwd = mkdtempSync(path.join(tmpdir(), 'divoom-cwd-test-'));
+
+    try {
+      process.chdir(tempCwd);
+      const manager = createManager();
+
+      manager.onPluginLoad();
+      await manager.flush();
+
+      expect(calls).toHaveLength(1);
+      const outDirArg = calls[0].args[calls[0].args.length - 1];
+      // Must be absolute, not relative to cwd
+      expect(path.isAbsolute(outDirArg)).toBe(true);
+      // Must not start with the temp cwd
+      expect(outDirArg.startsWith(tempCwd)).toBe(false);
+      // Must contain the expected path segments
+      expect(outDirArg).toContain('divoom');
+      expect(outDirArg).toContain('captures');
+    } finally {
+      process.chdir(originalCwd);
+      rmSync(tempCwd, { recursive: true, force: true });
+    }
+  });
+
+  test('empty XDG_DATA_HOME falls back to homedir/.local/share', async () => {
+    // Set empty XDG_DATA_HOME
+    process.env.XDG_DATA_HOME = '';
+    const homeDir = path.join(tempDir, 'home');
+    const outDirArg = getDivoomOutDir(homeDir);
+    // Must be absolute
+    expect(path.isAbsolute(outDirArg)).toBe(true);
+    expect(outDirArg).toStartWith(path.join(tempDir, 'home'));
+    // Must contain the expected path segments
+    expect(outDirArg).toContain('divoom');
+    expect(outDirArg).toContain('captures');
+  });
+
+  test('relative XDG_DATA_HOME falls back to homedir/.local/share', async () => {
+    // Set relative XDG_DATA_HOME (should be rejected)
+    process.env.XDG_DATA_HOME = 'relative/path/to/data';
+    const homeDir = path.join(tempDir, 'home');
+    const outDirArg = getDivoomOutDir(homeDir);
+    // Must be absolute
+    expect(path.isAbsolute(outDirArg)).toBe(true);
+    // Must not start with the relative path
+    expect(outDirArg.startsWith('relative')).toBe(false);
+    expect(outDirArg).toStartWith(path.join(tempDir, 'home'));
+    // Must contain the expected path segments
+    expect(outDirArg).toContain('divoom');
+    expect(outDirArg).toContain('captures');
+  });
+
+  test('whitespace-only XDG_DATA_HOME falls back to homedir/.local/share', async () => {
+    // Set whitespace-only XDG_DATA_HOME (should be rejected after trim)
+    process.env.XDG_DATA_HOME = '   ';
+    const homeDir = path.join(tempDir, 'home');
+    const outDirArg = getDivoomOutDir(homeDir);
+    // Must be absolute
+    expect(path.isAbsolute(outDirArg)).toBe(true);
+    expect(outDirArg).toStartWith(path.join(tempDir, 'home'));
+    // Must contain the expected path segments
+    expect(outDirArg).toContain('divoom');
+    expect(outDirArg).toContain('captures');
+  });
 });

+ 106 - 15
src/divoom/manager.ts

@@ -1,5 +1,6 @@
 import { spawn } from 'node:child_process';
-import { existsSync } from 'node:fs';
+import { existsSync, mkdirSync } from 'node:fs';
+import * as os from 'node:os';
 import path from 'node:path';
 import { fileURLToPath } from 'node:url';
 import type { DivoomConfig } from '../config';
@@ -27,6 +28,7 @@ const AGENT_GIFS: Record<string, string> = {
   designer: 'designer.gif',
   explorer: 'explorer.gif',
   fixer: 'fixer.gif',
+  input: 'input.gif',
   intro: 'intro.gif',
   librarian: 'librarian.gif',
   oracle: 'oracle.gif',
@@ -82,10 +84,32 @@ function isEnvEnabled(value: string | undefined): boolean {
   return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
 }
 
+function inputKey(sessionId: string, requestId: string): string {
+  return `${sessionId}:${requestId}`;
+}
+
+export function getDivoomOutDir(homeDir = os.homedir()): string {
+  const xdg = process.env.XDG_DATA_HOME?.trim();
+  const baseDir =
+    xdg && xdg.length > 0 && path.isAbsolute(xdg)
+      ? xdg
+      : path.join(homeDir, '.local', 'share');
+  return path.join(
+    baseDir,
+    'opencode',
+    'storage',
+    'oh-my-opencode-slim',
+    'divoom',
+    'captures',
+  );
+}
+
 export class DivoomManager {
   private assetDir: string | null;
   private config: DivoomConfig;
   private parentStates = new Map<string, ParentState>();
+  private pendingUserInputs = new Set<string>();
+  private orchestratorBusy = false;
   private latestRequestedGifPath?: string;
   private lastGifPath?: string;
   private sendQueue = Promise.resolve();
@@ -98,7 +122,9 @@ export class DivoomManager {
     this.config = {
       ...DEFAULT_DIVOOM_CONFIG,
       ...config,
-      enabled: config?.enabled ?? isEnvEnabled(process.env[DIVOOM_ENABLE_ENV]),
+      enabled: isEnvEnabled(process.env[DIVOOM_ENABLE_ENV])
+        ? true
+        : (config?.enabled ?? false),
       gifs: config?.gifs,
     };
     this.assetDir = options.assetDir ?? resolveAssetDir();
@@ -125,11 +151,13 @@ export class DivoomManager {
     const state = this.getParentState(input.parentSessionId);
     const wasIdle = state.activeCalls.size === 0;
     state.activeCalls.set(input.callId, agent);
+    this.orchestratorBusy = true;
 
-    if (!wasIdle || state.displayedAgent) return;
+    if (wasIdle && !state.displayedAgent) {
+      state.displayedAgent = agent;
+    }
 
-    state.displayedAgent = agent;
-    this.show(agent);
+    this.render();
   }
 
   onTaskEnd(input: { parentSessionId?: string; callId?: string }): void {
@@ -139,10 +167,25 @@ export class DivoomManager {
     if (!state) return;
 
     state.activeCalls.delete(input.callId);
-    if (state.activeCalls.size > 0) return;
+    if (state.activeCalls.size === 0) {
+      this.parentStates.delete(input.parentSessionId);
+    }
+
+    this.render();
+  }
+
+  onUserInputRequired(input: { sessionId?: string; requestId?: string }): void {
+    if (!input.sessionId || !input.requestId) return;
 
-    this.parentStates.delete(input.parentSessionId);
-    this.show('orchestrator');
+    this.pendingUserInputs.add(inputKey(input.sessionId, input.requestId));
+    this.render();
+  }
+
+  onUserInputResolved(input: { sessionId?: string; requestId?: string }): void {
+    if (!input.sessionId || !input.requestId) return;
+
+    this.pendingUserInputs.delete(inputKey(input.sessionId, input.requestId));
+    this.render();
   }
 
   onOrchestratorStatus(input: {
@@ -152,22 +195,50 @@ export class DivoomManager {
   }): void {
     if (!input.sessionId || !input.isOrchestrator) return;
 
-    const state = this.parentStates.get(input.sessionId);
     if (input.status === 'busy') {
-      if (state && state.activeCalls.size > 0) return;
-      this.show('orchestrator');
+      this.orchestratorBusy = true;
+      this.render();
       return;
     }
 
     if (input.status === 'idle') {
+      this.orchestratorBusy = false;
       this.parentStates.delete(input.sessionId);
-      this.show('intro');
+      this.render();
     }
   }
 
-  onSessionDeleted(sessionId?: string): void {
+  onSessionDeleted(input: {
+    sessionId?: string;
+    isOrchestrator?: boolean;
+  }): void {
+    const sessionId = input.sessionId;
     if (!sessionId) return;
+    if (input.isOrchestrator) this.orchestratorBusy = false;
     this.parentStates.delete(sessionId);
+    this.pendingUserInputs = new Set(
+      Array.from(this.pendingUserInputs).filter(
+        (key) => !key.startsWith(`${sessionId}:`),
+      ),
+    );
+    this.render();
+  }
+
+  private render(): void {
+    if (this.pendingUserInputs.size > 0) {
+      this.show('input');
+      return;
+    }
+
+    const activeAgent = Array.from(this.parentStates.values()).find(
+      (state) => state.displayedAgent && state.activeCalls.size > 0,
+    )?.displayedAgent;
+    if (activeAgent) {
+      this.show(activeAgent);
+      return;
+    }
+
+    this.show(this.orchestratorBusy ? 'orchestrator' : 'intro');
   }
 
   private getParentState(parentSessionId: string): ParentState {
@@ -191,17 +262,35 @@ export class DivoomManager {
 
     const fileName =
       this.config.gifs?.[agent] ?? AGENT_GIFS[agent] ?? AGENT_GIFS.orchestrator;
-    const gifPath = path.isAbsolute(fileName)
+    const requestedGifPath = path.isAbsolute(fileName)
       ? fileName
       : path.join(this.assetDir, fileName);
+    const fallbackGifPath = path.join(this.assetDir, AGENT_GIFS.intro);
+    const gifPath = existsSync(requestedGifPath)
+      ? requestedGifPath
+      : agent === 'input'
+        ? fallbackGifPath
+        : requestedGifPath;
     if (!existsSync(gifPath)) {
-      log('[divoom] gif not found', { agent, gifPath });
+      log('[divoom] gif not found', { agent, gifPath: requestedGifPath });
       return;
     }
 
     if (gifPath === this.latestRequestedGifPath) return;
     this.latestRequestedGifPath = gifPath;
 
+    const outDir = getDivoomOutDir();
+    try {
+      mkdirSync(outDir, { recursive: true });
+    } catch (error) {
+      this.clearLatestIfCurrent(gifPath);
+      log('[divoom] output directory not writable', {
+        outDir,
+        error: String(error),
+      });
+      return;
+    }
+
     const call = {
       command: this.config.python,
       args: [
@@ -217,6 +306,8 @@ export class DivoomManager {
         String(this.config.maxFrames),
         '--posterize-bits',
         String(this.config.posterizeBits),
+        '--out-dir',
+        outDir,
       ],
     };
 

BIN
src/divoom/oracle.gif


BIN
src/divoom/orchestrator.gif


+ 36 - 1
src/index.ts

@@ -737,6 +737,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
             sessionID?: string;
           };
           sessionID?: string;
+          id?: string;
+          requestID?: string;
           status?: { type: string };
         };
       };
@@ -796,6 +798,33 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         },
       );
 
+      if (
+        event.type === 'permission.asked' ||
+        event.type === 'question.asked'
+      ) {
+        const props = event.properties as
+          | { sessionID?: string; id?: string; requestID?: string }
+          | undefined;
+        divoomManager.onUserInputRequired({
+          sessionId: props?.sessionID,
+          requestId: props?.id ?? props?.requestID,
+        });
+      }
+
+      if (
+        event.type === 'permission.replied' ||
+        event.type === 'question.replied' ||
+        event.type === 'question.rejected'
+      ) {
+        const props = event.properties as
+          | { sessionID?: string; requestID?: string; id?: string }
+          | undefined;
+        divoomManager.onUserInputResolved({
+          sessionId: props?.sessionID,
+          requestId: props?.requestID ?? props?.id,
+        });
+      }
+
       if (input.event.type === 'session.status') {
         const props = input.event.properties as
           | { sessionID?: string; status?: { type?: string } }
@@ -814,7 +843,13 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         const props = input.event.properties as
           | { info?: { id?: string }; sessionID?: string }
           | undefined;
-        divoomManager.onSessionDeleted(props?.info?.id ?? props?.sessionID);
+        const sessionID = props?.info?.id ?? props?.sessionID;
+        divoomManager.onSessionDeleted({
+          sessionId: sessionID,
+          isOrchestrator: sessionID
+            ? sessionAgentMap.get(sessionID) === 'orchestrator'
+            : false,
+        });
       }
 
       if (input.event.type === 'session.deleted') {

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 301 - 301
src/interview/interview.test.ts


+ 2 - 6
src/interview/service.ts

@@ -527,9 +527,7 @@ export function createInterviewService(
         path: { id: interview.sessionID },
         body: {
           parts: [createInternalAgentTextPart(prompt)],
-          ...(model
-            ? { model: parseModelReference(model) ?? undefined }
-            : {}),
+          ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
         },
       });
       promptSent = true;
@@ -762,9 +760,7 @@ export function createInterviewService(
         path: { id: interview.sessionID },
         body: {
           parts: [createInternalAgentTextPart(prompt)],
-          ...(model
-            ? { model: parseModelReference(model) ?? undefined }
-            : {}),
+          ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
         },
       });
       promptSent = true;

+ 186 - 186
src/tools/council.test.ts

@@ -1,7 +1,7 @@
-import { describe, expect, mock, test } from "bun:test";
-import type { CouncilResult } from "../config/council-schema";
-import type { CouncilManager } from "../council/council-manager";
-import { createCouncilTool } from "./council";
+import { describe, expect, mock, test } from 'bun:test';
+import type { CouncilResult } from '../config/council-schema';
+import type { CouncilManager } from '../council/council-manager';
+import { createCouncilTool } from './council';
 
 function createMockPluginContext() {
   return {
@@ -13,7 +13,7 @@ function createMockPluginContext() {
         abort: mock(async () => ({})),
       },
     },
-    directory: "/tmp/test",
+    directory: '/tmp/test',
   } as any;
 }
 
@@ -21,7 +21,7 @@ function createMockPluginContext() {
 type TestCouncillorResult = {
   name: string;
   model?: string;
-  status: "completed" | "failed" | "timed_out";
+  status: 'completed' | 'failed' | 'timed_out';
   result?: string;
   error?: string;
 };
@@ -32,15 +32,15 @@ function createMockCouncilManager(
     result?: string;
     error?: string;
     councillorResults?: TestCouncillorResult[];
-  } = {}
+  } = {},
 ) {
-  const councillorResults: CouncilResult["councillorResults"] = (
+  const councillorResults: CouncilResult['councillorResults'] = (
     results.councillorResults ?? [
-      { name: "alpha", status: "completed", result: "Alpha response" },
-      { name: "beta", status: "completed", result: "Beta response" },
+      { name: 'alpha', status: 'completed', result: 'Alpha response' },
+      { name: 'beta', status: 'completed', result: 'Beta response' },
     ]
   ).map((cr) => ({
-    model: "test/model",
+    model: 'test/model',
     ...cr,
   }));
 
@@ -48,7 +48,7 @@ function createMockCouncilManager(
     runCouncil: mock(async (): Promise<CouncilResult> => {
       return {
         success: results.success ?? true,
-        result: "result" in results ? results.result : "Synthesized response",
+        result: 'result' in results ? results.result : 'Synthesized response',
         error: results.error,
         councillorResults,
       };
@@ -59,9 +59,9 @@ function createMockCouncilManager(
   return mockManager;
 }
 
-describe("council_session tool", () => {
-  describe("tool definition", () => {
-    test("creates council_session tool", () => {
+describe('council_session tool', () => {
+  describe('tool definition', () => {
+    test('creates council_session tool', () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager();
       const tools = createCouncilTool(ctx, councilManager);
@@ -72,487 +72,487 @@ describe("council_session tool", () => {
       expect(tools.council_session.args).toBeDefined();
     });
 
-    test("has correct tool description", () => {
+    test('has correct tool description', () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager();
       const tools = createCouncilTool(ctx, councilManager);
 
-      expect(tools.council_session.description).toContain("multi-LLM");
-      expect(tools.council_session.description).toContain("consensus");
-      expect(tools.council_session.description).toContain("councillors");
+      expect(tools.council_session.description).toContain('multi-LLM');
+      expect(tools.council_session.description).toContain('consensus');
+      expect(tools.council_session.description).toContain('councillors');
     });
 
-    test("defines required prompt argument", () => {
+    test('defines required prompt argument', () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager();
       const tools = createCouncilTool(ctx, councilManager);
 
       expect(tools.council_session.args.prompt).toBeDefined();
-      expect(tools.council_session.args).toHaveProperty("prompt");
+      expect(tools.council_session.args).toHaveProperty('prompt');
     });
 
-    test("defines optional preset argument", () => {
+    test('defines optional preset argument', () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager();
       const tools = createCouncilTool(ctx, councilManager);
 
       expect(tools.council_session.args.preset).toBeDefined();
-      expect(tools.council_session.args).toHaveProperty("preset");
+      expect(tools.council_session.args).toHaveProperty('preset');
     });
   });
 
-  describe("execute", () => {
-    test("calls councilManager.runCouncil with correct arguments", async () => {
+  describe('execute', () => {
+    test('calls councilManager.runCouncil with correct arguments', async () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager();
       const tools = createCouncilTool(ctx, councilManager);
 
       const _result = await tools.council_session.execute(
         {
-          prompt: "Test prompt",
-          preset: "custom",
+          prompt: 'Test prompt',
+          preset: 'custom',
         },
-        { sessionID: "test-session-123" } as any
+        { sessionID: 'test-session-123' } as any,
       );
 
       expect(councilManager.runCouncil).toHaveBeenCalledTimes(1);
       expect(councilManager.runCouncil).toHaveBeenCalledWith(
-        "Test prompt",
-        "custom",
-        "test-session-123"
+        'Test prompt',
+        'custom',
+        'test-session-123',
       );
     });
 
-    test("uses default preset when not specified", async () => {
+    test('uses default preset when not specified', async () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager();
       const tools = createCouncilTool(ctx, councilManager);
 
-      await tools.council_session.execute({ prompt: "Test prompt" }, {
-        sessionID: "test-session-123",
+      await tools.council_session.execute({ prompt: 'Test prompt' }, {
+        sessionID: 'test-session-123',
       } as any);
 
       expect(councilManager.runCouncil).toHaveBeenCalledWith(
-        "Test prompt",
+        'Test prompt',
         undefined,
-        "test-session-123"
+        'test-session-123',
       );
     });
 
-    test("returns successful council result with output", async () => {
+    test('returns successful council result with output', async () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager({
         success: true,
-        result: "Synthesized answer from council",
+        result: 'Synthesized answer from council',
         councillorResults: [
           {
-            name: "alpha",
-            model: "openai/gpt-5.4-mini",
-            status: "completed",
-            result: "Alpha says yes",
+            name: 'alpha',
+            model: 'openai/gpt-5.4-mini',
+            status: 'completed',
+            result: 'Alpha says yes',
           },
           {
-            name: "beta",
-            model: "google/gemini-3-pro",
-            status: "completed",
-            result: "Beta says no",
+            name: 'beta',
+            model: 'google/gemini-3-pro',
+            status: 'completed',
+            result: 'Beta says no',
           },
         ],
       });
       const tools = createCouncilTool(ctx, councilManager);
 
       const result = await tools.council_session.execute(
-        { prompt: "Test prompt" },
-        { sessionID: "test-session" } as any
+        { prompt: 'Test prompt' },
+        { sessionID: 'test-session' } as any,
       );
 
-      expect(result).toContain("Synthesized answer from council");
-      expect(result).toContain("Council: 2/2 councillors responded");
+      expect(result).toContain('Synthesized answer from council');
+      expect(result).toContain('Council: 2/2 councillors responded');
     });
 
-    test("appends councillor summary to successful result", async () => {
+    test('appends councillor summary to successful result', async () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager({
         success: true,
-        result: "Main answer",
+        result: 'Main answer',
         councillorResults: [
-          { name: "alpha", status: "completed", result: "A" },
-          { name: "beta", status: "completed", result: "B" },
-          { name: "gamma", status: "completed", result: "G" },
+          { name: 'alpha', status: 'completed', result: 'A' },
+          { name: 'beta', status: 'completed', result: 'B' },
+          { name: 'gamma', status: 'completed', result: 'G' },
         ],
       });
       const tools = createCouncilTool(ctx, councilManager);
 
-      const result = await tools.council_session.execute({ prompt: "Test" }, {
-        sessionID: "test",
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
       } as any);
 
-      expect(result).toContain("Main answer");
-      expect(result).toContain("Council: 3/3 councillors responded");
+      expect(result).toContain('Main answer');
+      expect(result).toContain('Council: 3/3 councillors responded');
       expect(result).toMatch(/---\s*\*Council:/);
     });
 
-    test("handles mixed councillor success/failure in summary", async () => {
+    test('handles mixed councillor success/failure in summary', async () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager({
         success: true,
-        result: "Answer",
+        result: 'Answer',
         councillorResults: [
-          { name: "alpha", status: "completed", result: "A" },
-          { name: "beta", status: "failed", error: "Error" },
-          { name: "gamma", status: "completed", result: "G" },
+          { name: 'alpha', status: 'completed', result: 'A' },
+          { name: 'beta', status: 'failed', error: 'Error' },
+          { name: 'gamma', status: 'completed', result: 'G' },
         ],
       });
       const tools = createCouncilTool(ctx, councilManager);
 
-      const result = await tools.council_session.execute({ prompt: "Test" }, {
-        sessionID: "test",
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
       } as any);
 
       // Summary should only count completed councillors
-      expect(result).toContain("Council: 2/3 councillors responded");
+      expect(result).toContain('Council: 2/3 councillors responded');
     });
 
-    test("handles all councillors failing", async () => {
+    test('handles all councillors failing', async () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager({
         success: false,
-        error: "All councillors failed",
+        error: 'All councillors failed',
         result: undefined,
         councillorResults: [
-          { name: "alpha", status: "failed", error: "Failed" },
-          { name: "beta", status: "timed_out", error: "Timeout" },
+          { name: 'alpha', status: 'failed', error: 'Failed' },
+          { name: 'beta', status: 'timed_out', error: 'Timeout' },
         ],
       });
       const tools = createCouncilTool(ctx, councilManager);
 
-      const result = await tools.council_session.execute({ prompt: "Test" }, {
-        sessionID: "test",
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
       } as any);
 
-      expect(result).toContain("Council session failed");
-      expect(result).toContain("All councillors failed");
+      expect(result).toContain('Council session failed');
+      expect(result).toContain('All councillors failed');
     });
 
-    test("handles case when result is undefined", async () => {
+    test('handles case when result is undefined', async () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager({
         success: true,
         result: undefined,
         councillorResults: [
-          { name: "alpha", status: "completed", result: "A" },
+          { name: 'alpha', status: 'completed', result: 'A' },
         ],
       });
       const tools = createCouncilTool(ctx, councilManager);
 
-      const result = await tools.council_session.execute({ prompt: "Test" }, {
-        sessionID: "test",
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
       } as any);
 
       // Tool uses result ?? '(No output)', so it should show (No output)
       // But the mock manager is returning undefined in the outer object
       // The tool actually gets the result from the returned object
-      expect(result).toContain("Council: 1/1 councillors responded");
+      expect(result).toContain('Council: 1/1 councillors responded');
     });
 
-    test("converts prompt to string", async () => {
+    test('converts prompt to string', async () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager();
       const tools = createCouncilTool(ctx, councilManager);
 
       await tools.council_session.execute({ prompt: 12345 as any }, {
-        sessionID: "test",
+        sessionID: 'test',
       } as any);
 
       expect(councilManager.runCouncil).toHaveBeenCalledWith(
-        "12345",
+        '12345',
         undefined,
-        "test"
+        'test',
       );
     });
 
-    test("handles preset as non-string (falls back to undefined)", async () => {
+    test('handles preset as non-string (falls back to undefined)', async () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager();
       const tools = createCouncilTool(ctx, councilManager);
 
       await tools.council_session.execute(
-        { preset: 123 as any, prompt: "Test" },
-        { sessionID: "test" } as any
+        { preset: 123 as any, prompt: 'Test' },
+        { sessionID: 'test' } as any,
       );
 
       expect(councilManager.runCouncil).toHaveBeenCalledWith(
-        "Test",
+        'Test',
         undefined,
-        "test"
+        'test',
       );
     });
   });
 
-  describe("error handling", () => {
-    test("throws error when toolContext is missing", async () => {
+  describe('error handling', () => {
+    test('throws error when toolContext is missing', async () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager();
       const tools = createCouncilTool(ctx, councilManager);
 
       await expect(
-        tools.council_session.execute({ prompt: "Test" }, undefined as any)
-      ).rejects.toThrow("Invalid toolContext");
+        tools.council_session.execute({ prompt: 'Test' }, undefined as any),
+      ).rejects.toThrow('Invalid toolContext');
     });
 
-    test("throws error when toolContext is not object", async () => {
+    test('throws error when toolContext is not object', async () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager();
       const tools = createCouncilTool(ctx, councilManager);
 
       await expect(
-        tools.council_session.execute({ prompt: "Test" }, "invalid" as any)
-      ).rejects.toThrow("Invalid toolContext");
+        tools.council_session.execute({ prompt: 'Test' }, 'invalid' as any),
+      ).rejects.toThrow('Invalid toolContext');
     });
 
-    test("throws error when toolContext is missing sessionID", async () => {
+    test('throws error when toolContext is missing sessionID', async () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager();
       const tools = createCouncilTool(ctx, councilManager);
 
       await expect(
-        tools.council_session.execute({ prompt: "Test" }, {} as any)
-      ).rejects.toThrow("Invalid toolContext");
+        tools.council_session.execute({ prompt: 'Test' }, {} as any),
+      ).rejects.toThrow('Invalid toolContext');
     });
 
-    test("handles CouncilManager throwing exception", async () => {
+    test('handles CouncilManager throwing exception', async () => {
       const ctx = createMockPluginContext();
       const councilManager = {
         runCouncil: mock(async () => {
-          throw new Error("Council manager crashed");
+          throw new Error('Council manager crashed');
         }),
         getDeprecatedFields: mock(() => undefined),
       } as unknown as CouncilManager;
       const tools = createCouncilTool(ctx, councilManager);
 
       await expect(
-        tools.council_session.execute({ prompt: "Test" }, {
-          sessionID: "test",
-        } as any)
-      ).rejects.toThrow("Council manager crashed");
+        tools.council_session.execute({ prompt: 'Test' }, {
+          sessionID: 'test',
+        } as any),
+      ).rejects.toThrow('Council manager crashed');
     });
   });
 
-  describe("agent guard", () => {
-    test("allows council agent to invoke council session", async () => {
+  describe('agent guard', () => {
+    test('allows council agent to invoke council session', async () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager({
         success: true,
-        result: "Synthesised answer",
+        result: 'Synthesised answer',
         councillorResults: [
-          { name: "alpha", status: "completed", result: "A" },
+          { name: 'alpha', status: 'completed', result: 'A' },
         ],
       });
       const tools = createCouncilTool(ctx, councilManager);
 
-      const result = await tools.council_session.execute({ prompt: "Test" }, {
-        sessionID: "test",
-        agent: "council",
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
+        agent: 'council',
       } as any);
 
-      expect(result).toContain("Synthesised answer");
+      expect(result).toContain('Synthesised answer');
       expect(councilManager.runCouncil).toHaveBeenCalledTimes(1);
     });
 
-    test("blocks orchestrator agent from invoking council session", async () => {
+    test('blocks orchestrator agent from invoking council session', async () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager();
       const tools = createCouncilTool(ctx, councilManager);
 
       expect(
-        tools.council_session.execute({ prompt: "Test" }, {
-          sessionID: "test",
-          agent: "orchestrator",
-        } as any)
+        tools.council_session.execute({ prompt: 'Test' }, {
+          sessionID: 'test',
+          agent: 'orchestrator',
+        } as any),
       ).rejects.toThrow(
-        "Council sessions can only be invoked by the council agent"
+        'Council sessions can only be invoked by the council agent',
       );
       expect(councilManager.runCouncil).not.toHaveBeenCalled();
     });
 
-    test("blocks disallowed agents from invoking council session", async () => {
+    test('blocks disallowed agents from invoking council session', async () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager();
       const tools = createCouncilTool(ctx, councilManager);
 
       expect(
-        tools.council_session.execute({ prompt: "Test" }, {
-          sessionID: "test",
-          agent: "explorer",
-        } as any)
+        tools.council_session.execute({ prompt: 'Test' }, {
+          sessionID: 'test',
+          agent: 'explorer',
+        } as any),
       ).rejects.toThrow(
-        "Council sessions can only be invoked by the council agent"
+        'Council sessions can only be invoked by the council agent',
       );
       expect(councilManager.runCouncil).not.toHaveBeenCalled();
     });
 
-    test("allows undefined agent (backward compatible)", async () => {
+    test('allows undefined agent (backward compatible)', async () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager({
         success: true,
-        result: "Synthesised answer",
+        result: 'Synthesised answer',
         councillorResults: [
-          { name: "alpha", status: "completed", result: "A" },
+          { name: 'alpha', status: 'completed', result: 'A' },
         ],
       });
       const tools = createCouncilTool(ctx, councilManager);
 
-      const result = await tools.council_session.execute({ prompt: "Test" }, {
-        sessionID: "test",
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
       } as any);
 
-      expect(result).toContain("Synthesised answer");
+      expect(result).toContain('Synthesised answer');
       expect(councilManager.runCouncil).toHaveBeenCalledTimes(1);
     });
   });
 
-  describe("edge cases", () => {
-    test("handles empty councillor results", async () => {
+  describe('edge cases', () => {
+    test('handles empty councillor results', async () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager({
         success: false,
-        error: "No councillors",
+        error: 'No councillors',
         result: undefined,
         councillorResults: [],
       });
       const tools = createCouncilTool(ctx, councilManager);
 
-      const result = await tools.council_session.execute({ prompt: "Test" }, {
-        sessionID: "test",
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
       } as any);
 
       // When success is false, tool returns error message without summary
-      expect(result).toContain("Council session failed");
-      expect(result).toContain("No councillors");
+      expect(result).toContain('Council session failed');
+      expect(result).toContain('No councillors');
     });
 
-    test("handles all councillors timed out", async () => {
+    test('handles all councillors timed out', async () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager({
         success: false,
-        error: "All timed out",
+        error: 'All timed out',
         result: undefined,
         councillorResults: [
-          { name: "alpha", status: "timed_out", error: "Timeout" },
-          { name: "beta", status: "timed_out", error: "Timeout" },
+          { name: 'alpha', status: 'timed_out', error: 'Timeout' },
+          { name: 'beta', status: 'timed_out', error: 'Timeout' },
         ],
       });
       const tools = createCouncilTool(ctx, councilManager);
 
-      const result = await tools.council_session.execute({ prompt: "Test" }, {
-        sessionID: "test",
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
       } as any);
 
       // When success is false, tool returns error message without summary
-      expect(result).toContain("Council session failed");
-      expect(result).toContain("All timed out");
+      expect(result).toContain('Council session failed');
+      expect(result).toContain('All timed out');
     });
 
-    test("handles single successful councillor", async () => {
+    test('handles single successful councillor', async () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager({
         success: true,
-        result: "Single result",
+        result: 'Single result',
         councillorResults: [
-          { name: "solo", status: "completed", result: "Solo answer" },
+          { name: 'solo', status: 'completed', result: 'Solo answer' },
         ],
       });
       const tools = createCouncilTool(ctx, councilManager);
 
-      const result = await tools.council_session.execute({ prompt: "Test" }, {
-        sessionID: "test",
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
       } as any);
 
-      expect(result).toContain("Single result");
-      expect(result).toContain("Council: 1/1 councillors responded");
+      expect(result).toContain('Single result');
+      expect(result).toContain('Council: 1/1 councillors responded');
     });
 
-    test("handles many councillors", async () => {
+    test('handles many councillors', async () => {
       const ctx = createMockPluginContext();
       const councilManager = createMockCouncilManager({
         success: true,
-        result: "Multi result",
+        result: 'Multi result',
         councillorResults: Array.from({ length: 10 }, (_, i) => ({
           name: `councillor${i}`,
-          status: "completed",
+          status: 'completed',
           result: `Response ${i}`,
         })),
       });
       const tools = createCouncilTool(ctx, councilManager);
 
-      const result = await tools.council_session.execute({ prompt: "Test" }, {
-        sessionID: "test",
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
       } as any);
 
-      expect(result).toContain("Council: 10/10 councillors responded");
+      expect(result).toContain('Council: 10/10 councillors responded');
     });
 
-    test("includes deprecation warning when deprecated config fields detected", async () => {
+    test('includes deprecation warning when deprecated config fields detected', async () => {
       const ctx = createMockPluginContext();
       const councilManager = {
         runCouncil: mock(async () => ({
           success: true,
-          result: "Synthesized response",
+          result: 'Synthesized response',
           councillorResults: [
             {
-              name: "alpha",
-              model: "test/model",
-              status: "completed",
-              result: "Response",
+              name: 'alpha',
+              model: 'test/model',
+              status: 'completed',
+              result: 'Response',
             },
           ],
         })),
-        getDeprecatedFields: mock(() => ["master", "master_timeout"]),
+        getDeprecatedFields: mock(() => ['master', 'master_timeout']),
         getLegacyMasterModel: mock(() => undefined),
       } as unknown as CouncilManager;
       const tools = createCouncilTool(ctx, councilManager);
 
-      const result = await tools.council_session.execute({ prompt: "Test" }, {
-        sessionID: "test",
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
       } as any);
 
-      expect(result).toContain("Config warning");
-      expect(result).toContain("`council.master`");
-      expect(result).toContain("`council.master_timeout`");
+      expect(result).toContain('Config warning');
+      expect(result).toContain('`council.master`');
+      expect(result).toContain('`council.master_timeout`');
       // master with no legacy model → both treated as ignored
-      expect(result).toContain("deprecated and ignored");
+      expect(result).toContain('deprecated and ignored');
     });
 
-    test("includes fallback warning when legacy master.model is used", async () => {
+    test('includes fallback warning when legacy master.model is used', async () => {
       const ctx = createMockPluginContext();
       const councilManager = {
         runCouncil: mock(async () => ({
           success: true,
-          result: "Synthesized response",
+          result: 'Synthesized response',
           councillorResults: [
             {
-              name: "alpha",
-              model: "test/model",
-              status: "completed",
-              result: "Response",
+              name: 'alpha',
+              model: 'test/model',
+              status: 'completed',
+              result: 'Response',
             },
           ],
         })),
-        getDeprecatedFields: mock(() => ["master", "master_timeout"]),
-        getLegacyMasterModel: mock(() => "anthropic/claude-opus-4-6"),
+        getDeprecatedFields: mock(() => ['master', 'master_timeout']),
+        getLegacyMasterModel: mock(() => 'anthropic/claude-opus-4-6'),
       } as unknown as CouncilManager;
       const tools = createCouncilTool(ctx, councilManager);
 
-      const result = await tools.council_session.execute({ prompt: "Test" }, {
-        sessionID: "test",
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
       } as any);
 
-      expect(result).toContain("Config warning");
-      expect(result).toContain("`council.master`");
+      expect(result).toContain('Config warning');
+      expect(result).toContain('`council.master`');
       // master with legacy model → fallback warning
-      expect(result).toContain("fallback for the council agent");
+      expect(result).toContain('fallback for the council agent');
       // master_timeout is still "ignored"
-      expect(result).toContain("deprecated and ignored");
+      expect(result).toContain('deprecated and ignored');
     });
   });
 });

+ 25 - 25
src/tools/council.ts

@@ -2,9 +2,9 @@ import {
   type PluginInput,
   type ToolDefinition,
   tool,
-} from "@opencode-ai/plugin";
-import type { CouncilManager } from "../council/council-manager";
-import { shortModelLabel } from "../utils/session";
+} from '@opencode-ai/plugin';
+import type { CouncilManager } from '../council/council-manager';
+import { shortModelLabel } from '../utils/session';
 
 const z = tool.schema;
 
@@ -13,14 +13,14 @@ const z = tool.schema;
  * Shows short model labels per councillor: "α: gpt-5.4-mini, β: gemini-3-pro"
  */
 function formatModelComposition(
-  councillorResults: Array<{ name: string; model: string }>
+  councillorResults: Array<{ name: string; model: string }>,
 ): string {
   return councillorResults
     .map((cr) => {
       const shortModel = shortModelLabel(cr.model);
       return `${cr.name}: ${shortModel}`;
     })
-    .join(", ");
+    .join(', ');
 }
 
 /**
@@ -32,7 +32,7 @@ function formatModelComposition(
  */
 export function createCouncilTool(
   _ctx: PluginInput,
-  councilManager: CouncilManager
+  councilManager: CouncilManager,
 ): Record<string, ToolDefinition> {
   const council_session = tool({
     description: `Launch a multi-LLM council session for consensus-based analysis.
@@ -41,52 +41,52 @@ Sends the prompt to multiple models (councillors) in parallel and returns their
 
 Returns the councillor responses with a summary footer.`,
     args: {
-      prompt: z.string().describe("The prompt to send to all councillors"),
+      prompt: z.string().describe('The prompt to send to all councillors'),
       preset: z
         .string()
         .optional()
         .describe(
-          'Council preset to use (default: "default"). Must match a preset in the council config.'
+          'Council preset to use (default: "default"). Must match a preset in the council config.',
         ),
     },
     async execute(args, toolContext) {
       if (
         !toolContext ||
-        typeof toolContext !== "object" ||
-        !("sessionID" in toolContext)
+        typeof toolContext !== 'object' ||
+        !('sessionID' in toolContext)
       ) {
-        throw new Error("Invalid toolContext: missing sessionID");
+        throw new Error('Invalid toolContext: missing sessionID');
       }
 
       // Guard: Only the council agent can invoke council sessions.
       // If agent is missing from context, allow through (backward compatible).
-      const allowedAgents = ["council"];
+      const allowedAgents = ['council'];
       const callingAgent = (toolContext as { agent?: string }).agent;
       if (callingAgent && !allowedAgents.includes(callingAgent)) {
         throw new Error(
-          `Council sessions can only be invoked by the council agent. Current agent: ${callingAgent}`
+          `Council sessions can only be invoked by the council agent. Current agent: ${callingAgent}`,
         );
       }
 
       const prompt = String(args.prompt);
-      const preset = typeof args.preset === "string" ? args.preset : undefined;
+      const preset = typeof args.preset === 'string' ? args.preset : undefined;
       const parentSessionId = (toolContext as { sessionID: string }).sessionID;
 
       const result = await councilManager.runCouncil(
         prompt,
         preset,
-        parentSessionId
+        parentSessionId,
       );
 
       if (!result.success) {
         return `Council session failed: ${result.error}`;
       }
 
-      let output = result.result ?? "(No output)";
+      let output = result.result ?? '(No output)';
 
       // Append councillor summary for transparency
       const completed = result.councillorResults.filter(
-        (cr) => cr.status === "completed"
+        (cr) => cr.status === 'completed',
       ).length;
       const total = result.councillorResults.length;
       const composition = formatModelComposition(result.councillorResults);
@@ -97,27 +97,27 @@ Returns the councillor responses with a summary footer.`,
       const deprecated = councilManager.getDeprecatedFields();
       if (deprecated && deprecated.length > 0) {
         const legacyMasterModel = councilManager.getLegacyMasterModel();
-        const hasMaster = deprecated.includes("master");
+        const hasMaster = deprecated.includes('master');
         const trulyIgnored =
           hasMaster && !legacyMasterModel
             ? deprecated // master has no model → treat as ignored too
-            : deprecated.filter((f) => f !== "master");
+            : deprecated.filter((f) => f !== 'master');
         const parts: string[] = [];
         if (hasMaster && legacyMasterModel) {
           parts.push(
-            `\`council.master\` is deprecated and will be removed in a future version. Its \`model\` is currently used as a fallback for the council agent — add a \`council\` entry to your preset to make this explicit.`
+            `\`council.master\` is deprecated and will be removed in a future version. Its \`model\` is currently used as a fallback for the council agent — add a \`council\` entry to your preset to make this explicit.`,
           );
         }
         if (trulyIgnored.length > 0) {
           parts.push(
-            `${trulyIgnored.map((f) => `\`council.${f}\``).join(", ")} ${
-              trulyIgnored.length === 1 ? "is" : "are"
+            `${trulyIgnored.map((f) => `\`council.${f}\``).join(', ')} ${
+              trulyIgnored.length === 1 ? 'is' : 'are'
             } deprecated and ignored — remove ${
-              trulyIgnored.length === 1 ? "it" : "them"
-            } from your config.`
+              trulyIgnored.length === 1 ? 'it' : 'them'
+            } from your config.`,
           );
         }
-        output += `\n⚠ Config warning: ${parts.join(" ")}`;
+        output += `\n⚠ Config warning: ${parts.join(' ')}`;
       }
 
       return output;

+ 101 - 101
src/utils/agent-variant.test.ts

@@ -1,264 +1,264 @@
-import { describe, expect, test } from "bun:test";
-import type { PluginConfig } from "../config";
+import { describe, expect, test } from 'bun:test';
+import type { PluginConfig } from '../config';
 import {
   applyAgentVariant,
   normalizeAgentName,
   resolveAgentVariant,
   resolveRuntimeAgentName,
   rewriteDisplayNameMentions,
-} from "./agent-variant";
+} from './agent-variant';
 
-describe("normalizeAgentName", () => {
-  test("returns name unchanged if no @ prefix", () => {
-    expect(normalizeAgentName("oracle")).toBe("oracle");
+describe('normalizeAgentName', () => {
+  test('returns name unchanged if no @ prefix', () => {
+    expect(normalizeAgentName('oracle')).toBe('oracle');
   });
 
-  test("strips @ prefix from agent name", () => {
-    expect(normalizeAgentName("@oracle")).toBe("oracle");
+  test('strips @ prefix from agent name', () => {
+    expect(normalizeAgentName('@oracle')).toBe('oracle');
   });
 
-  test("trims whitespace", () => {
-    expect(normalizeAgentName("  oracle  ")).toBe("oracle");
+  test('trims whitespace', () => {
+    expect(normalizeAgentName('  oracle  ')).toBe('oracle');
   });
 
-  test("handles @ prefix with whitespace", () => {
-    expect(normalizeAgentName("  @explore  ")).toBe("explore");
+  test('handles @ prefix with whitespace', () => {
+    expect(normalizeAgentName('  @explore  ')).toBe('explore');
   });
 
-  test("handles empty string", () => {
-    expect(normalizeAgentName("")).toBe("");
+  test('handles empty string', () => {
+    expect(normalizeAgentName('')).toBe('');
   });
 });
 
-describe("resolveAgentVariant", () => {
-  test("returns undefined when config is undefined", () => {
-    expect(resolveAgentVariant(undefined, "oracle")).toBeUndefined();
+describe('resolveAgentVariant', () => {
+  test('returns undefined when config is undefined', () => {
+    expect(resolveAgentVariant(undefined, 'oracle')).toBeUndefined();
   });
 
-  test("returns undefined when agents is undefined", () => {
+  test('returns undefined when agents is undefined', () => {
     const config = {} as PluginConfig;
-    expect(resolveAgentVariant(config, "oracle")).toBeUndefined();
+    expect(resolveAgentVariant(config, 'oracle')).toBeUndefined();
   });
 
-  test("returns undefined when agent has no variant", () => {
+  test('returns undefined when agent has no variant', () => {
     const config = {
       agents: {
-        oracle: { model: "gpt-4" },
+        oracle: { model: 'gpt-4' },
       },
     } as PluginConfig;
-    expect(resolveAgentVariant(config, "oracle")).toBeUndefined();
+    expect(resolveAgentVariant(config, 'oracle')).toBeUndefined();
   });
 
-  test("returns variant when configured", () => {
+  test('returns variant when configured', () => {
     const config = {
       agents: {
-        oracle: { variant: "high" },
+        oracle: { variant: 'high' },
       },
     } as PluginConfig;
-    expect(resolveAgentVariant(config, "oracle")).toBe("high");
+    expect(resolveAgentVariant(config, 'oracle')).toBe('high');
   });
 
-  test("normalizes agent name with @ prefix", () => {
+  test('normalizes agent name with @ prefix', () => {
     const config = {
       agents: {
-        oracle: { variant: "low" },
+        oracle: { variant: 'low' },
       },
     } as PluginConfig;
-    expect(resolveAgentVariant(config, "@oracle")).toBe("low");
+    expect(resolveAgentVariant(config, '@oracle')).toBe('low');
   });
 
-  test("returns undefined for empty string variant", () => {
+  test('returns undefined for empty string variant', () => {
     const config = {
       agents: {
-        oracle: { variant: "" },
+        oracle: { variant: '' },
       },
     } as PluginConfig;
-    expect(resolveAgentVariant(config, "oracle")).toBeUndefined();
+    expect(resolveAgentVariant(config, 'oracle')).toBeUndefined();
   });
 
-  test("returns undefined for whitespace-only variant", () => {
+  test('returns undefined for whitespace-only variant', () => {
     const config = {
       agents: {
-        oracle: { variant: "   " },
+        oracle: { variant: '   ' },
       },
     } as PluginConfig;
-    expect(resolveAgentVariant(config, "oracle")).toBeUndefined();
+    expect(resolveAgentVariant(config, 'oracle')).toBeUndefined();
   });
 
-  test("trims variant whitespace", () => {
+  test('trims variant whitespace', () => {
     const config = {
       agents: {
-        oracle: { variant: "  medium  " },
+        oracle: { variant: '  medium  ' },
       },
     } as PluginConfig;
-    expect(resolveAgentVariant(config, "oracle")).toBe("medium");
+    expect(resolveAgentVariant(config, 'oracle')).toBe('medium');
   });
 
-  test("returns undefined for non-string variant", () => {
+  test('returns undefined for non-string variant', () => {
     const config = {
       agents: {
         oracle: { variant: 123 as unknown as string },
       },
     } as PluginConfig;
-    expect(resolveAgentVariant(config, "oracle")).toBeUndefined();
+    expect(resolveAgentVariant(config, 'oracle')).toBeUndefined();
   });
 
-  test("resolves displayName alias to internal agent for variant lookup", () => {
+  test('resolves displayName alias to internal agent for variant lookup', () => {
     const config = {
       agents: {
-        oracle: { displayName: "advisor", variant: "high" },
+        oracle: { displayName: 'advisor', variant: 'high' },
       },
     } as PluginConfig;
-    expect(resolveAgentVariant(config, "@advisor")).toBe("high");
+    expect(resolveAgentVariant(config, '@advisor')).toBe('high');
   });
 });
 
-describe("resolveRuntimeAgentName", () => {
-  test("keeps internal agent names unchanged", () => {
+describe('resolveRuntimeAgentName', () => {
+  test('keeps internal agent names unchanged', () => {
     const config = {
       agents: {
-        oracle: { displayName: "advisor" },
+        oracle: { displayName: 'advisor' },
       },
     } as PluginConfig;
 
-    expect(resolveRuntimeAgentName(config, "oracle")).toBe("oracle");
+    expect(resolveRuntimeAgentName(config, 'oracle')).toBe('oracle');
   });
 
-  test("resolves displayName to internal name", () => {
+  test('resolves displayName to internal name', () => {
     const config = {
       agents: {
-        oracle: { displayName: "advisor" },
+        oracle: { displayName: 'advisor' },
       },
     } as PluginConfig;
 
-    expect(resolveRuntimeAgentName(config, "advisor")).toBe("oracle");
+    expect(resolveRuntimeAgentName(config, 'advisor')).toBe('oracle');
   });
 
-  test("resolves displayName with @ prefix and whitespace", () => {
+  test('resolves displayName with @ prefix and whitespace', () => {
     const config = {
       agents: {
-        oracle: { displayName: "advisor" },
+        oracle: { displayName: 'advisor' },
       },
     } as PluginConfig;
 
-    expect(resolveRuntimeAgentName(config, "  @advisor  ")).toBe("oracle");
+    expect(resolveRuntimeAgentName(config, '  @advisor  ')).toBe('oracle');
   });
 
-  test("resolves displayName configured via legacy alias key", () => {
+  test('resolves displayName configured via legacy alias key', () => {
     const config = {
       agents: {
-        explore: { displayName: "researcher" },
+        explore: { displayName: 'researcher' },
       },
     } as PluginConfig;
 
-    expect(resolveRuntimeAgentName(config, "researcher")).toBe("explorer");
+    expect(resolveRuntimeAgentName(config, 'researcher')).toBe('explorer');
   });
 
-  test("returns normalized name when no displayName match exists", () => {
+  test('returns normalized name when no displayName match exists', () => {
     const config = {
       agents: {
-        oracle: { displayName: "advisor" },
+        oracle: { displayName: 'advisor' },
       },
     } as PluginConfig;
 
-    expect(resolveRuntimeAgentName(config, "  @unknown  ")).toBe("unknown");
+    expect(resolveRuntimeAgentName(config, '  @unknown  ')).toBe('unknown');
   });
 });
 
-describe("rewriteDisplayNameMentions", () => {
-  test("rewrites displayName mentions to internal names for direct invocation", () => {
+describe('rewriteDisplayNameMentions', () => {
+  test('rewrites displayName mentions to internal names for direct invocation', () => {
     const config = {
       agents: {
-        oracle: { displayName: "advisor" },
+        oracle: { displayName: 'advisor' },
       },
     } as PluginConfig;
 
-    expect(rewriteDisplayNameMentions(config, "ask @advisor about this")).toBe(
-      "ask @oracle about this"
+    expect(rewriteDisplayNameMentions(config, 'ask @advisor about this')).toBe(
+      'ask @oracle about this',
     );
   });
 
-  test("keeps internal mentions working while rewriting aliases", () => {
+  test('keeps internal mentions working while rewriting aliases', () => {
     const config = {
       agents: {
-        oracle: { displayName: "advisor" },
+        oracle: { displayName: 'advisor' },
       },
     } as PluginConfig;
 
     expect(
-      rewriteDisplayNameMentions(config, "compare @advisor with @oracle")
-    ).toBe("compare @oracle with @oracle");
+      rewriteDisplayNameMentions(config, 'compare @advisor with @oracle'),
+    ).toBe('compare @oracle with @oracle');
   });
 
-  test("does not rewrite embedded text such as email addresses", () => {
+  test('does not rewrite embedded text such as email addresses', () => {
     const config = {
       agents: {
-        oracle: { displayName: "advisor" },
+        oracle: { displayName: 'advisor' },
       },
     } as PluginConfig;
 
     expect(
       rewriteDisplayNameMentions(
         config,
-        "email foo@advisor.com and ask @advisor directly"
-      )
-    ).toBe("email foo@advisor.com and ask @oracle directly");
+        'email foo@advisor.com and ask @advisor directly',
+      ),
+    ).toBe('email foo@advisor.com and ask @oracle directly');
   });
 
-  test("resolves custom agents by displayName for variant/runtime lookups", () => {
+  test('resolves custom agents by displayName for variant/runtime lookups', () => {
     const config = {
       agents: {
-        "custom-reviewer": {
-          displayName: "reviewer",
-          variant: "high",
-          model: "openai/gpt-5.5",
+        'custom-reviewer': {
+          displayName: 'reviewer',
+          variant: 'high',
+          model: 'openai/gpt-5.5',
         },
       },
     } as PluginConfig;
 
-    expect(resolveRuntimeAgentName(config, "@reviewer")).toBe(
-      "custom-reviewer"
+    expect(resolveRuntimeAgentName(config, '@reviewer')).toBe(
+      'custom-reviewer',
     );
     expect(
-      rewriteDisplayNameMentions(config, "ask @reviewer for details")
-    ).toBe("ask @custom-reviewer for details");
-    expect(resolveAgentVariant(config, "@reviewer")).toBe("high");
+      rewriteDisplayNameMentions(config, 'ask @reviewer for details'),
+    ).toBe('ask @custom-reviewer for details');
+    expect(resolveAgentVariant(config, '@reviewer')).toBe('high');
   });
 });
 
-describe("applyAgentVariant", () => {
-  test("returns body unchanged when variant is undefined", () => {
-    const body = { agent: "oracle", parts: [] };
+describe('applyAgentVariant', () => {
+  test('returns body unchanged when variant is undefined', () => {
+    const body = { agent: 'oracle', parts: [] };
     const result = applyAgentVariant(undefined, body);
     expect(result).toEqual(body);
     expect(result).toBe(body); // Same reference
   });
 
-  test("returns body unchanged when body already has variant", () => {
-    const body = { agent: "oracle", variant: "medium", parts: [] };
-    const result = applyAgentVariant("high", body);
-    expect(result.variant).toBe("medium");
+  test('returns body unchanged when body already has variant', () => {
+    const body = { agent: 'oracle', variant: 'medium', parts: [] };
+    const result = applyAgentVariant('high', body);
+    expect(result.variant).toBe('medium');
     expect(result).toBe(body); // Same reference
   });
 
-  test("applies variant to body without variant", () => {
-    const body = { agent: "oracle", parts: [] };
-    const result = applyAgentVariant("high", body);
-    expect(result.variant).toBe("high");
-    expect(result.agent).toBe("oracle");
+  test('applies variant to body without variant', () => {
+    const body = { agent: 'oracle', parts: [] };
+    const result = applyAgentVariant('high', body);
+    expect(result.variant).toBe('high');
+    expect(result.agent).toBe('oracle');
     expect(result).not.toBe(body); // New object
   });
 
-  test("preserves all existing body properties", () => {
+  test('preserves all existing body properties', () => {
     const body = {
-      agent: "oracle",
-      parts: [{ type: "text" as const, text: "hello" }],
+      agent: 'oracle',
+      parts: [{ type: 'text' as const, text: 'hello' }],
       tools: { task: false },
     };
-    const result = applyAgentVariant("low", body);
-    expect(result.agent).toBe("oracle");
-    expect(result.parts).toEqual([{ type: "text", text: "hello" }]);
+    const result = applyAgentVariant('low', body);
+    expect(result.agent).toBe('oracle');
+    expect(result.parts).toEqual([{ type: 'text', text: 'hello' }]);
     expect(result.tools).toEqual({ task: false });
-    expect(result.variant).toBe("low");
+    expect(result.variant).toBe('low');
   });
 });

+ 13 - 13
src/utils/session.ts

@@ -2,16 +2,16 @@
  * Shared session utilities for council and background managers.
  */
 
-import type { PluginInput } from "@opencode-ai/plugin";
+import type { PluginInput } from '@opencode-ai/plugin';
 
-type OpencodeClient = PluginInput["client"];
+type OpencodeClient = PluginInput['client'];
 
 /**
  * Extract the short model label from a "provider/model" string.
  * E.g. "openai/gpt-5.4-mini" → "gpt-5.4-mini"
  */
 export function shortModelLabel(model: string): string {
-  return model.split("/").pop() ?? model;
+  return model.split('/').pop() ?? model;
 }
 
 export type PromptBody = {
@@ -21,7 +21,7 @@ export type PromptBody = {
   noReply?: boolean;
   system?: string;
   tools?: { [key: string]: boolean };
-  parts: Array<{ type: "text"; text: string }>;
+  parts: Array<{ type: 'text'; text: string }>;
   variant?: string;
 };
 
@@ -31,9 +31,9 @@ export type PromptBody = {
  * @returns Object with providerID and modelID, or null if invalid
  */
 export function parseModelReference(
-  model: string
+  model: string,
 ): { providerID: string; modelID: string } | null {
-  const slashIndex = model.indexOf("/");
+  const slashIndex = model.indexOf('/');
   if (slashIndex <= 0 || slashIndex >= model.length - 1) {
     return null;
   }
@@ -53,8 +53,8 @@ export function parseModelReference(
  */
 export async function promptWithTimeout(
   client: OpencodeClient,
-  args: Parameters<OpencodeClient["session"]["prompt"]>[0],
-  timeoutMs: number
+  args: Parameters<OpencodeClient['session']['prompt']>[0],
+  timeoutMs: number,
 ): Promise<void> {
   if (timeoutMs <= 0) {
     await client.session.prompt(args);
@@ -104,7 +104,7 @@ export interface SessionExtractionResult {
 export async function extractSessionResult(
   client: OpencodeClient,
   sessionId: string,
-  options?: { includeReasoning?: boolean }
+  options?: { includeReasoning?: boolean },
 ): Promise<SessionExtractionResult> {
   const includeReasoning = options?.includeReasoning ?? true;
 
@@ -116,21 +116,21 @@ export async function extractSessionResult(
     parts?: Array<{ type: string; text?: string }>;
   }>;
   const assistantMessages = messages.filter(
-    (m) => m.info?.role === "assistant"
+    (m) => m.info?.role === 'assistant',
   );
 
   const extractedContent: string[] = [];
   for (const message of assistantMessages) {
     for (const part of message.parts ?? []) {
       const allowed = includeReasoning
-        ? part.type === "text" || part.type === "reasoning"
-        : part.type === "text";
+        ? part.type === 'text' || part.type === 'reasoning'
+        : part.type === 'text';
       if (allowed && part.text) {
         extractedContent.push(part.text);
       }
     }
   }
 
-  const text = extractedContent.filter((t) => t.length > 0).join("\n\n");
+  const text = extractedContent.filter((t) => t.length > 0).join('\n\n');
   return { text, empty: text.length === 0 };
 }

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä