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/CHANGELOG.md +87 -0
- package/README.md +11 -0
- 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/package.json +25 -2
- package/plugin/index.ts +55 -2
- package/plugin/tui.js +113 -0
- 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/build-tui.mjs +73 -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
- package/plugin/temp-lifecycle.spec.test.mjs +0 -55
|
@@ -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
|
+
}
|