@rizal72/oc-coffee 0.1.0 → 0.1.1

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