@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 +18 -8
- package/dist/gui-experimental.cjs +1 -0
- package/index.ts +172 -7
- package/package.json +7 -4
- package/rpc.ts +85 -0
- package/tui.js +53 -106
- package/vite.config.ts +6 -0
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
|
-
|
|
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
|
-
##
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Keeps the Mac awake while the agent works:
|
|
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
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// the
|
|
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
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
function
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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",
|
|
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:
|
|
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
|
});
|