opencode-ufr 0.2.9 → 0.2.10

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.
@@ -10,13 +10,11 @@ import { TcpStack } from "./stack"
10
10
  import { startProxy, type ProxyHandle } from "./proxy"
11
11
  import {
12
12
  authenticate,
13
- fetchTunnelConfig,
14
13
  openTunnel,
15
14
  VpnChallengeError,
16
15
  type TunnelHandle,
17
16
  } from "./fortinet"
18
17
  import type { Transport } from "../transport"
19
- import { isVpnPage } from "../../shared/vpn"
20
18
 
21
19
  export type VpnState = {
22
20
  mode: "off" | "connecting" | "up" | "failed"
@@ -78,8 +76,12 @@ export class VpnManager {
78
76
  name: "vpn",
79
77
  fetch: async (url, init) => {
80
78
  const path = await untilAborted(this.ensurePath(), init?.signal)
81
- if (path === "vpn" && this.proxy) {
82
- return await fetch(url, { ...init, proxy: `http://127.0.0.1:${this.proxy.port}` } as RequestInit)
79
+ if (path === "vpn") {
80
+ const proxy = this.proxy
81
+ // ensurePath answered "vpn" but teardown nulled the proxy meanwhile:
82
+ // fail fast instead of silently falling through to a direct fetch
83
+ if (!proxy) throw new Error("the vpn tunnel went down while the request was starting — retry")
84
+ return await fetch(url, { ...init, proxy: `http://127.0.0.1:${proxy.port}` } as RequestInit)
83
85
  }
84
86
  return await f(url, init)
85
87
  },
@@ -115,7 +117,7 @@ export class VpnManager {
115
117
  }
116
118
  // 2. tunnel needed — do we have a login?
117
119
  if (!this.o.credentials) {
118
- this.setState("failed", "off campus and no uni login stored — run `ufr login add <user>`")
120
+ this.setState("failed", "off campus and no uni login stored — add the uni login in opencode's /connect (Uni Freiburg)")
119
121
  return "failed"
120
122
  }
121
123
  // 3. bring the tunnel up
@@ -123,6 +125,7 @@ export class VpnManager {
123
125
  await this.open()
124
126
  return "vpn"
125
127
  } catch (e) {
128
+ if (this.stopped) return "failed" // shutting down: don't overwrite the stopped state
126
129
  const msg = (e as Error).message
127
130
  this.setState("failed", msg)
128
131
  this.scheduleReconnect(msg)
@@ -144,11 +147,7 @@ export class VpnManager {
144
147
  this.directOk = true
145
148
  return "direct"
146
149
  }
147
- if (type.includes("text/html")) {
148
- const html = await res.text().catch(() => "")
149
- this.directOk = false
150
- return isVpnPage(html) ? "tunnel-needed" : "tunnel-needed"
151
- }
150
+ // HTML or anything unexpected: the tunnel is the only path left
152
151
  this.directOk = false
153
152
  return "tunnel-needed"
154
153
  } catch {
@@ -172,7 +171,8 @@ export class VpnManager {
172
171
  }).catch((e) => {
173
172
  throw signal.aborted ? new Error(`the uni VPN gateway did not answer within ${Math.ceil(loginMs / 1000)} s`) : e
174
173
  })
175
- if (this.remoteIp === null) this.remoteIp = await resolveIp(this.o.upstreamHost)
174
+ // fresh per attempt: the DNS answer can change with the route
175
+ this.remoteIp = await resolveIp(this.o.upstreamHost)
176
176
 
177
177
  this.setState("connecting", "negotiating PPP")
178
178
  let sendIp: (datagram: Uint8Array) => void = () => {}
@@ -185,13 +185,23 @@ export class VpnManager {
185
185
  sendIp = (datagram) => ppp.sendIp(datagram)
186
186
  },
187
187
  onEstablished: (cfg) => {
188
+ if (this.stopped) return // stop() mid-connect: the tunnel is closed below; don't start the proxy
189
+ // parseTunnelConfig validates innerIp; this is a non-throwing assertion so a
190
+ // malformed value can never surface as a "tunnel framing error"
191
+ let local: Ipv4
192
+ try {
193
+ local = ipv4Parse(cfg.innerIp)
194
+ } catch {
195
+ this.o.log(`tunnel established with an invalid inner ip ("${cfg.innerIp}") — ignored`)
196
+ return
197
+ }
188
198
  if (!stackRef) {
189
- stackRef = new TcpStack({ local: ipv4Parse(cfg.innerIp), sink: (pkt) => sendIp(pkt), opts: { mss: Math.min(cfg.mru - 40, 1360) } }, this.o.log)
199
+ stackRef = new TcpStack({ local, sink: (pkt) => sendIp(pkt), opts: { mss: Math.min(cfg.mru - 40, 1360) } }, this.o.log)
190
200
  this.stack = stackRef
191
201
  this.proxy = startProxy({ stack: stackRef, remoteIp: this.remoteIp!, log: this.o.log })
192
202
  this.o.log(`proxy listening on 127.0.0.1:${this.proxy.port}`)
193
203
  } else {
194
- stackRef.setLocal(ipv4Parse(cfg.innerIp))
204
+ stackRef.setLocal(local)
195
205
  }
196
206
  this.reconnectAttempt = 0
197
207
  this.setState("up", `tunnel up (inner ip ${cfg.innerIp})`, cfg.innerIp, this.proxy?.port)
@@ -204,11 +214,21 @@ export class VpnManager {
204
214
  },
205
215
  onIp: (datagram) => stackRef?.receive(datagram),
206
216
  })
217
+ if (this.stopped) {
218
+ // stop() ran while the connect was in flight: the tunnel came up after the
219
+ // teardown — close it instead of leaking a live session
220
+ await tunnel.close()
221
+ throw new Error("stopped")
222
+ }
207
223
  this.tunnel = tunnel
208
224
 
209
225
  // wait for the XML phase + PPP/ICCP negotiation to finish
210
226
  const deadline = Date.now() + ESTABLISH_TIMEOUT_MS
211
227
  while (this.state.mode !== "up") {
228
+ if (this.stopped) {
229
+ await tunnel.close()
230
+ throw new Error("stopped")
231
+ }
212
232
  if (Date.now() > deadline) {
213
233
  await tunnel.close()
214
234
  throw new Error("the tunnel did not come up within 30 s")
@@ -271,8 +291,22 @@ function untilAborted<T>(p: Promise<T>, signal?: AbortSignal | null): Promise<T>
271
291
  })
272
292
  }
273
293
 
274
- async function resolveIp(host: string): Promise<Ipv4> {
294
+ const RESOLVE_TIMEOUT_MS = 10_000
295
+
296
+ /** Resolve the upstream host to an IPv4, with a deadline (a hung resolver must not stall the connect forever). */
297
+ async function resolveIp(host: string, timeoutMs = RESOLVE_TIMEOUT_MS): Promise<Ipv4> {
275
298
  const { lookup } = await import("node:dns/promises")
276
- const res = await lookup(host, { family: 4 })
277
- return ipv4Parse(res.address)
299
+ let timer: ReturnType<typeof setTimeout> | null = null
300
+ try {
301
+ const res = await Promise.race([
302
+ lookup(host, { family: 4 }),
303
+ new Promise<never>((_, reject) => {
304
+ timer = setTimeout(() => reject(new Error(`DNS lookup for ${host} did not answer within ${Math.ceil(timeoutMs / 1000)} s`)), timeoutMs)
305
+ timer.unref?.()
306
+ }),
307
+ ])
308
+ return ipv4Parse(res.address)
309
+ } finally {
310
+ if (timer) clearTimeout(timer)
311
+ }
278
312
  }
@@ -57,7 +57,9 @@ export function startProxy(o: {
57
57
  return
58
58
  }
59
59
  const port = Number(m[2])
60
- if (!allowedPorts.includes(port) || !m[1]!.toLowerCase().endsWith(suffix)) {
60
+ const host = m[1]!.toLowerCase()
61
+ // exact match or a subdomain — a bare endsWith would let evil-uni-freiburg.de through
62
+ if (!allowedPorts.includes(port) || (host !== suffix && !host.endsWith(`.${suffix}`))) {
61
63
  // the tunnel only carries traffic to the uni host
62
64
  log(`proxy: refusing CONNECT ${m[1]}:${port}`)
63
65
  socket.write("HTTP/1.1 403 Forbidden\r\n\r\n")
@@ -2,14 +2,14 @@
2
2
  * The /connect integration: opencode's built-in provider-connect panel lists
3
3
  * the "unifreiburg" integration; this plugin replaces its key method with one
4
4
  * that shows the optional uni-login form (login + password) alongside the API
5
- * key(s). When the user submits, opencode stores a Credential with the form
5
+ * key. When the user submits, opencode stores a Credential with the form
6
6
  * answers; this module watches for it and applies it to the keyring + config,
7
7
  * then restarts the gateway so the keys go live.
8
8
  */
9
9
 
10
10
  import { applyConnect, splitKeys } from "../shared/connect"
11
11
  import type { Paths } from "../shared/paths"
12
- import { applyKeyChange, disconnectAll } from "../cli/keys"
12
+ import { applyKeyChange, disconnectAll } from "../shared/keys"
13
13
 
14
14
  export const UFR_INTEGRATION_ID = "unifreiburg"
15
15
 
@@ -56,14 +56,14 @@ export async function registerConnect(d: ConnectDeps): Promise<ConnectRegistrati
56
56
  }
57
57
 
58
58
  // Replace the key method of the unifreiburg integration with ours: the
59
- // standard API-key(s) prompt plus the optional uni-login form.
59
+ // standard API-key prompt plus the optional uni-login form.
60
60
  await ctx.integration.transform((editor: any) => {
61
61
  if (typeof editor?.method?.update !== "function") return
62
62
  editor.method.update({
63
63
  integrationID: UFR_INTEGRATION_ID,
64
64
  method: {
65
65
  type: "key",
66
- label: "API keys (comma-separated)",
66
+ label: "API key",
67
67
  form: [
68
68
  {
69
69
  key: "login",
@@ -1,9 +1,10 @@
1
1
  import { spawn } from "node:child_process"
2
2
  import { mkdirSync } from "node:fs"
3
- import { readJson, readText } from "../shared/fs"
3
+ import { readText } from "../shared/fs"
4
+ import { readDaemonInfo, type DaemonInfo } from "../shared/daemon-client"
4
5
  import type { Paths } from "../shared/paths"
5
6
 
6
- export type DaemonInfo = { port: number; pid: number; version: string }
7
+ export type { DaemonInfo }
7
8
  export type Conn = { port: number; token: string }
8
9
  type FetchLike = (url: string, init?: RequestInit) => Promise<Response>
9
10
 
@@ -18,11 +19,6 @@ export function isNewer(a: string, b: string): boolean {
18
19
  return false
19
20
  }
20
21
 
21
- export async function readDaemonInfo(paths: Paths): Promise<DaemonInfo | null> {
22
- const j = (await readJson(paths.daemonFile)) as Partial<DaemonInfo> | null
23
- return j && typeof j.port === "number" && typeof j.version === "string" ? { port: j.port, pid: j.pid ?? 0, version: j.version } : null
24
- }
25
-
26
22
  async function readToken(paths: Paths): Promise<string | null> {
27
23
  return (await readText(paths.tokenFile))?.trim() || null
28
24
  }
@@ -31,7 +27,7 @@ async function readToken(paths: Paths): Promise<string | null> {
31
27
  export async function ensureDaemon(o: {
32
28
  paths: Paths
33
29
  version: string
34
- spawn: () => void
30
+ spawn: (onError: (e: Error) => void) => void
35
31
  fetch?: FetchLike
36
32
  sleep?: (ms: number) => Promise<void>
37
33
  timeoutMs?: number
@@ -40,10 +36,13 @@ export async function ensureDaemon(o: {
40
36
  const f: FetchLike = o.fetch ?? ((u, i) => fetch(u, i))
41
37
  const sleep = o.sleep ?? ((ms: number) => Bun.sleep(ms))
42
38
  const now = o.now ?? Date.now
43
- const health = async (port: number) => {
39
+ const health = async (port: number): Promise<{ version: string } | null> => {
44
40
  try {
45
41
  const r = await f(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(2_000) })
46
- return r.ok ? ((await r.json()) as { version?: string }) : null
42
+ if (!r.ok) return null
43
+ const h = (await r.json()) as { version?: unknown }
44
+ // Only reuse a daemon that identifies itself — a 200 without a version could be anything on that port.
45
+ return typeof h.version === "string" ? { version: h.version } : null
47
46
  } catch {
48
47
  return null
49
48
  }
@@ -59,27 +58,56 @@ export async function ensureDaemon(o: {
59
58
  const h = await health(info.port)
60
59
  const token = await readToken(o.paths)
61
60
  if (h && token) {
62
- if (!h.version || !isNewer(o.version, h.version)) return { port: info.port, token }
63
- const st = (await f(`http://127.0.0.1:${info.port}/v1/_status`, authed(token))
64
- .then((r) => (r.ok ? r.json() : null))
65
- .catch(() => null)) as { inFlight?: number } | null
66
- if (!st || (st.inFlight ?? 0) > 0) return { port: info.port, token } // upgrade later, never mid-request
67
- await f(`http://127.0.0.1:${info.port}/v1/_shutdown`, authed(token, "POST")).catch(() => null)
68
- for (let i = 0; i < 25 && (await health(info.port)); i++) await sleep(200)
61
+ if (!isNewer(o.version, h.version)) return { port: info.port, token }
62
+ const res = await f(`http://127.0.0.1:${info.port}/v1/_status`, authed(token)).catch(() => null)
63
+ if (res !== null && !res.ok && (res.status === 401 || res.status === 403)) {
64
+ // The stored token is rejected — handing the conn back would only serve
65
+ // 401s, and this daemon can be neither stopped nor replaced without a
66
+ // valid token. Fail with the reason instead of pretending success.
67
+ throw new Error(
68
+ "the running opencode-ufr gateway rejects the stored token — restart it or remove the opencode-ufr state directory",
69
+ )
70
+ }
71
+ // Any other non-ok answer (a 503 while the daemon restarts, say) is not a
72
+ // token rejection: it lands in the couldn't-verify path below.
73
+ const st = res !== null ? ((await res.json().catch(() => null)) as { inFlight?: unknown } | null) : null
74
+ const inFlight = st !== null && typeof st.inFlight === "number" ? st.inFlight : null
75
+ if (inFlight === null) {
76
+ // Couldn't verify (transport hiccup on the probe, or a non-ok _status
77
+ // above) — not confirmed idle: fall through to the spawn path below
78
+ // instead of trusting the guess. Spawning while the old daemon still
79
+ // holds the lock is safe: the child cannot take over and exits quietly
80
+ // (no error event — only a failed exec fires one), and the health poll
81
+ // below keeps answering with the running daemon's conn either way.
82
+ } else if (inFlight > 0) {
83
+ return { port: info.port, token } // upgrade later, never mid-request
84
+ } else {
85
+ await f(`http://127.0.0.1:${info.port}/v1/_shutdown`, authed(token, "POST")).catch(() => null)
86
+ for (let i = 0; i < 25 && (await health(info.port)); i++) await sleep(200)
87
+ }
69
88
  }
70
89
  }
71
90
 
72
- o.spawn()
91
+ const spawnError: { message: string | null } = { message: null }
92
+ o.spawn((e) => {
93
+ spawnError.message = e.message
94
+ })
73
95
  // Startup can take up to ~35 s: two catalog fetches (models.json, UFR /api/models)
74
96
  // with 15 s and 20 s timeouts before /health answers.
75
97
  const timeoutMs = o.timeoutMs ?? 40_000
76
98
  const deadline = now() + timeoutMs
77
99
  while (now() < deadline) {
100
+ if (spawnError.message !== null) {
101
+ throw new Error(`the opencode-ufr gateway failed to start (${spawnError.message}) — see daemon.log`)
102
+ }
78
103
  await sleep(200)
79
104
  const next = await readDaemonInfo(o.paths)
80
105
  const token = await readToken(o.paths)
81
106
  if (next && token && (await health(next.port))) return { port: next.port, token }
82
107
  }
108
+ if (spawnError.message !== null) {
109
+ throw new Error(`the opencode-ufr gateway failed to start (${spawnError.message}) — see daemon.log`)
110
+ }
83
111
  throw new Error(`the opencode-ufr gateway did not start within ${Math.round(timeoutMs / 1000)} s — see daemon.log`)
84
112
  }
85
113
 
@@ -88,7 +116,7 @@ export async function ensureDaemon(o: {
88
116
  * It runs in `cwd` (the state directory), never in the spawning project: Bun auto-loads
89
117
  * .env / bunfig.toml from the working directory, and on Windows the folder would stay locked.
90
118
  */
91
- export function spawnDaemon(entry: string, cwd: string): void {
119
+ export function spawnDaemon(entry: string, cwd: string, onError?: (e: Error) => void): void {
92
120
  mkdirSync(cwd, { recursive: true })
93
121
  const child = spawn(process.execPath, [entry], {
94
122
  cwd,
@@ -97,5 +125,6 @@ export function spawnDaemon(entry: string, cwd: string): void {
97
125
  windowsHide: true,
98
126
  env: { ...process.env, BUN_BE_BUN: "1" },
99
127
  })
128
+ if (onError) child.on("error", (e) => onError(e instanceof Error ? e : new Error(String(e))))
100
129
  child.unref()
101
130
  }
@@ -1,14 +1,43 @@
1
1
  import { fileURLToPath } from "node:url"
2
2
  import type { OpencodeStamp } from "../daemon/catalog"
3
- import { daemonRequest } from "../cli/daemon-client"
3
+ import { daemonRequest } from "../shared/daemon-client"
4
+ import { disconnectAll } from "../shared/keys"
4
5
  import { type Paths, resolvePaths } from "../shared/paths"
5
- import { KeyringStore } from "../shared/secrets"
6
+ import { KeyringStore, type SecretStore } from "../shared/secrets"
6
7
  import { VERSION } from "../shared/version"
7
8
  import { type Conn, ensureDaemon, spawnDaemon } from "./ensure-daemon"
8
9
  import { registerConnect, UFR_INTEGRATION_ID } from "./connect"
9
10
  import { toModelInfo } from "./model-info"
10
11
 
11
12
  const DAEMON_ENTRY = fileURLToPath(new URL("../daemon/main.ts", import.meta.url))
13
+ const PDF2MD_SCRIPT = fileURLToPath(new URL("../client/pdf2md.ts", import.meta.url))
14
+ const PDF2MD_SKILL = fileURLToPath(new URL("../../skills/pdf2md/SKILL.md", import.meta.url))
15
+
16
+ /**
17
+ * Registers the pdf2md skill with opencode (V2 plugins can add skills via a
18
+ * transform). The SKILL.md ships with the package; the script path is baked in
19
+ * at registration time because it depends on where npm cached the package.
20
+ */
21
+ async function registerSkill(ctx: any): Promise<void> {
22
+ if (typeof ctx?.skill?.transform !== "function") return // older opencode — no skills
23
+ try {
24
+ const raw = await Bun.file(PDF2MD_SKILL).text()
25
+ const content = raw.replace("{{PDF2MD_SCRIPT}}", PDF2MD_SCRIPT)
26
+ await ctx.skill.transform((editor: any) => {
27
+ editor.add({
28
+ id: "ufr-pdf2md",
29
+ name: "PDF to Markdown",
30
+ description:
31
+ "Convert a PDF to clean Markdown using UFR vision models — OCR with tables, math, circuit diagrams, refinement and cross-page table merging, all rate-limit-safe through the local gateway.",
32
+ path: PDF2MD_SKILL,
33
+ content,
34
+ })
35
+ })
36
+ log("registered skill ufr-pdf2md")
37
+ } catch (e) {
38
+ log(`skill registration failed: ${(e as Error).message}`)
39
+ }
40
+ }
12
41
 
13
42
  // opencode takes a request's key from the provider's integration (default: the one with the provider's
14
43
  // id), and a key credential there replaces settings.apiKey. The /connect credential on "unifreiburg"
@@ -20,7 +49,11 @@ function log(msg: string): void {
20
49
  }
21
50
 
22
51
  async function connect(paths: Paths): Promise<Conn> {
23
- return ensureDaemon({ paths, version: VERSION, spawn: () => spawnDaemon(DAEMON_ENTRY, paths.stateDir) })
52
+ return ensureDaemon({
53
+ paths,
54
+ version: VERSION,
55
+ spawn: (onError) => spawnDaemon(DAEMON_ENTRY, paths.stateDir, onError),
56
+ })
24
57
  }
25
58
 
26
59
  /** Whether this opencode exposes the integration API (needed to see connection state). */
@@ -39,18 +72,22 @@ async function registerProvider(
39
72
  try {
40
73
  conn = await connect(paths)
41
74
  } catch (e) {
42
- log(`${(e as Error).message} — run \`ufr status\``)
75
+ log(`${(e as Error).message} — see daemon.log in the opencode-ufr state directory`)
43
76
  return false
44
77
  }
45
78
  const list = await fetch(`http://127.0.0.1:${conn.port}/v1/models`, {
46
79
  headers: { Authorization: `Bearer ${conn.token}` },
47
80
  signal: AbortSignal.timeout(10_000),
48
81
  })
49
- .then(async (r) => (r.ok ? ((await r.json()) as { data: { id: string; opencode: OpencodeStamp }[] }).data : []))
50
- .catch(() => [])
82
+ .then(async (r) => (r.ok ? ((await r.json()) as { data: { id: string; opencode: OpencodeStamp }[] }).data : null))
83
+ .catch(() => null)
84
+ if (list === null) {
85
+ log("gateway unreachable — retrying")
86
+ return false
87
+ }
51
88
  if (list.length === 0) {
52
89
  // Never register an empty provider: it breaks the whole model picker.
53
- log("no models available — run `ufr setup`, connect to the uni VPN, then `ufr status` for details")
90
+ log("no models available — connect to the uni VPN off campus, then restart opencode")
54
91
  return false
55
92
  }
56
93
  if (typeof ctx?.provider?.transform !== "function") {
@@ -92,50 +129,88 @@ export async function heartbeat(ctx: any, paths: Paths): Promise<void> {
92
129
  if (!ok) await connect(paths).catch((e) => log(`gateway restart failed: ${(e as Error).message}`))
93
130
  }
94
131
 
95
- export default {
96
- id: "opencode-ufr",
97
- async setup(ctx: any) {
98
- const options = (ctx?.options ?? {}) as Record<string, unknown>
99
- const providerId = typeof options.providerId === "string" ? options.providerId : "unifreiburg"
100
- const providerName = typeof options.providerName === "string" ? options.providerName : "Uni Freiburg"
101
- const paths = resolvePaths()
132
+ /**
133
+ * The plugin entry point. `inject.secrets` exists for tests only — production
134
+ * always uses the OS keyring.
135
+ */
136
+ export async function setupPlugin(ctx: any, inject: { secrets?: SecretStore } = {}): Promise<() => void> {
137
+ const options = (ctx?.options ?? {}) as Record<string, unknown>
138
+ const providerId = typeof options.providerId === "string" ? options.providerId : "unifreiburg"
139
+ const providerName = typeof options.providerName === "string" ? options.providerName : "Uni Freiburg"
140
+ const paths = resolvePaths()
141
+ const secrets = inject.secrets ?? new KeyringStore()
102
142
 
103
- let registered = false
104
- const registerProviderFn = async (): Promise<boolean> => {
105
- if (registered) return true
106
- registered = await registerProvider(ctx, providerId, providerName, paths)
107
- return registered
108
- }
143
+ let registered = false
144
+ const registerProviderFn = async (): Promise<boolean> => {
145
+ if (registered) return true
146
+ registered = await registerProvider(ctx, providerId, providerName, paths)
147
+ return registered
148
+ }
109
149
 
110
- // /connect integration first: it is the only way to connect when no
111
- // provider is registered yet, so it must exist even while disconnected.
112
- const stopConnect = await registerConnect({
113
- ctx,
114
- paths,
115
- secrets: new KeyringStore(),
116
- log,
117
- register: registerProviderFn,
118
- providerRegistered: false,
119
- }).catch((e) => {
120
- log(`/connect integration failed: ${(e as Error).message}`)
121
- return { stop: () => {}, applyNow: async () => false }
122
- })
150
+ // /connect integration first: it is the only way to connect when no
151
+ // provider is registered yet, so it must exist even while disconnected.
152
+ const stopConnect = await registerConnect({
153
+ ctx,
154
+ paths,
155
+ secrets,
156
+ log,
157
+ register: registerProviderFn,
158
+ providerRegistered: false,
159
+ }).catch((e) => {
160
+ log(`/connect integration failed: ${(e as Error).message}`)
161
+ return { stop: () => {}, applyNow: async () => false }
162
+ })
123
163
 
124
- // The provider is registered only while the integration is connected:
125
- // removing it in opencode must remove the models too.
126
- if (!hasIntegration(ctx)) {
127
- await registerProviderFn() // old opencode without the integration API — register as before
164
+ // The provider is registered only while the integration is connected:
165
+ // removing it in opencode must remove the models too.
166
+ if (!hasIntegration(ctx)) {
167
+ await registerProviderFn() // old opencode without the integration API — register as before
168
+ } else {
169
+ const connection = await ctx.integration.connection.active(UFR_INTEGRATION_ID).catch(() => undefined)
170
+ if (connection) {
171
+ // Registering can block ~80 s (daemon boot + watcher retries) — never
172
+ // stall opencode startup: run the first registration in the background;
173
+ // the /connect watcher (polling every few seconds) completes it if needed.
174
+ log("unifreiburg connected — registering the provider in the background")
175
+ void registerProviderFn().catch((e) => log(`provider registration failed: ${(e as Error).message}`))
128
176
  } else {
129
- const connection = await ctx.integration.connection.active(UFR_INTEGRATION_ID).catch(() => undefined)
130
- if (connection) await registerProviderFn()
131
- else log(`${providerId} not connected — open /connect in opencode to set it up`)
177
+ // A removal while opencode was closed leaves the keyring untouched (the
178
+ // watcher only sees the transition while opencode runs) — wipe it here.
179
+ // A failing loadConfig (a broken config.json) must not reject setupPlugin:
180
+ // the plugin still has to load so /connect can repair the setup.
181
+ const r = await disconnectAll({ paths, secrets, fetch: (u, i) => fetch(u, i), log }).catch((e) => {
182
+ log(`cleanup of the removed connection failed: ${(e as Error).message}`)
183
+ return { removed: [], loginRemoved: false }
184
+ })
185
+ if (r.removed.length > 0 || r.loginRemoved) {
186
+ log(`unifreiburg was removed in opencode — wiped what was left: ${r.removed.join(", ")}${r.loginRemoved ? " + uni login" : ""}`)
187
+ }
188
+ log(`${providerId} not connected — open /connect in opencode to set it up`)
132
189
  }
190
+ }
133
191
 
134
- const beat = setInterval(() => void heartbeat(ctx, paths), 60_000)
135
- ;(beat as { unref?: () => void }).unref?.()
136
- return () => {
137
- clearInterval(beat)
138
- stopConnect.stop()
139
- }
192
+ // The pdf2md skill is independent of the connection state — register it always.
193
+ await registerSkill(ctx)
194
+
195
+ // One heartbeat at a time: a slow one (daemon boot) must not pile up or race the /connect watcher.
196
+ let beating = false
197
+ const beat = setInterval(() => {
198
+ if (beating) return
199
+ beating = true
200
+ void heartbeat(ctx, paths).finally(() => {
201
+ beating = false
202
+ })
203
+ }, 60_000)
204
+ ;(beat as { unref?: () => void }).unref?.()
205
+ return () => {
206
+ clearInterval(beat)
207
+ stopConnect.stop()
208
+ }
209
+ }
210
+
211
+ export default {
212
+ id: "opencode-ufr",
213
+ async setup(ctx: any) {
214
+ return setupPlugin(ctx)
140
215
  },
141
216
  }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * TUI contribution: live gateway throughput in opencode's sidebar. Loaded by
3
+ * opencode's CLI because package.json exports "./tui" — the server plugin
4
+ * (index.ts) needs no changes for this to load.
5
+ *
6
+ * Polls GET /v1/_status every second and appends one line to the right
7
+ * panel: req/s and tok/s over the gateway's rolling 60 s window. Status
8
+ * polls don't reset the daemon's idle timer (see server.ts), so leaving the
9
+ * panel open never keeps the gateway alive.
10
+ */
11
+ import { Plugin } from "@opencode/plugin/tui"
12
+ import { createSignal, onCleanup, onMount, Show } from "solid-js"
13
+ import { daemonRequest } from "../shared/daemon-client"
14
+ import { resolvePaths, type Paths } from "../shared/paths"
15
+
16
+ /** The rates block of GET /v1/_status (see StatusJson in daemon.ts). */
17
+ type Rates = {
18
+ windowMs: number
19
+ requests: number
20
+ reqPerSec: number
21
+ tokensInPerSec: number
22
+ tokensOutPerSec: number
23
+ }
24
+
25
+ const POLL_MS = 1_000
26
+
27
+ /** 6743 → "6.7k" — prompt traffic through opencode dwarfs output. */
28
+ function fmtTokens(v: number): string {
29
+ return v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(v)
30
+ }
31
+
32
+ function RatesLine(props: { paths: Paths }) {
33
+ const [rates, setRates] = createSignal<Rates | null>(null)
34
+ let busy = false
35
+ const poll = async () => {
36
+ if (busy) return
37
+ busy = true
38
+ try {
39
+ const res = await daemonRequest(props.paths, (u, i) => fetch(u, i), "/v1/_status")
40
+ if (!res?.ok) {
41
+ setRates(null) // daemon down, restarting or new token — the line hides
42
+ return
43
+ }
44
+ const j = (await res.json()) as { rates?: Rates }
45
+ setRates(j.rates ?? null)
46
+ } finally {
47
+ busy = false
48
+ }
49
+ }
50
+ let timer: ReturnType<typeof setInterval> | undefined
51
+ onMount(() => {
52
+ void poll()
53
+ timer = setInterval(() => void poll(), POLL_MS)
54
+ })
55
+ onCleanup(() => clearInterval(timer))
56
+ const label = () => {
57
+ const r = rates()
58
+ if (!r) return null
59
+ return `UFR ${r.reqPerSec} req/s · ${fmtTokens(r.tokensOutPerSec)} tok/s out · ${fmtTokens(r.tokensInPerSec)} tok/s in`
60
+ }
61
+ return (
62
+ <Show when={label()}>
63
+ <text>{label()}</text>
64
+ </Show>
65
+ )
66
+ }
67
+
68
+ export default Plugin.define({
69
+ id: "opencode-ufr.stats",
70
+ setup(context) {
71
+ const paths = resolvePaths()
72
+ return context.ui.slot({
73
+ append: "sidebar.content",
74
+ render: () => <RatesLine paths={paths} />,
75
+ })
76
+ },
77
+ })