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.
- package/CHANGELOG.md +101 -0
- package/README.md +16 -5
- package/_shared/workspace-hygiene.md +6 -4
- package/bin/lib/codeops-progress.d.mts +236 -0
- package/bin/lib/codeops-progress.mjs +564 -0
- package/bin/lib/codeops-rpc.d.mts +160 -0
- package/bin/lib/codeops-rpc.mjs +177 -0
- package/bin/lib/tmp-hygiene.d.mts +0 -3
- package/bin/lib/tmp-hygiene.mjs +2 -34
- package/package.json +24 -2
- package/plugin/index.ts +61 -13
- package/plugin/tui.tsx +112 -0
- package/scripts/__pycache__/codeops_plan.cpython-312.pyc +0 -0
- package/scripts/__pycache__/install_agents.cpython-312.pyc +0 -0
- package/scripts/codeops_plan.py +67 -3
- package/scripts/codeops_plan_migrate.py +17 -3
- package/skills/exec-plan/SKILL.md +5 -0
- package/skills/exec-plan/execution-protocol.md +29 -0
|
@@ -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
|
package/bin/lib/tmp-hygiene.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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
|
|
74
|
-
//
|
|
75
|
-
//
|
|
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
|
|
239
|
-
// a long-lived runtime does not remember
|
|
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
|
-
//
|
|
390
|
-
//
|
|
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
|
+
})
|
|
Binary file
|
|
Binary file
|