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
24 changes: 24 additions & 0 deletions .opencode-plugin/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,27 @@ test("allowsOrbitCommand: sync --branch prompts (pool-wide state, root-only)", (
// Lookalike must not trip it.
assert.equal(allowsOrbitCommand("orbit sync --branches"), true)
})

test("rawContext anchors the shell to PluginInput.directory", async () => {
// Bun's `$` inherits the opencode process cwd, which equals the project
// only when opencode was launched from it — the plugin must anchor to the
// SDK-provided directory itself (serve/desktop modes differ).
const cwdCalls: string[] = []
const fake$ = ((_strings: TemplateStringsArray) => {
const chain = {
cwd(d: string) { cwdCalls.push(d); return chain },
nothrow() { return chain },
quiet() { return chain },
// thenable: rawContext awaits the chain
then(onFulfilled: (v: { exitCode: number; text: () => string }) => unknown) {
return Promise.resolve({ exitCode: 0, text: () => "CTX" }).then(onFulfilled)
},
}
return chain
})
const hooks = await orbitPlugin({ client: {}, $: fake$, directory: "/proj/dir" } as never)
const output = { system: [] as string[] }
await hooks["experimental.chat.system.transform"]!({ sessionID: "t1" } as never, output as never)
assert.deepEqual(cwdCalls, ["/proj/dir"])
assert.match(output.system[0] ?? "", /CTX/)
})
14 changes: 10 additions & 4 deletions .opencode-plugin/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ const allowsOrbitCommand = (cmd: string): boolean => {
return true
}

const orbitPlugin = (async ({ client, $ }) => {
const orbitPlugin = (async ({ client, $, directory }) => {
// Per-session cache for injected system context. Refreshed when a bash
// tool that invokes the orbit CLI executes (workspace state may have
// changed). compactedSessions marks sessions that have been compacted —
Expand All @@ -134,12 +134,18 @@ const orbitPlugin = (async ({ client, $ }) => {

// Run an orbit context command and return its raw markdown. Returns "" on
// any failure (orbit missing / not in a workspace — the command fails
// fast in both cases).
// fast in both cases). The shell is anchored to the SDK-provided project
// directory: Bun's `$` otherwise inherits the opencode process cwd, which
// equals the project only when opencode was launched from it. Verified
// against opencode source (2026-08-14): PluginInput.directory is
// per-instance — plugin state is cached per directory (InstanceState
// scoped cache) and events route by directory — so the anchor holds in
// serve mode (multi-project server) as well.
const rawContext = async (startup: boolean): Promise<string> => {
try {
const res = startup
? await $`orbit context --startup`.nothrow().quiet()
: await $`orbit context`.nothrow().quiet()
? await $`orbit context --startup`.cwd(directory).nothrow().quiet()
: await $`orbit context`.cwd(directory).nothrow().quiet()
if (res.exitCode !== 0) return ""
return res.text().trim()
} catch {
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ Hardens the destructive surface: `prune` and `sync --force`/`--branch` become ma
- Session guard warns when process ancestry is unreadable, instead of failing silently open.
- `orbit info` and the `orbit context --startup` reignite block no longer fetch — read paths are purely local again (zero network): #29's touchpoint fetch made every `info` and every session start with worktrees pay N serial remote round-trips (the default branch plus each tracked branch, one fetch each), multiplying with pool residue. Ruling: without an async daemon, auto-fetch on a main path taxes a synchronous caller for advisory freshness, and low friction outranks it — auto-fetch may return only off the main path. Layer-1 staleness (`remoteAhead`) now reads last-fetched refs, refreshed by the remaining fetching touchpoints (`orbit sync` / `orbit prune`) or the user's own fetch/pull; fetch-config maintenance (a local write) stays.
- Bare `orbit prune` no longer reaps an empty repo's default-branch config: pool maintenance's orphan-config sweep treats the pool HEAD's target branch as always alive (possibly unborn) — its `branch.<name>.*` section is first-push routing, not residue. The protection tracks HEAD and self-releases once the branch gains a ref or the pool switches defaults; non-empty repos are unchanged (the ref check already keeps such sections). ([#36](https://github.com/orbcli/orbit/pull/36))
- Session-injection hooks anchor their working directory to the host-injected project dir before workspace detection: hook CWD is not a cross-host contract, so a host running hooks from outside the project silently disabled `<orbit-context>` injection for the entire session ("not in a workspace" is a designed silent no-op, so nothing ever surfaced). The shared `session-start.sh` / `session-resume.sh` now `cd` to `CLAUDE_PROJECT_DIR` (Claude Code's documented contract, also injected by Qoder) with `QODER_PROJECT_DIR` as fallback — guarded so empty/unset/invalid values and env-less hosts (codex sets hook CWD correctly by contract) pass through unchanged — and the OpenCode plugin anchors its shell to the SDK's `PluginInput.directory` instead of inheriting the opencode process cwd. ([#37](https://github.com/orbcli/orbit/pull/37))

#### Removal

Expand Down
13 changes: 13 additions & 0 deletions docs/spec-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@
merge; "behind" = sync; "over budget" = curate).
- **Fail-safe.** Every hook is a silent no-op when orbit is missing or CWD is
not in a workspace (`orbit context` fails fast in both cases).
- **Host-CWD anchoring.** Hook CWD is not a cross-host contract: a host may
run hooks from a directory other than the project (Claude Code only
promises "the current directory"; codex sets it to the session cwd). The
shared scripts therefore anchor to the host-injected project dir before
detection — `CLAUDE_PROJECT_DIR` (Claude Code's documented contract, also
injected by Qoder), then `QODER_PROJECT_DIR` (Qoder's documented
fallback) — guarded by `[ -n ]`/`[ -d ]` so empty/unset/invalid values
and env-less hosts (codex) pass through as a no-op. The OpenCode plugin
anchors its shell to the SDK-provided `PluginInput.directory` via
`.cwd(...)` instead of inheriting the opencode process cwd —
`directory` is per-instance (opencode materializes plugin state per
project directory, serve mode included), so the anchor names the
session's project in every run mode.

## Event routing

Expand Down
10 changes: 10 additions & 0 deletions hooks/session-resume.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@

command -v orbit >/dev/null 2>&1 || exit 0

# Same CWD anchoring as session-start.sh: hook CWD is not a cross-host
# contract, so anchor to the host-injected project dir
# (CLAUDE_PROJECT_DIR → QODER_PROJECT_DIR) before the CWD-based workspace
# detection. Guarded — empty/unset/invalid values are a silent no-op.
_orbit_anchor="${CLAUDE_PROJECT_DIR:-${QODER_PROJECT_DIR:-}}"
if [ -n "$_orbit_anchor" ] && [ -d "$_orbit_anchor" ]; then
cd "$_orbit_anchor" >/dev/null 2>&1 || true
fi
unset _orbit_anchor

HINT='<!-- orbit workspace: invoke the orbit skill (skip only if you can fully recall its "Safety rules" section, count included, from the loaded skill text — not from a summary; on any doubt, invoke) -->'

if out=$(orbit context 2>/dev/null) && [ -n "$out" ]; then
Expand Down
13 changes: 13 additions & 0 deletions hooks/session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ EOF
exit 0
fi

# Hook CWD is not a cross-host contract — a host may run hooks from outside
# the project directory — while orbit's workspace detection is CWD-based.
# Anchor to the host-injected project dir first: CLAUDE_PROJECT_DIR (Claude
# Code's documented contract, also injected by Qoder), then
# QODER_PROJECT_DIR (Qoder's documented fallback). Empty/unset/invalid
# values and hosts with a correct hook CWD (codex injects neither) pass
# through as a silent no-op.
_orbit_anchor="${CLAUDE_PROJECT_DIR:-${QODER_PROJECT_DIR:-}}"
if [ -n "$_orbit_anchor" ] && [ -d "$_orbit_anchor" ]; then
cd "$_orbit_anchor" >/dev/null 2>&1 || true
fi
unset _orbit_anchor

HINT='<!-- orbit workspace: invoke the orbit skill before your first reply -->'

if out=$(orbit context --startup 2>/dev/null) && [ -n "$out" ]; then
Expand Down
80 changes: 80 additions & 0 deletions tests/26_hook_cwd_anchor.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env bats
# hooks/session-start.sh & hooks/session-resume.sh — host-CWD anchor contract.
#
# Hook CWD is not a cross-host contract — a host may run hooks from outside
# the project directory. The shared scripts anchor to the host-injected
# project dir (CLAUDE_PROJECT_DIR → QODER_PROJECT_DIR) before calling
# `orbit context`, whose workspace detection is CWD-based. These tests pin
# the anchor chain: fallback order, the guards (empty / unset / nonexistent /
# not-a-dir), and the no-op fallthrough for env-less hosts (codex).

setup() {
load test_helper/common
common_setup
START_HOOK="$BATS_TEST_DIRNAME/../hooks/session-start.sh"
RESUME_HOOK="$BATS_TEST_DIRNAME/../hooks/session-resume.sh"
# Stub orbit on PATH: reports its argv and physical cwd.
STUB_BIN="$SANDBOX/bin"
mkdir -p "$STUB_BIN"
cat >"$STUB_BIN/orbit" <<'EOF'
#!/usr/bin/env bash
printf 'STUB args=%s cwd=%s\n' "$*" "$(pwd -P)"
EOF
chmod +x "$STUB_BIN/orbit"
export PATH="$STUB_BIN:$PATH"
PROJ="$SANDBOX/proj"
OTHER="$SANDBOX/other"
LAUNCH="$SANDBOX/launch"
mkdir -p "$PROJ" "$OTHER" "$LAUNCH"
PROJ_P="$(cd "$PROJ" && pwd -P)"
OTHER_P="$(cd "$OTHER" && pwd -P)"
LAUNCH_P="$(cd "$LAUNCH" && pwd -P)"
}

teardown() {
common_teardown
}

# Run both hooks from $LAUNCH with env vars passed as NAME=VALUE args;
# assert every run reports the expected physical cwd.
# $1: expected cwd; remaining args: env assignments for `env`.
assert_hooks_cwd() {
local want="$1"; shift
local hook args
for hook in "$START_HOOK" "$RESUME_HOOK"; do
if [ "$hook" = "$START_HOOK" ]; then args="context --startup"; else args="context"; fi
cd "$LAUNCH"
run env -u CLAUDE_PROJECT_DIR -u QODER_PROJECT_DIR "$@" bash "$hook"
[ "$status" -eq 0 ]
assert_contains "$output" "STUB args=$args cwd=$want"
done
}

@test "anchor: CLAUDE_PROJECT_DIR wins when set" {
assert_hooks_cwd "$PROJ_P" "CLAUDE_PROJECT_DIR=$PROJ"
}

@test "anchor: QODER_PROJECT_DIR is the fallback" {
assert_hooks_cwd "$PROJ_P" "QODER_PROJECT_DIR=$PROJ"
}

@test "anchor: CLAUDE_PROJECT_DIR precedes QODER_PROJECT_DIR" {
assert_hooks_cwd "$PROJ_P" "CLAUDE_PROJECT_DIR=$PROJ" "QODER_PROJECT_DIR=$OTHER"
}

@test "anchor: empty CLAUDE_PROJECT_DIR falls through to QODER_PROJECT_DIR" {
assert_hooks_cwd "$PROJ_P" "CLAUDE_PROJECT_DIR=" "QODER_PROJECT_DIR=$PROJ"
}

@test "anchor: unset env keeps the launch cwd (env-less hosts unaffected)" {
assert_hooks_cwd "$LAUNCH_P"
}

@test "anchor: nonexistent dir is refused" {
assert_hooks_cwd "$LAUNCH_P" "CLAUDE_PROJECT_DIR=$SANDBOX/no-such-dir"
}

@test "anchor: a regular file is not a dir anchor" {
touch "$SANDBOX/afile"
assert_hooks_cwd "$LAUNCH_P" "CLAUDE_PROJECT_DIR=$SANDBOX/afile"
}
Loading