opencode-tabby-status 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 (3) hide show
  1. package/README.md +53 -0
  2. package/package.json +34 -0
  3. package/tabby-status.js +412 -0
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # opencode-tabby-status
2
+
3
+ opencode plugin: terminal tab title, progress, and color follow the session
4
+ status. Zero dependencies.
5
+
6
+ ## Install
7
+
8
+ From npm — add it to your opencode config (`~/.config/opencode/opencode.jsonc`):
9
+
10
+ ```jsonc
11
+ { "plugin": ["opencode-tabby-status"] }
12
+ ```
13
+
14
+ opencode installs the package itself; restart it once.
15
+
16
+ From a clone (local file mode — auto-discovered and hot-reloaded):
17
+
18
+ ```sh
19
+ cp tabby-status.js ~/.config/opencode/plugins/
20
+ ```
21
+
22
+ ## What it does
23
+
24
+ On `session.status` / `permission.asked` / `form.created` /
25
+ `session.execution.failed` / `session.created` it emits, best-effort (never
26
+ breaks a session):
27
+
28
+ - OSC 0 tab title: `⚡/❓/✅/❌ opencode · <state>` — honored by Tabby,
29
+ Ghostty, WezTerm, Windows Terminal, VS Code, iTerm2
30
+ - OSC 9;4 progress: indeterminate while working, clear on done, error state
31
+ on failure
32
+ - iTerm2 OSC 6;1 tab color: orange / gold / green / red, reset on idle
33
+ - Spool file in `$TMPDIR/tabby-claude-status.d/` for the companion Tabby
34
+ plugin (`tabby-opencode-status`, or the existing `tabby-claude-status`),
35
+ matched to the right tab via PID ancestry. The v2 server is detached, so the
36
+ spool includes the TUI client's pid (found via its `-s <sessionID>` command
37
+ line) and its ancestor chain, not just the server's
38
+
39
+ ## Env overrides
40
+
41
+ | Var | Effect |
42
+ |---|---|
43
+ | `TABBY_STATUS_NO_OSC=1` | disable OSC emission |
44
+ | `TABBY_STATUS_NO_SPOOL=1` | disable spool files |
45
+ | `TABBY_STATUS_NO_TITLE=1` | don't touch tab title |
46
+ | `TABBY_STATUS_BELL=1` | bell on done/error |
47
+ | `TABBY_STATUS_SPOOL_DIR=…` | override spool dir |
48
+
49
+ ## Test
50
+
51
+ ```sh
52
+ npm test
53
+ ```
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "opencode-tabby-status",
3
+ "version": "0.1.0",
4
+ "description": "Show opencode session status in the terminal tab (title, progress, color) + spool files for the Tabby plugin",
5
+ "type": "module",
6
+ "main": "tabby-status.js",
7
+ "files": [
8
+ "tabby-status.js"
9
+ ],
10
+ "license": "MIT",
11
+ "author": {
12
+ "name": "tmiland",
13
+ "email": "326116980+tmiland@users.noreply.github.com"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/tmiland-lab/tabby-opencode-status.git",
18
+ "directory": "packages/opencode-tabby-status"
19
+ },
20
+ "keywords": [
21
+ "opencode",
22
+ "opencode-plugin",
23
+ "tabby",
24
+ "terminal",
25
+ "tab-color",
26
+ "osc"
27
+ ],
28
+ "peerDependencies": {
29
+ "@opencode/plugin": "^2.0.0"
30
+ },
31
+ "scripts": {
32
+ "test": "node --check tabby-status.js && node test/smoke.mjs"
33
+ }
34
+ }
@@ -0,0 +1,412 @@
1
+ // Tabby tab status for opencode (v2 plugin API).
2
+ // - Emits OSC tab-title / progress / iTerm2 tab-color sequences on session status
3
+ // changes (works in Tabby, Ghostty, WezTerm, Windows Terminal, VS Code, iTerm2).
4
+ // - Writes tabby-claude-status-compatible spool files to $TMPDIR/tabby-claude-status.d/
5
+ // so the Tabby plugin `tabby-claude-status` (Settings → Plugins) can paint the
6
+ // tab's true color / progress bar / emoji prefix for opencode with zero changes.
7
+ //
8
+ // Mapping (opencode v2 event -> Claude-compatible event for Tabby matching):
9
+ // session.execution.started / session.step.started -> PreToolUse (working)
10
+ // session.execution.succeeded / .interrupted -> Stop (done)
11
+ // session.created -> SessionStart (idle)
12
+ // permission.asked / form.created -> PermissionRequest (question)
13
+ // form.replied / form.cancelled -> PreToolUse (working)
14
+ // session.execution.failed -> PostToolUseFailure (error)
15
+ // session.status / session.idle -> handled if they fire
16
+ // NB: v2 does not emit session.status for ordinary turns; the execution/step
17
+ // lifecycle events above are the reliable status source.
18
+ //
19
+ // Colors (iTerm2 OSC 6;1 tab-color, honored by iTerm2; harmless elsewhere):
20
+ // working orange, question yellow, done green, error red, idle reset.
21
+ // Tabby true tab color comes from the tabby-claude-status plugin reading the
22
+ // spool dir below — install it for the bottom-border color.
23
+ //
24
+ // v2 ancestry note: the opencode server is a detached daemon (reparented to
25
+ // systemd), so the plugin's own process ancestry no longer reaches the Tabby
26
+ // pty. The TUI client that owns a session is found by its command line
27
+ // (`opencode ... -s <sessionID>`) and its pid + ancestor chain are folded into
28
+ // each spool file so the Tabby plugin's pty intersection still matches the tab.
29
+ //
30
+ // Env overrides:
31
+ // TABBY_STATUS_NO_OSC=1 disable OSC emission
32
+ // TABBY_STATUS_NO_SPOOL=1 disable spool files
33
+ // TABBY_STATUS_NO_TITLE=1 don't touch tab title
34
+ // TABBY_STATUS_BELL=1 bell on done/error
35
+ // TABBY_STATUS_SPOOL_DIR=... override spool dir (default $TMPDIR/tabby-claude-status.d)
36
+
37
+ import fs from "node:fs"
38
+ import os from "node:os"
39
+ import path from "node:path"
40
+ import { createRequire } from "node:module"
41
+
42
+ const require = createRequire(import.meta.url)
43
+
44
+ const SPOOL_DIR =
45
+ process.env.TABBY_STATUS_SPOOL_DIR || path.join(os.tmpdir(), "tabby-claude-status.d")
46
+
47
+ const BEL = "\x07"
48
+ const OSC_TITLE = (s) => `\x1b]0;${s}${BEL}`
49
+ const OSC_PROGRESS = (state, pct) =>
50
+ pct === undefined ? `\x1b]9;4;${state}${BEL}` : `\x1b]9;4;${state};${pct}${BEL}`
51
+ // iTerm2 tab color: OSC 6;1;bg;red;brightness;N etc. Reset: OSC 6;1;bg;*;default
52
+ const TAB_COLOR = ({ r, g, b }) =>
53
+ `\x1b]6;1;bg;red;brightness;${r}${BEL}` +
54
+ `\x1b]6;1;bg;green;brightness;${g}${BEL}` +
55
+ `\x1b]6;1;bg;blue;brightness;${b}${BEL}`
56
+ const TAB_COLOR_RESET = `\x1b]6;1;bg;*;default${BEL}`
57
+
58
+ const COLORS = {
59
+ working: { r: 255, g: 165, b: 0 }, // orange
60
+ question: { r: 255, g: 215, b: 0 }, // gold
61
+ done: { r: 80, g: 200, b: 120 }, // green
62
+ error: { r: 255, g: 80, b: 80 }, // red
63
+ }
64
+
65
+ const EMOJI = { working: "⚡", question: "❓", done: "✅", error: "❌", idle: "🌱" }
66
+
67
+ function getAncestorPids(startPid, depth) {
68
+ // Linux fast path: /proc/<pid>/stat, zero subprocesses
69
+ if (process.platform === "linux") {
70
+ const pids = []
71
+ let current = startPid
72
+ for (let i = 0; i < depth; i++) {
73
+ try {
74
+ const stat = fs.readFileSync(`/proc/${current}/stat`, "utf8")
75
+ const m = stat.match(/\)\s+\S+\s+(\d+)/)
76
+ const parent = m ? parseInt(m[1], 10) : null
77
+ if (!parent || parent <= 1) break
78
+ pids.push(parent)
79
+ current = parent
80
+ } catch {
81
+ break
82
+ }
83
+ }
84
+ return pids
85
+ }
86
+ // macOS / fallback: ps per level
87
+ try {
88
+ const { execSync } = require("node:child_process")
89
+ const pids = []
90
+ let current = startPid
91
+ for (let i = 0; i < depth; i++) {
92
+ try {
93
+ const out = execSync(`ps -o ppid= -p ${current}`, { encoding: "utf8", timeout: 2000 })
94
+ const parent = parseInt(out.trim(), 10) || null
95
+ if (!parent || parent <= 1) break
96
+ pids.push(parent)
97
+ current = parent
98
+ } catch {
99
+ break
100
+ }
101
+ }
102
+ return pids
103
+ } catch {
104
+ return []
105
+ }
106
+ }
107
+
108
+ // --- tab routing (v2) -------------------------------------------------------
109
+ // The server is detached, so walk up from the TUI client instead. The client
110
+ // carries its session in argv (`opencode -s ses_...`); child (subagent)
111
+ // sessions are resolved to their root via session.created parentIDs.
112
+ const SESSION_PARENTS = new Map()
113
+ const CLIENT_CACHE_TTL = 5000
114
+ const clientCache = new Map()
115
+
116
+ function rootSession(sessionID) {
117
+ let cur = sessionID
118
+ for (let i = 0; i < 20; i++) {
119
+ const parent = SESSION_PARENTS.get(cur)
120
+ if (!parent) return cur
121
+ cur = parent
122
+ }
123
+ return cur
124
+ }
125
+
126
+ function scanClientPids(sessionID) {
127
+ const pids = []
128
+ if (!sessionID || process.platform !== "linux") return pids
129
+ try {
130
+ for (const entry of fs.readdirSync("/proc")) {
131
+ if (!/^\d+$/.test(entry)) continue
132
+ const pid = parseInt(entry, 10)
133
+ if (pid === process.pid) continue
134
+ try {
135
+ const raw = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8")
136
+ if (raw.includes("opencode") && raw.includes(sessionID)) pids.push(pid)
137
+ } catch {
138
+ // process vanished between readdir and read
139
+ }
140
+ }
141
+ } catch {
142
+ // no /proc
143
+ }
144
+ return pids
145
+ }
146
+
147
+ function findClientPids(sessionID) {
148
+ if (!sessionID) return []
149
+ const now = Date.now()
150
+ const hit = clientCache.get(sessionID)
151
+ if (hit && now - hit.ts < CLIENT_CACHE_TTL) return hit.pids
152
+ let pids = scanClientPids(sessionID)
153
+ if (!pids.length) {
154
+ const root = rootSession(sessionID)
155
+ if (root !== sessionID) pids = scanClientPids(root)
156
+ }
157
+ clientCache.set(sessionID, { ts: now, pids })
158
+ if (clientCache.size > 50) {
159
+ for (const [k, v] of clientCache) if (now - v.ts > CLIENT_CACHE_TTL * 4) clientCache.delete(k)
160
+ }
161
+ return pids
162
+ }
163
+
164
+ // Diagnostics: a filtered ring buffer plus per-event counters, so we can see
165
+ // which V2 events actually fire (session.status turned out not to be emitted
166
+ // for normal turns) without high-frequency deltas flushing the buffer.
167
+ // Written to $TMPDIR/tabby-status.events.json.
168
+ const EVENT_LOG = []
169
+ const EVENT_COUNTS = new Map()
170
+ const EVENT_LOG_FILE = path.join(os.tmpdir(), "tabby-status.events.json")
171
+ const NOISY_EVENT = /\.(delta|streamed|progress|updated)$/
172
+
173
+ let lastDiagWrite = 0
174
+
175
+ function recordEvent(type, data) {
176
+ try {
177
+ const session = data?.sessionID || data?.form?.sessionID || ""
178
+ const key = `${type}:${session.slice(0, 14)}`
179
+ EVENT_COUNTS.set(key, (EVENT_COUNTS.get(key) || 0) + 1)
180
+ const noisy = NOISY_EVENT.test(type)
181
+ if (!noisy) {
182
+ EVENT_LOG.push(key)
183
+ if (EVENT_LOG.length > 120) EVENT_LOG.shift()
184
+ }
185
+ const now = Date.now()
186
+ // Noisy deltas fire dozens of times per second — throttle those writes.
187
+ if (noisy && now - lastDiagWrite < 2000) return
188
+ lastDiagWrite = now
189
+ const counts = {}
190
+ for (const [k, v] of EVENT_COUNTS) counts[k] = v
191
+ fs.writeFileSync(EVENT_LOG_FILE, JSON.stringify({ ts: now, events: EVENT_LOG, counts }))
192
+ } catch {
193
+ // diagnostics are best-effort
194
+ }
195
+ }
196
+
197
+ function emit(raw) {
198
+ if (process.env.TABBY_STATUS_NO_OSC === "1") return
199
+ try {
200
+ // stderr: stdout may be piped to the TUI renderer; stderr reaches the pty.
201
+ if (process.stderr?.isTTY) process.stderr.write(raw)
202
+ else process.stdout?.write?.(raw)
203
+ } catch {
204
+ // never break a session on terminal output
205
+ }
206
+ }
207
+
208
+ function spool(claudeEvent, sessionID, extra) {
209
+ if (process.env.TABBY_STATUS_NO_SPOOL === "1") return
210
+ try {
211
+ const ancestors = new Set(getAncestorPids(process.pid, 6))
212
+ for (const pid of findClientPids(sessionID)) {
213
+ ancestors.add(pid)
214
+ for (const a of getAncestorPids(pid, 6)) ancestors.add(a)
215
+ }
216
+ const status = {
217
+ ts: Date.now(),
218
+ event: claudeEvent,
219
+ session: sessionID || "",
220
+ ppid: process.ppid,
221
+ ancestors: [...ancestors],
222
+ cwd: process.cwd?.() || extra?.cwd || "",
223
+ ...extra,
224
+ }
225
+ fs.mkdirSync(SPOOL_DIR, { recursive: true })
226
+ const rand = Math.random().toString(36).slice(2, 8)
227
+ const finalFile = path.join(SPOOL_DIR, `${status.ts}-${process.pid}-${rand}.json`)
228
+ fs.writeFileSync(`${finalFile}.tmp`, JSON.stringify(status))
229
+ fs.renameSync(`${finalFile}.tmp`, finalFile)
230
+ pruneSpool()
231
+ } catch {
232
+ // spool is best-effort
233
+ }
234
+ }
235
+
236
+ // The Tabby watcher deletes files as it consumes them, but if no watcher is
237
+ // installed they would pile up — drop anything older than 10 min. Fresh files
238
+ // are never touched, so a running watcher always wins the race.
239
+ function pruneSpool() {
240
+ try {
241
+ const now = Date.now()
242
+ for (const f of fs.readdirSync(SPOOL_DIR)) {
243
+ if (!f.endsWith(".json") && !f.endsWith(".json.tmp")) continue
244
+ const ts = parseInt(f.split("-")[0], 10)
245
+ if (!Number.isFinite(ts) || now - ts > 10 * 60 * 1000) {
246
+ try {
247
+ fs.rmSync(path.join(SPOOL_DIR, f))
248
+ } catch {
249
+ // ignore
250
+ }
251
+ }
252
+ }
253
+ } catch {
254
+ // ignore
255
+ }
256
+ }
257
+
258
+ function show(kind, sessionID) {
259
+ const emoji = EMOJI[kind] || ""
260
+ if (process.env.TABBY_STATUS_NO_TITLE !== "1") {
261
+ emit(OSC_TITLE(`${emoji} opencode · ${kind}`))
262
+ }
263
+ if (kind === "working") {
264
+ emit(OSC_PROGRESS(3)) // indeterminate spinner (ConEmu OSC 9;4)
265
+ emit(TAB_COLOR(COLORS.working))
266
+ } else if (kind === "question") {
267
+ emit(OSC_PROGRESS(4)) // paused/warning
268
+ emit(TAB_COLOR(COLORS.question))
269
+ emit("\x07") // bell: needs input
270
+ } else if (kind === "error") {
271
+ emit(OSC_PROGRESS(2))
272
+ emit(TAB_COLOR(COLORS.error))
273
+ if (process.env.TABBY_STATUS_BELL === "1") emit("\x07")
274
+ } else if (kind === "done") {
275
+ emit(OSC_PROGRESS(1, 100))
276
+ emit(TAB_COLOR(COLORS.done))
277
+ if (process.env.TABBY_STATUS_BELL === "1") emit("\x07")
278
+ // clear progress shortly after so the bar doesn't stick
279
+ setTimeout(() => emit(OSC_PROGRESS(0)), 1500).unref?.()
280
+ } else {
281
+ emit(OSC_PROGRESS(0))
282
+ emit(TAB_COLOR_RESET)
283
+ }
284
+ }
285
+
286
+ // Map a single opencode v2 event to the terminal/spool side effects.
287
+ // Exported for the smoke test; the runtime only needs the default export.
288
+ export function handle(ev) {
289
+ const t = ev?.type
290
+ const d = ev?.data || {}
291
+ recordEvent(t, d)
292
+ // Agent-run lifecycle: the reliable V2 status source. `session.status` is
293
+ // not emitted for ordinary turns, so map the execution/step events instead.
294
+ if (t === "session.execution.started") {
295
+ show("working", d.sessionID)
296
+ spool("PreToolUse", d.sessionID)
297
+ return
298
+ }
299
+ if (t === "session.execution.succeeded" || t === "session.execution.interrupted") {
300
+ show("done", d.sessionID)
301
+ spool("Stop", d.sessionID)
302
+ return
303
+ }
304
+ if (t === "session.step.started") {
305
+ show("working", d.sessionID)
306
+ spool("PreToolUse", d.sessionID)
307
+ return
308
+ }
309
+ if (t === "session.retry.scheduled") {
310
+ show("working", d.sessionID)
311
+ spool("PreToolUse", d.sessionID)
312
+ return
313
+ }
314
+ if (t === "session.status") {
315
+ const s = d.status?.type
316
+ if (s === "busy") {
317
+ show("working", d.sessionID)
318
+ spool("PreToolUse", d.sessionID)
319
+ } else if (s === "retry") {
320
+ show("working", d.sessionID)
321
+ spool("PreToolUse", d.sessionID)
322
+ } else if (s === "idle") {
323
+ show("done", d.sessionID)
324
+ spool("Stop", d.sessionID)
325
+ }
326
+ return
327
+ }
328
+ if (t === "session.idle") {
329
+ show("done", d.sessionID)
330
+ spool("Stop", d.sessionID)
331
+ return
332
+ }
333
+ if (t === "session.created") {
334
+ if (d.parentID) SESSION_PARENTS.set(d.sessionID, d.parentID)
335
+ show("idle", d.sessionID)
336
+ spool("SessionStart", d.sessionID)
337
+ return
338
+ }
339
+ if (t === "permission.asked") {
340
+ const id = d.sessionID || ""
341
+ show("question", id)
342
+ spool("PermissionRequest", id)
343
+ return
344
+ }
345
+ if (t === "permission.replied") {
346
+ // Without this the tab stays stuck on "question" after you answer.
347
+ const id = d.sessionID || ""
348
+ show("working", id)
349
+ spool("PreToolUse", id)
350
+ return
351
+ }
352
+ if (t === "form.created") {
353
+ const id = d.form?.sessionID || d.sessionID || ""
354
+ show("question", id)
355
+ spool("PermissionRequest", id)
356
+ return
357
+ }
358
+ if (t === "form.replied") {
359
+ const id = d.sessionID || ""
360
+ show("working", id)
361
+ spool("PreToolUse", id)
362
+ return
363
+ }
364
+ if (t === "form.cancelled") {
365
+ const id = d.sessionID || ""
366
+ show("working", id)
367
+ spool("PreToolUse", id)
368
+ return
369
+ }
370
+ if (t === "session.execution.failed") {
371
+ const id = d.sessionID || ""
372
+ show("error", id)
373
+ spool("PostToolUseFailure", id)
374
+ return
375
+ }
376
+ }
377
+
378
+ export default {
379
+ id: "opencode-tabby-status",
380
+ async setup(ctx) {
381
+ // Fresh process looks idle until first event.
382
+ show("idle", "")
383
+
384
+ let stopped = false
385
+ const events = ctx.event.subscribe()
386
+ const loop = (async () => {
387
+ try {
388
+ for await (const ev of events) {
389
+ if (stopped) break
390
+ try {
391
+ handle(ev)
392
+ } catch {
393
+ // never break a session on a bad event
394
+ }
395
+ }
396
+ } catch {
397
+ // subscription closed / errored — best-effort plugin
398
+ }
399
+ })()
400
+
401
+ return async () => {
402
+ stopped = true
403
+ try {
404
+ const closer = events?.return
405
+ if (typeof closer === "function") await closer.call(events)
406
+ } catch {
407
+ // ignore
408
+ }
409
+ void loop.catch(() => {})
410
+ }
411
+ },
412
+ }