Skip to content
Merged
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
21 changes: 19 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,16 @@ jobs:
jq -e '.plugins' .claude-plugin/marketplace.json >/dev/null
jq -e '.name and .version' .codex-plugin/plugin.json >/dev/null
jq -e '.plugins' .agents/plugins/marketplace.json >/dev/null
jq -e '.name and .version' .omp-plugin/package.json >/dev/null
- name: Check plugin version agreement (Linux)
if: runner.os == 'Linux'
run: |
c=$(jq -r .version .claude-plugin/plugin.json)
x=$(jq -r .version .codex-plugin/plugin.json)
o=$(jq -r .version .opencode-plugin/package.json)
if [ "$c" != "$x" ] || [ "$c" != "$o" ]; then
echo "version drift: .claude-plugin/plugin.json=$c .codex-plugin/plugin.json=$x .opencode-plugin/package.json=$o" >&2
m=$(jq -r .version .omp-plugin/package.json)
if [ "$c" != "$x" ] || [ "$c" != "$o" ] || [ "$c" != "$m" ]; then
echo "version drift: .claude-plugin/plugin.json=$c .codex-plugin/plugin.json=$x .opencode-plugin/package.json=$o .omp-plugin/package.json=$m" >&2
exit 1
fi
- name: Typography (Linux)
Expand Down Expand Up @@ -105,3 +107,18 @@ jobs:
run: npm run typecheck
- name: Run tests
run: npm test

omp-plugin:
runs-on: ubuntu-latest
defaults:
run:
working-directory: .omp-plugin
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Typecheck
run: bun run typecheck
- name: Run tests
run: bun test
8 changes: 6 additions & 2 deletions .local-ci.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
"checks": [
{
"label": "manifest validation",
"run": "jq -e '.hooks | keys' hooks/hooks.json >/dev/null && jq -e '.name and .version' .claude-plugin/plugin.json >/dev/null && jq -e '.plugins' .claude-plugin/marketplace.json >/dev/null && jq -e '.name and .version' .codex-plugin/plugin.json >/dev/null && jq -e '.plugins' .agents/plugins/marketplace.json >/dev/null"
"run": "jq -e '.hooks | keys' hooks/hooks.json >/dev/null && jq -e '.name and .version' .claude-plugin/plugin.json >/dev/null && jq -e '.plugins' .claude-plugin/marketplace.json >/dev/null && jq -e '.name and .version' .codex-plugin/plugin.json >/dev/null && jq -e '.plugins' .agents/plugins/marketplace.json >/dev/null && jq -e '.name and .version' .omp-plugin/package.json >/dev/null"
},
{
"label": "plugin version agreement",
"run": "set -e; c=$(jq -r .version .claude-plugin/plugin.json); x=$(jq -r .version .codex-plugin/plugin.json); o=$(jq -r .version .opencode-plugin/package.json); [ \"$c\" = \"$x\" ] && [ \"$c\" = \"$o\" ] || { echo \"version drift: .claude-plugin/plugin.json=$c .codex-plugin/plugin.json=$x .opencode-plugin/package.json=$o\" >&2; exit 1; }"
"run": "set -e; c=$(jq -r .version .claude-plugin/plugin.json); x=$(jq -r .version .codex-plugin/plugin.json); o=$(jq -r .version .opencode-plugin/package.json); m=$(jq -r .version .omp-plugin/package.json); [ \"$c\" = \"$x\" ] && [ \"$c\" = \"$o\" ] && [ \"$c\" = \"$m\" ] || { echo \"version drift: .claude-plugin/plugin.json=$c .codex-plugin/plugin.json=$x .opencode-plugin/package.json=$o .omp-plugin/package.json=$m\" >&2; exit 1; }"
},
{
"label": "hook tests",
Expand All @@ -19,6 +19,10 @@
{
"label": "opencode plugin",
"run": "cd .opencode-plugin && npm ci && npm run typecheck && npm test"
},
{
"label": "omp plugin",
"run": "cd .omp-plugin && bun install --frozen-lockfile && bun run typecheck && bun test"
}
]
}
3 changes: 3 additions & 0 deletions .omp-plugin/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
*.log
.DS_Store
23 changes: 23 additions & 0 deletions .omp-plugin/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions .omp-plugin/hooks
24 changes: 24 additions & 0 deletions .omp-plugin/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@dynamicagency/throughline-omp",
"version": "0.15.0",
"private": true,
"type": "module",
"description": "Continuous, state-aware session memory for OMP (Oh My Pi). Captures what you did and what is (commands, file changes, decisions, and live git/PR state), then hands it off with judgment at session wrap-up. Readable, local-first artifacts (commit them if you choose).",
"author": {
"name": "Dynamic Agency",
"email": "support@dynamicagency.com"
},
"homepage": "https://github.com/dynamic/throughline",
"repository": "https://github.com/dynamic/throughline",
"license": "MIT",
"keywords": ["memory", "handoff", "session", "context", "continuity", "compaction", "git-state", "cross-harness", "resume", "onboarding", "omp", "oh-my-pi"],
"scripts": {
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "bun test"
},
"devDependencies": {
"@types/node": "^20.0.0",
"bun-types": "^1.3.0",
"typescript": "^5.4.0"
}
}
1 change: 1 addition & 0 deletions .omp-plugin/skills
219 changes: 219 additions & 0 deletions .omp-plugin/throughline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// Integration test for the OMP hook shim (hooks/post/throughline.ts).
//
// Deliberately does NOT mock node:child_process: it imports the real shim
// and fires synthetic OMP events at it, letting each handler drive the real
// hooks/*.sh scripts against a scratch project directory, then asserts on
// the resulting buffer file. A mocked-execFile unit test would only prove
// "the shim called execFile with some string" — it can't catch a JSON
// field-name mismatch against what the shell scripts' own `jq` filters
// expect, which is exactly the class of bug this design is meant to avoid
// (see dynamic/throughline#85 and the .opencode-plugin tool-naming bug it
// cites). Running the real scripts, as tests/run.sh already does for the
// Claude Code hooks, is the layer that actually catches that.
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import * as os from "node:os";
import * as path from "node:path";

const SHIM = path.join(import.meta.dir, "..", "hooks", "post", "throughline.ts");
const SESSION_ID = "test-session-id";

let scratchDir: string;
let originalCwd: string;
let registered: Record<string, ((event: unknown, ctx: unknown) => Promise<void> | void)[]>;
let sentMessages: { customType?: string; content?: string }[];
let ctx: { cwd: string; sessionManager: { getSessionId: () => string } };

async function fire(event: string, payload: unknown): Promise<void> {
const handlers = registered[event] ?? [];
expect(handlers.length).toBeGreaterThan(0);
for (const h of handlers) await h(payload, ctx);
}

function bufferPath(): string {
return path.join(scratchDir, ".claude", "throughline", "buffer", `session-${SESSION_ID}.md`);
}

beforeEach(async () => {
scratchDir = mkdtempSync(path.join(os.tmpdir(), "tl-omp-test-"));
originalCwd = process.cwd();
process.chdir(scratchDir);

registered = {};
sentMessages = [];
const pi = {
on(event: string, handler: (event: unknown, ctx: unknown) => Promise<void> | void) {
(registered[event] ??= []).push(handler);
},
sendMessage(msg: { customType?: string; content?: string }) {
sentMessages.push(msg);
},
};
ctx = { cwd: scratchDir, sessionManager: { getSessionId: () => SESSION_ID } };

// Cache-busting query so bun re-evaluates the module (and its registered
// handlers) fresh for every test rather than reusing a prior import.
const mod = await import(`${SHIM}?t=${Date.now()}-${Math.random()}`);
mod.default(pi);
});

afterEach(() => {
process.chdir(originalCwd);
rmSync(scratchDir, { recursive: true, force: true });
});

describe("throughline OMP shim", () => {
test("registers all six lifecycle events", () => {
for (const event of [
"session_start",
"before_agent_start",
"tool_result",
"session_before_compact",
"session_compact",
"session_shutdown",
]) {
expect(registered[event]?.length).toBe(1);
}
});

test("session_start sends an onboarding message", async () => {
await fire("session_start", { type: "session_start" });
expect(sentMessages.length).toBe(1);
expect(sentMessages[0].customType).toBe("throughline-onboard");
expect(sentMessages[0].content).toContain("throughline");
});

test("large payload does not crash when the target hook script exits before reading stdin", async () => {
// Regression: every hooks/*.sh script guard-clause exits 0 (before ever
// reading stdin) when THROUGHLINE_DISABLE is set. A payload large enough
// to exceed the pipe buffer then hits EPIPE on the write - unhandled,
// that tears down the whole host process, not just this one hook call.
const prev = process.env.THROUGHLINE_DISABLE;
process.env.THROUGHLINE_DISABLE = "1";
try {
const bigPrompt = "x".repeat(5 * 1024 * 1024);
await fire("before_agent_start", { type: "before_agent_start", prompt: bigPrompt });
} finally {
if (prev === undefined) delete process.env.THROUGHLINE_DISABLE;
else process.env.THROUGHLINE_DISABLE = prev;
}
});

test("before_agent_start captures the prompt", async () => {
await fire("before_agent_start", { type: "before_agent_start", prompt: "hello from omp" });
const buf = readFileSync(bufferPath(), "utf8");
expect(buf).toContain("**prompt** hello from omp");
});

test("tool_result captures bash, write, grep, and mcp__* tools", async () => {
await fire("tool_result", {
type: "tool_result",
toolName: "bash",
toolCallId: "1",
input: { command: "echo hi" },
content: [],
isError: false,
});
await fire("tool_result", {
type: "tool_result",
toolName: "write",
toolCallId: "2",
input: { path: "foo.txt", content: "x" },
content: [],
isError: false,
});
await fire("tool_result", {
type: "tool_result",
toolName: "grep",
toolCallId: "3",
input: { pattern: "TODO" },
content: [],
isError: false,
});
await fire("tool_result", {
type: "tool_result",
toolName: "mcp__github_search_issues",
toolCallId: "4",
input: {},
content: [],
isError: false,
});

const buf = readFileSync(bufferPath(), "utf8");
expect(buf).toContain("**bash**");
expect(buf).toContain("echo hi");
expect(buf).toContain("**Write** foo.txt");
expect(buf).toContain("**grep** `TODO`");
expect(buf).toContain("**mcp__github_search_issues**");
});

test("tool_result falls back to task's prompt when description is an empty string", async () => {
// Regression: an empty-string description must still fall through to
// prompt (matches session-capture.sh's own empty-aware jq select) - a
// naive `??` fallback stops at the empty string and drops the intent.
await fire("tool_result", {
type: "tool_result",
toolName: "task",
toolCallId: "1",
input: { subagent_type: "Explore", description: "", prompt: "find the auth code paths" },
content: [],
isError: false,
});
const buf = readFileSync(bufferPath(), "utf8");
expect(buf).toContain("find the auth code paths");
});

test("tool_result skips read and glob (matches Claude Code's own exclusion)", async () => {
await fire("tool_result", {
type: "tool_result",
toolName: "read",
toolCallId: "1",
input: { path: "foo.txt" },
content: [],
isError: false,
});
await fire("tool_result", {
type: "tool_result",
toolName: "glob",
toolCallId: "2",
input: { pattern: "*.ts" },
content: [],
isError: false,
});
expect(existsSync(bufferPath())).toBe(false);
});

test("tool_result drops unrecognized non-mcp tools rather than guessing a shape", async () => {
await fire("tool_result", {
type: "tool_result",
toolName: "todo",
toolCallId: "1",
input: {},
content: [],
isError: false,
});
expect(existsSync(bufferPath())).toBe(false);
});

test("session_before_compact stamps a compaction boundary", async () => {
// Needs an existing buffer to stamp — capture something first.
await fire("before_agent_start", { type: "before_agent_start", prompt: "hi" });
await fire("session_before_compact", { type: "session_before_compact" });
const buf = readFileSync(bufferPath(), "utf8");
expect(buf).toContain("<!-- compaction-boundary");
});

test("session_compact re-sends onboarding context (post-compaction recovery)", async () => {
await fire("before_agent_start", { type: "before_agent_start", prompt: "hi" });
await fire("session_compact", { type: "session_compact" });
expect(sentMessages.length).toBe(1);
expect(sentMessages[0].content).toContain("Context was just compacted");
});

test("session_shutdown stamps the buffer as ended", async () => {
await fire("before_agent_start", { type: "before_agent_start", prompt: "hi" });
await fire("session_shutdown", { type: "session_shutdown" });
const buf = readFileSync(bufferPath(), "utf8");
expect(buf).toContain("<!-- session-ended");
});
});
13 changes: 13 additions & 0 deletions .omp-plugin/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node", "bun-types"]
},
"include": ["../hooks/post/*.ts", "*.test.ts"]
}
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ All notable changes to throughline are documented here. Format loosely follows
## [Unreleased]

### Added
- **OMP (Oh My Pi) support** (issue #85): a 4th delivery format, `.omp-plugin/`.
OMP's hooks are in-process TypeScript event handlers rather than subprocess
hooks, so a thin shim (`hooks/post/throughline.ts`) registers against six of
OMP's own events (`session_start`, `before_agent_start`, `tool_result`,
`session_before_compact`, `session_compact`, `session_shutdown`) and shells
out to the identical `hooks/*.sh` scripts Claude Code and Codex CLI run -
no capture/redaction logic is duplicated. Skills are auto-discovered via a
symlink (`.omp-plugin/skills` -> `../skills`), zero extra step. Install via
`omp plugin link .omp-plugin` from a checkout of this repo.
- `session-onboard.sh --doctor` (issue #71): a read-only diagnostic that
prints the resolved data root, activation state and reason, `jq`
availability, live/archived buffer counts, and the three env vars'
Expand Down
Loading
Loading