@opencode-cockpit/client 0.1.1 → 0.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencode-cockpit/client",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Auto-spawning, reconnecting, typed client for cockpitd",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,10 +31,10 @@
31
31
  "access": "public"
32
32
  },
33
33
  "dependencies": {
34
- "@opencode-cockpit/protocol": "0.1.1"
34
+ "@opencode-cockpit/protocol": "0.1.3"
35
35
  },
36
36
  "devDependencies": {
37
- "@opencode-cockpit/daemon": "0.1.1"
37
+ "@opencode-cockpit/daemon": "0.1.3"
38
38
  },
39
39
  "engines": {
40
40
  "bun": ">=1.3.5"
package/src/client.ts CHANGED
@@ -39,6 +39,38 @@ export interface OutdatedDaemon {
39
39
  expected: string
40
40
  }
41
41
 
42
+ /**
43
+ * Orders build ids (`<semver>+<hash>`, see daemonBuildId). Higher semver wins; equal versions with
44
+ * different hashes are local development builds, where the client's code counts as newer. A
45
+ * daemon without a build id predates build ids and is always older.
46
+ */
47
+ export function compareBuilds(client: string, daemon: string | undefined): number {
48
+ if (!daemon) return 1
49
+ if (client === daemon) return 0
50
+ const [cv = "", ch = ""] = client.split("+")
51
+ const [dv = "", dh = ""] = daemon.split("+")
52
+ const order = compareSemver(cv, dv)
53
+ if (order !== 0) return order
54
+ return ch === dh ? 0 : 1
55
+ }
56
+
57
+ function compareSemver(a: string, b: string): number {
58
+ const parse = (v: string) => {
59
+ const [core = "", pre] = v.split("-", 2)
60
+ return { nums: core.split(".").map((n) => Number.parseInt(n, 10) || 0), pre }
61
+ }
62
+ const x = parse(a)
63
+ const y = parse(b)
64
+ for (let i = 0; i < 3; i++) {
65
+ const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0)
66
+ if (d !== 0) return Math.sign(d)
67
+ }
68
+ if (x.pre === y.pre) return 0
69
+ if (x.pre === undefined) return 1 // 1.0.0 > 1.0.0-beta
70
+ if (y.pre === undefined) return -1
71
+ return x.pre < y.pre ? -1 : 1
72
+ }
73
+
42
74
  const IDEMPOTENT = new Set<string>([
43
75
  "daemon.hello",
44
76
  "daemon.status",
@@ -199,7 +231,9 @@ export class CockpitClient {
199
231
  }
200
232
 
201
233
  const expected = this.options.expectedBuild
202
- if (expected && this.hello.build !== expected) {
234
+ // Only move forward: several plugins at different versions share one daemon, and letting an
235
+ // older one replace a newer daemon would make them take turns replacing each other.
236
+ if (expected && compareBuilds(expected, this.hello.build) > 0) {
203
237
  const status = (await conn.request("daemon.status", {})) as { modules: { busy: boolean }[] }
204
238
  const busy = status.modules.some((m) => m.busy)
205
239
  if (!busy && this.options.spawn && !replaced) {
package/src/feature.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Guards against loading the same cockpit feature twice, e.g. when both `opencode-cockpit` and
3
+ * `@opencode-cockpit/shell` are configured. OpenCode does not deduplicate plugin tools, and
4
+ * duplicate tool names make model requests fail, so the first copy loaded wins and later copies
5
+ * stay inactive.
6
+ *
7
+ * Claims are scoped to an object that all plugins of one OpenCode instance share: the plugin
8
+ * input on the server side, the renderer in the TUI. Separate instances claim independently.
9
+ */
10
+
11
+ const REGISTRY = Symbol.for("opencode-cockpit.features")
12
+
13
+ type Registry = WeakMap<object, Map<string, string>>
14
+
15
+ function registry(): Registry {
16
+ const host = globalThis as { [REGISTRY]?: Registry }
17
+ host[REGISTRY] ??= new WeakMap()
18
+ return host[REGISTRY]
19
+ }
20
+
21
+ export interface FeatureClaim {
22
+ /** True when this copy owns the feature and should register itself. */
23
+ active: boolean
24
+ /** Source label of the copy that owns the feature. */
25
+ owner: string
26
+ /** Give up ownership, so a reloaded plugin can claim again. No-op for inactive claims. */
27
+ release(): void
28
+ }
29
+
30
+ export function claimFeature(scope: object, feature: string, source: string): FeatureClaim {
31
+ const reg = registry()
32
+ let claims = reg.get(scope)
33
+ if (!claims) {
34
+ claims = new Map()
35
+ reg.set(scope, claims)
36
+ }
37
+ const owner = claims.get(feature)
38
+ if (owner !== undefined) return { active: false, owner, release() {} }
39
+
40
+ claims.set(feature, source)
41
+ let released = false
42
+ return {
43
+ active: true,
44
+ owner: source,
45
+ release() {
46
+ if (released) return
47
+ released = true
48
+ if (claims.get(feature) === source) claims.delete(feature)
49
+ },
50
+ }
51
+ }
52
+
53
+ export function duplicateFeatureMessage(feature: string, owner: string, skipped: string): string {
54
+ return `${feature} is configured twice (${owner} and ${skipped}). Using ${owner}; remove one of them from your OpenCode config.`
55
+ }
package/src/index.ts CHANGED
@@ -1,2 +1,9 @@
1
- export { type ClientOptions, CockpitClient, type ConnectionState, type OutdatedDaemon } from "./client.ts"
1
+ export {
2
+ type ClientOptions,
3
+ CockpitClient,
4
+ type ConnectionState,
5
+ compareBuilds,
6
+ type OutdatedDaemon,
7
+ } from "./client.ts"
8
+ export { claimFeature, duplicateFeatureMessage, type FeatureClaim } from "./feature.ts"
2
9
  export type { SpawnOptions } from "./spawn.ts"