Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5a356f8
chore: ignore WebStorm and local opencode folders
igorroc Sep 30, 2026
cb17e7c
Windows: opencode plugin and its installer
igorroc Sep 30, 2026
5087654
Windows: route agent events to an opencode pill
igorroc Sep 30, 2026
2c5e511
Windows: opencode CLI as a chat backend
igorroc Sep 30, 2026
3e9f8d3
Windows: Settings sections for opencode plugin and chat
igorroc Sep 30, 2026
ee32033
Docs: document the opencode plugin in the Windows README
igorroc Sep 30, 2026
b704602
Windows: include opencode plugin (coucou.js) in Tauri config and upda…
igorroc Sep 30, 2026
05e9fc5
Windows: deliberate island wake, and always arm the fully-compact timer
imanoldiaz Oct 1, 2026
7987bb2
Windows: manage an opencode server for commands and sessions
imanoldiaz Oct 1, 2026
262aad2
Windows: send chat turns to the running server instead of a process p…
imanoldiaz Oct 1, 2026
50a583b
Windows: session picker ordering, delete, history, and slash filtering
imanoldiaz Oct 1, 2026
f4ffced
Windows: persistent opencode server toggle in the opencode settings s…
imanoldiaz Oct 1, 2026
62df62f
Windows: styles for the session row delete affordance
imanoldiaz Oct 1, 2026
25fb3db
Windows: four-step island retraction with independent timers
imanoldiaz Oct 1, 2026
c43ad04
Windows: document that the cursor display preference does not follow …
imanoldiaz Oct 1, 2026
2c23411
Windows: follow the pointer for the cursor display preference
imanoldiaz Oct 1, 2026
c0abe58
Windows: make the outside click reach the island from either direction
imanoldiaz Oct 1, 2026
05d92ad
Merge upstream main into the Windows opencode branch
imanoldiaz Oct 2, 2026
1f5a0d0
Windows: a usable chat window — commands, model picker, and Escape
imanoldiaz Oct 2, 2026
7ee64b8
Windows: let the open island be dragged by its header, and drag in lo…
imanoldiaz Oct 3, 2026
c44b9d4
Windows: hold only the tail of the conversation in the log
imanoldiaz Oct 3, 2026
e7b758e
Merge upstream main into the Windows opencode branch
imanoldiaz Oct 3, 2026
d92c1ba
Merge upstream main (v0.1.4, Mochi wardrobe) into the Windows opencod…
imanoldiaz Oct 4, 2026
aaf665f
Route opencode lifecycle events through the event hook
imanoldiaz Oct 13, 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ DerivedData/
# Local only (never published)
.claude/
_prive/
.idea/
15 changes: 15 additions & 0 deletions windows/Cargo.lock

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

17 changes: 17 additions & 0 deletions windows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,22 @@ in time, Coucou stays quiet and Claude Code asks in the terminal as usual.

It works from any terminal — Windows Terminal, PowerShell, VS Code, Git Bash.

## opencode

Open **Settings… → opencode → Install plugin…**. This copies `coucou.js` (in
`opencode-plugin/` next to the source) to
`%USERPROFILE%\.config\opencode\plugins\coucou.js` — opencode auto-loads
global plugins, so no `opencode.json` edit is needed. You get a preview of
what will change, a dated backup of any previous copy, and nothing is written
until you click. Uninstall removes only Coucou's file.

The plugin forwards session, tool and permission events through the same
`coucou-hook.exe` relay and named pipe Claude Code uses, so the island shows
opencode sessions in their own pill with the same **Deny / Allow** approval
card. The same guarantee holds: **an opencode session is never blocked by
Coucou** — if nobody answers in time, opencode asks in the TUI as usual.
Restart opencode after installing so it picks the plugin up.

## Chat and keys

**Settings… → Claude** takes your Anthropic API key. Keys live in the **Windows
Expand Down Expand Up @@ -128,6 +144,7 @@ windows/
settings/ the settings window
src-tauri/ Rust backend: window, named pipe, Claude API, pollers
hook/ coucou-hook.exe, the Claude Code relay
opencode-plugin/ coucou.js, the opencode plugin (same relay, same pipe)
scripts/ icon generator
```

Expand Down
278 changes: 278 additions & 0 deletions windows/opencode-plugin/coucou.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
// coucou.js — Coucou plugin for opencode.
//
// Forwards opencode session / tool / permission events to Coucou over the
// named pipe `\\.\pipe\coucou-<sid>` via `coucou-hook.exe`, the same relay
// Claude Code hooks use. Payloads reuse the Claude Code hook shape
// (`hook_event_name`, `session_id`, `cwd`, `tool_name`, `tool_input`, …)
// plus `"agent": "opencode"`, so the island routes them to the opencode pill.
//
// Hard rule (same as nb-hook / coucou-hook): **never block opencode.**
// * Fire-and-forget events get a 2 s relay budget and are abandoned after.
// * Only `permission.asked` waits (up to 110 s) for the island's decision.
// No answer — Coucou closed, timeout, relay missing — resolves to
// "ask", and opencode asks in the TUI exactly as if Coucou were absent.
//
// Install: copied to `~/.config/opencode/plugins/coucou.js` by Coucou's
// settings window (or manually). No `opencode.json` edit is needed:
// files in the global plugin directory are auto-loaded at startup.
//
// Version stamp — the Tauri installer compares this to detect outdated copies.
// Bump on any protocol change.
// COUCOU_PLUGIN_VERSION is matched by src-tauri/src/opencode.rs (do not rename).
const COUCOU_PLUGIN_VERSION = 2;

// ── Relay resolution ──────────────────────────────────────────────────────────

function relayCandidates() {
const list = [];
try {
const local = typeof process !== "undefined" ? process.env.LOCALAPPDATA : "";
if (local) list.push(`${local}\\Coucou\\bin\\coucou-hook.exe`);
} catch { /* process.env unavailable — fall through */ }
return list;
}

/** stdin bytes for the relay: JSON line, like the Claude Code hook payload. */
function line(obj) {
return `${JSON.stringify(obj)}\n`;
}

/**
* Spawn the relay synchronously with `input` on stdin.
* Returns trimmed stdout ("" when the relay is missing, crashed or timed out).
* Never throws — silence is the safe answer.
*/
async function relay(event, payload, timeoutMs, $) {
const candidates = relayCandidates();
if (candidates.length === 0) return "";
const body = line({ ...payload, hook_event_name: event, agent: "opencode" });

// Prefer opencode's Bun shell API ($), fall back to node child_process.
try {
if ($) {
const proc = $`${candidates[0]} ${event}`.stdin(body);
const done = await Promise.race([
proc.then((r) => (typeof r === "string" ? r : r?.stdout ?? "")),
new Promise((resolve) => setTimeout(() => resolve(""), timeoutMs)),
]);
return String(done ?? "").trim();
}
} catch { /* fall through to spawnSync */ }

try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { spawnSync } = require("node:child_process");
const res = spawnSync(candidates[0], [event], {
input: body,
encoding: "utf8",
timeout: timeoutMs,
windowsHide: true,
});
return String(res.stdout ?? "").trim();
} catch {
return "";
}
}

function fireAndForget(event, payload, $) {
// Deliberately unawaited: the 2 s budget lives inside the relay call chain
// via timeout, and nothing here may delay the agent.
void relay(event, payload, 2000, $).catch(() => {});
}

// ── Payload helpers (defensive: event shapes vary across opencode versions) ──

function pick(obj, paths) {
for (const p of paths) {
const v = p.split(".").reduce((o, k) => (o == null ? o : o[k]), obj);
if (typeof v === "string" && v) return v;
if (typeof v === "number") return String(v);
}
return "";
}

function sessionIdOf(input, fallback) {
return (
pick(input, ["sessionID", "session.id", "sessionId", "id"]) ||
fallback ||
"unknown"
);
}

/**
* The best one-line summary a lifecycle event can give, or `{}` when it has
* none.
*
* `pick` only yields strings and numbers, so an error object has to be
* unwrapped by hand: the SDK puts the human-readable text on
* `error.data.message`, never on `error` itself, so `pick(props, ["error"])`
* returns "" and the failure reaches the island with no explanation.
*/
function eventMessage(props) {
const err = props.error;
if (err && typeof err === "object") {
const text = pick(err, ["data.message", "message"]);
if (text) return { message: text };
}
const message = pick(props, ["message", "summary", "title", "error.message"]);
return message ? { message } : {};
}

// ── Decision translation ─────────────────────────────────────────────────────

/**
* coucou-hook.exe answers PermissionRequest with Claude-shaped JSON:
* {"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"|"deny"}}}
* Map it back to what opencode's `permission.asked` understands.
* Returns "allow" | "deny" | null (null = no usable answer → ask in TUI).
*/
function translateDecision(stdout) {
if (!stdout) return null;
const lower = stdout.toLowerCase();
if (lower === "allow" || lower === "deny") return lower;
try {
const parsed = JSON.parse(stdout);
const behavior = parsed?.hookSpecificOutput?.decision?.behavior;
if (behavior === "allow" || behavior === "deny") return behavior;
if (parsed?.behavior === "allow" || parsed?.behavior === "deny") return parsed.behavior;
if (parsed?.decision === "allow" || parsed?.decision === "deny") return parsed.decision;
} catch { /* not JSON — fall through */ }
return null;
}

// ── Plugin ────────────────────────────────────────────────────────────────────

export const CoucouPlugin = async ({ $, directory }) => {
const cwd = directory || (typeof process !== "undefined" ? process.cwd() : "");

/** Base payload shared by every event. */
const base = (extra = {}) => ({ cwd, ...extra });

// Session and permission lifecycle events are NOT hook keys. opencode only
// invokes the keys declared in the Hooks interface — `tool.execute.*`,
// `chat.*`, `permission.ask`, `command.execute.before`, `shell.env` and the
// rest. Everything else arrives through the single `event` hook and has to be
// dispatched here by `event.type`. Registering `session.created` and friends
// as top-level keys looks right and is silently never called.
const EVENT_MAP = {
"session.created": "SessionStart",
"session.idle": "Stop",
"session.error": "StopFailure",
"session.deleted": "SessionEnd",
};

return {
// ── Lifecycle, via the one hook that actually fires ─────────────────────
event: async ({ event }) => {
const props = event.properties || {};
const info = props.info || {};
const session_id = sessionIdOf(props, "") || sessionIdOf(info, "");

const name = EVENT_MAP[event.type];
if (name) {
fireAndForget(name, base({ session_id, ...eventMessage(props) }), $);
return;
}

// The user answered in the TUI (island unreachable or too slow):
// clear a stale approval card so it stops lying.
if (event.type === "permission.replied") {
fireAndForget("PostToolUse", base({ session_id }), $);
}
},

// The prompt text is not on the message. `UserMessage` carries only
// role/agent/model — the text lives in its parts. `message.updated` hands
// over `properties.info`, a Message, so reading text off it finds nothing
// and that event could never have carried a prompt. `chat.message` is the
// hook that is given the parts.
"chat.message": async (input, output) => {
const parts = Array.isArray(output?.parts) ? output.parts : [];
const text = parts
.filter((p) => p && p.type === "text" && typeof p.text === "string" && p.text)
.map((p) => p.text)
.join("\n")
.trim();
if (!text) return;
const session_id =
pick(input, ["sessionID"]) || pick(output?.message || {}, ["id"]) || "";
fireAndForget("UserPromptSubmit", base({ session_id, prompt: text.slice(0, 2000) }), $);
},

// ── Tools ───────────────────────────────────────────────────────────────
"tool.execute.before": async (input, output) => {
const tool = pick(input, ["tool", "toolName", "name"]) || "Tool";
const args =
(output && typeof output.args === "object" && output.args) ||
input.args ||
input.input ||
{};
// The question tool surfaces as an island question card.
if (tool.toLowerCase() === "question") {
const q = pick({ args }, ["args.question", "args.prompt", "args.text"]) || "opencode asks a question";
fireAndForget(
"Notification",
base({ session_id: sessionIdOf(input, ""), message: `${q}?` }),
$,
);
return;
}
fireAndForget(
"PreToolUse",
base({ session_id: sessionIdOf(input, ""), tool_name: tool, tool_input: args }),
$,
);
},
"tool.execute.after": async (input, output) => {
const failed = Boolean(output?.error || input?.error);
fireAndForget(
failed ? "PostToolUseFailure" : "PostToolUse",
base({ session_id: sessionIdOf(input, "") }),
$,
);
},

// ── Permission (the only blocking hook) ─────────────────────────────────
// The key is `permission.ask`, per the Hooks interface — there is no
// `permission.asked`. Signature: (input: Permission, output: { status }).
"permission.ask": async (input, output) => {
const tool = pick(input, ["tool", "toolName", "permission", "name"]) || "Tool";
const args =
(output && typeof output.args === "object" && output.args) ||
input.args ||
input.input ||
{};
const suggestions = input.suggestions || input.patterns || [];

const stdout = await relay(
"PermissionRequest",
base({
session_id: sessionIdOf(input, ""),
tool_name: tool,
tool_input: args,
permission_suggestions: suggestions,
}),
110_000,
$,
).catch(() => "");
const decision = translateDecision(stdout);

// No answer (Coucou closed / timeout): fall through silently so opencode
// asks in the TUI — exactly the "ask" behaviour.
if (!decision) return;

// The Hooks interface types this as `output.status: "ask" | "deny" |
// "allow"` — there is no `decision` or `behavior` field to write. Assign
// only that, and only when it is actually present: touching anything else
// here risks denying a tool, which must never happen by accident.
try {
if (output && typeof output === "object" && "status" in output) {
output.status = decision;
}
} catch { /* notification-only fallback */ }
// Returning the bare word covers resolvers that use the return value.
// Resolvers that ignore it simply ask in the TUI — still correct.
return decision;
},
};
};
Loading