@rizal72/oc-coffee 0.1.1 → 0.2.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.
package/README.md CHANGED
@@ -2,10 +2,7 @@
2
2
 
3
3
  Keeps the Mac awake while the agent is working, and lets it sleep between turns.
4
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.
5
+ **macOS only**: it drives `caffeinate`. Elsewhere the plugin loads and the cup never lights.
9
6
 
10
7
  ## Install
11
8
 
@@ -19,7 +16,18 @@ Or by hand, one line in `~/.config/opencode/opencode.jsonc`:
19
16
  "plugins": ["@rizal72/oc-coffee"]
20
17
  ```
21
18
 
22
- ## Use
19
+ ## TUI
20
+
21
+ A cup in the prompt footer, green while a run is active and grey when idle. `/coffee` alone opens the
22
+ menu, `/coffee display` sets a level, `/coffee status` reports.
23
+
24
+ ## GUI
25
+
26
+ The cup becomes a pill above the composer: the level as the capsule, the four levels as its rows. Picking
27
+ one there moves the same level the terminal shows, because both ask the server rather than their own
28
+ screen.
29
+
30
+ ## How it works
23
31
 
24
32
  | level | what is blocked |
25
33
  |---|---|
@@ -28,8 +36,10 @@ Or by hand, one line in `~/.config/opencode/opencode.jsonc`:
28
36
  | `display` | idle and display sleep — `-i -d` |
29
37
  | `system` | everything, and the user is declared active — `-i -d -s -u` |
30
38
 
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.
39
+ The level and the child belong to the server half: one `caffeinate` runs, however many windows are open and
40
+ whichever client changed it. The assertion exists only while a run is active — engaged when the agent
41
+ starts, released when it succeeds, fails or is interrupted — and the child is bound to the server's
42
+ process (`caffeinate -w`), so it cannot outlive the thing that owns it. A closed window is not the signal;
43
+ the end of the run is.
34
44
 
35
45
  Requires OpenCode v2 and macOS. TypeScript, no build step, no runtime dependencies.
@@ -0,0 +1 @@
1
+ Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:`Module`}});let e=require("@opencode/plugin/gui"),t=require("solid-js"),n=require("@opencode/plugin/rpc");var r=[`off`,`idle`,`display`,`system`],i={off:[],idle:[`-i`],display:[`-i`,`-d`],system:[`-i`,`-d`,`-s`,`-u`]},a=e=>i[e].length===0?`no sleep prevention`:`caffeinate ${i[e].join(` `)}`,o={type:`string`,enum:r},s={type:`object`,properties:{level:o,running:{type:`number`},held:{type:`boolean`}},required:[`level`,`running`,`held`],additionalProperties:!1},c=(e,t)=>new Promise((n,r)=>{let i=setTimeout(()=>r(Error(`rpc timed out after ${t}ms`)),t);e.then(e=>{clearTimeout(i),n(e)},e=>{clearTimeout(i),r(e)})}),l=n.Rpc.define({id:`coffee`,methods:{snapshot:{input:{type:`object`,properties:{},additionalProperties:!1},output:s},set:{input:{type:`object`,properties:{level:o},required:[`level`],additionalProperties:!1},output:s}},events:{}}),u=2e3,d=15e3,f=`monitor`,p=e.Plugin.define({setup(n){let[i,o]=(0,t.createSignal)(void 0),s,p;(0,t.createEffect)(()=>{let e=n.sessions.current(),t=e?.server.id;if(s&&clearInterval(s),s=void 0,!t){o(void 0);return}let r=()=>{p!==t&&(p=t,c(e.server.client.rpc(l).snapshot({}),d).then(e=>{n.sessions.current()?.server.id===t&&o({server:t,value:e})}).catch(()=>{}).finally(()=>{p===t&&(p=void 0)}))};r(),s=setInterval(r,u)}),(0,t.onCleanup)(()=>{s&&clearInterval(s)});let m=e=>{let t=n.sessions.current();t&&c(t.server.client.rpc(l).set({level:e}),d).then(e=>o({server:t.server.id,value:e})).catch(e=>n.toast.show({title:`Coffee`,description:e instanceof Error?e.message:String(e),variant:`error`}))};n.add(e.ComposerPill,{id:`coffee`,order:5,show:e=>{let t=i();if(!t||t.server!==e.session.server.id)return;let n=t.value;return{label:n.level,icon:f,tone:n.held?`success`:void 0,title:`Coffee: sleep prevention`,rows:r.map(e=>({id:e,label:e,...e===n.level?{icon:`check`,tone:`success`}:{},detail:a(e),run:()=>m(e)}))}}})}});exports.default=p;
package/index.ts CHANGED
@@ -1,11 +1,176 @@
1
1
  // oc-coffee — server half.
2
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 {
3
+ // The level and the assertion are one fact for the whole machine, so they live here: the server is the
4
+ // single process every client talks to, it survives them, and it is the one that runs the turns. The
5
+ // terminal and the window only read and write it over `rpc.ts`.
6
+ //
7
+ // **One process, one child.** The loader instantiates this plugin once per location — two open projects
8
+ // mean two instances, and they do not share module scope — so the state cannot live in this closure: it
9
+ // lives on the process, behind a symbol. The first instance to load starts it, every other instance
10
+ // answers from it, and both clients get the same answer wherever they are.
11
+ //
12
+ // Counting is over **all** sessions: the assertion is machine-wide, so one session finishing must not
13
+ // put the Mac back to sleep while another works. Subagents count too, because their work is work.
14
+ //
15
+ // The child is `caffeinate … -w <pid>`, bound to this process: if the server dies the work dies with
16
+ // it, so an assertion falling with it says the truth and needs no cleanup. A level change mid-run
17
+ // respawns the child with the new flags, which is exactly when it is wanted.
18
+ import { execFileSync, spawn, type ChildProcess } from "node:child_process"
19
+
20
+ import { Plugin } from "@opencode/plugin"
21
+
22
+ import { Coffee, FLAGS, isLevel, type CoffeeSnapshot, type Level } from "./rpc.ts"
23
+
24
+ const KEY = "coffee.level"
25
+ const DEFAULT: Level = "display"
26
+
27
+ /** The process-wide state. A symbol, so no other plugin's key can collide with ours. */
28
+ const MACHINE = Symbol.for("oc-coffee.machine")
29
+
30
+ /** The run's edges, and the signs of life inside one. The host renamed these once — `execution.*` became
31
+ * `step.*` plus the tool events — so both shapes are watched: the cup is a view of "the agent is
32
+ * working", and the names of the events that say it have already changed once. */
33
+ const WATCHED = [
34
+ "session.step.started",
35
+ "session.step.ended",
36
+ "session.tool.called",
37
+ "session.tool.success",
38
+ "session.execution.started",
39
+ "session.execution.succeeded",
40
+ "session.execution.failed",
41
+ "session.execution.interrupted",
42
+ "session.deleted",
43
+ "session.archived",
44
+ ] as const
45
+
46
+ /** A step ends before its tools run and the next one starts after them, so the turn is not a step: it is
47
+ * open from the first step to the step that finishes with anything but `tool-calls`. Nothing here is
48
+ * timed, which is what lets a command run for a minute without the cup dropping under it. */
49
+
50
+ interface Machine {
51
+ readonly snapshot: () => CoffeeSnapshot
52
+ readonly setLevel: (next: Level) => Promise<CoffeeSnapshot>
53
+ }
54
+
55
+ const machine = (ctx: any): Promise<Machine> => ((globalThis as any)[MACHINE] ??= start(ctx))
56
+
57
+ async function start(ctx: any): Promise<Machine> {
58
+ // macOS only: `caffeinate` is not on the PATH elsewhere, so the spawn is skipped rather than failed.
59
+ const supported = process.platform === "darwin"
60
+
61
+ // `-w` binds the child to the server, and the server outlives the plugin — so a child from a plugin
62
+ // that is gone is still there, holding the assertion with flags nothing will ever change. Reaped
63
+ // before any spawn: an orphan is ours by construction, its `-w` is this process.
64
+ try {
65
+ const suffix = `-w ${process.pid}`
66
+ const table = execFileSync("ps", ["-A", "-o", "pid=", "-o", "command="], { encoding: "utf8" })
67
+ for (const line of table.split("\n")) {
68
+ const match = /^\s*(\d+)\s+caffeinate\b.*\s-w\s+\d+\s*$/.exec(line)
69
+ if (!match || !line.trimEnd().endsWith(suffix)) continue
70
+ try {
71
+ process.kill(Number(match[1]), "SIGTERM")
72
+ } catch {}
73
+ }
74
+ } catch {}
75
+
76
+ let level: Level = DEFAULT
77
+ const stored = await ctx.storage.get(KEY).catch(() => undefined)
78
+ if (isLevel(stored)) level = stored
79
+
80
+ const running = new Set<string>()
81
+ let child: ChildProcess | undefined
82
+ /** The level the live child was spawned with, so a change can be told from a no-op. */
83
+ let childLevel: Level | undefined
84
+
85
+ const stop = () => {
86
+ const held = child
87
+ child = undefined
88
+ childLevel = undefined
89
+ held?.kill("SIGTERM")
90
+ }
91
+
92
+ // The assertion is up exactly while there is work and the level asks for it.
93
+ const apply = () => {
94
+ const wanted = supported && level !== "off" && running.size > 0
95
+ if (wanted && child && childLevel !== level) stop()
96
+ if (wanted && !child) {
97
+ const spawned = spawn("caffeinate", [...FLAGS[level as Exclude<Level, "off">], "-w", String(process.pid)], {
98
+ stdio: "ignore",
99
+ })
100
+ child = spawned
101
+ childLevel = level
102
+ spawned.on("error", () => {
103
+ if (child === spawned) stop()
104
+ })
105
+ spawned.on("exit", () => {
106
+ if (child === spawned) stop()
107
+ })
108
+ }
109
+ if (!wanted && child) stop()
110
+ }
111
+
112
+ const snapshot = (): CoffeeSnapshot => ({ level, running: running.size, held: !!child })
113
+
114
+ // A stream can end while the process lives: one hiccup used to stop the cup until the next restart, and
115
+ // it did so silently. So this is a loop that always comes back, with a backoff so a subscription that
116
+ // cannot open at all cannot spin either.
117
+ void (async () => {
118
+ let wait = 1_000
119
+ for (;;) {
120
+ try {
121
+ for await (const event of ctx.event.subscribe({ types: [...WATCHED] })) {
122
+ wait = 1_000
123
+ const data = (event as any).data ?? {}
124
+ const id = String(data.sessionID ?? "")
125
+ const opened = event.type === "session.step.started" || event.type === "session.execution.started"
126
+ const closed =
127
+ event.type === "session.execution.succeeded" ||
128
+ event.type === "session.execution.failed" ||
129
+ event.type === "session.execution.interrupted" ||
130
+ event.type === "session.deleted" ||
131
+ event.type === "session.archived" ||
132
+ (event.type === "session.step.ended" && String(data.finish ?? "") !== "tool-calls")
133
+ if (opened && id) running.add(id)
134
+ else if (closed && id) running.delete(id)
135
+ apply()
136
+ }
137
+ } catch (error) {
138
+ console.warn("oc-coffee: the event stream ended, resubscribing —", error instanceof Error ? error.message : error)
139
+ }
140
+ // The set survives a resubscribe on purpose: a stream that hiccups mid-run must not release
141
+ // the assertion, and over-holding is the safe direction for a keep-awake. A session that really
142
+ // ended still clears itself on its own `execution.*` event.
143
+ apply()
144
+ await new Promise((resolve) => setTimeout(resolve, wait))
145
+ wait = Math.min(wait * 2, 30_000)
146
+ }
147
+ })()
148
+
149
+ return {
150
+ snapshot,
151
+ setLevel: async (next: Level) => {
152
+ // Persisted first: a rejected write must leave the level it found, with no child moved.
153
+ await ctx.storage.set(KEY, next)
154
+ level = next
155
+ apply()
156
+ return snapshot()
157
+ },
158
+ }
159
+ }
160
+
161
+ export default Plugin.define({
7
162
  id: "oc-coffee",
8
- setup() {
9
- // nothing to do on the server
163
+ async setup(ctx) {
164
+ const coffee = await machine(ctx)
165
+
166
+ await ctx.rpc.register(Coffee, {
167
+ snapshot: async (): Promise<CoffeeSnapshot> => coffee.snapshot(),
168
+ set: async (input: any) => {
169
+ if (!isLevel(input?.level)) throw new Error(`unknown coffee level: ${String(input?.level)}`)
170
+ return coffee.setLevel(input.level)
171
+ },
172
+ })
173
+ // No cleanup: the machine belongs to the process, not to a location. Disabling the plugin leaves
174
+ // the child until the server restarts, which is the price of one assertion for one machine.
10
175
  },
11
- }
176
+ })
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@rizal72/oc-coffee",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
- "description": "Keeps the Mac awake while the agent works: cup badge in the footer, /coffee command",
5
+ "description": "Keeps the Mac awake while the agent works: the server owns the level, the terminal draws a cup badge, the desktop a pill",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/rizal72/opencode-plugins.git"
@@ -11,7 +11,8 @@
11
11
  "files": [
12
12
  "*.ts",
13
13
  "tui.js",
14
- "README.md"
14
+ "README.md",
15
+ "dist"
15
16
  ],
16
17
  "keywords": [
17
18
  "opencode",
@@ -24,6 +25,8 @@
24
25
  },
25
26
  "exports": {
26
27
  ".": "./index.ts",
27
- "./tui": "./tui.js"
28
+ "./tui": "./tui.js",
29
+ "./rpc": "./rpc.ts",
30
+ "./gui-experimental": "./dist/gui-experimental.cjs"
28
31
  }
29
32
  }
package/rpc.ts ADDED
@@ -0,0 +1,85 @@
1
+ // The contract both clients read: the level is one fact for the machine, so the server owns it and
2
+ // neither the terminal nor the window ever keeps a copy of its own.
3
+ import { Rpc } from "@opencode/plugin/rpc"
4
+
5
+ export const LEVELS = ["off", "idle", "display", "system"] as const
6
+
7
+ export type Level = (typeof LEVELS)[number]
8
+
9
+ export const isLevel = (value: unknown): value is Level => LEVELS.includes(value as Level)
10
+
11
+ /** The `caffeinate` flags each level means. One table: the server spawns from it, the clients print it. */
12
+ export const FLAGS: Record<Level, readonly string[]> = {
13
+ off: [],
14
+ idle: ["-i"],
15
+ display: ["-i", "-d"],
16
+ system: ["-i", "-d", "-s", "-u"],
17
+ }
18
+
19
+ /** The row's `detail`, and the command's report: the flags as they are run, or none at all. */
20
+ export const flagsText = (level: Level): string =>
21
+ FLAGS[level].length === 0 ? "no sleep prevention" : `caffeinate ${FLAGS[level].join(" ")}`
22
+
23
+ export const Level = {
24
+ type: "string",
25
+ enum: LEVELS,
26
+ }
27
+
28
+ const Snapshot = {
29
+ type: "object",
30
+ properties: {
31
+ level: Level,
32
+ /** Sessions executing right now, across the whole server: the assertion is machine-wide. */
33
+ running: { type: "number" },
34
+ /** Whether our own `caffeinate` is up. */
35
+ held: { type: "boolean" },
36
+ },
37
+ required: ["level", "running", "held"],
38
+ additionalProperties: false,
39
+ } as const
40
+
41
+ /** A stalled call must not freeze a widget: the rejection is the point, the timer is cleared with it. */
42
+ export const withTimeout = <T,>(promise: Promise<T>, ms: number): Promise<T> =>
43
+ new Promise<T>((resolve, reject) => {
44
+ const timer = setTimeout(() => reject(new Error(`rpc timed out after ${ms}ms`)), ms)
45
+ promise.then(
46
+ (value) => {
47
+ clearTimeout(timer)
48
+ resolve(value)
49
+ },
50
+ (error) => {
51
+ clearTimeout(timer)
52
+ reject(error)
53
+ },
54
+ )
55
+ })
56
+
57
+ export interface CoffeeSnapshot {
58
+ level: Level
59
+ running: number
60
+ held: boolean
61
+ }
62
+
63
+ export const Coffee = Rpc.define({
64
+ id: "coffee",
65
+ methods: {
66
+ snapshot: {
67
+ input: { type: "object", properties: {}, additionalProperties: false },
68
+ output: Snapshot,
69
+ },
70
+ // The answer is the state after the write, so a click repaints without waiting for the poll.
71
+ set: {
72
+ input: {
73
+ type: "object",
74
+ properties: { level: Level },
75
+ required: ["level"],
76
+ additionalProperties: false,
77
+ },
78
+ output: Snapshot,
79
+ },
80
+ },
81
+ // Present (empty) on purpose: a missing events map fails the whole plugin.
82
+ events: {},
83
+ })
84
+
85
+ export default Coffee
package/tui.js CHANGED
@@ -7,41 +7,31 @@ import { setProp as _$setProp } from "@opentui/solid";
7
7
  import { createElement as _$createElement } from "@opentui/solid";
8
8
  // oc-coffee — TUI half.
9
9
  //
10
- // Keeps the Mac awake while the agent works, the port of pi's `pi-coffee`.
10
+ // Keeps the Mac awake while the agent works: the port of pi's `pi-coffee`.
11
11
  //
12
12
  // off | idle | display | system -> caffeinate (), (-i), (-i -d), (-i -d -s -u)
13
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.
14
+ // The level and the assertion are not held here any more. They are one fact for the whole machine —
15
+ // the desktop app is a second client, and two clients with a copy each are two answers to one
16
+ // question — so the server owns them (`index.ts`) and this half only reads and writes them over
17
+ // `rpc.ts`. Measured 2026-10-27: the server does receive `session.execution.started` and
18
+ // `.succeeded`, which the 2026-10-16 note said it did not.
19
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.
20
+ // The badge shows the level whenever it is not `off` — the setting, not the moment — and only the
21
+ // cup lights up (green) while the assertion is up, so the state is one mark and the level stays
22
+ // legible. `/coffee` opens the menu, `/coffee <level>` sets one, `/coffee status` reports.
24
23
  //
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.
24
+ // macOS only: elsewhere the server never spawns the child, and the badge simply never lights.
32
25
  import { Plugin } from "@opencode/plugin/tui";
33
- import { spawn } from "node:child_process";
34
26
  import { createRoot, createSignal } from "solid-js";
27
+ import { Coffee, LEVELS, flagsText, isLevel, withTimeout } from "./rpc.ts";
35
28
 
36
29
  /** md-coffee_outline (U+F06CA), the glyph pi's own badge uses. */
37
30
  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
- };
31
+
32
+ /** Short: the read is a local call, and the cup must light while the agent is still working. */
33
+ const REFRESH_MS = 2_000;
34
+ const RPC_TIMEOUT_MS = 15_000;
45
35
 
46
36
  /** The four menu rows, the wording pi uses. */
47
37
  const LEVEL_MENU = {
@@ -54,22 +44,16 @@ const LEVEL_MENU = {
54
44
  /** Fallbacks, used only when the theme defines none of the candidates. */
55
45
  const FALLBACK_GREEN = "#3fb950";
56
46
  const FALLBACK_DIM = "#8b949e";
57
- function isLevel(value) {
58
- return typeof value === "string" && LEVELS.includes(value);
59
- }
47
+
48
+ /** The server's own default, so the first paint is not an empty badge; the first read replaces it. */
49
+ const DEFAULT = {
50
+ level: "display",
51
+ running: 0,
52
+ held: false
53
+ };
60
54
  export default Plugin.define({
61
55
  id: "oc-coffee.tui",
62
56
  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
57
  const themeColour = (...paths) => {
74
58
  for (const path of paths) {
75
59
  let node = context.theme;
@@ -80,70 +64,38 @@ export default Plugin.define({
80
64
  };
81
65
  const GREEN = themeColour(["syntax", "string"], ["diff", "text", "added"], ["text", "feedback", "success", "base"]) ?? FALLBACK_GREEN;
82
66
  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");
67
+ const rpc = context.client.rpc(Coffee);
68
+ // Reactive: the badge must repaint when the assertion goes up or down, and that now happens in
69
+ // the server, so it arrives through a read rather than through a local variable.
70
+ const [snapshot, setSnapshot] = createRoot(() => createSignal(DEFAULT));
71
+ const level = () => snapshot().level;
72
+ const held = () => snapshot().held;
73
+ async function read() {
74
+ try {
75
+ const next = await withTimeout(rpc.snapshot({}), RPC_TIMEOUT_MS);
76
+ setSnapshot(next);
77
+ } catch {
78
+ // Keep the last good reading: a failed read says nothing about the level.
79
+ }
112
80
  }
113
81
 
114
82
  /** The level can only change from the command, where a failure is worth a word. */
115
83
  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();
84
+ try {
85
+ // The RPC answers the state after the write, so the badge repaints without a second read.
86
+ setSnapshot(await rpc.set({
87
+ level: next
88
+ }));
89
+ } catch (error) {
90
+ context.ui.toast.show({
91
+ message: `coffee: ${error instanceof Error ? error.message : String(error)}`,
92
+ variant: "warning"
93
+ });
94
+ }
122
95
  }
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
- });
96
+ const describe = () => `Sleep prevention: ${level() === "off" ? "off" : `${level()} (${flagsText(level())})`} — assertion ${held() ? "up" : "idle"}`;
97
+ void read();
98
+ const timer = setInterval(() => void read(), REFRESH_MS);
147
99
 
148
100
  /** Always visible, `off` included: the badge is the setting, and seeing `off` is how you
149
101
  * know the plugin is loaded and deliberately doing nothing. (pi hides it when off; the
@@ -163,7 +115,7 @@ export default Plugin.define({
163
115
  _$setProp(_el$4, "fg", DIM);
164
116
  _$setProp(_el$4, "wrapMode", "none");
165
117
  _$insert(_el$4, level, null);
166
- _$effect(_$p => _$setProp(_el$2, "fg", active() ? GREEN : DIM, _$p));
118
+ _$effect(_$p => _$setProp(_el$2, "fg", held() ? GREEN : DIM, _$p));
167
119
  return _el$;
168
120
  })();
169
121
  context.ui.slot({
@@ -182,6 +134,7 @@ export default Plugin.define({
182
134
  arguments: true
183
135
  },
184
136
  run: async input => {
137
+ await read();
185
138
  const argument = (input ?? "").trim().split(/\s+/).filter(Boolean)[0];
186
139
  if (argument === "status") {
187
140
  await context.ui.dialog.alert({
@@ -208,7 +161,7 @@ export default Plugin.define({
208
161
  title: `${CUP_GLYPH} Sleep prevention`,
209
162
  current: level(),
210
163
  options: LEVELS.map(candidate => ({
211
- title: candidate === level() ? `${LEVEL_MENU[candidate]}` : LEVEL_MENU[candidate],
164
+ title: LEVEL_MENU[candidate],
212
165
  value: candidate
213
166
  }))
214
167
  });
@@ -221,12 +174,6 @@ export default Plugin.define({
221
174
  }
222
175
  }]
223
176
  }));
224
- return () => {
225
- stopStarted?.();
226
- stopSucceeded?.();
227
- stopFailed?.();
228
- stopInterrupted?.();
229
- release();
230
- };
177
+ return () => clearInterval(timer);
231
178
  }
232
179
  });
package/vite.config.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { defineConfig } from "vite"
2
+ import { gui } from "@opencode/plugin/gui/vite"
3
+
4
+ export default defineConfig({
5
+ plugins: [gui({ entry: "gui.tsx", outFile: "dist/gui-experimental.cjs" })],
6
+ })