Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
060ab2c
docs: opencode v2 plugin migration plan
sysread Sep 22, 2026
956fd08
docs: user setup compatibility guarantee in v2 plan
sysread Sep 22, 2026
a1525f9
refactor: extract host-capabilities seam and dual-shape plugin entry
sysread Sep 22, 2026
b0f51d7
test: cover extracted skew warning text
sysread Sep 22, 2026
f13acfb
test: v2 adapter contract tests and dual-runtime CI legs
sysread Sep 22, 2026
0aa091a
docs: dual-adapter architecture in dev docs and user guides
sysread Sep 22, 2026
4634038
Merge branch 'main' into opencode-v2-migration
sysread Sep 22, 2026
98db5f6
docs: opencode dual-adapter architecture, gotcha, module map
sysread Sep 22, 2026
c30a6bb
fix: wire v2 tool buffering, await prompt hook, gate noReply deliveries
sysread Sep 23, 2026
f710c40
fix: gate hygiene report behind noReply delivery, cover v2 error paths
sysread Sep 23, 2026
634e965
fix: convert runtime system-prompt strings to v2 SystemPart objects
sysread Sep 23, 2026
de651e3
fix: create v2 extraction sessions with location, not parentID
sysread Sep 23, 2026
a0c952b
fix: log extraction trigger failures to the debug log
sysread Sep 23, 2026
b495c94
fix: expose runtime debug handle, log v2 event pump traffic
sysread Sep 23, 2026
a6ad874
fix: translate v2 bus events to the runtime's v1 event shapes
sysread Sep 23, 2026
6574e67
fix: resolve location-less v2 execution events via session lookup
sysread Sep 23, 2026
eb6d2de
fix: pre-convert v2 tool input schemas to JSON Schema with our zod
sysread Sep 23, 2026
306cc4c
fix: restore wrap-up greenlight detection and compact action on v2
sysread Sep 23, 2026
81ab8c7
fix: drop fixture mise.toml that mise trust enforcement rejects
sysread Sep 23, 2026
93306c8
docs: record v2 smoke results and upstream publish-channel issue
sysread Sep 23, 2026
8604a6d
Fix v2 adapter defects found in review
sysread Sep 24, 2026
c508fd4
Round-2 review: degrades, casts, docs, and cleanup
sysread Sep 24, 2026
98487ee
Repoint prediction-consolidation plan citations to runtime.ts
sysread Sep 24, 2026
7d76995
Round-3 review items: reload state-loss docs, toast caveat, await the…
sysread Sep 24, 2026
8408b96
Persist volatile runtime state across v2 reloads and restarts
sysread Sep 24, 2026
4a2a834
Round-4 review: instance-scoped journal, crash-safe child recovery, a…
sysread Sep 24, 2026
16ad505
Re-host registered chat sessions after a v2 plugin reload
sysread Sep 24, 2026
db62cb8
Fix re-host project match: rows store the repo slug, not a path
sysread Sep 24, 2026
e262dd3
Round-5 review: journal the hosted set; gate the re-attach notice on …
sysread Sep 24, 2026
ca1c9a4
chat list: session id column and --active flag
sysread Sep 24, 2026
006650b
chat list: active-only by default; --stale opts into the stale section
sysread Sep 24, 2026
a0ffa0e
Drop foreign-pid hosted rows on restart; remove unreachable branch
sysread Sep 24, 2026
fb1b556
Exit on v2 targets tab close, not app exit; upstream follow-up posted
sysread Sep 24, 2026
1b9ba69
Reap leaves a name claim so a resumed session keeps its identity
sysread Sep 24, 2026
6b3a180
Debug-trace the v2 prompt and generate hooks (THATCH_DEBUG)
sysread Sep 24, 2026
a34b97b
Generate-hook entry trace (nudge delivery probe)
sysread Sep 24, 2026
c758c52
Inject nudges via the context hook; the generate hook never fires for…
sysread Sep 24, 2026
65e008a
Plan: watcher continuity across restarts (fast-follow)
sysread Sep 24, 2026
ea24244
CI: bare test summary job for the required status check context
sysread Sep 24, 2026
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
19 changes: 17 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,29 @@ on:
branches: [main]

jobs:
test:
# The matrix legs carry the real gate (typecheck, tests, markdownlint) per
# Bun runtime: opencode v1 embeds Bun 1.3.14; v2 embeds 1.4.2. The plugin
# runs inside the host's embedded runtime, so both must pass.
test-matrix:
runs-on: ubuntu-latest
strategy:
matrix:
bun-version: ["1.3.14", "1.4.2"]
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.14"
bun-version: ${{ matrix.bun-version }}
- run: bun install
- run: bunx tsc -p tsconfig.check.json
- run: bun test tests/*.test.ts
- run: bunx markdownlint-cli2

# Bare-named summary job: the main branch ruleset requires a status check
# with context "test", and matrix legs report as "test (1.3.14)" etc.,
# which never satisfies a bare "test" requirement.
test:
needs: test-matrix
runs-on: ubuntu-latest
steps:
- run: echo "matrix legs green"
20 changes: 19 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,33 @@ permissions:
issues: write

jobs:
# Release gate: the same checks CI runs (the tag push itself does not
# trigger ci.yml), across the dual-runtime matrix a release must pass on
# (opencode v1 embeds Bun 1.3.14; v2 embeds 1.4.2).
test:
runs-on: ubuntu-latest
strategy:
matrix:
bun-version: ["1.3.14", "1.4.2"]
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ matrix.bun-version }}
- run: bun install
- run: bunx tsc -p tsconfig.check.json
- run: bun test tests/*.test.ts
- run: bunx markdownlint-cli2

publish:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.14"
- run: bun install
- run: bun test tests/*.test.ts
# OIDC publish needs npm >= 11.5.1. setup-node installs into the
# user-writable tool cache (the runner's stock node in /usr/local is
# root-owned, so npm can't self-upgrade there). Deliberately NO
Expand Down
37 changes: 30 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,11 @@ everything runs on your machine.
{ "plugin": ["@jeffober/thatch"] }
```

> **OpenCode 2.0 is not yet supported.** OpenCode 2.0 replaced the plugin API
> (plugins now need a default export with `id`/`setup` instead of the v1
> `server` hook object). Thatch currently targets the 1.18.x plugin API and
> will fail to load on 2.0. Upgrade to 2.0 only after a thatch release adds
> v2 support.

On next start, OpenCode npm-installs thatch and its tools are available
immediately. For async extraction (child sessions run in the background):
immediately. Works with both opencode 1.x and 2.x (the same package supports
both plugin APIs). Until the next release ships, npm's `latest` still
targets 1.x only. For async extraction (child sessions run in the
background):

```bash
export OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true
Expand All @@ -36,6 +33,32 @@ export OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true
Without this env var, extraction still works - the child session runs
synchronously (fire-and-forget) instead of asynchronously.

### What works in opencode 2.x

The v2 plugin API is missing a few surfaces the v1 API had. On 2.x:

- **No toasts.** Toasts are dropped on v2 (no publish surface). The
model-facing nudges they accompanied - watcher and chat wake nudges -
still arrive; the extraction-result and other TUI-only toasts have no
replacement.
- **Plugin state survives reloads.** Editing the plugin (or `opencode
plugin update`) reloads it, but extraction buffers, watcher
registrations, and armed wrap-ups are journaled and restored; a process
restart restores the state of a resumed (`-c`/`-s`) session and prunes
the rest.
- **Extraction children are visible.** The fact-extractor child sessions are
top-level sessions the host cannot delete, so they accumulate in the
session picker, and "continue last session" (`-c`) can land in one after
any session that triggered extraction.
- **`/thatch/compact` does not auto-compact.** The checklist and memory
flush run, but the compaction itself cannot be triggered from the plugin
API - run `/compact` yourself after the wrap-up completes. `/thatch/exit`
cannot auto-exit for the same reason.
- **No `-c` session listing.** The chat resume listing degrades.

Everything else - tools, memory, nudges, chat, watchers - behaves the same
on both versions.

Then **prime your project memory** by running `thatch prime` in your project directory.
This launches an `opencode` session to build an initial map of the code base
and seed the memory.
Expand Down
47 changes: 31 additions & 16 deletions bin/thatch
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,13 @@ function usage(): never {
Full conversation as OpenAI chat-completions JSONL
thatch session search <query> [--session <id>] [--regex] [--limit N]
JSONL of matching parts across sessions (decoded content)
thatch chat list [--stale N|all] Registered chat sessions, grouped Active/Stale:
name, age, status, project, topic.
--stale N (days) hides stale sessions
older than N; default 1. --stale all
shows every stale session.
thatch chat list [--stale N|all] Registered chat sessions. Default shows
only active (fresh-heartbeat) sessions:
name, session id, age, status, project,
topic. --stale adds the stale section:
N bounds it to sessions seen in the
last N days (default 1); all shows
every stale session.
thatch chat tail [--once] [--limit N|all] [--match RE]
[--from NAME] [--to NAME] [--since DT] [--until DT]
Follow cross-session chat as a JSONL
Expand Down Expand Up @@ -415,7 +417,7 @@ async function sessionCommand(rest: string[]): Promise<void> {
// ---------------------------------------------------------------------------

/** Column headers for the `chat list` roster, in display order. */
const CHAT_ROSTER_HEADER = ["NAME", "AGE", "STATUS", "PROJECT", "TOPIC"];
const CHAT_ROSTER_HEADER = ["NAME", "SESSION ID", "AGE", "STATUS", "PROJECT", "TOPIC"];

/**
* Renders the `chat list` roster as two sections - Active and Stale - each
Expand All @@ -436,6 +438,7 @@ function formatChatRoster(sessions: ChatSessionRow[], staleCapMs: number | null
const renderTable = (rows: ChatSessionRow[]) => {
const cells = (r: ChatSessionRow) => [
r.name,
r.session_id,
humanAge(r.last_seen),
chatLiveness(r),
r.project ?? "unknown",
Expand All @@ -447,9 +450,10 @@ function formatChatRoster(sessions: ChatSessionRow[], staleCapMs: number | null
const align = (line: string[]) => line.map((c, i) => (i < line.length - 1 ? c.padEnd(widths[i]) : c));
const header = align(CHAT_ROSTER_HEADER).map((h) => paint("1", h)).join(" ");
const body = rows.map((r) => {
const [name, age, status, project, topic] = align(cells(r));
const [name, sessionID, age, status, project, topic] = align(cells(r));
return [
paint("32", name),
paint("2", sessionID),
paint("2", age),
paint(status === "stale" ? "31" : "32", status),
project,
Expand Down Expand Up @@ -491,21 +495,32 @@ async function chatCommand(rest: string[]): Promise<void> {
// sessions from burying the fresh signal. --stale all restores the
// unbounded view. A bad value dies loudly rather than silently
// shrinking the roster.
const staleArg = flagValue(restArgs, "--stale");
// Default: active sessions only. --stale [N|all] opts into the
// stale section - bare --stale keeps the one-day display cap, N
// bounds it, all removes it. The value is parsed by hand (not
// flagValue) because bare --stale is meaningful.
const showStale = restArgs.includes("--stale");
const staleIdx = restArgs.indexOf("--stale");
const staleNext = showStale ? restArgs[staleIdx + 1] : undefined;
let staleCapMs: number | null = 24 * 60 * 60 * 1000;
if (staleArg !== undefined) {
if (staleArg === "all") staleCapMs = null;
else if (!/^\d+(\.\d+)?$/.test(staleArg) || Number(staleArg) <= 0) {
console.error(`error: --stale expects a positive number of days or "all", got "${staleArg}"`);
if (staleNext !== undefined) {
if (staleNext === "all") staleCapMs = null;
else if (!/^\d+(\.\d+)?$/.test(staleNext) || Number(staleNext) <= 0) {
console.error(`error: --stale expects a positive number of days or "all", got "${staleNext}"`);
process.exit(1);
} else staleCapMs = Number(staleArg) * 24 * 60 * 60 * 1000;
} else staleCapMs = Number(staleNext) * 24 * 60 * 60 * 1000;
}
const sessions = db.listChatSessions();
let sessions = db.listChatSessions();
if (!showStale) sessions = sessions.filter((r) => chatLiveness(r) === "fresh");
if (sessions.length === 0) {
console.log("No sessions registered. Sessions join the directory automatically on their first idle (opencode), or via chat_register.");
console.log(
showStale
? "No sessions registered. Sessions join the directory automatically on their first idle (opencode), or via chat_register."
: "No active sessions. (Stale sessions are hidden; run with --stale to include them.)",
);
break;
}
console.log(formatChatRoster(sessions, staleCapMs));
console.log(formatChatRoster(sessions, showStale ? staleCapMs : null));
break;
}

Expand Down
Loading
Loading