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.
- package/README.md +53 -0
- package/package.json +34 -0
- 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
|
+
}
|
package/tabby-status.js
ADDED
|
@@ -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
|
+
}
|