Skip to content
Open
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
18 changes: 18 additions & 0 deletions examples/hooks/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "@sandbox-agent/example-hooks",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@sandbox-agent/example-shared": "workspace:*",
"sandbox-agent": "workspace:*"
},
"devDependencies": {
"@types/node": "latest",
"tsx": "latest",
"typescript": "latest"
}
}
126 changes: 126 additions & 0 deletions examples/hooks/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* Hooks Example — writes agent hooks via the filesystem API, sends a prompt,
* then verifies hooks fired by reading a shared log file.
*
* Usage:
* SANDBOX_AGENT_DEV=1 pnpm start # from local source
* pnpm start # published image
*/

import { SandboxAgent } from "sandbox-agent";
import { buildInspectorUrl } from "@sandbox-agent/example-shared";
import { startDockerSandbox } from "@sandbox-agent/example-shared/docker";

process.on("unhandledRejection", (reason) => {
console.error(" (background:", reason instanceof Error ? reason.message : JSON.stringify(reason), ")");
});

const HOOK_LOG = "/tmp/hooks.log";
const enc = new TextEncoder();
const dec = new TextDecoder();

async function writeText(client: SandboxAgent, path: string, content: string) {
await client.writeFsFile({ path }, enc.encode(content));
}

// ---------------------------------------------------------------------------
// Per-agent hook setup — each writes to HOOK_LOG when triggered
// ---------------------------------------------------------------------------

async function setupClaudeHook(client: SandboxAgent) {
// Claude reads hooks from ~/.claude/settings.json.
// "Stop" fires every time Claude finishes a response.
await client.mkdirFs({ path: "/root/.claude" });
await writeText(client, "/root/.claude/settings.json", JSON.stringify({
hooks: {
Stop: [{
matcher: "",
hooks: [{ type: "command", command: `echo "claude-hook-fired" >> ${HOOK_LOG}` }],
}],
},
}, null, 2));
}

async function setupCodexHook(client: SandboxAgent) {
// Codex reads ~/.codex/config.toml.
// "notify" runs an external program on agent-turn-complete.
await client.mkdirFs({ path: "/root/.codex" });
await writeText(client, "/root/.codex/config.toml",
`notify = ["/root/.codex/notify-hook.sh"]\n`);
await writeText(client, "/root/.codex/notify-hook.sh",
`#!/bin/bash\necho "codex-hook-fired" >> ${HOOK_LOG}\n`);
await client.runProcess({ command: "chmod", args: ["+x", "/root/.codex/notify-hook.sh"] });
}

async function setupOpencodeHook(client: SandboxAgent) {
// OpenCode loads plugins listed in opencode.json.
// The plugin appends to HOOK_LOG when loaded.
const plugin = [
`import { appendFileSync } from "node:fs";`,
`export const HookPlugin = async () => {`,
` appendFileSync("${HOOK_LOG}", "opencode-hook-fired\\n");`,
` return {};`,
`};`,
].join("\n");

const config = JSON.stringify({ plugin: ["./plugins/hook.mjs"] }, null, 2);

for (const dir of ["/root/.config/opencode", "/root/.opencode"]) {
await client.mkdirFs({ path: `${dir}/plugins` });
await writeText(client, `${dir}/plugins/hook.mjs`, plugin);
await writeText(client, `${dir}/opencode.json`, config);
}
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------

console.log("Starting sandbox...");
const { baseUrl, cleanup } = await startDockerSandbox({ port: 3004 });
const client = await SandboxAgent.connect({ baseUrl });

const agents = ["claude", "codex", "opencode"] as const;
for (const agent of agents) {
process.stdout.write(`Installing ${agent}... `);
try { await client.installAgent(agent); console.log("done"); }
catch { console.log("skipped"); }
}

console.log("\nWriting hooks...");
await setupClaudeHook(client);
await setupCodexHook(client);
await setupOpencodeHook(client);
await writeText(client, HOOK_LOG, "");

console.log("Sending prompts...\n");
for (const agent of agents) {
process.stdout.write(` ${agent.padEnd(9)}`);
try {
const session = await client.createSession({ agent, sessionInit: { cwd: "/root", mcpServers: [] } });
console.log(buildInspectorUrl({ baseUrl, sessionId: session.id }));
process.stdout.write(` prompting... `);
await session.prompt([{ type: "text", text: "Say exactly: hello world" }]);
console.log("done");
} catch (err: unknown) {
console.log(err instanceof Error ? err.message : JSON.stringify(err));
}
await new Promise((r) => setTimeout(r, 2000));
}

console.log("\nHook log:");
try {
const log = dec.decode(await client.readFsFile({ path: HOOK_LOG }));
const lines = log.trim().split("\n").filter(Boolean);
for (const line of lines) console.log(` + ${line}`);
if (!lines.length) console.log(" (empty)");

const has = (s: string) => lines.some((l) => l.includes(s));
console.log(`\n Claude=${has("claude") ? "PASS" : "FAIL"} Codex=${has("codex") ? "PASS" : "FAIL"} OpenCode=${has("opencode") ? "PASS" : "FAIL"}`);
} catch {
console.log(" (file not found)");
}

console.log("\nCtrl+C to stop.");
const keepAlive = setInterval(() => {}, 60_000);
process.on("SIGINT", () => { clearInterval(keepAlive); cleanup().then(() => process.exit(0)); });
16 changes: 16 additions & 0 deletions examples/hooks/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.ts"]
}
Loading