opencode-codeops 2.1.1 → 2.2.1

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/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
+ })
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Build the packaged TUI entry.
3
+ *
4
+ * OpenCode transpiles plugin TSX with its OpenTUI renderer runtime only for
5
+ * files outside `node_modules`. A published package always installs into
6
+ * `node_modules`, so a source `.tsx` entry falls back to the default React
7
+ * JSX transform and fails to load ("Cannot find package 'react'"). Compiling
8
+ * `plugin/tui.tsx` with `@opentui/solid`'s own transform — the same pipeline
9
+ * the host applies to project-local plugins — produces a plain-JavaScript
10
+ * entry whose runtime imports (`@opentui/solid`, `solid-js`) the host
11
+ * rewrites to its bundled renderer.
12
+ *
13
+ * The generated artifact `plugin/tui.js` is committed and shipped as the
14
+ * `./tui` export; `scripts/tui-entry-build.spec.test.mjs` fails when it is
15
+ * stale, so the release verification blocks a stale entry.
16
+ *
17
+ * @module build-tui
18
+ */
19
+
20
+ import { readFileSync, writeFileSync } from "node:fs"
21
+ import { createRequire } from "node:module"
22
+ import { dirname, join } from "node:path"
23
+ import { fileURLToPath, pathToFileURL } from "node:url"
24
+
25
+ /** Repository root: this file lives in `<root>/scripts/`. */
26
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
27
+
28
+ /** Source of the TUI entry, authored as TSX. */
29
+ const SOURCE = join(ROOT, "plugin", "tui.tsx")
30
+
31
+ /** Generated JavaScript entry shipped as `./tui`. */
32
+ const TARGET = join(ROOT, "plugin", "tui.js")
33
+
34
+ /** Header marking the artifact as generated. */
35
+ const HEADER =
36
+ "// Generated from plugin/tui.tsx — do not edit; run `npm run build:tui` to regenerate.\n"
37
+
38
+ /**
39
+ * Load `@opentui/solid`'s Solid JSX transform.
40
+ *
41
+ * The transform module is not part of the package's public exports map, so it
42
+ * is resolved through the package root: resolve the package entry point, then
43
+ * import the transform beside it.
44
+ *
45
+ * @returns The `transformSolidSource(code, options)` function.
46
+ */
47
+ async function loadTransform() {
48
+ const require = createRequire(join(ROOT, "scripts", "build-tui.mjs"))
49
+ const packageEntry = require.resolve("@opentui/solid")
50
+ const modulePath = join(dirname(packageEntry), "scripts", "solid-transform.js")
51
+ const module = await import(pathToFileURL(modulePath).href)
52
+ return module.transformSolidSource
53
+ }
54
+
55
+ /**
56
+ * Compile the current `plugin/tui.tsx` into the shipped entry source.
57
+ *
58
+ * @returns The complete `plugin/tui.js` content, header included.
59
+ */
60
+ export async function buildTuiSource() {
61
+ const transformSolidSource = await loadTransform()
62
+ const source = readFileSync(SOURCE, "utf8")
63
+ const transformed = await transformSolidSource(source, { filename: SOURCE })
64
+ const code = typeof transformed === "string" ? transformed : transformed.code
65
+ return HEADER + code
66
+ }
67
+
68
+ // When run directly (`npm run build:tui`), regenerate the committed artifact.
69
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
70
+ const code = await buildTuiSource()
71
+ writeFileSync(TARGET, code)
72
+ console.log(`built plugin/tui.js (${code.length} chars)`)
73
+ }
@@ -13,6 +13,7 @@ import re
13
13
  import sys
14
14
  from dataclasses import asdict, dataclass
15
15
  from pathlib import Path
16
+ from typing import Iterator
16
17
 
17
18
 
18
19
  IMPLEMENTS_RE = re.compile(r"^>[ \t]*\*\*Implements\*\*:[ \t]*(.+?)[ \t]*$", re.MULTILINE)
@@ -21,7 +22,9 @@ TARGET_RE = re.compile(
21
22
  r"(?:RD-(?:[A-Za-z0-9]+-)*\d+|T-\d+|REQ-[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)"
22
23
  r"(?![A-Za-z0-9_-])"
23
24
  )
24
- TASK_RE = re.compile(r"^-\s*\[([ xX~!])\]\s+(.+?)\s*$", re.MULTILINE)
25
+ TASK_RE = re.compile(r"^-\s*\[([ xX~!])\]\s+(.+?)\s*$")
26
+ FENCE_OPEN_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})")
27
+ TASK_ID_RE = re.compile(r"^\s*(?:\*\*)?([0-9]+\.[0-9]+\.[0-9]+|T-[0-9]+\.[0-9]+)(?=[\s*—–-]|$)")
25
28
  BLOCKED_REASON_RE = re.compile(r"(?:blocked|reason)\s*:\s*\S", re.IGNORECASE)
26
29
  _PROGRESS_WIDTH = 10
27
30
  _PROGRESS_FILLED = "█"
@@ -67,9 +70,70 @@ def parse_implements(index_text: str) -> tuple[str, ...]:
67
70
  return tuple(dict.fromkeys(TARGET_RE.findall(match.group(1))))
68
71
 
69
72
 
73
+ def _task_lines(execution_text: str) -> Iterator[tuple[str, str, str]]:
74
+ """Yield ``(marker, text, task_id)`` for every real execution-task line.
75
+
76
+ A line qualifies when it carries one of the four checkbox markers outside a
77
+ fenced code block and its text begins with a task id. Fences follow the
78
+ Markdown shape: an opening run of three or more backticks or tildes (at
79
+ most three leading spaces, optional info string) hides everything up to a
80
+ closing run of the same character with at least the same length and nothing
81
+ else on the line; an unclosed fence hides the rest of the document. An id
82
+ is ``N.N.N`` or ``T-N.N`` after an optional ``**`` opener and must be
83
+ followed by whitespace, ``*``, a dash, or the end of the line, so
84
+ near-misses such as ``1.1.1.1``, ``T-05.1x``, and ``1.1.1:`` stay ignored.
85
+ Duplicate ids are yielded; the caller decides which occurrence counts.
86
+ """
87
+ fence_char = ""
88
+ fence_length = 0
89
+ for line in execution_text.splitlines():
90
+ line = line.rstrip()
91
+ if fence_char:
92
+ indent = len(line) - len(line.lstrip(" "))
93
+ body = line[indent:] if indent <= 3 else ""
94
+ if len(body) >= fence_length and body == fence_char * len(body):
95
+ fence_char = ""
96
+ continue
97
+ opener = FENCE_OPEN_RE.match(line)
98
+ if opener:
99
+ fence_char = opener.group(1)[0]
100
+ fence_length = len(opener.group(1))
101
+ continue
102
+ task = TASK_RE.match(line)
103
+ task_id = TASK_ID_RE.match(task.group(2)) if task else None
104
+ if task_id is None:
105
+ continue
106
+ yield task.group(1).lower(), task.group(2).strip(), task_id.group(1)
107
+
108
+
70
109
  def parse_tasks(execution_text: str) -> tuple[Task, ...]:
71
- """Parse only the four authoritative execution checklist markers."""
72
- return tuple(Task(marker.lower(), text.strip()) for marker, text in TASK_RE.findall(execution_text))
110
+ """Return the execution tasks a plan document really declares.
111
+
112
+ Fenced examples and checkbox lines without a task id (deliverable and
113
+ acceptance lists, quoted templates) are ignored, and a task id that appears
114
+ more than once counts once, from its first occurrence in document order.
115
+ """
116
+ tasks: list[Task] = []
117
+ seen: set[str] = set()
118
+ for marker, text, task_id in _task_lines(execution_text):
119
+ if task_id in seen:
120
+ continue
121
+ seen.add(task_id)
122
+ tasks.append(Task(marker, text))
123
+ return tuple(tasks)
124
+
125
+
126
+ def has_checkbox_lines(execution_text: str) -> bool:
127
+ """Return whether a document carries at least one checklist line.
128
+
129
+ Presence is deliberately id-agnostic: legacy execution plans may list
130
+ tasks without ids, and migration admission must stay tolerant of them.
131
+ Counting correctness is ``parse_tasks``' job; this predicate only answers
132
+ "does the document look like it declares tasks at all". Fences are not
133
+ stripped here for the same reason — a quoted checklist still proves the
134
+ document format.
135
+ """
136
+ return any(TASK_RE.match(line.rstrip()) for line in execution_text.splitlines())
73
137
 
74
138
 
75
139
  def next_task(tasks: tuple[Task, ...]) -> Task | None:
@@ -17,9 +17,21 @@ from dataclasses import dataclass
17
17
  from pathlib import Path
18
18
 
19
19
  try:
20
- from scripts.codeops_plan import BLOCKED_REASON_RE, IMPLEMENTS_RE, parse_implements, parse_tasks
20
+ from scripts.codeops_plan import (
21
+ BLOCKED_REASON_RE,
22
+ IMPLEMENTS_RE,
23
+ has_checkbox_lines,
24
+ parse_implements,
25
+ parse_tasks,
26
+ )
21
27
  except ModuleNotFoundError: # Direct execution adds scripts/, not the repository root, to sys.path.
22
- from codeops_plan import BLOCKED_REASON_RE, IMPLEMENTS_RE, parse_implements, parse_tasks
28
+ from codeops_plan import (
29
+ BLOCKED_REASON_RE,
30
+ IMPLEMENTS_RE,
31
+ has_checkbox_lines,
32
+ parse_implements,
33
+ parse_tasks,
34
+ )
23
35
 
24
36
 
25
37
  TARGET_ID_RE = re.compile(
@@ -231,7 +243,9 @@ def inspect_migration(codeops_root: Path) -> Migration:
231
243
  continue
232
244
  execution_text = execution.read_text(encoding="utf-8")
233
245
  tasks = parse_tasks(execution_text)
234
- if not tasks:
246
+ # Presence stays checkbox-based (id-agnostic) so legacy id-less
247
+ # checklists keep migrating; only real tasks are checked further.
248
+ if not has_checkbox_lines(execution_text):
235
249
  problems.append(f"{execution}: contains no execution tasks")
236
250
  for task in tasks:
237
251
  if task.marker == "!" and not BLOCKED_REASON_RE.search(task.text):
@@ -172,6 +172,11 @@ For each task, in order:
172
172
  the Master Progress Checklist — see the protocol's dual-format detection) and bump the Progress
173
173
  counter / Last Updated stamp as soon as implementation finishes (crash-safe), promote it to
174
174
  `[x]` only after its verification passes. A task never shows `[x]` with a failing verify.
175
+
176
+ - **Report progress** — when the `codeops_progress` tool is available — at the transition
177
+ points in [execution-protocol.md](execution-protocol.md) §Progress Reporting; a failed or
178
+ missing report never blocks any step.
179
+
175
180
  3. **Verify** — run your project's verify command (from the project's AGENTS.md, or detected
176
181
  project conventions), output captured per the protocol's **Verify-output capture rule**
177
182
  (PASS one-liner; on failure the last 50 log lines + log path). Pass → promote `[~]` → `[x]`;
@@ -444,6 +444,35 @@ otherwise still `[~]` — with the progress counter and Last Updated stamp curre
444
444
 
445
445
  ---
446
446
 
447
+ ## Progress Reporting (fail-soft)
448
+
449
+ While a plan executes, the agent may report live progress through the `codeops_progress` tool so
450
+ the sidebar shows the current run. Reporting is an observability aid only: the Markdown execution
451
+ plan remains the single progress source of truth, and a missing tool or a failed call never
452
+ blocks, delays, or changes any verify, commit, or plan update — when the tool is unavailable,
453
+ execution proceeds silently.
454
+
455
+ Report at these moments; `plan` is the plan folder name, and every call carries `plan` and
456
+ `activity` (absent optional fields are carried by the run state):
457
+
458
+ | Moment | Report payload |
459
+ | ------ | -------------- |
460
+ | Run start (start point determined) | `{ plan, phase, task, activity: "implementing" }` |
461
+ | Phase start (subsequent phases) | `{ plan, phase, activity: "starting" }` |
462
+ | Task implemented (after the `[~]` mark) | `{ plan, task, activity: "implementing" }` |
463
+ | Verification (before running verify) | `{ plan, activity: "verifying" }` |
464
+ | Task verified (after `[x]` + the progress-bar display) | `{ plan, verified, total, activity: "implementing" }` — counts from that display |
465
+ | Blocker (after the `[!]` mark) | `{ plan, activity: "blocked", detail: "<short reason>" }` |
466
+ | Delegation (around an executor/reviewer dispatch) | `{ plan, activity: "delegating", detail: "<role>" }` |
467
+ | Review step (post-phase quality loop) | `{ plan, activity: "reviewing" }` |
468
+ | Waiting (before pausing for an explicit user decision) | `{ plan, activity: "waiting" }` |
469
+ | Run done (all tasks complete) | `{ plan, activity: "done" }` |
470
+
471
+ One call per listed moment, no other calls (noise control). A reporting failure or a host without
472
+ the tool never blocks, delays, or changes any step.
473
+
474
+ ---
475
+
447
476
  ## Step 3: Session Wrap-Up
448
477
 
449
478
  1. Complete the current task before stopping.
@@ -1,55 +0,0 @@
1
- /**
2
- * Specification tests for the scratch-directory lifecycle.
3
- *
4
- * The plugin exports one scratch directory (`$CODEOPS_TMPDIR`) per plugin
5
- * runtime, and every session served by that runtime shares it. Deleting the
6
- * directory when a plugin instance unloads destroys in-flight scratch files of
7
- * sessions that are still running, so the contract is: the plugin never
8
- * deletes the shared directory; the age-based sweep reclaims abandoned
9
- * directories instead.
10
- *
11
- * This is the regression oracle for that contract: a failure here means the
12
- * repository reintroduced early deletion, never that the test is wrong.
13
- *
14
- * @module temp-lifecycle.spec.test
15
- */
16
-
17
- import assert from "node:assert/strict"
18
- import { readFileSync } from "node:fs"
19
- import { dirname, join, resolve } from "node:path"
20
- import { describe, it } from "node:test"
21
- import { fileURLToPath } from "node:url"
22
-
23
- const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..")
24
-
25
- /**
26
- * Read a repository file as UTF-8 text.
27
- * @param {string} relativePath - Path relative to the repository root.
28
- * @returns {string} The file contents.
29
- */
30
- function read(relativePath) {
31
- return readFileSync(join(ROOT, relativePath), "utf8")
32
- }
33
-
34
- describe("scratch-directory lifecycle", () => {
35
- it("keeps the plugin free of scratch-directory deletion", () => {
36
- const plugin = read("plugin/index.ts")
37
- assert.doesNotMatch(plugin, /removeSessionTmpDir/)
38
- assert.match(plugin, /cleanStaleTmpDirs\(\)/)
39
- assert.match(plugin, /controller\.abort\(\)/)
40
- })
41
-
42
- it("keeps the temp hygiene module free of exact-directory deletion", () => {
43
- assert.doesNotMatch(read("bin/lib/tmp-hygiene.mjs"), /removeSessionTmpDir/)
44
- assert.doesNotMatch(read("bin/lib/tmp-hygiene.d.mts"), /removeSessionTmpDir/)
45
- })
46
-
47
- it("documents the age-based sweep as the automatic cleanup", () => {
48
- const hygiene = read("_shared/workspace-hygiene.md")
49
- assert.doesNotMatch(hygiene, /removes that runtime's\s+directory when the plugin unloads/i)
50
- assert.match(hygiene, /seven days/i)
51
- const readme = read("README.md")
52
- assert.doesNotMatch(readme, /removes the\s+directory when it unloads/i)
53
- assert.match(readme, /sweeps\s+directories abandoned/i)
54
- })
55
- })