@rizal72/oc-coffee 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +34 -2
  2. package/index.ts +11 -0
  3. package/package.json +13 -5
  4. package/tui.tsx +215 -0
package/README.md CHANGED
@@ -1,3 +1,35 @@
1
- # Temporary Holding Version
1
+ # @rizal72/oc-coffee
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Keeps the Mac awake while the agent is working, and lets it sleep between turns.
4
+
5
+ A cup in the prompt footer shows the state — green while a run is active — and `/coffee` cycles the
6
+ level.
7
+
8
+ **macOS only**: it drives `caffeinate`. Elsewhere the plugin loads and the badge stays off.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ opencode plugin add @rizal72/oc-coffee
14
+ ```
15
+
16
+ Or by hand, one line in `~/.config/opencode/opencode.jsonc`:
17
+
18
+ ```jsonc
19
+ "plugins": ["@rizal72/oc-coffee"]
20
+ ```
21
+
22
+ ## Use
23
+
24
+ | level | what is blocked |
25
+ |---|---|
26
+ | `off` | nothing |
27
+ | `idle` | idle sleep — `caffeinate -i` |
28
+ | `display` | idle and display sleep — `-i -d` |
29
+ | `system` | everything, and the user is declared active — `-i -d -s -u` |
30
+
31
+ The assertion lives only while a run is active: it is engaged when the agent starts and released when
32
+ it succeeds, fails or is interrupted. It is bound to the TUI's own process (`caffeinate -w`), so it
33
+ dies with the window however the window closes — a closed terminal cannot leave the machine awake.
34
+
35
+ Requires OpenCode v2 and macOS. TypeScript, no build step, no runtime dependencies.
package/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ // oc-coffee — server half.
2
+ //
3
+ // Deliberately empty: everything happens in the TUI half. It exists because the loader
4
+ // resolves a plugin directory by entry file names (`index`/`server`, `tui`, `rpc`) and a
5
+ // missing server entry fails the whole load.
6
+ export default {
7
+ id: "oc-coffee",
8
+ setup() {
9
+ // nothing to do on the server
10
+ },
11
+ }
package/package.json CHANGED
@@ -1,6 +1,14 @@
1
1
  {
2
- "name": "@rizal72/oc-coffee",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@rizal72/oc-coffee",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Keeps the Mac awake while the agent works: cup badge in the footer, /coffee command",
6
+ "license": "MIT",
7
+ "files": ["*.ts", "*.tsx", "README.md"],
8
+ "keywords": ["opencode", "opencode-plugin", "macos", "caffeinate"],
9
+ "publishConfig": { "access": "public" },
10
+ "exports": {
11
+ ".": "./index.ts",
12
+ "./tui": "./tui.tsx"
13
+ }
14
+ }
package/tui.tsx ADDED
@@ -0,0 +1,215 @@
1
+ // oc-coffee — TUI half.
2
+ //
3
+ // Keeps the Mac awake while the agent works, the port of pi's `pi-coffee`.
4
+ //
5
+ // off | idle | display | system -> caffeinate (), (-i), (-i -d), (-i -d -s -u)
6
+ //
7
+ // Four cumulative levels. The assertion is held only while a run is active — engaged on
8
+ // `session.execution.started`, released on `.succeeded` / `.failed` / `.interrupted` — so the
9
+ // Mac sleeps normally between turns. `-w <pid>` binds the child to this process, so it dies
10
+ // with the TUI however the TUI exits, SIGKILL included, and a closed window can never leave
11
+ // the machine awake for ever.
12
+ //
13
+ // The assertion lives here and not in the server because the server cannot see a run: the
14
+ // public event stream carries only catalogue events (`model.updated`, `provider.updated`, …)
15
+ // and there is no "session is executing" state to read. Measured 2026-10-16, including a
16
+ // clean headless run. The TUI is the only half that knows.
17
+ //
18
+ // The badge shows the level whenever it is not `off` — the setting, not the moment — and only
19
+ // the cup lights up (green) while the assertion is up, so the state is one mark and the level
20
+ // stays legible. `/coffee` opens the menu, `/coffee <level>` sets one, `/coffee status` reports.
21
+ // The level is persisted with the TUI's own durable storage, which replaces pi's
22
+ // `pi-coffee.json`; there is no file of ours.
23
+ //
24
+ // macOS only: elsewhere the plugin stays loaded and simply never engages.
25
+ import { Plugin } from "@opencode/plugin/tui"
26
+ import { spawn, type ChildProcess } from "node:child_process"
27
+ import { createRoot, createSignal } from "solid-js"
28
+
29
+ /** md-coffee_outline (U+F06CA), the glyph pi's own badge uses. */
30
+ const CUP_GLYPH = "\u{f06ca}"
31
+
32
+ const LEVELS = ["off", "idle", "display", "system"] as const
33
+ type Level = (typeof LEVELS)[number]
34
+
35
+ const LEVEL_FLAGS: Record<Level, string[]> = {
36
+ off: [],
37
+ idle: ["-i"],
38
+ display: ["-i", "-d"],
39
+ system: ["-i", "-d", "-s", "-u"],
40
+ }
41
+
42
+ /** The four menu rows, the wording pi uses. */
43
+ const LEVEL_MENU: Record<Level, string> = {
44
+ off: "Off — Do not prevent any sleep",
45
+ idle: "Prevent Idle Sleep — Keep the system awake while the agent works (caffeinate -i)",
46
+ display: "Prevent Display Sleep — Also keep the display from idle-sleeping (caffeinate -i -d)",
47
+ system: "Prevent System Sleep — Also block all system sleep on AC and declare the user active (caffeinate -i -d -s -u)",
48
+ }
49
+
50
+ /** Fallbacks, used only when the theme defines none of the candidates. */
51
+ const FALLBACK_GREEN = "#3fb950"
52
+ const FALLBACK_DIM = "#8b949e"
53
+
54
+ function isLevel(value: unknown): value is Level {
55
+ return typeof value === "string" && (LEVELS as readonly string[]).includes(value)
56
+ }
57
+
58
+ export default Plugin.define({
59
+ id: "oc-coffee.tui",
60
+ setup(context: any) {
61
+ const supported = process.platform === "darwin"
62
+
63
+ // Durable: survives a restart and is shared by every TUI. A JSON object is what the
64
+ // storage takes, so the level rides in one.
65
+ const [stored, updateStored] = context.storage.store("coffee", { initial: { level: "display" as Level } })
66
+ const level = (): Level => (isLevel(stored.level) ? stored.level : "display")
67
+
68
+ const themeColour = (...paths: string[][]): any => {
69
+ for (const path of paths) {
70
+ let node: any = context.theme
71
+ for (const key of path) node = node?.[key]
72
+ if (node !== undefined && node !== null) return node
73
+ }
74
+ return undefined
75
+ }
76
+ const GREEN =
77
+ themeColour(["syntax", "string"], ["diff", "text", "added"], ["text", "feedback", "success", "base"]) ??
78
+ FALLBACK_GREEN
79
+ const DIM = themeColour(["text", "muted"], ["text", "faint"]) ?? FALLBACK_DIM
80
+
81
+ let child: ChildProcess | null = null
82
+ /** Runs in flight: several sessions can execute at once, so it is a count, not a flag. */
83
+ let running = 0
84
+ /** Reactive: the badge must repaint the moment the assertion goes up or down. A plain
85
+ * variable read in the render would be read once and never again. */
86
+ const [active, setActive] = createRoot(() => createSignal(false))
87
+
88
+ function engage(): void {
89
+ if (!supported || level() === "off" || child) return
90
+ const spawned = spawn("caffeinate", [...LEVEL_FLAGS[level()], "-w", String(process.pid)], {
91
+ stdio: "ignore",
92
+ })
93
+ child = spawned
94
+ setActive(true)
95
+ spawned.on("error", () => {
96
+ if (child !== spawned) return
97
+ child = null
98
+ setActive(false)
99
+ })
100
+ spawned.on("exit", () => {
101
+ if (child !== spawned) return
102
+ child = null
103
+ setActive(false)
104
+ })
105
+ }
106
+
107
+ function release(): void {
108
+ const held = child
109
+ child = null
110
+ setActive(false)
111
+ held?.kill("SIGTERM")
112
+ }
113
+
114
+ /** The level can only change from the command, where a failure is worth a word. */
115
+ async function setLevel(next: Level): Promise<void> {
116
+ const wasUp = child !== null
117
+ if (wasUp) release()
118
+ await updateStored((draft: any) => {
119
+ draft.level = next
120
+ })
121
+ if (wasUp || running > 0) engage()
122
+ }
123
+
124
+ const describe = (): string => {
125
+ const flags = LEVEL_FLAGS[level()]
126
+ const labels = flags.length === 0 ? "Sleep prevention: off" : `Sleep prevention: ${level()} (caffeinate ${flags.join(" ")})`
127
+ return `${labels} — assertion ${child ? "up" : "idle"}`
128
+ }
129
+
130
+ // One listener per boundary. `running` counts sessions executing right now, so the
131
+ // assertion is up while it is positive and the level is not `off`.
132
+ const stopStarted = context.data.on("session.execution.started", () => {
133
+ running += 1
134
+ if (running === 1) engage()
135
+ })
136
+ const stopSucceeded = context.data.on("session.execution.succeeded", () => {
137
+ running = Math.max(0, running - 1)
138
+ if (running === 0) release()
139
+ })
140
+ const stopFailed = context.data.on("session.execution.failed", () => {
141
+ running = Math.max(0, running - 1)
142
+ if (running === 0) release()
143
+ })
144
+ const stopInterrupted = context.data.on("session.execution.interrupted", () => {
145
+ running = Math.max(0, running - 1)
146
+ if (running === 0) release()
147
+ })
148
+
149
+ /** Always visible, `off` included: the badge is the setting, and seeing `off` is how you
150
+ * know the plugin is loaded and deliberately doing nothing. (pi hides it when off; the
151
+ * owner asked for the name instead.) */
152
+ const Badge = () => (
153
+ <box flexDirection="row" flexShrink={0}>
154
+ <text fg={active() ? GREEN : DIM} wrapMode="none">{CUP_GLYPH}</text>
155
+ <text fg={DIM} wrapMode="none"> {level()}</text>
156
+ </box>
157
+ )
158
+
159
+ context.ui.slot({ append: "prompt.footer.status", render: () => <Badge /> })
160
+
161
+ context.keymap.layer(() => ({
162
+ mode: "global",
163
+ commands: [
164
+ {
165
+ id: "oc-coffee.menu",
166
+ title: "Coffee: sleep prevention",
167
+ group: "Coffee",
168
+ palette: true,
169
+ slash: { name: "coffee", arguments: true },
170
+ run: async (input?: string) => {
171
+ const argument = (input ?? "").trim().split(/\s+/).filter(Boolean)[0]
172
+
173
+ if (argument === "status") {
174
+ await context.ui.dialog.alert({ title: `${CUP_GLYPH} Coffee`, message: describe() })
175
+ return
176
+ }
177
+ if (isLevel(argument)) {
178
+ await setLevel(argument)
179
+ context.ui.toast.show({ message: describe() })
180
+ return
181
+ }
182
+ if (argument !== undefined) {
183
+ context.ui.toast.show({
184
+ message: `usage: /coffee [${LEVELS.join(" | ")} | status]`,
185
+ variant: "warning",
186
+ })
187
+ return
188
+ }
189
+
190
+ const picked = await context.ui.dialog.select({
191
+ title: `${CUP_GLYPH} Sleep prevention`,
192
+ current: level(),
193
+ options: LEVELS.map((candidate) => ({
194
+ title: candidate === level() ? `${LEVEL_MENU[candidate]}` : LEVEL_MENU[candidate],
195
+ value: candidate,
196
+ })),
197
+ })
198
+ if (picked) {
199
+ await setLevel(String(picked) as Level)
200
+ context.ui.toast.show({ message: describe() })
201
+ }
202
+ },
203
+ },
204
+ ],
205
+ }))
206
+
207
+ return () => {
208
+ stopStarted?.()
209
+ stopSucceeded?.()
210
+ stopFailed?.()
211
+ stopInterrupted?.()
212
+ release()
213
+ }
214
+ },
215
+ })