Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# AGENTS.md

> On 1st message, greet user with: "AI-Driven Development On ⚡"
> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v{version} · [Releases](https://github.com/ai-driven-dev/framework/releases)", {version} being the one the `AIDD framework:` session context reports; when it reports none, write "Framework version unknown" in its place, never a plugin or CLI version. When that context announces an update, add that notice verbatim on the next line.

## Behavior

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CLAUDE.md

> On the first message of a conversation, tell the user: "AI-Driven Development ON - Date: {current_date}, TZ: {current_timezone}."
> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v{version} · [Releases](https://github.com/ai-driven-dev/framework/releases)", {version} being the one the `AIDD framework:` session context reports; when it reports none, write "Framework version unknown" in its place, never a plugin or CLI version. When that context announces an update, add that notice verbatim on the next line.

## Behavior

Expand Down
2 changes: 1 addition & 1 deletion cli/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Memory ownership (CLAUDE.md, AGENTS.md, copilot-instructions.md) is delegated to

## Hook adaptation

OpenCode needs JS adapters. The CLI owns the shared host protocol; plugins own payload mapping. Its helper is delivered once outside plugin discovery, tracked as a tool file, and backfilled only when missing. Installation must neither rewrite user configuration nor claim existing untracked helpers. Generic `SessionStart` runs idempotently at host initialization.
OpenCode needs JS adapters. The CLI owns the shared host protocol; plugins own payload mapping. Its helper is delivered once outside plugin discovery, tracked as a tool file, and backfilled only when missing. Installation must neither rewrite user configuration nor claim existing untracked helpers. Generic `SessionStart` runs idempotently at host initialization; what its scripts print (`additionalContext` or plain text, as Claude Code and Codex read it) is added to every model call through V1's `experimental.chat.system.transform` or V2's `session.hook("context")`.

<details>
<summary>Tool compatibility and adapter contracts</summary>
Expand Down
2 changes: 1 addition & 1 deletion cli/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# AGENTS.md

> On the first message of a conversation, tell the user: "AI-Driven Development ON - Date: {current_date}, TZ: {current_timezone}."
> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v{version} · [Releases](https://github.com/ai-driven-dev/framework/releases)", {version} being the one the `AIDD framework:` session context reports; when it reports none, write "Framework version unknown" in its place, never a plugin or CLI version. When that context announces an update, add that notice verbatim on the next line.

## Behavior

Expand Down
21 changes: 21 additions & 0 deletions cli/assets/configs/opencode/opencode-events.js.txt
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,22 @@ export async function setupOpencodeEvents(ctx, factory) {
const controller = new AbortController();
const toolCalls = new Map();
const hooks = await factory({ directory: ctx.location.directory });
const registrations = [];
// V1's system transform, replayed on V2's context hook: same text, one block per model call.
const transform = hooks["experimental.chat.system.transform"];
if (typeof transform === "function" && typeof ctx.session?.hook === "function") {
try {
registrations.push(
await ctx.session.hook("context", async (event) => {
const output = { system: [] };
await transform({}, output);
for (const text of output.system) event.system.push({ type: "text", text });
})
);
} catch {
// A host without the hook keeps the events below.
}
}
// The subscription lives until cleanup. Awaiting it here would block plugin startup.
void (async () => {
try {
Expand All @@ -85,5 +101,10 @@ export async function setupOpencodeEvents(ctx, factory) {
return () => {
controller.abort();
toolCalls.clear();
for (const registration of registrations) {
Promise.resolve()
.then(() => registration?.dispose?.())
.catch(() => {});
}
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
*
* - `SessionStart` runs once per server/directory at factory initialization and requires
* idempotent commands. V1 did not deliver `session.created` to this event hook;
* the V2 adapter delivers it.
* the V2 adapter delivers it. What the scripts print reaches every model call, the context
* Claude Code and Codex add: V1's `experimental.chat.system.transform`, V2's
* `session.hook("context")` through `opencode-events.js`.
* - `Stop` maps to V1's `session.idle` or V2's execution terminal, once per turn.
* - `PostToolUse` maps to `message.part.updated` whose `part.state.status === "completed"`, the
* shape measured live: `part.tool` names the tool, `part.state.input` its arguments.
Expand Down Expand Up @@ -131,20 +133,44 @@ const HOOKS_DIR = fileURLToPath(new URL("../hooks/${plugin}/", import.meta.url))
const SESSION_START = ${callTableLiteral(table.sessionStart)};
const STOP = ${callTableLiteral(table.stop)};
const POST_TOOL_USE = ${callTableLiteral(table.postToolUse)};
const MAX_CONTEXT_CHARS = 16000;

// Asynchronous on purpose: this can spawn one script per matching hook across every mapped
// event, and blocking OpenCode's event loop once per hook would multiply the cost. A failed spawn (ENOENT, a killed timeout)
// must not throw past this function - both listeners below, plus the caller's own
// try/catch, exist because a spawn that never launches can still throw on the stdin write.
function runHook(script, args, payload, directory) {
const child = spawn("node", [HOOKS_DIR + script, ...args], {
cwd: directory,
stdio: ["pipe", "ignore", "ignore"],
timeout: 5000,
// Resolves with the script's stdout once it exits, or "" when it never ran.
function runHook(script, args, payload, directory, { captureOutput = false } = {}) {
return new Promise((resolve) => {
const child = spawn("node", [HOOKS_DIR + script, ...args], {
cwd: directory,
stdio: ["pipe", captureOutput ? "pipe" : "ignore", "ignore"],
timeout: 5000,
});
let output = "";
child.stdout?.on("data", (chunk) => {
if (output.length < MAX_CONTEXT_CHARS) output += chunk;
});
child.on("error", () => resolve(""));
child.on("close", () => resolve(output));
child.stdin.on("error", () => {});
child.stdin.end(JSON.stringify(payload));
});
child.on("error", () => {});
child.stdin.on("error", () => {});
child.stdin.end(JSON.stringify(payload));
}

/** Pure: a SessionStart script's stdout -> the context Claude Code and Codex would add for it:
* \`hookSpecificOutput.additionalContext\` from JSON, plain text as is, "" for nothing. A
* \`systemMessage\` has no OpenCode surface, and the context already carries what it says. */
function sessionContextOf(output) {
const text = output.trim();
if (text === "") return "";
try {
const parsed = JSON.parse(text);
const context = parsed?.hookSpecificOutput?.additionalContext;
return typeof context === "string" ? context.trim() : "";
} catch {
return text;
}
}

/** Pure: \`session.idle\` -> every Stop hook's own {script, args, payload} - or \`[]\` for
Expand Down Expand Up @@ -187,19 +213,32 @@ export const ${ident} = async (input) => {
// SessionStart's own approximation (module doc comment above): fired once here, never
// per session. Silent on purpose: a memory refresh
// that breaks OpenCode's own startup is worse than one that never ran.
let sessionContext = Promise.resolve("");
try {
for (const hook of SESSION_START) {
runHook(
hook.script,
hook.args,
{ hook_event_name: "SessionStart", session_id: null, cwd: input.directory },
input.directory
);
}
sessionContext = Promise.all(
SESSION_START.map((hook) =>
runHook(
hook.script,
hook.args,
{ hook_event_name: "SessionStart", session_id: null, cwd: input.directory },
input.directory,
{ captureOutput: true }
)
)
)
.then((outputs) => outputs.map(sessionContextOf).filter(Boolean).join("\\n\\n"))
.catch(() => "");
} catch {
// Silent on purpose - see above.
}
return {
// What SessionStart printed reaches every model call, as Claude Code and Codex add it to
// the conversation. V1 calls this per request; V2 reaches it through opencode-events.js.
// \`sessionContext\` never rejects, so a model call never fails over it.
"experimental.chat.system.transform": async (_input, output) => {
const context = await sessionContext;
if (context && Array.isArray(output?.system)) output.system.push(context);
},
event: async ({ event }) => {
try {
const directory = event?.properties?.directory ?? input.directory;
Expand All @@ -219,6 +258,7 @@ export const ${ident} = async (input) => {

${ident}.stopCallsFor = stopCallsFor;
${ident}.postToolUseCallsFor = postToolUseCallsFor;
${ident}.sessionContextOf = sessionContextOf;

export default {
id: ${JSON.stringify(`${plugin}-hooks`)},
Expand Down
6 changes: 6 additions & 0 deletions cli/src/presentation/prompts/menu-use-case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,12 @@ const INSTALLED_NODES: MenuNode[] = [
value: "system",
description: "CLI update and authentication",
children: [
{
name: "Check for CLI update",
value: "self-update-check",
description: "Check whether a newer CLI is published, without installing it",
command: ["update", "--check"],
},
{
name: "Update CLI",
value: "self-update",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// What a SessionStart script prints only reaches OpenCode through the generated module's own
// spawn and its V1/V2 entrypoints, so proving it means writing, importing and running that file.
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { generateOpencodeHooksBridge } from "../../../../../../src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.js";
import { REPOSITORY_ROOT } from "../../../../../helpers/repository-root.js";

// Built rather than written as a literal "${CLAUDE_PLUGIN_ROOT}" string: biome reads a plain
// string holding "${...}" as a forgotten template literal.
const ROOT = "$" + "{CLAUDE_PLUGIN_ROOT}";

const HOOKS_JSON = JSON.stringify({
hooks: {
SessionStart: [
{
hooks: [
{ type: "command", command: `node ${ROOT}/hooks/as-json.cjs` },
{ type: "command", command: `node ${ROOT}/hooks/as-text.cjs` },
{ type: "command", command: `node ${ROOT}/hooks/silent.cjs` },
],
},
],
},
});

const SCRIPTS: Record<string, string> = {
"as-json.cjs": `process.stdout.write(JSON.stringify({
systemMessage: "shown to the person",
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: "AIDD framework: v9.9.9" },
}));`,
"as-text.cjs": `process.stdout.write("plain context\\n");`,
"silent.cjs": "",
};

const EXPECTED_CONTEXT = "AIDD framework: v9.9.9\n\nplain context";

type SystemTransform = (input: unknown, output: { system: string[] }) => Promise<void>;
type ContextHook = (event: { system: unknown[] }) => Promise<void>;

interface GeneratedModule {
factory: (input: { directory: string }) => Promise<Record<string, unknown>>;
setup: (ctx: unknown) => Promise<() => void>;
sessionContextOf: (output: string) => string;
directory: string;
}

const tempDirs: string[] = [];
afterEach(async () => {
for (const dir of tempDirs.splice(0)) execFileSync("rm", ["-rf", dir]);
});

async function importGeneratedModule(): Promise<GeneratedModule> {
const generated = generateOpencodeHooksBridge(HOOKS_JSON, "aidd-sample");
if (generated === null) throw new Error("expected a generated module");
const dir = await mkdtemp(join(tmpdir(), "aidd-opencode-bridge-context-"));
tempDirs.push(dir);
await mkdir(join(dir, "plugin"));
await mkdir(join(dir, "hooks", "aidd-sample"), { recursive: true });
await writeFile(
join(dir, "hooks", "opencode-events.js"),
await readFile(
join(REPOSITORY_ROOT, "cli/assets/configs/opencode/opencode-events.js.txt"),
"utf8"
)
);
for (const [name, body] of Object.entries(SCRIPTS)) {
await writeFile(join(dir, "hooks", "aidd-sample", name), body, "utf8");
}
const modulePath = join(dir, "plugin", "bridge.mjs");
await writeFile(modulePath, generated, "utf8");
const mod: Record<string, unknown> = await import(pathToFileURL(modulePath).href);
const factory = mod.AiddSampleHooks as GeneratedModule["factory"];
const plugin = mod.default as { setup: GeneratedModule["setup"] };
const sessionContextOf = Reflect.get(factory, "sessionContextOf") as (o: string) => string;
return { factory, setup: plugin.setup, sessionContextOf, directory: dir };
}

describe("the generated bridge hands SessionStart's output to every model call", () => {
it("V1: the system transform adds each script's context, JSON and plain text alike", async () => {
const { factory, directory } = await importGeneratedModule();
const hooks = await factory({ directory });
const transform = hooks["experimental.chat.system.transform"] as SystemTransform;

const first = { system: ["base"] };
await transform({}, first);
const second = { system: [] as string[] };
await transform({}, second);

expect(first.system).toEqual(["base", EXPECTED_CONTEXT]);
expect(second.system).toEqual([EXPECTED_CONTEXT]);
});

it("V2: setup registers a context hook pushing the same text as a system block", async () => {
const { setup, directory } = await importGeneratedModule();
const registered: Record<string, ContextHook> = {};
let disposed = 0;
const ctx = {
location: { directory },
event: {
subscribe: async function* () {
// No event: only the context hook is under test.
},
},
session: {
hook: async (name: string, callback: ContextHook) => {
registered[name] = callback;
return {
dispose: async () => {
disposed += 1;
},
};
},
},
};

const cleanup = await setup(ctx);
const event = { system: [] as unknown[] };
await registered.context?.(event);
cleanup();
await new Promise((resolve) => setTimeout(resolve, 0));

expect(event.system).toEqual([{ type: "text", text: EXPECTED_CONTEXT }]);
expect(disposed).toBe(1);
});

it("V2 without a session hook API still sets up, adding nothing", async () => {
const { setup } = await importGeneratedModule();
const ctx = {
// Nothing here awaits the scripts, so they must not run inside the directory the test
// removes: Windows refuses to delete a live process's working directory.
location: { directory: tmpdir() },
event: {
subscribe: async function* () {
// No event.
},
},
};

await expect(setup(ctx)).resolves.toBeTypeOf("function");
});

it("reads a script's output the way Claude Code and Codex do", async () => {
const { sessionContextOf } = await importGeneratedModule();

expect(sessionContextOf("")).toBe("");
expect(sessionContextOf(" plain \n")).toBe("plain");
expect(
sessionContextOf(JSON.stringify({ hookSpecificOutput: { additionalContext: " ctx " } }))
).toBe("ctx");
expect(sessionContextOf(JSON.stringify({ systemMessage: "only shown" }))).toBe("");
});
});
Loading
Loading