oh-my-agent-opencode 0.0.0-stage → 20.38.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,55 @@
1
- # Temporary Holding Version
1
+ # oh-my-agent-opencode
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The [oh-my-agent](https://github.com/first-fluke/oh-my-agent) hook bridge as an
4
+ npm plugin for [OpenCode](https://opencode.ai) and [Kilo Code](https://kilo.ai).
5
+ It registers the same in-process handlers `oma link` installs per project
6
+ (`.opencode/plugins/oma/`), without copying anything into the project, and
7
+ serves both plugin contracts from one default export: the OpenCode 2.x
8
+ `{ id, setup }` definition and the V1 `server()` object form Kilo Code and
9
+ OpenCode 1.18.29+ load.
10
+
11
+ Requires the `oma` CLI on PATH (`npm install -g oh-my-agent`) and `bun`.
12
+ Every hook fails open: without `oma` the host runs unchanged.
13
+
14
+ ## OpenCode
15
+
16
+ Add the package to `plugin` in `.opencode/opencode.json` (project) or
17
+ `~/.config/opencode/opencode.json` (global). OpenCode installs npm plugins
18
+ on startup.
19
+
20
+ ```json
21
+ {
22
+ "$schema": "https://opencode.ai/config.json",
23
+ "plugin": ["oh-my-agent-opencode"]
24
+ }
25
+ ```
26
+
27
+ Pin a release with `"oh-my-agent-opencode@<version>"`.
28
+
29
+ ## Kilo Code
30
+
31
+ Kilo reads the same key from `.kilo/kilo.json` or
32
+ `~/.config/kilo/kilo.json`:
33
+
34
+ ```json
35
+ {
36
+ "$schema": "https://app.kilo.ai/config.json",
37
+ "plugin": ["oh-my-agent-opencode"]
38
+ }
39
+ ```
40
+
41
+ ## What the bridge does
42
+
43
+ | OpenCode 2.x hook | Kilo / 1.x hook | oh-my-agent handler |
44
+ | --- | --- | --- |
45
+ | session `prompt` | `chat.message` | keyword-detector (workflow activation) and skill-injector |
46
+ | session `context` | `experimental.chat.system.transform` | injects the detected context into the system prompt |
47
+ | tool `execute.before` (bash) | `tool.execute.before` | scm-guard (blocks staging likely secrets), test-filter (failures-only test output) |
48
+ | session `experimental.idle` | `session.idle` event | persistent-mode re-enters an unfinished workflow |
49
+
50
+ Each handler entry in this package runs
51
+ `oma hook script --vendor opencode --script <name>`; the handler logic lives
52
+ in the installed CLI, so updating `oma` updates the behaviour.
53
+
54
+ A project that already has `./plugins/oma/oma.ts` registered by `oma link`
55
+ does not need this package; keep one or the other.
@@ -0,0 +1,24 @@
1
+ // Generated by OMA. Handler implementation lives in the CLI.
2
+ import { spawnSync } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
4
+
5
+ if (import.meta.main) {
6
+ try {
7
+ const result = spawnSync(process.env.OMA_BIN || "oma", [
8
+ "hook", "script", "--vendor", "opencode",
9
+ "--script", "code-intelligence-guard.ts",
10
+ ], {
11
+ input: readFileSync(0),
12
+ cwd: process.cwd(),
13
+ env: process.env,
14
+ encoding: "utf-8",
15
+ timeout: 7000,
16
+ maxBuffer: 4 * 1024 * 1024,
17
+ });
18
+ if (result.error) throw result.error;
19
+ if (result.stdout) process.stdout.write(result.stdout);
20
+ if (result.stderr) process.stderr.write(result.stderr);
21
+ } catch (error) {
22
+ process.stderr.write("[oma hook] standalone dispatch unavailable: " + String(error) + "\n");
23
+ }
24
+ }
@@ -0,0 +1,24 @@
1
+ // Generated by OMA. Handler implementation lives in the CLI.
2
+ import { spawnSync } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
4
+
5
+ if (import.meta.main) {
6
+ try {
7
+ const result = spawnSync(process.env.OMA_BIN || "oma", [
8
+ "hook", "script", "--vendor", "opencode",
9
+ "--script", "code-intelligence-primer.ts",
10
+ ], {
11
+ input: readFileSync(0),
12
+ cwd: process.cwd(),
13
+ env: process.env,
14
+ encoding: "utf-8",
15
+ timeout: 7000,
16
+ maxBuffer: 4 * 1024 * 1024,
17
+ });
18
+ if (result.error) throw result.error;
19
+ if (result.stdout) process.stdout.write(result.stdout);
20
+ if (result.stderr) process.stderr.write(result.stderr);
21
+ } catch (error) {
22
+ process.stderr.write("[oma hook] standalone dispatch unavailable: " + String(error) + "\n");
23
+ }
24
+ }
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env bash
2
+ # filter-test-output.sh — Strip passing test indicators, keep failures and summaries
3
+ # Pipe usage: test_command 2>&1 | bash filter-test-output.sh
4
+ #
5
+ # Supported: vitest, jest, mocha, pytest, go test, cargo test, rspec,
6
+ # flutter/dart test, swift test, dotnet test, gradle/mvn test,
7
+ # mix test, phpunit
8
+ # Bypass: OMA_TEST_FILTER=0
9
+
10
+ if [[ "${OMA_TEST_FILTER:-1}" == "0" ]]; then
11
+ cat
12
+ exit 0
13
+ fi
14
+
15
+ awk '
16
+ # vitest/jest/mocha: remove individual passing tests
17
+ /^[[:space:]]*[✓√✔][[:space:]]/ { next }
18
+
19
+ # jest: remove PASS file headers
20
+ /^[[:space:]]*PASS[[:space:]]/ { next }
21
+
22
+ # go test: remove passing tests and ok package lines
23
+ /^--- PASS:/ { next }
24
+ /^[[:space:]]*ok[[:space:]]+[a-zA-Z]/ { next }
25
+
26
+ # pytest: remove PASSED lines
27
+ /PASSED[[:space:]]*$/ { next }
28
+
29
+ # cargo test: remove "test ... ok" lines
30
+ /^test .+ \.\.\. ok$/ { next }
31
+
32
+ # flutter/dart: remove passing tests (+N without -N, excluding summary)
33
+ /^[0-9][0-9]:[0-9][0-9] \+[0-9]+: / && !/\-[0-9]+/ && !/All tests/ { next }
34
+
35
+ # swift: remove passed test cases
36
+ /Test Case .* passed/ { next }
37
+
38
+ # dotnet: remove individual Passed lines (keep "Passed!" summary)
39
+ /^[[:space:]]*Passed[[:space:]]/ && !/Passed!/ { next }
40
+
41
+ # gradle/mvn: remove individual test pass indicators
42
+ /^[[:space:]]*Tests run:.*Failures: 0.*Errors: 0/ { next }
43
+
44
+ # phpunit: remove dots-only progress lines
45
+ /^[.]+$/ { next }
46
+
47
+ # rspec: remove passing example dots (single-char lines)
48
+ /^[.]+[[:space:]]*$/ { next }
49
+
50
+ # keep everything else
51
+ { print }
52
+ '
package/index.ts ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * oh-my-agent — opencode-family npm package entry (`oh-my-agent-opencode`).
3
+ *
4
+ * SSOT source. `oma emit --target opencode-package` ships this file as the
5
+ * package `main`/`exports` next to the bridge (`./oma.ts`) and the handler
6
+ * entries it spawns. It is NOT part of the per-project install, which keeps
7
+ * registering `./plugins/oma/oma.ts` directly.
8
+ *
9
+ * Two plugin contracts coexist in the family, and a package plugin must
10
+ * satisfy whichever host imports it:
11
+ *
12
+ * - OpenCode 2.x (opencode.ai/docs/build/plugins): the default export is a
13
+ * definition `{ id, setup(ctx) }` (`Plugin.define` is the identity). Hooks
14
+ * are registered through context domains — `ctx.session.hook("prompt" |
15
+ * "context" | "experimental.idle")`, `ctx.tool.hook("execute.before")`.
16
+ * A function default export is rejected ("Plugin must export a default
17
+ * definition with an id and an effect or setup function").
18
+ * - Kilo Code CLI (7.x) and OpenCode 1.18.29+: a package plugin "must
19
+ * default export an object with server()" — the V1 object form. `server`
20
+ * receives the same `{ project, client, $, directory, worktree }` input
21
+ * the function-style bridge takes and returns the hooks object. (Older 1.x
22
+ * loaders that only accept a bare function export are not served by the
23
+ * package; they still work with the per-project `./plugins/oma/oma.ts`.)
24
+ *
25
+ * So the default export carries both: `server` (the unchanged legacy bridge,
26
+ * called by the V1 loaders) and `id` + `setup` (the v2 definition, whose
27
+ * `setup` adapts the v2 events onto that same legacy hooks object) — one
28
+ * bridge implementation, zero duplicated handler logic. Dependency-free: the
29
+ * v2 context is typed structurally, so neither `@opencode/plugin` nor
30
+ * `@opencode-ai/plugin` is needed at runtime.
31
+ *
32
+ * v2 mapping (→ legacy hook the bridge already implements):
33
+ * session "prompt" → "chat.message" (keyword-detector, skill-injector)
34
+ * session "context" → "experimental.chat.system.transform" (inject)
35
+ * tool "execute.before" → "tool.execute.before" (scm-guard, test-filter;
36
+ * a throw rejects the call, as in v1)
37
+ * session "experimental.idle" → event session.idle (persistent-mode re-entry
38
+ * through ctx.session.prompt)
39
+ */
40
+
41
+ import legacy from "./oma.ts";
42
+
43
+ type LegacyHooks = Awaited<ReturnType<typeof legacy>>;
44
+
45
+ /** Structural subset of the OpenCode 2.x plugin context this entry uses. */
46
+ interface OmaV2Context {
47
+ location?: { directory?: string };
48
+ session: {
49
+ hook: (
50
+ name: string,
51
+ callback: (event: Record<string, unknown>) => Promise<void> | void,
52
+ ) => Promise<unknown>;
53
+ prompt?: (input: Record<string, unknown>) => Promise<unknown>;
54
+ };
55
+ tool: {
56
+ hook: (
57
+ name: string,
58
+ callback: (event: Record<string, unknown>) => Promise<void> | void,
59
+ ) => Promise<unknown>;
60
+ };
61
+ }
62
+
63
+ function sessionIDOf(event: Record<string, unknown>): string | undefined {
64
+ return typeof event.sessionID === "string" ? event.sessionID : undefined;
65
+ }
66
+
67
+ /**
68
+ * The package plugin. V1 object loaders (Kilo, OpenCode 1.18.29+) call
69
+ * `server(input)` and use the returned hooks; OpenCode 2.x reads `id` and
70
+ * calls `setup(ctx)`, which instantiates the legacy bridge with a client
71
+ * adapter (so session.idle re-entry posts through `ctx.session.prompt`) and
72
+ * registers one v2 hook per legacy handler.
73
+ */
74
+ export default {
75
+ id: "oh-my-agent",
76
+ server: legacy,
77
+ async setup(ctx: OmaV2Context): Promise<void> {
78
+ const directory = ctx.location?.directory ?? process.cwd();
79
+ const hooks: LegacyHooks = await legacy({
80
+ directory,
81
+ client: {
82
+ session: {
83
+ prompt: async ({ path, body }) => {
84
+ if (!ctx.session.prompt) return;
85
+ await ctx.session.prompt({
86
+ sessionID: path.id,
87
+ text: body.parts.map((part) => part.text).join("\n"),
88
+ });
89
+ },
90
+ },
91
+ },
92
+ });
93
+
94
+ await ctx.session.hook("prompt", async (event) => {
95
+ const sessionID = sessionIDOf(event);
96
+ const prompt = event.prompt as { text?: unknown } | undefined;
97
+ const text = typeof prompt?.text === "string" ? prompt.text : "";
98
+ await hooks["chat.message"](
99
+ { sessionID },
100
+ { parts: [{ type: "text", text }] },
101
+ );
102
+ });
103
+
104
+ await ctx.session.hook("context", async (event) => {
105
+ const sessionID = sessionIDOf(event);
106
+ const out: { system: string[] } = { system: [] };
107
+ await hooks["experimental.chat.system.transform"]({ sessionID }, out);
108
+ const system = event.system;
109
+ if (!Array.isArray(system)) return;
110
+ for (const text of out.system) system.push({ type: "text", text });
111
+ });
112
+
113
+ await ctx.tool.hook("execute.before", async (event) => {
114
+ const input = event.input;
115
+ if (!input || typeof input !== "object") return;
116
+ // The legacy hook mutates `output.args.command`; `args` IS the v2
117
+ // input object, so a test-filter rewrite lands on the real call.
118
+ await hooks["tool.execute.before"](
119
+ { tool: typeof event.tool === "string" ? event.tool : undefined },
120
+ { args: input as { command?: string } },
121
+ );
122
+ });
123
+
124
+ await ctx.session.hook("experimental.idle", async (event) => {
125
+ const sessionID = sessionIDOf(event);
126
+ if (!sessionID) return;
127
+ await hooks.event({
128
+ event: { type: "session.idle", properties: { sessionID } },
129
+ });
130
+ });
131
+ },
132
+ };
@@ -0,0 +1,24 @@
1
+ // Generated by OMA. Handler implementation lives in the CLI.
2
+ import { spawnSync } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
4
+
5
+ if (import.meta.main) {
6
+ try {
7
+ const result = spawnSync(process.env.OMA_BIN || "oma", [
8
+ "hook", "script", "--vendor", "opencode",
9
+ "--script", "keyword-detector.ts",
10
+ ], {
11
+ input: readFileSync(0),
12
+ cwd: process.cwd(),
13
+ env: process.env,
14
+ encoding: "utf-8",
15
+ timeout: 7000,
16
+ maxBuffer: 4 * 1024 * 1024,
17
+ });
18
+ if (result.error) throw result.error;
19
+ if (result.stdout) process.stdout.write(result.stdout);
20
+ if (result.stderr) process.stderr.write(result.stderr);
21
+ } catch (error) {
22
+ process.stderr.write("[oma hook] standalone dispatch unavailable: " + String(error) + "\n");
23
+ }
24
+ }
package/oma.ts ADDED
@@ -0,0 +1,484 @@
1
+ /**
2
+ * oh-my-agent — opencode (Sst opencode) plugin bridge.
3
+ *
4
+ * SSOT source. At install time `installOpencodePlugin` copies this file to
5
+ * `.opencode/plugins/oma/oma.ts` alongside the core hook scripts, and
6
+ * `registerOpencodePlugin` adds it to `.opencode/opencode.jsonc` (the nested
7
+ * subdir is invisible to opencode's flat auto-discovery).
8
+ *
9
+ * Why a bridge instead of a per-vendor variants JSON entry: opencode does NOT
10
+ * register settings-file hooks like the other vendors. It loads in-process
11
+ * TypeScript plugins whose handlers are invoked with an `(input, output)` pair
12
+ * and are expected to MUTATE `output` (return values are ignored). So rather
13
+ * than the generic `installHooksFromVariant` path (events → settings file →
14
+ * `bun <script>` subprocess), opencode gets this thin shim that maps opencode
15
+ * lifecycle handlers onto oma's existing, vendor-agnostic core scripts via
16
+ * subprocess. All matching logic stays in the core scripts.
17
+ *
18
+ * The core scripts installed under `.opencode/plugins/oma/` are not recognised
19
+ * as an opencode vendor (there is no `.opencode` case in the shared
20
+ * `inferVendorFromScriptPath`), so they resolve to the `claude` output dialect.
21
+ * This bridge therefore reads the claude-shaped stdout: `additionalContext`
22
+ * for prompt hooks, `hookSpecificOutput.updatedInput.command` for PreToolUse,
23
+ * and `{ decision: "block", reason }` for the Stop equivalent. Extraction is
24
+ * defensive (top-level OR `hookSpecificOutput`) so a dialect shift does not
25
+ * silently break the bridge.
26
+ *
27
+ * Intentionally dependency-free (no @opencode-ai/plugin import) so this file
28
+ * works in any user project without requiring opencode as a dev dependency.
29
+ * Handler and client shapes are satisfied structurally via inline types.
30
+ *
31
+ * Event mapping:
32
+ * chat.message ← UserPromptSubmit
33
+ * Extracts user text from output.parts, runs keyword-detector +
34
+ * skill-injector, and stashes the resulting context per session (keyed
35
+ * by input.sessionID). The opencode session id is forwarded to the core
36
+ * scripts so keyword-detector's persistent-mode activation writes a
37
+ * session-scoped state file (an unresolved id would refuse to persist).
38
+ * experimental.chat.system.transform ← context injection
39
+ * Peeks the stashed context for input.sessionID and pushes it onto
40
+ * output.system. Non-consuming: the stash is refreshed (or cleared)
41
+ * every chat.message, so re-injection across model calls in a turn is
42
+ * harmless and a chat.message→transform ordering assumption cannot
43
+ * permanently drop context.
44
+ * tool.execute.before ← PreToolUse (bash only)
45
+ * Runs scm-guard first (throws to block `git add` of likely-secret
46
+ * files — throwing is opencode's documented block mechanism, see
47
+ * opencode.ai/docs/plugins), then test-filter on output.args.command,
48
+ * writing the rewritten command back onto output.args.command.
49
+ * Known upstream caveat: plugin hooks do not intercept subagent tool
50
+ * calls (anomalyco/opencode#5894), so the guard covers the primary
51
+ * agent only.
52
+ * event: "session.idle" ← Stop (BEST-EFFORT / re-entrant)
53
+ * Runs persistent-mode. If it returns a block decision, the workflow is
54
+ * still active: re-enter the loop by posting the block reason as a new
55
+ * prompt via client.session.prompt. Best-effort — any failure is
56
+ * swallowed. persistent-mode's own reinforcement cap (5) is the primary
57
+ * terminator; a per-session consecutive-idle counter is a runaway
58
+ * backstop only.
59
+ */
60
+
61
+ import { spawnSync } from "node:child_process";
62
+ import { fileURLToPath } from "node:url";
63
+
64
+ /**
65
+ * persistent-mode may run a goal stop gate (25s cap in persistent-mode.ts).
66
+ * Its budget matches the variant Stop handler timeout (30s) so this bridge
67
+ * never kills the hook mid-gate — the other core scripts keep 5s.
68
+ */
69
+ const PERSISTENT_MODE_TIMEOUT_MS = 30_000;
70
+
71
+ /** Absolute path to a core script copied next to this bridge at install time. */
72
+ function corePath(script: string): string {
73
+ return fileURLToPath(new URL(`./${script}`, import.meta.url));
74
+ }
75
+
76
+ /**
77
+ * Directory of this bridge, exported to the handlers as OMA_HOOK_BRIDGE_DIR so
78
+ * test-filter finds `filter-test-output.sh` when the bridge ships as the
79
+ * `oh-my-agent-opencode` npm package (outside the project tree).
80
+ */
81
+ function bridgeEnv(): NodeJS.ProcessEnv {
82
+ try {
83
+ return {
84
+ ...process.env,
85
+ OMA_HOOK_BRIDGE_DIR: fileURLToPath(new URL(".", import.meta.url)),
86
+ };
87
+ } catch {
88
+ return process.env;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Run an oma core hook script as a subprocess: feed it JSON on stdin, parse
94
+ * its JSON stdout. Fail-open (returns null) on any error — a broken hook must
95
+ * never block the agent. Spawns with `cwd` = opencode's working directory so
96
+ * the core scripts resolve the project (git) root the same way they do for
97
+ * every other vendor. Non-zero exit (persistent-mode blocks with exit 2) is
98
+ * expected: stdout is still captured and parsed.
99
+ */
100
+ function runCore(
101
+ script: string,
102
+ payload: Record<string, unknown>,
103
+ cwd: string,
104
+ timeoutMs = 5000,
105
+ ): Record<string, unknown> | null {
106
+ try {
107
+ const res = spawnSync("bun", [corePath(script)], {
108
+ input: JSON.stringify(payload),
109
+ cwd,
110
+ encoding: "utf-8",
111
+ timeout: timeoutMs,
112
+ env: bridgeEnv(),
113
+ });
114
+ const out = (res.stdout ?? "").trim();
115
+ if (!out) return null;
116
+ return JSON.parse(out) as Record<string, unknown>;
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Extract injected context from a prompt-hook (keyword-detector / skill-
124
+ * injector) result. The core scripts emit the `claude` dialect, which carries
125
+ * `additionalContext` both at the top level and under `hookSpecificOutput`;
126
+ * read either so a dialect change does not silently drop context.
127
+ */
128
+ function extractAdditionalContext(
129
+ out: Record<string, unknown> | null,
130
+ ): string | null {
131
+ if (!out) return null;
132
+ if (typeof out.additionalContext === "string" && out.additionalContext) {
133
+ return out.additionalContext;
134
+ }
135
+ const hso = out.hookSpecificOutput;
136
+ if (hso && typeof hso === "object") {
137
+ const nested = (hso as Record<string, unknown>).additionalContext;
138
+ if (typeof nested === "string" && nested) return nested;
139
+ }
140
+ return null;
141
+ }
142
+
143
+ /**
144
+ * Extract the rewritten command from a test-filter (PreToolUse) result. The
145
+ * `claude` dialect nests it under `hookSpecificOutput.updatedInput.command`;
146
+ * a top-level `updatedInput.command` is also accepted for dialect resilience.
147
+ */
148
+ function extractUpdatedCommand(
149
+ out: Record<string, unknown> | null,
150
+ ): string | null {
151
+ if (!out) return null;
152
+ const readCommand = (v: unknown): string | null => {
153
+ if (v && typeof v === "object") {
154
+ const cmd = (v as Record<string, unknown>).command;
155
+ if (typeof cmd === "string" && cmd) return cmd;
156
+ }
157
+ return null;
158
+ };
159
+ const direct = readCommand(out.updatedInput);
160
+ if (direct) return direct;
161
+ const hso = out.hookSpecificOutput;
162
+ if (hso && typeof hso === "object") {
163
+ return readCommand((hso as Record<string, unknown>).updatedInput);
164
+ }
165
+ return null;
166
+ }
167
+
168
+ /**
169
+ * Extract a PreToolUse deny reason from an scm-guard result. The core script
170
+ * emits the `claude` dialect (`hookSpecificOutput.permissionDecision: "deny"`
171
+ * + `permissionDecisionReason`); a top-level `permission: "deny"` +
172
+ * `user_message`/`reason` is also accepted for dialect resilience.
173
+ */
174
+ function extractDenyReason(out: Record<string, unknown> | null): string | null {
175
+ if (!out) return null;
176
+ const hso = out.hookSpecificOutput;
177
+ if (hso && typeof hso === "object") {
178
+ const h = hso as Record<string, unknown>;
179
+ if (h.permissionDecision === "deny") {
180
+ return typeof h.permissionDecisionReason === "string" &&
181
+ h.permissionDecisionReason
182
+ ? h.permissionDecisionReason
183
+ : "Blocked by oma scm-guard.";
184
+ }
185
+ }
186
+ if (out.permission === "deny" || out.decision === "deny") {
187
+ const reason = out.user_message ?? out.reason;
188
+ return typeof reason === "string" && reason
189
+ ? reason
190
+ : "Blocked by oma scm-guard.";
191
+ }
192
+ return null;
193
+ }
194
+
195
+ /** Join the text parts of a chat.message output into the user's prompt text. */
196
+ function extractPromptText(output: {
197
+ message?: unknown;
198
+ parts?: Array<{ type?: string; text?: string }>;
199
+ }): string {
200
+ const parts = Array.isArray(output.parts) ? output.parts : [];
201
+ const texts = parts
202
+ .filter((p) => p && p.type === "text" && typeof p.text === "string")
203
+ .map((p) => p.text as string);
204
+ if (texts.length > 0) return stripWrappingQuotes(texts.join("\n"));
205
+ // Defensive fallback: some builds may surface text on the message object.
206
+ const msg = output.message as { text?: unknown } | undefined;
207
+ if (msg && typeof msg.text === "string") return stripWrappingQuotes(msg.text);
208
+ return "";
209
+ }
210
+
211
+ /**
212
+ * `opencode run "<prompt>"` delivers the part text wrapped in literal double
213
+ * quotes (`"work: …"`), which defeats keyword detection anchored at the prompt
214
+ * start. A prompt that is entirely enclosed in one matching pair of quotes is
215
+ * that CLI artifact — unwrap exactly one pair; interior quotes are untouched.
216
+ */
217
+ function stripWrappingQuotes(text: string): string {
218
+ const t = text.trim();
219
+ if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) {
220
+ return t.slice(1, -1);
221
+ }
222
+ return t;
223
+ }
224
+
225
+ // ── Per-session bridge state ──────────────────────────────────
226
+ //
227
+ // opencode plugins are loaded once per server process, so module-level maps
228
+ // persist across a session's handler invocations. Both maps are keyed by the
229
+ // opencode sessionID and bounded so a long-lived server cannot leak memory
230
+ // across many sessions.
231
+
232
+ const MAX_TRACKED_SESSIONS = 256;
233
+
234
+ /**
235
+ * Context computed on chat.message, awaiting injection by the system-prompt
236
+ * transform. Overwrite-per-session: each chat.message replaces the entry
237
+ * (with `""` when nothing matched), so the transform never injects stale
238
+ * context and a peek does not need to consume.
239
+ */
240
+ const pendingContext = new Map<string, string>();
241
+
242
+ /**
243
+ * Consecutive session.idle re-entries per session, reset by any chat.message.
244
+ * A runaway backstop only — persistent-mode's reinforcement cap terminates the
245
+ * loop first under normal operation.
246
+ */
247
+ const idleReentryCount = new Map<string, number>();
248
+ const MAX_CONSECUTIVE_IDLE_REENTRIES = 50;
249
+
250
+ /** Set a bounded map entry, evicting the oldest key when over capacity. */
251
+ function setBounded<V>(map: Map<string, V>, key: string, value: V): void {
252
+ if (map.has(key)) map.delete(key);
253
+ map.set(key, value);
254
+ while (map.size > MAX_TRACKED_SESSIONS) {
255
+ const oldest = map.keys().next().value;
256
+ if (oldest === undefined) break;
257
+ map.delete(oldest);
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Minimal structural view of the opencode SDK client used for loop re-entry.
263
+ * Kept optional and dependency-free; the real client is a superset.
264
+ */
265
+ type OmaOpencodeClient = {
266
+ session?: {
267
+ prompt?: (options: {
268
+ path: { id: string };
269
+ body: { parts: Array<{ type: "text"; text: string }> };
270
+ }) => Promise<unknown>;
271
+ };
272
+ };
273
+
274
+ /**
275
+ * oh-my-agent opencode plugin.
276
+ *
277
+ * Structural shape satisfies the opencode `Plugin` contract without importing
278
+ * @opencode-ai/plugin, keeping this bridge dependency-free in the user's
279
+ * project. Handlers mutate their `output` argument (return values are ignored).
280
+ *
281
+ * @satisfies Plugin
282
+ */
283
+ export default (async ({
284
+ directory,
285
+ client,
286
+ }: {
287
+ directory?: string;
288
+ client?: OmaOpencodeClient;
289
+ }) => {
290
+ const cwd = directory ?? process.cwd();
291
+
292
+ return {
293
+ /**
294
+ * chat.message ← UserPromptSubmit
295
+ *
296
+ * Runs keyword-detector (workflow activation) and skill-injector (context
297
+ * injection), then stashes the concatenated context for this session so
298
+ * `experimental.chat.system.transform` can inject it. Forwards the opencode
299
+ * sessionID so persistent-workflow state is written session-scoped.
300
+ */
301
+ "chat.message": async (
302
+ input: { sessionID?: string },
303
+ output: {
304
+ message?: unknown;
305
+ parts?: Array<{ type?: string; text?: string }>;
306
+ },
307
+ ): Promise<void> => {
308
+ const sessionID = input.sessionID ?? "";
309
+ // A genuine user turn resets the runaway idle backstop.
310
+ if (sessionID) idleReentryCount.delete(sessionID);
311
+
312
+ const prompt = extractPromptText(output);
313
+ const payload = {
314
+ prompt,
315
+ cwd,
316
+ sessionId: sessionID,
317
+ hook_event_name: "UserPromptSubmit",
318
+ };
319
+
320
+ const parts: string[] = [];
321
+ const kd = extractAdditionalContext(
322
+ runCore("keyword-detector.ts", payload, cwd),
323
+ );
324
+ if (kd) parts.push(kd);
325
+ const si = extractAdditionalContext(
326
+ runCore("skill-injector.ts", payload, cwd),
327
+ );
328
+ if (si) parts.push(si);
329
+
330
+ // Overwrite per session — even with "" — so the transform reflects this
331
+ // turn's detection and never re-injects a previous turn's context.
332
+ if (sessionID) setBounded(pendingContext, sessionID, parts.join("\n\n"));
333
+ },
334
+
335
+ /**
336
+ * experimental.chat.system.transform ← context injection
337
+ *
338
+ * Peeks (non-consuming) the context stashed by chat.message for this
339
+ * session and appends it to the system prompt. chat.message runs before the
340
+ * system transform within a turn; peeking keeps injection idempotent across
341
+ * multiple model calls in a turn and prevents an ordering race from
342
+ * permanently dropping context (the stash is refreshed every chat.message).
343
+ */
344
+ "experimental.chat.system.transform": async (
345
+ input: { sessionID?: string },
346
+ output: { system?: string[] },
347
+ ): Promise<void> => {
348
+ const sessionID = input.sessionID;
349
+ if (!sessionID) return;
350
+ const ctx = pendingContext.get(sessionID);
351
+ if (ctx && Array.isArray(output.system)) {
352
+ output.system.push(ctx);
353
+ }
354
+ },
355
+
356
+ /**
357
+ * tool.execute.before ← PreToolUse (bash)
358
+ *
359
+ * `input.tool` is a plain string and the command lives on
360
+ * `output.args.command`. First runs scm-guard — throwing is opencode's
361
+ * documented mechanism for blocking a tool call, so a deny becomes a
362
+ * `throw` (this is the ONE deliberate non-fail-open path in this bridge).
363
+ * Then runs the OMA test-filter for bash commands to rewrite test-runner
364
+ * invocations so only failures reach the model, and writes the rewritten
365
+ * command back. Non-bash tools pass through unchanged.
366
+ */
367
+ "tool.execute.before": async (
368
+ input: { tool?: string },
369
+ output: { args?: { command?: string } },
370
+ ): Promise<void> => {
371
+ if (input.tool !== "bash") return;
372
+ const command = output.args?.command;
373
+ if (typeof command !== "string" || !command) return;
374
+
375
+ const denyReason = extractDenyReason(
376
+ runCore(
377
+ "scm-guard.ts",
378
+ {
379
+ tool_name: "Bash",
380
+ tool_input: { command },
381
+ cwd,
382
+ hook_event_name: "PreToolUse",
383
+ },
384
+ cwd,
385
+ ),
386
+ );
387
+ if (denyReason) throw new Error(denyReason);
388
+
389
+ const updated = extractUpdatedCommand(
390
+ runCore(
391
+ "test-filter.ts",
392
+ {
393
+ tool_name: "Bash",
394
+ tool_input: { command },
395
+ cwd,
396
+ hook_event_name: "PreToolUse",
397
+ },
398
+ cwd,
399
+ ),
400
+ );
401
+ if (updated && output.args) {
402
+ output.args.command = updated;
403
+ }
404
+ },
405
+
406
+ /**
407
+ * event: "session.idle" ← Stop (BEST-EFFORT / re-entrant)
408
+ *
409
+ * opencode delivers session.idle over the event bus (it is no longer a
410
+ * top-level hook). Unlike the Claude `Stop` hook this cannot block
411
+ * termination, but the SDK client lets us re-enter the loop: run
412
+ * persistent-mode and, if a persistent workflow is still active (block
413
+ * decision), post the block reason back as a new prompt so the agent
414
+ * continues.
415
+ *
416
+ * BEST-EFFORT: every step is wrapped so a failure can never throw into
417
+ * opencode. persistent-mode's own reinforcement cap (5) terminates the
418
+ * loop under normal operation; the per-session consecutive-idle counter is
419
+ * a runaway backstop for pathological cases only.
420
+ */
421
+ event: async (input: {
422
+ event?: { type?: string; properties?: Record<string, unknown> };
423
+ }): Promise<void> => {
424
+ const event = input.event;
425
+ if (event?.type !== "session.idle") return;
426
+ const sessionID = (event.properties as { sessionID?: string } | undefined)
427
+ ?.sessionID;
428
+ if (!sessionID) return;
429
+
430
+ const count = idleReentryCount.get(sessionID) ?? 0;
431
+ if (count >= MAX_CONSECUTIVE_IDLE_REENTRIES) return;
432
+
433
+ const pm = runCore(
434
+ "persistent-mode.ts",
435
+ { cwd, sessionId: sessionID, hook_event_name: "Stop" },
436
+ cwd,
437
+ PERSISTENT_MODE_TIMEOUT_MS,
438
+ );
439
+
440
+ if (pm?.decision !== "block") {
441
+ // Workflow complete / not active — clear the backstop counter.
442
+ idleReentryCount.delete(sessionID);
443
+ return;
444
+ }
445
+
446
+ const reason =
447
+ typeof pm.reason === "string" && pm.reason
448
+ ? pm.reason
449
+ : "The active OMA workflow is not complete. Continue executing it.";
450
+ idleReentryCount.set(sessionID, count + 1);
451
+
452
+ try {
453
+ await client?.session?.prompt?.({
454
+ path: { id: sessionID },
455
+ body: { parts: [{ type: "text", text: reason }] },
456
+ });
457
+ } catch {
458
+ // Best-effort re-entry — never throw into opencode.
459
+ }
460
+ },
461
+ };
462
+ }) satisfies (ctx: {
463
+ directory?: string;
464
+ client?: OmaOpencodeClient;
465
+ }) => Promise<{
466
+ "chat.message": (
467
+ input: { sessionID?: string },
468
+ output: {
469
+ message?: unknown;
470
+ parts?: Array<{ type?: string; text?: string }>;
471
+ },
472
+ ) => Promise<void>;
473
+ "experimental.chat.system.transform": (
474
+ input: { sessionID?: string },
475
+ output: { system?: string[] },
476
+ ) => Promise<void>;
477
+ "tool.execute.before": (
478
+ input: { tool?: string },
479
+ output: { args?: { command?: string } },
480
+ ) => Promise<void>;
481
+ event: (input: {
482
+ event?: { type?: string; properties?: Record<string, unknown> };
483
+ }) => Promise<void>;
484
+ }>;
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "oh-my-agent-opencode",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "20.38.4",
4
+ "description": "oh-my-agent hook bridge for OpenCode and Kilo Code",
5
+ "license": "MIT",
6
+ "homepage": "https://firstfluke.com/oh-my-agent",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/first-fluke/oh-my-agent"
10
+ },
11
+ "type": "module",
12
+ "keywords": [
13
+ "opencode",
14
+ "opencode-plugin",
15
+ "kilo",
16
+ "kilocode",
17
+ "oh-my-agent"
18
+ ],
19
+ "main": "./index.ts",
20
+ "exports": {
21
+ ".": "./index.ts"
22
+ },
23
+ "files": [
24
+ "index.ts",
25
+ "oma.ts",
26
+ "keyword-detector.ts",
27
+ "skill-injector.ts",
28
+ "code-intelligence-primer.ts",
29
+ "serena-primer.ts",
30
+ "state-boundary.ts",
31
+ "scm-guard.ts",
32
+ "code-intelligence-guard.ts",
33
+ "test-filter.ts",
34
+ "refactor-guard.ts",
35
+ "persistent-mode.ts",
36
+ "filter-test-output.sh",
37
+ "README.md"
38
+ ]
39
+ }
@@ -0,0 +1,24 @@
1
+ // Generated by OMA. Handler implementation lives in the CLI.
2
+ import { spawnSync } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
4
+
5
+ if (import.meta.main) {
6
+ try {
7
+ const result = spawnSync(process.env.OMA_BIN || "oma", [
8
+ "hook", "script", "--vendor", "opencode",
9
+ "--script", "persistent-mode.ts",
10
+ ], {
11
+ input: readFileSync(0),
12
+ cwd: process.cwd(),
13
+ env: process.env,
14
+ encoding: "utf-8",
15
+ timeout: 35000,
16
+ maxBuffer: 4 * 1024 * 1024,
17
+ });
18
+ if (result.error) throw result.error;
19
+ if (result.stdout) process.stdout.write(result.stdout);
20
+ if (result.stderr) process.stderr.write(result.stderr);
21
+ } catch (error) {
22
+ process.stderr.write("[oma hook] standalone dispatch unavailable: " + String(error) + "\n");
23
+ }
24
+ }
@@ -0,0 +1,24 @@
1
+ // Generated by OMA. Handler implementation lives in the CLI.
2
+ import { spawnSync } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
4
+
5
+ if (import.meta.main) {
6
+ try {
7
+ const result = spawnSync(process.env.OMA_BIN || "oma", [
8
+ "hook", "script", "--vendor", "opencode",
9
+ "--script", "refactor-guard.ts",
10
+ ], {
11
+ input: readFileSync(0),
12
+ cwd: process.cwd(),
13
+ env: process.env,
14
+ encoding: "utf-8",
15
+ timeout: 7000,
16
+ maxBuffer: 4 * 1024 * 1024,
17
+ });
18
+ if (result.error) throw result.error;
19
+ if (result.stdout) process.stdout.write(result.stdout);
20
+ if (result.stderr) process.stderr.write(result.stderr);
21
+ } catch (error) {
22
+ process.stderr.write("[oma hook] standalone dispatch unavailable: " + String(error) + "\n");
23
+ }
24
+ }
package/scm-guard.ts ADDED
@@ -0,0 +1,24 @@
1
+ // Generated by OMA. Handler implementation lives in the CLI.
2
+ import { spawnSync } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
4
+
5
+ if (import.meta.main) {
6
+ try {
7
+ const result = spawnSync(process.env.OMA_BIN || "oma", [
8
+ "hook", "script", "--vendor", "opencode",
9
+ "--script", "scm-guard.ts",
10
+ ], {
11
+ input: readFileSync(0),
12
+ cwd: process.cwd(),
13
+ env: process.env,
14
+ encoding: "utf-8",
15
+ timeout: 7000,
16
+ maxBuffer: 4 * 1024 * 1024,
17
+ });
18
+ if (result.error) throw result.error;
19
+ if (result.stdout) process.stdout.write(result.stdout);
20
+ if (result.stderr) process.stderr.write(result.stderr);
21
+ } catch (error) {
22
+ process.stderr.write("[oma hook] standalone dispatch unavailable: " + String(error) + "\n");
23
+ }
24
+ }
@@ -0,0 +1,24 @@
1
+ // Generated by OMA. Handler implementation lives in the CLI.
2
+ import { spawnSync } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
4
+
5
+ if (import.meta.main) {
6
+ try {
7
+ const result = spawnSync(process.env.OMA_BIN || "oma", [
8
+ "hook", "script", "--vendor", "opencode",
9
+ "--script", "serena-primer.ts",
10
+ ], {
11
+ input: readFileSync(0),
12
+ cwd: process.cwd(),
13
+ env: process.env,
14
+ encoding: "utf-8",
15
+ timeout: 7000,
16
+ maxBuffer: 4 * 1024 * 1024,
17
+ });
18
+ if (result.error) throw result.error;
19
+ if (result.stdout) process.stdout.write(result.stdout);
20
+ if (result.stderr) process.stderr.write(result.stderr);
21
+ } catch (error) {
22
+ process.stderr.write("[oma hook] standalone dispatch unavailable: " + String(error) + "\n");
23
+ }
24
+ }
@@ -0,0 +1,24 @@
1
+ // Generated by OMA. Handler implementation lives in the CLI.
2
+ import { spawnSync } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
4
+
5
+ if (import.meta.main) {
6
+ try {
7
+ const result = spawnSync(process.env.OMA_BIN || "oma", [
8
+ "hook", "script", "--vendor", "opencode",
9
+ "--script", "skill-injector.ts",
10
+ ], {
11
+ input: readFileSync(0),
12
+ cwd: process.cwd(),
13
+ env: process.env,
14
+ encoding: "utf-8",
15
+ timeout: 7000,
16
+ maxBuffer: 4 * 1024 * 1024,
17
+ });
18
+ if (result.error) throw result.error;
19
+ if (result.stdout) process.stdout.write(result.stdout);
20
+ if (result.stderr) process.stderr.write(result.stderr);
21
+ } catch (error) {
22
+ process.stderr.write("[oma hook] standalone dispatch unavailable: " + String(error) + "\n");
23
+ }
24
+ }
@@ -0,0 +1,24 @@
1
+ // Generated by OMA. Handler implementation lives in the CLI.
2
+ import { spawnSync } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
4
+
5
+ if (import.meta.main) {
6
+ try {
7
+ const result = spawnSync(process.env.OMA_BIN || "oma", [
8
+ "hook", "script", "--vendor", "opencode",
9
+ "--script", "state-boundary.ts",
10
+ ], {
11
+ input: readFileSync(0),
12
+ cwd: process.cwd(),
13
+ env: process.env,
14
+ encoding: "utf-8",
15
+ timeout: 7000,
16
+ maxBuffer: 4 * 1024 * 1024,
17
+ });
18
+ if (result.error) throw result.error;
19
+ if (result.stdout) process.stdout.write(result.stdout);
20
+ if (result.stderr) process.stderr.write(result.stderr);
21
+ } catch (error) {
22
+ process.stderr.write("[oma hook] standalone dispatch unavailable: " + String(error) + "\n");
23
+ }
24
+ }
package/test-filter.ts ADDED
@@ -0,0 +1,24 @@
1
+ // Generated by OMA. Handler implementation lives in the CLI.
2
+ import { spawnSync } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
4
+
5
+ if (import.meta.main) {
6
+ try {
7
+ const result = spawnSync(process.env.OMA_BIN || "oma", [
8
+ "hook", "script", "--vendor", "opencode",
9
+ "--script", "test-filter.ts",
10
+ ], {
11
+ input: readFileSync(0),
12
+ cwd: process.cwd(),
13
+ env: process.env,
14
+ encoding: "utf-8",
15
+ timeout: 7000,
16
+ maxBuffer: 4 * 1024 * 1024,
17
+ });
18
+ if (result.error) throw result.error;
19
+ if (result.stdout) process.stdout.write(result.stdout);
20
+ if (result.stderr) process.stderr.write(result.stderr);
21
+ } catch (error) {
22
+ process.stderr.write("[oma hook] standalone dispatch unavailable: " + String(error) + "\n");
23
+ }
24
+ }