opencode-codeops 2.1.1 → 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,564 @@
1
+ /**
2
+ * The live progress core: contracts, guards, normalization, and the run-state
3
+ * runtime shared by the server plugin and the TUI.
4
+ *
5
+ * The module is deliberately host-free. Schemas are plain JSON-Schema objects,
6
+ * the runtime keeps one in-memory run per location, and every guard is total
7
+ * (it never throws on unknown input). `node --test` can therefore exercise the
8
+ * whole contract without an OpenCode host.
9
+ *
10
+ * @module codeops-progress
11
+ */
12
+
13
+ /**
14
+ * The eight activity values a progress report and a run snapshot can carry.
15
+ * The order is the presentation order used in the tool schema.
16
+ */
17
+ const CODE_OPS_ACTIVITIES = [
18
+ "starting",
19
+ "implementing",
20
+ "verifying",
21
+ "reviewing",
22
+ "delegating",
23
+ "waiting",
24
+ "blocked",
25
+ "done",
26
+ ]
27
+
28
+ /** The keys a progress report may carry; anything else is rejected. */
29
+ const CODE_OPS_REPORT_KEYS = [
30
+ "plan",
31
+ "phase",
32
+ "task",
33
+ "activity",
34
+ "detail",
35
+ "verified",
36
+ "total",
37
+ ]
38
+
39
+ /** The keys a complete run snapshot carries. */
40
+ const CODE_OPS_RUN_STATE_KEYS = [
41
+ "plan",
42
+ "phase",
43
+ "task",
44
+ "activity",
45
+ "detail",
46
+ "verified",
47
+ "total",
48
+ "startedAt",
49
+ "updatedAt",
50
+ "sessionID",
51
+ ]
52
+
53
+ /** The name of the agent-callable tool that reports task progress. */
54
+ export const CODE_OPS_TOOL_NAME = "codeops_progress"
55
+
56
+ /** The description of the progress tool, shown in the host's tool list. */
57
+ export const CODE_OPS_TOOL_DESCRIPTION =
58
+ "Report live CodeOps task progress so the sidebar shows the current run. Use at run, phase, and task transitions, and for blocked, waiting, delegating, reviewing, and done states."
59
+
60
+ /** Longest accepted free-text value; longer values are truncated at this length. */
61
+ export const CODE_OPS_STRING_CAP = 200
62
+
63
+ /** Largest accepted `verified`/`total` count; larger values are clamped. */
64
+ export const CODE_OPS_COUNT_MAX = 1_000_000
65
+
66
+ /** Age in milliseconds at which a run is shown as stale (10 minutes). */
67
+ export const CODE_OPS_STALE_MS = 600_000
68
+
69
+ /**
70
+ * The input schema of the progress tool: a partial run report.
71
+ *
72
+ * Only `plan` and `activity` are required; every other field is an optional
73
+ * update, and unknown fields are rejected by the host's schema validation and
74
+ * by the runtime guard alike.
75
+ */
76
+ export const ProgressReportSchema = {
77
+ type: "object",
78
+ additionalProperties: false,
79
+ required: ["plan", "activity"],
80
+ properties: {
81
+ plan: { type: "string" },
82
+ phase: { type: "string" },
83
+ task: { type: "string" },
84
+ activity: { type: "string", enum: CODE_OPS_ACTIVITIES },
85
+ detail: { type: "string" },
86
+ verified: { type: "integer", minimum: 0 },
87
+ total: { type: "integer", minimum: 0 },
88
+ },
89
+ }
90
+
91
+ /** The output schema of the progress tool: a boolean acknowledgement. */
92
+ export const ProgressOutputSchema = {
93
+ type: "object",
94
+ additionalProperties: false,
95
+ required: ["ok"],
96
+ properties: { ok: { type: "boolean" } },
97
+ }
98
+
99
+ /**
100
+ * The schema of one complete run snapshot.
101
+ *
102
+ * A snapshot is the RPC `progress` payload and the `updated` event data; it
103
+ * always carries all ten fields, with `null` marking an unknown optional value
104
+ * and `total: null` meaning "counts unknown".
105
+ */
106
+ export const RunStateSchema = {
107
+ type: "object",
108
+ additionalProperties: false,
109
+ required: CODE_OPS_RUN_STATE_KEYS,
110
+ properties: {
111
+ plan: { type: "string" },
112
+ phase: { type: ["string", "null"] },
113
+ task: { type: ["string", "null"] },
114
+ detail: { type: ["string", "null"] },
115
+ activity: { type: "string", enum: CODE_OPS_ACTIVITIES },
116
+ verified: { type: "integer", minimum: 0 },
117
+ total: { type: ["integer", "null"], minimum: 0 },
118
+ startedAt: { type: "integer", minimum: 0 },
119
+ updatedAt: { type: "integer", minimum: 0 },
120
+ sessionID: { type: "string" },
121
+ },
122
+ }
123
+
124
+ /**
125
+ * The schema of the `cleared` event payload: the cleared run's identity and
126
+ * the server-stamped clear time. A viewer uses `clearedAt` to ignore late
127
+ * updates or snapshots that belong to the cleared run.
128
+ */
129
+ export const RunClearedSchema = {
130
+ type: "object",
131
+ additionalProperties: false,
132
+ required: ["plan", "sessionID", "clearedAt"],
133
+ properties: {
134
+ plan: { type: "string" },
135
+ sessionID: { type: "string" },
136
+ clearedAt: { type: "integer", minimum: 0 },
137
+ },
138
+ }
139
+
140
+ /**
141
+ * Test whether a value is a plain object (and not null or an array).
142
+ *
143
+ * @param value - Any value.
144
+ * @returns `true` for a plain, non-array object.
145
+ */
146
+ function isPlainObject(value) {
147
+ return typeof value === "object" && value !== null && !Array.isArray(value)
148
+ }
149
+
150
+ /**
151
+ * Test whether a value is a non-negative integer count.
152
+ *
153
+ * @param value - Any value.
154
+ * @returns `true` for an integer `>= 0`.
155
+ */
156
+ function isCount(value) {
157
+ return Number.isInteger(value) && value >= 0
158
+ }
159
+
160
+ /**
161
+ * Test whether a value is a string or `null`.
162
+ *
163
+ * @param value - Any value.
164
+ * @returns `true` for a string or `null`.
165
+ */
166
+ function isNullableString(value) {
167
+ return value === null || typeof value === "string"
168
+ }
169
+
170
+ /**
171
+ * Validate an unknown value as a progress report.
172
+ *
173
+ * Mirrors {@link ProgressReportSchema}: `plan` and `activity` are required,
174
+ * the optional fields must have their declared types, and unknown fields are
175
+ * rejected. This is the runtime's line of defense if the host's schema
176
+ * validation is bypassed or stricter than the schema.
177
+ *
178
+ * @param value - Value received from the tool handler or a test.
179
+ * @returns `true` only for a report matching the schema.
180
+ */
181
+ export function isCodeOpsProgressReport(value) {
182
+ if (!isPlainObject(value)) return false
183
+ const keys = Object.keys(value)
184
+ if (keys.length < 2 || keys.length > CODE_OPS_REPORT_KEYS.length) return false
185
+ if (!keys.every((key) => CODE_OPS_REPORT_KEYS.includes(key))) return false
186
+ if (typeof value.plan !== "string") return false
187
+ if (!CODE_OPS_ACTIVITIES.includes(value.activity)) return false
188
+ if (Object.hasOwn(value, "phase") && typeof value.phase !== "string") return false
189
+ if (Object.hasOwn(value, "task") && typeof value.task !== "string") return false
190
+ if (Object.hasOwn(value, "detail") && typeof value.detail !== "string") return false
191
+ if (Object.hasOwn(value, "verified") && !isCount(value.verified)) return false
192
+ if (Object.hasOwn(value, "total") && !isCount(value.total)) return false
193
+ return true
194
+ }
195
+
196
+ /**
197
+ * Validate an unknown value as a complete run snapshot.
198
+ *
199
+ * Mirrors {@link RunStateSchema}: all ten fields are required, optional
200
+ * string fields may be `null`, counts and timestamps must be non-negative
201
+ * integers, and unknown fields are rejected.
202
+ *
203
+ * @param value - Value received from an RPC call or an event.
204
+ * @returns `true` only for a snapshot matching the schema.
205
+ */
206
+ export function isCodeOpsRunState(value) {
207
+ if (!isPlainObject(value)) return false
208
+ const keys = Object.keys(value)
209
+ if (keys.length !== CODE_OPS_RUN_STATE_KEYS.length) return false
210
+ if (!keys.every((key) => CODE_OPS_RUN_STATE_KEYS.includes(key))) return false
211
+ return (
212
+ typeof value.plan === "string" &&
213
+ isNullableString(value.phase) &&
214
+ isNullableString(value.task) &&
215
+ isNullableString(value.detail) &&
216
+ CODE_OPS_ACTIVITIES.includes(value.activity) &&
217
+ isCount(value.verified) &&
218
+ (value.total === null || isCount(value.total)) &&
219
+ isCount(value.startedAt) &&
220
+ isCount(value.updatedAt) &&
221
+ typeof value.sessionID === "string"
222
+ )
223
+ }
224
+
225
+ /**
226
+ * Validate an unknown value as a cleared-event payload.
227
+ *
228
+ * @param value - Value received from a `cleared` event.
229
+ * @returns `true` only for a payload matching {@link RunClearedSchema}.
230
+ */
231
+ function isRunCleared(value) {
232
+ if (!isPlainObject(value)) return false
233
+ const keys = Object.keys(value)
234
+ if (keys.length !== 3) return false
235
+ return (
236
+ typeof value.plan === "string" &&
237
+ typeof value.sessionID === "string" &&
238
+ isCount(value.clearedAt)
239
+ )
240
+ }
241
+
242
+ /**
243
+ * Accept a validated run snapshot from an `updated` event.
244
+ *
245
+ * The event envelope must target the viewer's directory, and the event data
246
+ * must pass {@link isCodeOpsRunState}. A plain function (not a predicate) is
247
+ * used so the narrowed value is directly usable at the call site.
248
+ *
249
+ * @param event - Event envelope carrying `location.directory` and `data`.
250
+ * @param directory - The viewer's project directory.
251
+ * @returns The validated snapshot, or `null` when the event does not belong
252
+ * to the viewer or its data is invalid.
253
+ */
254
+ export function acceptRunUpdate(event, directory) {
255
+ if (event?.location?.directory !== directory) return null
256
+ return isCodeOpsRunState(event.data) ? event.data : null
257
+ }
258
+
259
+ /**
260
+ * Accept a validated cleared payload from a `cleared` event.
261
+ *
262
+ * @param event - Event envelope carrying `location.directory` and `data`.
263
+ * @param directory - The viewer's project directory.
264
+ * @returns The validated cleared identity, or `null` when the event does not
265
+ * belong to the viewer or its data is invalid.
266
+ */
267
+ export function acceptRunCleared(event, directory) {
268
+ if (event?.location?.directory !== directory) return null
269
+ return isRunCleared(event.data) ? event.data : null
270
+ }
271
+
272
+ /**
273
+ * Normalize an unknown progress report into the merge-ready form.
274
+ *
275
+ * Text fields are trimmed and truncated at {@link CODE_OPS_STRING_CAP};
276
+ * whitespace-only optional strings become `null` (an explicit clear). Counts
277
+ * are floored and clamped to `0…CODE_OPS_COUNT_MAX`. Only fields present in
278
+ * the input appear in the result, so the runtime can distinguish "absent,
279
+ * carry the current value" from "present, replace it".
280
+ *
281
+ * @param value - Value received from the tool handler.
282
+ * @returns The normalized report, or `null` when the shape is not a valid
283
+ * report (the caller must treat `null` as a rejection).
284
+ */
285
+ export function normalizeProgressReport(value) {
286
+ if (!isPlainObject(value)) return null
287
+ if (!Object.keys(value).every((key) => CODE_OPS_REPORT_KEYS.includes(key))) return null
288
+ if (typeof value.plan !== "string") return null
289
+ if (!CODE_OPS_ACTIVITIES.includes(value.activity)) return null
290
+
291
+ const plan = truncateText(value.plan.trim())
292
+ if (plan === "") return null
293
+
294
+ const normalized = { plan, activity: value.activity }
295
+ for (const key of ["phase", "task", "detail"]) {
296
+ if (!Object.hasOwn(value, key)) continue
297
+ if (typeof value[key] !== "string") return null
298
+ const text = truncateText(value[key].trim())
299
+ normalized[key] = text === "" ? null : text
300
+ }
301
+ for (const key of ["verified", "total"]) {
302
+ if (!Object.hasOwn(value, key)) continue
303
+ if (typeof value[key] !== "number" || !Number.isFinite(value[key])) return null
304
+ normalized[key] = Math.min(CODE_OPS_COUNT_MAX, Math.max(0, Math.floor(value[key])))
305
+ }
306
+ return normalized
307
+ }
308
+
309
+ /**
310
+ * Truncate free text at the string cap.
311
+ *
312
+ * @param text - The (already trimmed) text.
313
+ * @returns The text, at most {@link CODE_OPS_STRING_CAP} characters long.
314
+ */
315
+ function truncateText(text) {
316
+ return text.length > CODE_OPS_STRING_CAP ? text.slice(0, CODE_OPS_STRING_CAP) : text
317
+ }
318
+
319
+ /**
320
+ * Build the display label for a snapshot's activity.
321
+ *
322
+ * Four activities render as their own word. `delegating`, `waiting`, and
323
+ * `blocked` attach the reported detail when one is present, so the sidebar
324
+ * shows where a delegation went, what the run waits for, or why it stopped.
325
+ *
326
+ * @param snapshot - The run snapshot.
327
+ * @returns The activity label for the second display line.
328
+ */
329
+ function activityLabel(snapshot) {
330
+ switch (snapshot.activity) {
331
+ case "delegating":
332
+ return snapshot.detail === null ? "delegating" : `delegating to ${snapshot.detail}`
333
+ case "waiting":
334
+ return snapshot.detail === null ? "waiting" : `waiting (${snapshot.detail})`
335
+ case "blocked":
336
+ return snapshot.detail === null ? "blocked" : `blocked: ${snapshot.detail}`
337
+ default:
338
+ return snapshot.activity
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Format a snapshot timestamp as local `HH:MM` clock text.
344
+ *
345
+ * The viewer's local time zone applies: exact for a same-host client,
346
+ * approximate when the server runs elsewhere.
347
+ *
348
+ * @param ms - Epoch milliseconds (server-stamped).
349
+ * @returns The local time as `HH:MM`.
350
+ */
351
+ function formatLocalTime(ms) {
352
+ const date = new Date(ms)
353
+ const hours = String(date.getHours()).padStart(2, "0")
354
+ const minutes = String(date.getMinutes()).padStart(2, "0")
355
+ return `${hours}:${minutes}`
356
+ }
357
+
358
+ /**
359
+ * Create one in-memory run-state runtime.
360
+ *
361
+ * The runtime holds a single active run: the first accepted report creates it,
362
+ * same-plan reports merge into it field by field, and a report for a different
363
+ * plan replaces it (last reporter owns). Every accepted report emits the new
364
+ * snapshot to a bound emitter; clearing emits the cleared identity.
365
+ *
366
+ * @returns {object} The runtime: `report`, `snapshot`, `clearSession`, and
367
+ * `bindEmit`.
368
+ */
369
+ export function createCodeOpsProgress() {
370
+ let current = null
371
+ let emitFn = null
372
+
373
+ /**
374
+ * Emit one event through the bound emitter, swallowing every failure.
375
+ *
376
+ * A missing binding, a synchronous throw, and a rejected promise all leave
377
+ * the report or clear successful — the state is already truthful.
378
+ *
379
+ * @param name - Event name (`updated` or `cleared`).
380
+ * @param payload - Event data.
381
+ */
382
+ function emitEvent(name, payload) {
383
+ if (emitFn === null) return
384
+ try {
385
+ const result = emitFn(name, payload)
386
+ if (result !== null && typeof result === "object" && typeof result.then === "function") {
387
+ result.then(undefined, () => {})
388
+ }
389
+ } catch {
390
+ // An emitter failure never breaks a report or a clear.
391
+ }
392
+ }
393
+
394
+ return {
395
+ /**
396
+ * Normalize and apply one progress report.
397
+ *
398
+ * @param input - The raw report; rejected when it does not normalize.
399
+ * @param sessionID - The reporting session, recorded as the run's owner.
400
+ * @param nowMs - Server time override for tests; defaults to `Date.now()`.
401
+ * @returns The resulting snapshot, or `null` when the report is rejected
402
+ * (the state is then untouched and no event is emitted).
403
+ */
404
+ report(input, sessionID, nowMs) {
405
+ const normalized = normalizeProgressReport(input)
406
+ if (normalized === null) return null
407
+ const now = nowMs ?? Date.now()
408
+ if (current === null || current.plan !== normalized.plan) {
409
+ current = {
410
+ plan: normalized.plan,
411
+ phase: Object.hasOwn(normalized, "phase") ? normalized.phase : null,
412
+ task: Object.hasOwn(normalized, "task") ? normalized.task : null,
413
+ detail: Object.hasOwn(normalized, "detail") ? normalized.detail : null,
414
+ activity: normalized.activity,
415
+ verified: Object.hasOwn(normalized, "verified") ? normalized.verified : 0,
416
+ total: Object.hasOwn(normalized, "total") ? normalized.total : null,
417
+ startedAt: now,
418
+ updatedAt: now,
419
+ sessionID,
420
+ }
421
+ } else {
422
+ const merged = { ...current, activity: normalized.activity, updatedAt: now, sessionID }
423
+ for (const key of ["phase", "task", "detail", "verified", "total"]) {
424
+ if (Object.hasOwn(normalized, key)) merged[key] = normalized[key]
425
+ }
426
+ current = merged
427
+ }
428
+ emitEvent("updated", current)
429
+ return current
430
+ },
431
+
432
+ /**
433
+ * Read the current snapshot.
434
+ *
435
+ * @returns The snapshot, or `null` when no run is active.
436
+ */
437
+ snapshot() {
438
+ return current
439
+ },
440
+
441
+ /**
442
+ * Clear the active run when the session owns it.
443
+ *
444
+ * @param sessionID - The session requesting the clear.
445
+ * @returns The cleared identity `{ plan, sessionID, clearedAt }`, or
446
+ * `null` when no run exists or a different session owns it.
447
+ */
448
+ clearSession(sessionID) {
449
+ if (current === null || current.sessionID !== sessionID) return null
450
+ const cleared = { plan: current.plan, sessionID: current.sessionID, clearedAt: Date.now() }
451
+ current = null
452
+ emitEvent("cleared", cleared)
453
+ return cleared
454
+ },
455
+
456
+ /**
457
+ * Bind the event emitter used by every later report and clear.
458
+ *
459
+ * @param emit - `(name, payload) => void|Promise<void>`; a non-function
460
+ * value unbinds emission without failing the runtime.
461
+ */
462
+ bindEmit(emit) {
463
+ emitFn = typeof emit === "function" ? emit : null
464
+ },
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Pick the newer of two snapshots for a monotonic view update.
470
+ *
471
+ * An equal `updatedAt` lets the incoming snapshot win so the view converges
472
+ * on the latest delivery.
473
+ *
474
+ * @param current - The snapshot currently displayed, or `null`.
475
+ * @param incoming - The snapshot that just arrived.
476
+ * @returns `incoming` when it is at least as new, otherwise `current`.
477
+ */
478
+ export function mergeRunState(current, incoming) {
479
+ if (current === null) return incoming
480
+ return incoming.updatedAt >= current.updatedAt ? incoming : current
481
+ }
482
+
483
+ /**
484
+ * Test whether a run has not been updated for ten minutes.
485
+ *
486
+ * @param snapshot - The run snapshot (only `updatedAt` is read).
487
+ * @param nowMs - The viewer's current time.
488
+ * @returns `true` when the last update is {@link CODE_OPS_STALE_MS} old or older.
489
+ */
490
+ export function isRunStale(snapshot, nowMs) {
491
+ return nowMs - snapshot.updatedAt >= CODE_OPS_STALE_MS
492
+ }
493
+
494
+ /**
495
+ * Render the visible sidebar lines for a run snapshot.
496
+ *
497
+ * The function is the single source of the displayed text; the TUI renders
498
+ * these strings as plain text. The as-of time refreshes whenever the
499
+ * component re-renders — there are no timers — so a longer-idle run shows
500
+ * the stale suffix on its next render.
501
+ *
502
+ * @param snapshot - The run snapshot.
503
+ * @param nowMs - The viewer's current time, used only for the stale check.
504
+ * @returns The three display lines and the stale flag.
505
+ */
506
+ export function describeRun(snapshot, nowMs) {
507
+ const stale = isRunStale(snapshot, nowMs)
508
+ const activityText = activityLabel(snapshot)
509
+ const lines = [
510
+ `CodeOps · ${snapshot.plan}`,
511
+ snapshot.phase === null ? activityText : `${snapshot.phase} · ${activityText}`,
512
+ ]
513
+ const segments = []
514
+ if (snapshot.task !== null) segments.push(snapshot.task)
515
+ if (snapshot.total !== null) segments.push(`${snapshot.verified}/${snapshot.total} verified`)
516
+ segments.push(`as of ${formatLocalTime(snapshot.updatedAt)}${stale ? " (stale)" : ""}`)
517
+ lines.push(segments.join(" · "))
518
+ return { lines, stale }
519
+ }
520
+
521
+ /**
522
+ * Register the agent-callable progress tool on a plugin context,
523
+ * feature-detected and never-throwing.
524
+ *
525
+ * The handler validates defensively through the runtime and always answers
526
+ * with the boolean acknowledgement shape: a rejected report yields
527
+ * `{ ok: false }` without throwing, so a reporting mistake can never break an
528
+ * agent turn. Registration failures are contained the same way as the RPC
529
+ * guard: a missing `tool.transform`, a throwing transform, and any other
530
+ * failure all report `false`.
531
+ *
532
+ * @param ctx - Server plugin context; only `tool.transform` is read.
533
+ * @param runtime - The run-state runtime the handler reports into.
534
+ * @returns `true` when the transform completed, `false` when the host lacks
535
+ * the tool API or the registration failed. Never throws.
536
+ */
537
+ export async function registerCodeOpsProgressTool(ctx, runtime) {
538
+ if (typeof ctx?.tool?.transform !== "function") return false
539
+ try {
540
+ await ctx.tool.transform((tool) =>
541
+ tool.add({
542
+ name: CODE_OPS_TOOL_NAME,
543
+ description: CODE_OPS_TOOL_DESCRIPTION,
544
+ input: ProgressReportSchema,
545
+ output: ProgressOutputSchema,
546
+ // The host adapter wraps the handler in a promise constructor and
547
+ // rejects a synchronous return, so the handler must be async even
548
+ // though it never awaits.
549
+ async execute(input, toolContext) {
550
+ try {
551
+ const accepted = runtime?.report(input, toolContext?.sessionID ?? "") ?? null
552
+ return { output: { ok: accepted !== null } }
553
+ } catch {
554
+ // A reporting failure must never break an agent turn.
555
+ return { output: { ok: false } }
556
+ }
557
+ },
558
+ })
559
+ )
560
+ return true
561
+ } catch {
562
+ return false
563
+ }
564
+ }