opencode-codeops 2.1.0 → 2.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.
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Type declarations for the CodeOps RPC helper.
3
+ *
4
+ * The helper is plain JavaScript so `node --test` can exercise it directly;
5
+ * these declarations give the TypeScript plugin entries typed access. The
6
+ * definition carries the status method, the progress method, and the
7
+ * updated/cleared events.
8
+ */
9
+
10
+ import type {
11
+ CodeOpsProgressRuntime,
12
+ CodeOpsRunClearedSchema,
13
+ CodeOpsRunState,
14
+ CodeOpsRunStateSchema,
15
+ } from "./codeops-progress.mjs"
16
+
17
+ /** JSON-Schema shape of the empty input accepted by the `status` method. */
18
+ export type CodeOpsRpcStatusInput = {
19
+ readonly type: "object"
20
+ readonly additionalProperties: false
21
+ }
22
+
23
+ /** JSON-Schema shape of the payload the `status` method returns. */
24
+ export type CodeOpsRpcStatusOutput = {
25
+ readonly type: "object"
26
+ readonly additionalProperties: false
27
+ readonly required: readonly ["pluginVersion", "openCodeVersion", "directory"]
28
+ readonly properties: {
29
+ readonly pluginVersion: { readonly type: "string" }
30
+ readonly openCodeVersion: { readonly type: "string" }
31
+ readonly directory: { readonly type: "string" }
32
+ }
33
+ }
34
+
35
+ /** The status payload after runtime validation. */
36
+ export interface CodeOpsStatus {
37
+ readonly pluginVersion: string
38
+ readonly openCodeVersion: string
39
+ readonly directory: string
40
+ }
41
+
42
+ /** JSON-Schema shape of the empty input accepted by the `progress` method. */
43
+ export type CodeOpsRpcProgressInput = {
44
+ readonly type: "object"
45
+ readonly additionalProperties: false
46
+ }
47
+
48
+ /** JSON-Schema shape of the `progress` method's payload: a snapshot or null. */
49
+ export type CodeOpsRpcProgressOutput = {
50
+ readonly anyOf: readonly [CodeOpsRunStateSchema, { readonly type: "null" }]
51
+ }
52
+
53
+ /** The `updated` event of the codeops RPC: the full run snapshot. */
54
+ export interface CodeOpsRpcUpdatedEvent {
55
+ readonly schema: CodeOpsRunStateSchema
56
+ }
57
+
58
+ /** The `cleared` event of the codeops RPC: the cleared run's identity. */
59
+ export interface CodeOpsRpcClearedEvent {
60
+ readonly schema: CodeOpsRunClearedSchema
61
+ }
62
+
63
+ /** The portable RPC definition shared by the server plugin and the TUI entry. */
64
+ export interface CodeOpsRpcDefinition {
65
+ readonly id: "codeops"
66
+ readonly methods: {
67
+ readonly status: {
68
+ readonly input: CodeOpsRpcStatusInput
69
+ readonly output: CodeOpsRpcStatusOutput
70
+ }
71
+ readonly progress: {
72
+ readonly input: CodeOpsRpcProgressInput
73
+ readonly output: CodeOpsRpcProgressOutput
74
+ }
75
+ }
76
+ readonly events: {
77
+ readonly updated: CodeOpsRpcUpdatedEvent
78
+ readonly cleared: CodeOpsRpcClearedEvent
79
+ }
80
+ }
81
+
82
+ /** The shared definition object; plain data with no runtime imports. */
83
+ export declare const CodeOpsRpc: CodeOpsRpcDefinition
84
+
85
+ /** Options accepted by {@link registerCodeOpsRpc}. */
86
+ export interface RegisterCodeOpsRpcOptions {
87
+ /** Version of the installed package, reported by the status method. */
88
+ pluginVersion?: string
89
+ /** The run-state runtime the progress method serves and binds emission from. */
90
+ runtime?: CodeOpsProgressRuntime
91
+ }
92
+
93
+ /**
94
+ * Feature-detect `ctx.rpc.register`, register the codeops RPC when present,
95
+ * and report success. Never throws.
96
+ */
97
+ export declare function registerCodeOpsRpc(
98
+ ctx: unknown,
99
+ options?: RegisterCodeOpsRpcOptions
100
+ ): Promise<boolean>
101
+
102
+ /** Validate an unknown RPC payload as a complete CodeOps status record. */
103
+ export declare function isCodeOpsStatus(value: unknown): value is CodeOpsStatus
104
+
105
+ /** The location a status call is scoped to. */
106
+ export interface CodeOpsLocation {
107
+ readonly directory: string
108
+ readonly workspaceID?: string
109
+ }
110
+
111
+ /** Call options forwarded to the status call. */
112
+ export interface CodeOpsCallOptions {
113
+ readonly location?: CodeOpsLocation
114
+ }
115
+
116
+ /** Structural client shape {@link requestStatus} consumes. */
117
+ export interface CodeOpsStatusClient {
118
+ readonly rpc: (definition: CodeOpsRpcDefinition) => {
119
+ readonly status: (
120
+ input: Record<string, never>,
121
+ options?: CodeOpsCallOptions
122
+ ) => Promise<unknown>
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Request the raw status payload from a host client, converting every
128
+ * transport failure into `undefined`.
129
+ *
130
+ * Pass the calling session's location in `options`: the host resolves RPC
131
+ * calls against it, and a call without one can miss the plugin's registration
132
+ * when the session's project differs from the host's default location.
133
+ */
134
+ export declare function requestStatus(
135
+ client: CodeOpsStatusClient,
136
+ options?: CodeOpsCallOptions
137
+ ): Promise<unknown>
138
+
139
+ /** Structural client shape {@link requestProgress} consumes. */
140
+ export interface CodeOpsProgressClient {
141
+ readonly rpc: (definition: CodeOpsRpcDefinition) => {
142
+ readonly progress: (
143
+ input: Record<string, never>,
144
+ options?: CodeOpsCallOptions
145
+ ) => Promise<unknown>
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Request the current run snapshot from a host client, converting every
151
+ * transport failure into `null`.
152
+ *
153
+ * Pass the calling session's location in `options`: the host resolves RPC
154
+ * calls against it, and a call without one can miss the plugin's registration
155
+ * when the session's project differs from the host's default location.
156
+ */
157
+ export declare function requestProgress(
158
+ client: CodeOpsProgressClient,
159
+ options?: CodeOpsCallOptions
160
+ ): Promise<CodeOpsRunState | null>
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The CodeOps RPC: definition, registration guard, payload guard, and the
3
+ * progress snapshot helper.
4
+ *
5
+ * The definition is plain data — the SDK's `Rpc.define` performs identity and
6
+ * reserved-name validation only — so this module needs no runtime import of
7
+ * the SDK and `node --test` can exercise it directly. The definition carries
8
+ * the `status` method, the `progress` method that answers with the current run
9
+ * snapshot, and the `updated`/`cleared` events the sidebar follows. The
10
+ * registration guard keeps the surface safe on builds that do not implement
11
+ * custom RPCs: it never throws and reports success with a boolean. When the
12
+ * host returns a registration exposing `events.emit`, the guard binds the
13
+ * runtime's emission through it, so accepted reports push live updates.
14
+ *
15
+ * @module codeops-rpc
16
+ */
17
+
18
+ import { RunClearedSchema, RunStateSchema, isCodeOpsRunState } from "./codeops-progress.mjs"
19
+
20
+ /**
21
+ * The portable `codeops` RPC definition shared by the server plugin and the
22
+ * TUI entry: a `status` method with an empty-object input and a three-string
23
+ * payload, a `progress` method answering with the current run snapshot (or
24
+ * `null`), and the `updated`/`cleared` events that push every accepted change.
25
+ */
26
+ export const CodeOpsRpc = {
27
+ id: "codeops",
28
+ methods: {
29
+ status: {
30
+ input: { type: "object", additionalProperties: false },
31
+ output: {
32
+ type: "object",
33
+ additionalProperties: false,
34
+ required: ["pluginVersion", "openCodeVersion", "directory"],
35
+ properties: {
36
+ pluginVersion: { type: "string" },
37
+ openCodeVersion: { type: "string" },
38
+ directory: { type: "string" },
39
+ },
40
+ },
41
+ },
42
+ progress: {
43
+ input: { type: "object", additionalProperties: false },
44
+ output: { anyOf: [RunStateSchema, { type: "null" }] },
45
+ },
46
+ },
47
+ events: {
48
+ updated: { schema: RunStateSchema },
49
+ cleared: { schema: RunClearedSchema },
50
+ },
51
+ }
52
+
53
+ /**
54
+ * Coerce an optional host value to the string form the payload requires.
55
+ *
56
+ * @param value - Value reported by the host context.
57
+ * @returns The value as a string, or an empty string when it is absent.
58
+ */
59
+ function asText(value) {
60
+ return value === undefined || value === null ? "" : String(value)
61
+ }
62
+
63
+ /**
64
+ * Register the codeops RPC on a plugin context, feature-detected and
65
+ * never-throwing.
66
+ *
67
+ * When a runtime is supplied, the `progress` method answers with its current
68
+ * snapshot and — if the returned registration exposes `events.emit` — the
69
+ * runtime binds its emission through that function, so every accepted report
70
+ * or clear pushes a live event.
71
+ *
72
+ * @param ctx - Server plugin context; only `rpc.register`, `app.version`, and
73
+ * `location.directory` are read.
74
+ * @param {{ pluginVersion?: string, runtime?: object }} [options] -
75
+ * Registration options.
76
+ * @returns `true` when the registration call completed, `false` when the host
77
+ * lacks the RPC API or the registration failed. Never throws.
78
+ */
79
+ export async function registerCodeOpsRpc(ctx, { pluginVersion, runtime } = {}) {
80
+ if (typeof ctx?.rpc?.register !== "function") return false
81
+ try {
82
+ const registration = await ctx.rpc.register(CodeOpsRpc, {
83
+ async status() {
84
+ return {
85
+ pluginVersion: asText(pluginVersion),
86
+ openCodeVersion: asText(ctx?.app?.version),
87
+ directory: asText(ctx?.location?.directory),
88
+ }
89
+ },
90
+ // The host adapter requires handlers to return promises; a synchronous
91
+ // return fails the call. async even though nothing is awaited.
92
+ async progress() {
93
+ return runtime?.snapshot() ?? null
94
+ },
95
+ })
96
+ if (
97
+ runtime &&
98
+ typeof runtime.bindEmit === "function" &&
99
+ typeof registration?.events?.emit === "function"
100
+ ) {
101
+ runtime.bindEmit(registration.events.emit)
102
+ }
103
+ return true
104
+ } catch {
105
+ return false
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Validate an unknown RPC payload as a complete CodeOps status record.
111
+ *
112
+ * @param value - Value received from the RPC.
113
+ * @returns `true` only for a plain object carrying exactly the three string
114
+ * fields of the contract.
115
+ */
116
+ export function isCodeOpsStatus(value) {
117
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false
118
+ if (Object.keys(value).length !== 3) return false
119
+ return (
120
+ typeof value.pluginVersion === "string" &&
121
+ typeof value.openCodeVersion === "string" &&
122
+ typeof value.directory === "string"
123
+ )
124
+ }
125
+
126
+ /**
127
+ * Request the codeops status payload from a host client, converting every
128
+ * transport failure into `undefined`.
129
+ *
130
+ * A caller can therefore render from the result without a `catch`: a host
131
+ * without the RPC, a server plugin that failed to register, and a failed call
132
+ * all collapse into the same "no status" outcome. The payload is returned as
133
+ * received; narrow it with {@link isCodeOpsStatus} before use.
134
+ *
135
+ * Pass the calling session's location in `options`. The host resolves RPC
136
+ * calls against a location, and a call without one can miss the plugin's
137
+ * registration — the default location does not necessarily match a project
138
+ * opened through a path argument.
139
+ *
140
+ * @param client - Host client exposing `rpc(definition).status({}, options)`.
141
+ * @param {{ location?: { directory?: string, workspaceID?: string } }} [options]
142
+ * - Call options forwarded to the status call.
143
+ * @returns The raw status payload, or `undefined` when the call fails.
144
+ */
145
+ export async function requestStatus(client, options) {
146
+ try {
147
+ return await client.rpc(CodeOpsRpc).status({}, options)
148
+ } catch {
149
+ return undefined
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Request the current run snapshot from a host client, converting every
155
+ * failure into `null`.
156
+ *
157
+ * Pass the calling session's location in `options`, for the same reason as
158
+ * {@link requestStatus}: the host resolves RPC calls against a location, and
159
+ * a call without one can miss the plugin's registration. The payload is
160
+ * validated before it is returned, so an invalid payload, a "no run" answer,
161
+ * and any transport failure all collapse into `null` and the caller can
162
+ * render without a `catch`.
163
+ *
164
+ * @param client - Host client exposing `rpc(definition).progress({}, options)`.
165
+ * @param {{ location?: { directory?: string, workspaceID?: string } }} [options]
166
+ * - Call options forwarded to the progress call.
167
+ * @returns The validated snapshot, or `null` when no run is active or the
168
+ * call failed. Never throws.
169
+ */
170
+ export async function requestProgress(client, options) {
171
+ try {
172
+ const payload = await client.rpc(CodeOpsRpc).progress({}, options)
173
+ return isCodeOpsRunState(payload) ? payload : null
174
+ } catch {
175
+ return null
176
+ }
177
+ }
@@ -33,6 +33,3 @@ export interface CleanStaleTmpDirsOptions {
33
33
 
34
34
  /** Remove session directories abandoned by interrupted or crashed runs. */
35
35
  export declare function cleanStaleTmpDirs(options?: CleanStaleTmpDirsOptions): string[]
36
-
37
- /** Remove exactly one session's temp directory. */
38
- export declare function removeSessionTmpDir(sessionID?: string, base?: string): boolean
@@ -9,8 +9,7 @@
9
9
  * - it computes the per-session directory the plugin exports as
10
10
  * `$CODEOPS_TMPDIR`, under a CodeOps-owned root inside the OS temp directory;
11
11
  * - it sweeps directories abandoned by interrupted runs once they are older
12
- * than a safety cap; and
13
- * - it removes exactly one session's directory when that session is deleted.
12
+ * than a safety cap.
14
13
  *
15
14
  * Safety is the whole point: every path derives from a sanitized session
16
15
  * identifier, the sweep walks only the CodeOps-owned root, symlinked entries
@@ -22,7 +21,7 @@
22
21
 
23
22
  import { lstatSync, mkdirSync, readdirSync, rmSync } from "node:fs"
24
23
  import { tmpdir } from "node:os"
25
- import { join, resolve, sep } from "node:path"
24
+ import { join, resolve } from "node:path"
26
25
 
27
26
  /**
28
27
  * Age after which a session directory is treated as abandoned.
@@ -147,34 +146,3 @@ export function cleanStaleTmpDirs({
147
146
 
148
147
  return removed
149
148
  }
150
-
151
- /**
152
- * Remove exactly one session's temp directory.
153
- *
154
- * Used when a session is deleted: its scratch can no longer be needed. The
155
- * removal refuses anything that is not a regular directory strictly inside
156
- * the CodeOps temp root, so a symlink or an unexpected path is left alone.
157
- *
158
- * @param sessionID - Session identifier whose directory should be removed
159
- * @param base - Base temp directory (injectable for tests)
160
- * @returns True when the directory was removed
161
- */
162
- export function removeSessionTmpDir(sessionID, base = tmpdir()) {
163
- const root = resolve(codeopsTmpRoot(base))
164
- const dir = sessionTmpDir(sessionID, base)
165
-
166
- if (!resolve(dir).startsWith(root + sep)) return false
167
-
168
- try {
169
- if (!lstatSync(dir).isDirectory()) return false
170
- } catch {
171
- return false
172
- }
173
-
174
- try {
175
- rmSync(dir, { recursive: true, force: true })
176
- return true
177
- } catch {
178
- return false
179
- }
180
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-codeops",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Specification-first engineering for complex systems — CodeOps plugin for OpenCode",
5
5
  "type": "module",
6
6
  "engines": {
@@ -10,7 +10,8 @@
10
10
  "main": "./plugin/index.ts",
11
11
  "exports": {
12
12
  ".": "./plugin/index.ts",
13
- "./server": "./plugin/index.ts"
13
+ "./server": "./plugin/index.ts",
14
+ "./tui": "./plugin/tui.tsx"
14
15
  },
15
16
  "bin": {
16
17
  "opencode-codeops": "bin/index.mjs"
@@ -24,6 +25,8 @@
24
25
  },
25
26
  "files": [
26
27
  "plugin/",
28
+ "!plugin/*.test.mjs",
29
+ "!plugin/*.spec.test.mjs",
27
30
  "skills/",
28
31
  "_shared/",
29
32
  "standards/",
@@ -68,7 +71,26 @@
68
71
  "@opencode/plugin": "^2.0.24"
69
72
  },
70
73
  "devDependencies": {
74
+ "@opentui/core": "0.5.17",
75
+ "@opentui/solid": "0.5.17",
71
76
  "@types/node": "^22.20.2",
77
+ "solid-js": "1.9.12",
72
78
  "typescript": "^5.0.0"
79
+ },
80
+ "peerDependencies": {
81
+ "@opentui/core": ">=0.5.14",
82
+ "@opentui/solid": ">=0.5.14",
83
+ "solid-js": ">=1.9.0"
84
+ },
85
+ "peerDependenciesMeta": {
86
+ "@opentui/core": {
87
+ "optional": true
88
+ },
89
+ "@opentui/solid": {
90
+ "optional": true
91
+ },
92
+ "solid-js": {
93
+ "optional": true
94
+ }
73
95
  }
74
96
  }
package/plugin/index.ts CHANGED
@@ -8,8 +8,12 @@ import { fileURLToPath } from "node:url"
8
8
  import {
9
9
  cleanStaleTmpDirs,
10
10
  ensureSessionTmpDir,
11
- removeSessionTmpDir,
12
11
  } from "../bin/lib/tmp-hygiene.mjs"
12
+ import {
13
+ createCodeOpsProgress,
14
+ registerCodeOpsProgressTool,
15
+ } from "../bin/lib/codeops-progress.mjs"
16
+ import { registerCodeOpsRpc } from "../bin/lib/codeops-rpc.mjs"
13
17
  import {
14
18
  appendEffortTrace,
15
19
  applyEffort,
@@ -70,9 +74,9 @@ const standardsText = `${codingStandards}\n\n${outputStyle}`
70
74
  // OpenCode 2 does not expose a session identifier to shell hooks, so the plugin
71
75
  // owns one scratch directory per plugin runtime instead of one per session.
72
76
  // Skills and subagents still receive it as CODEOPS_TMPDIR and delete their own
73
- // scratch; the plugin removes the runtime directory on unload and sweeps
74
- // directories abandoned by earlier runs. Two sessions served by the same
75
- // runtime share this directory; see _shared/workspace-hygiene.md.
77
+ // scratch; the startup sweep reclaims directories abandoned by earlier runs.
78
+ // Several sessions can share one runtime directory, so the plugin must never
79
+ // delete it on unload; see _shared/workspace-hygiene.md.
76
80
  // ---------------------------------------------------------------------------
77
81
  const runtimeID = `runtime-${randomUUID()}`
78
82
 
@@ -224,6 +228,11 @@ export default Plugin.define({
224
228
  const warnedEffortLevels = new Set<string>()
225
229
  const effortTraceEnabled = isEffortTraceEnabled(process.env.CODEOPS_EFFORT_TRACE)
226
230
 
231
+ // The live run state is memory-only, for the lifetime of this plugin
232
+ // instance: it is never persisted and clears when the reporting session
233
+ // ends, so it can never outlive the run it describes.
234
+ const progress = createCodeOpsProgress()
235
+
227
236
  // Sweep scratch directories abandoned by earlier interrupted runs, once.
228
237
  // Cleanup is best effort: a failure must never block a session.
229
238
  try {
@@ -235,8 +244,9 @@ export default Plugin.define({
235
244
  warnOnVersionSkew(directory)
236
245
 
237
246
  // ---------------------------------------------------------------------
238
- // Session lifecycle: drop captured markers when a session is deleted, so
239
- // a long-lived runtime does not remember ended sessions.
247
+ // Session lifecycle: drop captured markers and the session's live run
248
+ // when a session is deleted, so a long-lived runtime does not remember
249
+ // ended sessions.
240
250
  // ---------------------------------------------------------------------
241
251
  const controller = new AbortController()
242
252
  void (async () => {
@@ -244,6 +254,7 @@ export default Plugin.define({
244
254
  for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
245
255
  if (event.type === "session.deleted") {
246
256
  effortMarkers.delete(event.data.sessionID)
257
+ progress.clearSession(event.data.sessionID)
247
258
  }
248
259
  }
249
260
  } catch {
@@ -386,16 +397,53 @@ export default Plugin.define({
386
397
  })
387
398
 
388
399
  // ---------------------------------------------------------------------
389
- // Plugin cleanup: stop listening for events and remove the runtime's
390
- // scratch directory. Both are best effort.
400
+ // CodeOps RPC: expose the plugin identity and the live run snapshot for
401
+ // the sidebar. Registration is feature-detected and never blocks the
402
+ // plugin: builds without custom RPCs keep working unchanged, and the
403
+ // sidebar simply stays hidden. When the host hands back an event emitter,
404
+ // the registration binds it into the runtime, so accepted reports and
405
+ // clears push live events to connected clients.
406
+ // ---------------------------------------------------------------------
407
+ try {
408
+ const registered = await registerCodeOpsRpc(ctx, {
409
+ pluginVersion: packageVersion,
410
+ runtime: progress,
411
+ })
412
+ if (!registered) {
413
+ warnContentFree(
414
+ "The codeops status RPC is unavailable in this OpenCode build; " +
415
+ "the sidebar status stays hidden."
416
+ )
417
+ }
418
+ } catch {
419
+ warnContentFree("Could not register the codeops status RPC.")
420
+ }
421
+
422
+ // ---------------------------------------------------------------------
423
+ // Progress tool: let the agent report live task progress for the sidebar.
424
+ // The tool registers behind feature-detection; on every failure the
425
+ // plugin, the agent loop, and the Markdown execution plan keep working
426
+ // exactly as before.
427
+ // ---------------------------------------------------------------------
428
+ try {
429
+ const registered = await registerCodeOpsProgressTool(ctx, progress)
430
+ if (!registered) {
431
+ warnContentFree(
432
+ "The codeops progress tool is unavailable in this OpenCode build; " +
433
+ "the sidebar stays hidden."
434
+ )
435
+ }
436
+ } catch {
437
+ warnContentFree("Could not register the codeops progress tool.")
438
+ }
439
+
440
+ // ---------------------------------------------------------------------
441
+ // Plugin cleanup: stop listening for events. The runtime's scratch
442
+ // directory is deliberately left in place — other sessions may still be
443
+ // using it — and the startup sweep reclaims abandoned directories.
391
444
  // ---------------------------------------------------------------------
392
445
  return () => {
393
446
  controller.abort()
394
- try {
395
- removeSessionTmpDir(runtimeID)
396
- } catch {
397
- // Best effort only.
398
- }
399
447
  }
400
448
  },
401
449
  })
package/plugin/tui.tsx ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The CodeOps TUI entry: the live run-progress view in the session sidebar.
3
+ *
4
+ * The view is deliberately honest: it shows only reported values from the
5
+ * server's run state, always with an as-of time, and it renders nothing when
6
+ * there is no run, when the host lacks the events API, or when every call
7
+ * fails. There are no timers — the text recomputes whenever the component
8
+ * re-renders — and no file access; all display and merge logic lives in the
9
+ * shared helper module so it can be tested under plain Node.
10
+ *
11
+ * @module tui
12
+ */
13
+
14
+ import { Plugin, usePlugin } from "@opencode/plugin/tui"
15
+ import { createEffect, createResource, createSignal, onCleanup, Show } from "solid-js"
16
+
17
+ import {
18
+ acceptRunCleared,
19
+ acceptRunUpdate,
20
+ describeRun,
21
+ mergeRunState,
22
+ } from "../bin/lib/codeops-progress.mjs"
23
+ import type { CodeOpsRunState } from "../bin/lib/codeops-progress.mjs"
24
+ import { CodeOpsRpc, requestProgress } from "../bin/lib/codeops-rpc.mjs"
25
+
26
+ /**
27
+ * The live run-progress view.
28
+ *
29
+ * Resolves the session's location once, subscribes to the `updated` and
30
+ * `cleared` events before requesting an initial snapshot, and merges both
31
+ * delivery paths monotonically. A clear records its server-stamped
32
+ * `clearedAt`; both merge paths ignore deliveries at or before it, so a late
33
+ * snapshot or update can never resurrect a cleared run. Every failure path is
34
+ * silent: a host without the events API still renders from the snapshot, and
35
+ * an invalid or missing snapshot renders nothing.
36
+ *
37
+ * @returns The three display lines, or nothing when no run is known.
38
+ */
39
+ function CodeOpsProgress() {
40
+ const context = usePlugin()
41
+ const location = context.location ?? context.data.location.default()
42
+ const directory = location?.directory ?? ""
43
+ const [run, setRun] = createSignal<CodeOpsRunState | null>(null)
44
+ const [clearedAt, setClearedAt] = createSignal(0)
45
+
46
+ // Subscribe BEFORE the snapshot: events are live-only, so a snapshot
47
+ // fetched first could miss an update arriving in between. Guarded: a host
48
+ // without the events API still renders from the snapshot below.
49
+ const stops: (() => void)[] = []
50
+ try {
51
+ const events = context.client.rpc(CodeOpsRpc).events
52
+ const stopUpdated = events.on("updated", (event) => {
53
+ const snapshot = acceptRunUpdate(event, directory)
54
+ if (snapshot && snapshot.updatedAt > clearedAt()) {
55
+ setRun((current) => mergeRunState(current, snapshot))
56
+ }
57
+ })
58
+ stops.push(stopUpdated)
59
+ const stopCleared = events.on("cleared", (event) => {
60
+ const cleared = acceptRunCleared(event, directory)
61
+ if (!cleared) return
62
+ // Record the clear time for every accepted clear, before the identity
63
+ // gate: a clear that arrives before the initial snapshot must still
64
+ // suppress that snapshot, or a cleared run could reappear.
65
+ setClearedAt(cleared.clearedAt)
66
+ const current = run()
67
+ if (current && cleared.plan === current.plan && cleared.sessionID === current.sessionID) {
68
+ setRun(null)
69
+ }
70
+ })
71
+ stops.push(stopCleared)
72
+ } catch {
73
+ // Events unavailable: the snapshot-only view below still renders.
74
+ }
75
+ onCleanup(() => stops.forEach((stop) => stop()))
76
+
77
+ // Initial snapshot; null or a failure means "nothing known". The clear
78
+ // guard applies here too: a snapshot at or before the last clear is ignored.
79
+ const [initial] = createResource(async () => requestProgress(context.client, { location }))
80
+ createEffect(() => {
81
+ const snapshot = initial()
82
+ if (snapshot && snapshot.updatedAt > clearedAt()) {
83
+ setRun((current) => mergeRunState(current, snapshot))
84
+ }
85
+ })
86
+
87
+ return (
88
+ <Show when={run()}>
89
+ {(snapshot) => {
90
+ const view = () => describeRun(snapshot(), Date.now())
91
+ return (
92
+ <>
93
+ {view().lines.map((line) => (
94
+ <text>{line}</text>
95
+ ))}
96
+ </>
97
+ )
98
+ }}
99
+ </Show>
100
+ )
101
+ }
102
+
103
+ /**
104
+ * The opencode-codeops TUI plugin: claims the sidebar content slot with the
105
+ * live run view. The host disposes the claim when the plugin unloads.
106
+ */
107
+ export default Plugin.define({
108
+ id: "opencode-codeops-tui",
109
+ setup(context) {
110
+ return context.ui.slot({ append: "sidebar.content", render: () => <CodeOpsProgress /> })
111
+ },
112
+ })