Skip to content

Commit 372fb44

Browse files
committed
feat(island): wire Codex notify + OpenCode plugin into status indicator
1 parent 5af0877 commit 372fb44

6 files changed

Lines changed: 425 additions & 31 deletions

File tree

scripts/coffee-cli-codex-notify.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
#!/usr/bin/env python3
2+
# Coffee CLI — Codex Notify Forwarder
3+
#
4+
# Registered as `notify` in ~/.codex/config.toml by Coffee CLI at launch.
5+
# Codex calls it once per significant event with the JSON payload appended
6+
# as the FINAL argv argument (not stdin — see legacy_notify in
7+
# codex-rs/hooks/src/legacy_notify.rs).
8+
#
9+
# Currently Codex only emits one event type:
10+
# {"type": "agent-turn-complete", ...}
11+
# which we map to `idle` (turn finished, dot turns green).
12+
#
13+
# "working" state is NOT emitted by Codex — that's covered by the frontend
14+
# optimistic update when the user presses Enter (see TierTerminal.tsx).
15+
#
16+
# Env vars (injected by Coffee CLI when spawning Codex in a tab):
17+
# COFFEE_CLI_TAB_ID — tab/session UUID
18+
# COFFEE_CLI_HOOK_PORT — loopback port of the Rust hook server
19+
# COFFEE_CLI_TOOL — must be "codex" for this forwarder to fire
20+
#
21+
# IMPORTANT: `notify` is a *global* Codex config. This script also fires for
22+
# Codex sessions started outside Coffee CLI (e.g. plain terminal), so it must
23+
# no-op silently when env vars are missing or COFFEE_CLI_TOOL != "codex".
24+
# Exit 0 on every path — a flaky notify must never block the agent turn.
25+
26+
import json
27+
import os
28+
import socket
29+
import sys
30+
31+
32+
def main() -> None:
33+
if len(sys.argv) < 2:
34+
sys.exit(0)
35+
try:
36+
data = json.loads(sys.argv[-1])
37+
except Exception:
38+
sys.exit(0)
39+
40+
tab_id = os.environ.get("COFFEE_CLI_TAB_ID")
41+
port = os.environ.get("COFFEE_CLI_HOOK_PORT")
42+
tool = os.environ.get("COFFEE_CLI_TOOL", "")
43+
if not tab_id or not port or tool != "codex":
44+
sys.exit(0)
45+
46+
event = data.get("type", "")
47+
if event == "agent-turn-complete":
48+
status = "idle"
49+
else:
50+
# Future-proof: unknown event types are ignored, not guessed.
51+
sys.exit(0)
52+
53+
payload = {
54+
"tab_id": tab_id,
55+
"tool": tool,
56+
"status": status,
57+
"event": event,
58+
}
59+
60+
try:
61+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
62+
s.settimeout(1.0)
63+
s.connect(("127.0.0.1", int(port)))
64+
s.sendall((json.dumps(payload) + "\n").encode("utf-8"))
65+
try:
66+
s.recv(256)
67+
except Exception:
68+
pass
69+
s.close()
70+
except Exception:
71+
pass
72+
73+
sys.exit(0)
74+
75+
76+
if __name__ == "__main__":
77+
main()
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Coffee CLI — OpenCode Plugin
2+
//
3+
// Auto-loaded by OpenCode from ~/.config/opencode/plugins/ on every session.
4+
// Subscribes to OpenCode's bus events via the catch-all `event` hook and
5+
// forwards a normalized 3-state payload to the Coffee CLI hook server over
6+
// loopback TCP — same protocol the Claude/Codex forwarders speak.
7+
//
8+
// Event-to-state mapping (verified against
9+
// packages/opencode/src/session/status.ts and
10+
// packages/sdk/js/src/gen/types.gen.ts on the dev branch):
11+
//
12+
// working — session.status with status.type === "busy" | "retry"
13+
// permission.replied (user just answered, agent resumes)
14+
// wait_input — permission.updated (a permission record exists awaiting
15+
// response — Permission has no status
16+
// field, so the event itself signals it)
17+
// idle — session.idle / session.error
18+
// session.status with status.type === "idle"
19+
//
20+
// What is NOT mapped (intentionally):
21+
// message.updated / message.part.updated — too noisy, fires per chunk
22+
// during streaming AND after stream ends (cleanup writes), which trapped
23+
// the dot in "working" for ~30s past actual idle (auto-idle fallback).
24+
// Working is already covered by session.status=busy; we don't need a
25+
// second source.
26+
//
27+
// tool.execute.before / tool.execute.after — these are NAMED hooks in the
28+
// plugin Hooks interface, not bus events; the catch-all `event` handler
29+
// never receives them.
30+
//
31+
// Env vars (injected by Coffee CLI when spawning opencode):
32+
// COFFEE_CLI_TAB_ID, COFFEE_CLI_HOOK_PORT, COFFEE_CLI_TOOL=opencode
33+
// Plugin auto-loads for ALL OpenCode sessions, including those started
34+
// outside Coffee CLI — must no-op silently when env vars are missing.
35+
36+
import net from "node:net";
37+
38+
const COFFEE_DEBUG = process.env.COFFEE_CLI_DEBUG === "1";
39+
40+
function debug(...args) {
41+
if (COFFEE_DEBUG) {
42+
try { process.stderr.write(`[coffee-cli-island] ${args.join(" ")}\n`); } catch (_) {}
43+
}
44+
}
45+
46+
function send(payload) {
47+
const tab_id = process.env.COFFEE_CLI_TAB_ID;
48+
const port = process.env.COFFEE_CLI_HOOK_PORT;
49+
const tool = process.env.COFFEE_CLI_TOOL || "";
50+
if (!tab_id || !port || tool !== "opencode") return;
51+
52+
try {
53+
const s = net.createConnection({ host: "127.0.0.1", port: parseInt(port, 10) });
54+
s.setTimeout(800);
55+
const cleanup = () => { try { s.destroy(); } catch (_) {} };
56+
s.on("error", (err) => { debug("send err", err && err.message); cleanup(); });
57+
s.on("timeout", () => { debug("send timeout"); cleanup(); });
58+
s.on("connect", () => {
59+
const body = JSON.stringify({ tab_id, tool, ...payload }) + "\n";
60+
s.write(body);
61+
// Server acks with `{}\n` then closes. Give it 150ms to round-trip.
62+
setTimeout(cleanup, 150);
63+
});
64+
} catch (err) {
65+
debug("send caught", err && err.message);
66+
}
67+
}
68+
69+
function mapEvent(evt) {
70+
if (!evt || typeof evt !== "object") return null;
71+
const type = evt.type || "";
72+
const props = evt.properties || {};
73+
74+
switch (type) {
75+
case "session.idle":
76+
case "session.error":
77+
return { status: "idle", event: type };
78+
79+
case "session.status": {
80+
// properties.status is a SessionStatus object: {type: "idle"|"retry"|"busy", ...}.
81+
// Old version of this plugin compared the whole object to "busy" — never
82+
// matched, which is why idle was missed.
83+
const inner = props.status || {};
84+
const t = inner.type;
85+
if (t === "busy" || t === "retry") return { status: "working", event: type };
86+
if (t === "idle") return { status: "idle", event: type };
87+
return null;
88+
}
89+
90+
case "permission.updated":
91+
// Permission record was created or changed. Easiest 3-state read: any
92+
// permission.updated means the agent is blocked waiting for the user.
93+
// (permission.replied below transitions us back out.)
94+
return { status: "wait_input", event: type };
95+
96+
case "permission.replied":
97+
// User answered — back to working until the next status flip.
98+
return { status: "working", event: type };
99+
100+
default:
101+
return null;
102+
}
103+
}
104+
105+
// OpenCode loader (packages/opencode/src/plugin/index.ts) iterates Object.values
106+
// of the loaded module and treats every function as a Plugin in legacy mode.
107+
// We export a single named Plugin function; default-export is the same
108+
// reference and is deduped by the loader's identity Set.
109+
export const CoffeeCliIslandPlugin = async () => {
110+
debug("plugin loaded; tab=", process.env.COFFEE_CLI_TAB_ID || "<unset>",
111+
"port=", process.env.COFFEE_CLI_HOOK_PORT || "<unset>");
112+
return {
113+
event: async ({ event }) => {
114+
const mapped = mapEvent(event);
115+
if (mapped) {
116+
debug("→", event && event.type, "=>", mapped.status);
117+
send(mapped);
118+
}
119+
},
120+
};
121+
};
122+
123+
export default CoffeeCliIslandPlugin;

src-ui/src/components/center/CenterPanel.tsx

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1119,15 +1119,20 @@ export function CenterPanel() {
11191119
{icon}
11201120
<span className="tab-title" style={{ flex: '0 1 auto', minWidth: 0, whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden' }}>{title}</span>
11211121
<div className="tab-actions">
1122-
{/* Claude is the only CLI we drive a real status machine for —
1123-
hook events from coffee-cli-hook.py flip the dot color.
1124-
VibeID gets the same indicator on its 2-phase progress
1125-
because we know with certainty work is in flight. */}
1126-
{(session.tool === 'claude' || session.tool === 'vibeid' || session.tool === 'insights_prerun') && (
1122+
{/* Indicator gate. Three groups:
1123+
1. AI CLIs with hook integration — claude/codex/opencode each
1124+
have a forwarder (Python script for claude+codex, Bun
1125+
plugin for opencode) wired to the same agent-status bus.
1126+
Color follows session.agentStatus.
1127+
2. VibeID / insights_prerun — work is always in flight while
1128+
these tabs exist; pin to working (orange).
1129+
Anything else (terminal, history, multi-agent, etc.) gets
1130+
no indicator. */}
1131+
{(session.tool === 'claude' || session.tool === 'codex' || session.tool === 'opencode' || session.tool === 'vibeid' || session.tool === 'insights_prerun') && (
11271132
<div className={`tab-status-grid status-${
1128-
session.tool === 'claude'
1129-
? (session.agentStatus === 'wait_input' ? 'waiting' : session.agentStatus ?? 'idle')
1130-
: 'working'
1133+
session.tool === 'vibeid' || session.tool === 'insights_prerun'
1134+
? 'working'
1135+
: (session.agentStatus === 'wait_input' ? 'waiting' : session.agentStatus ?? 'idle')
11311136
}`}>
11321137
{Array.from({ length: 9 }, (_, i) => <div key={i} className="tab-status-dot" />)}
11331138
</div>

src-ui/src/components/center/TierTerminal.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -402,11 +402,10 @@ function TierTerminalImpl({
402402
term.onData((data) => {
403403
commands.tierTerminalInput(sessionId, data).catch(() => {});
404404
// Optimistic status update — Dynamic Island style. A newline means
405-
// the user just submitted; turn the dot to "executing" immediately
406-
// so the UI reacts before any hook event arrives. Scoped to Claude
407-
// only — the other CLIs have a steady "executing" pulse and don't
408-
// consume agentStatus, so emitting for them is wasted dispatch.
409-
if ((data.includes('\r') || data.includes('\n')) && tool === 'claude') {
405+
// the user just submitted; flip the dot to "working" immediately so
406+
// the UI reacts before any hook/notify event arrives. Scoped to the
407+
// CLIs we drive a real status machine for (claude/codex/opencode).
408+
if ((data.includes('\r') || data.includes('\n')) && (tool === 'claude' || tool === 'codex' || tool === 'opencode')) {
410409
notifyUserInputSubmitted(sessionId, tool);
411410
}
412411
});

0 commit comments

Comments
 (0)