opencode-codeops 2.2.0 → 2.3.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,66 @@
1
+ /**
2
+ * Type declarations for the CodeOps Discovery plugin integration.
3
+ *
4
+ * The module is plain JavaScript so `node --test` can exercise it directly;
5
+ * these declarations give the TypeScript plugin entry point typed access.
6
+ */
7
+
8
+ import type { DiscoveryRuntime } from "./discovery-server.mjs"
9
+
10
+ /** The project's Discovery settings. */
11
+ export interface DiscoverySettings {
12
+ enabled: boolean
13
+ chatMaxQuestions: number
14
+ }
15
+
16
+ /** The dependencies of one controller. */
17
+ export interface DiscoveryControllerDeps {
18
+ directory: string
19
+ dataDir: string
20
+ assetsDir: string
21
+ version: string
22
+ openBrowser?: (url: string) => boolean
23
+ branchOf?: (directory: string) => string | null
24
+ warn?: (message: string) => void
25
+ }
26
+
27
+ /** The result of a tool call. */
28
+ export interface DiscoveryToolResult {
29
+ ok: boolean
30
+ reason?: string
31
+ message?: string
32
+ [key: string]: unknown
33
+ }
34
+
35
+ /** The per-project Discovery controller. */
36
+ export interface DiscoveryController {
37
+ /** Re-read the setting and enable or disable Discovery. */
38
+ sync(): Promise<void>
39
+ isEnabled(): boolean
40
+ onSessionDeleted(sessionID: string): Promise<void>
41
+ /** Unregister, reload the tool list, and close the listener when idle. */
42
+ dispose(): Promise<void>
43
+ /** The runtime, or null while Discovery has never been enabled. */
44
+ readonly runtime: DiscoveryRuntime | null
45
+ /** Publish a plan round. */
46
+ publish(input: unknown, sessionID: string): Promise<DiscoveryToolResult>
47
+ /** Read submitted answers. */
48
+ answers(input: unknown, sessionID: string): Promise<DiscoveryToolResult>
49
+ /** Record or retire questions. */
50
+ resolve(input: unknown, sessionID: string): Promise<DiscoveryToolResult>
51
+ }
52
+
53
+ /** Read the project's Discovery settings. */
54
+ export declare function readDiscoveryConfig(directory: string): DiscoverySettings
55
+
56
+ /** Create the per-project controller; never throws. */
57
+ export declare function createDiscoveryController(
58
+ ctx: unknown,
59
+ deps: DiscoveryControllerDeps
60
+ ): DiscoveryController
61
+
62
+ /** Register the three agent tools; never throws. */
63
+ export declare function registerDiscoveryTools(
64
+ ctx: unknown,
65
+ controller: DiscoveryController
66
+ ): Promise<boolean>
@@ -0,0 +1,540 @@
1
+ /**
2
+ * Discovery plugin integration.
3
+ *
4
+ * Connects Discovery to OpenCode: the opt-in check, the controller lifecycle,
5
+ * the three agent tools, waking sessions, delivery, orphaning, and opening the
6
+ * browser. The plugin entry point wires this module behind feature detection.
7
+ *
8
+ * Everything here is defensive: a missing host API, an unreadable config, or a
9
+ * failing store call degrades to a documented result object. No handler throws
10
+ * into an agent turn.
11
+ *
12
+ * @module discovery-tools
13
+ */
14
+
15
+ import { execFile } from "node:child_process"
16
+ import { readFileSync } from "node:fs"
17
+ import { join } from "node:path"
18
+
19
+ import { DiscoveryPublishSchema, PLAN_KINDS } from "./discovery-rules.mjs"
20
+ import { createDiscoveryStore } from "./discovery-store.mjs"
21
+ import { getDiscoveryRuntime } from "./discovery-server.mjs"
22
+
23
+ /** The default question limit when the config names none. */
24
+ const DEFAULT_CHAT_MAX_QUESTIONS = 3
25
+
26
+ /** The name of the publish tool. */
27
+ const PUBLISH_TOOL = "codeops_discovery_publish"
28
+
29
+ /** The name of the answers tool. */
30
+ const ANSWERS_TOOL = "codeops_discovery_answers"
31
+
32
+ /** The name of the resolve tool. */
33
+ const RESOLVE_TOOL = "codeops_discovery_resolve"
34
+
35
+ /** The short tool descriptions shown to the agent. */
36
+ const DESCRIPTIONS = {
37
+ [PUBLISH_TOOL]:
38
+ "Publish a round of discovery questions and open the browser page. " +
39
+ "Read _shared/discovery.md before using it.",
40
+ [ANSWERS_TOOL]:
41
+ "Read the answers the user submitted for a published plan. " +
42
+ "Record them with codeops_discovery_resolve afterwards. " +
43
+ "Read _shared/discovery.md before using it.",
44
+ [RESOLVE_TOOL]:
45
+ "Record submitted answers as resolved (with their register ids) or retire questions. " +
46
+ "Read _shared/discovery.md before using it.",
47
+ }
48
+
49
+ /** The answers tool input schema. */
50
+ const ANSWERS_INPUT = {
51
+ type: "object",
52
+ additionalProperties: false,
53
+ required: ["artifact", "kind"],
54
+ properties: {
55
+ artifact: { type: "string", minLength: 1, maxLength: 300 },
56
+ kind: { type: "string", enum: [...PLAN_KINDS] },
57
+ },
58
+ }
59
+
60
+ /** The resolve tool input schema. */
61
+ const RESOLVE_INPUT = {
62
+ type: "object",
63
+ additionalProperties: false,
64
+ required: ["artifact", "kind"],
65
+ properties: {
66
+ artifact: { type: "string", minLength: 1, maxLength: 300 },
67
+ kind: { type: "string", enum: [...PLAN_KINDS] },
68
+ recorded: {
69
+ type: "array",
70
+ items: {
71
+ type: "object",
72
+ additionalProperties: false,
73
+ required: ["key", "as"],
74
+ properties: {
75
+ key: { type: "string", minLength: 1 },
76
+ as: { type: "string", minLength: 1 },
77
+ },
78
+ },
79
+ },
80
+ retire: { type: "array", items: { type: "string", minLength: 1 } },
81
+ },
82
+ }
83
+
84
+ /** A short failure result. */
85
+ function failure(reason, message, extra = {}) {
86
+ return { ok: false, reason, message, ...extra }
87
+ }
88
+
89
+ /** Open a URL with the platform's default browser without a shell. */
90
+ function defaultOpenBrowser(url) {
91
+ try {
92
+ const [command, args] =
93
+ process.platform === "darwin"
94
+ ? ["open", [url]]
95
+ : process.platform === "win32"
96
+ ? ["cmd", ["/c", "start", "", url]]
97
+ : ["xdg-open", [url]]
98
+ const child = execFile(command, args, { detached: true, stdio: "ignore" })
99
+ child.on("error", () => {})
100
+ child.unref()
101
+ return true
102
+ } catch {
103
+ return false
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Read the project's Discovery settings.
109
+ *
110
+ * An unreadable file, a missing `discovery` object, or a missing/non-boolean
111
+ * `enabled` means disabled. An invalid `chatMaxQuestions` falls back to 3
112
+ * without disabling Discovery.
113
+ *
114
+ * @param {string} directory - The project directory.
115
+ * @returns {{ enabled: boolean, chatMaxQuestions: number }} The settings.
116
+ */
117
+ export function readDiscoveryConfig(directory) {
118
+ let parsed
119
+ try {
120
+ parsed = JSON.parse(readFileSync(join(directory, "codeops", "codeops.json"), "utf8"))
121
+ } catch {
122
+ return { enabled: false, chatMaxQuestions: DEFAULT_CHAT_MAX_QUESTIONS }
123
+ }
124
+ const discovery = parsed?.discovery
125
+ if (discovery === null || typeof discovery !== "object" || Array.isArray(discovery)) {
126
+ return { enabled: false, chatMaxQuestions: DEFAULT_CHAT_MAX_QUESTIONS }
127
+ }
128
+ if (typeof discovery.enabled !== "boolean" || discovery.enabled !== true) {
129
+ return { enabled: false, chatMaxQuestions: DEFAULT_CHAT_MAX_QUESTIONS }
130
+ }
131
+ const limit =
132
+ Number.isInteger(discovery.chatMaxQuestions) && discovery.chatMaxQuestions >= 0
133
+ ? discovery.chatMaxQuestions
134
+ : DEFAULT_CHAT_MAX_QUESTIONS
135
+ return { enabled: true, chatMaxQuestions: limit }
136
+ }
137
+
138
+ /**
139
+ * Create the per-instance Discovery controller.
140
+ *
141
+ * The store and the process-wide runtime are created on the first **enabled**
142
+ * `sync()` and never while the project is disabled. Every method contains its
143
+ * own failures; the controller never throws to its caller.
144
+ *
145
+ * @param {unknown} ctx - The plugin context; only `tool`, `session`, and
146
+ * `location` are read.
147
+ * @param {{ directory: string, dataDir: string, assetsDir: string, version: string,
148
+ * openBrowser?: (url: string) => boolean,
149
+ * branchOf?: (directory: string) => string | null,
150
+ * warn?: (message: string) => void }} deps - Controller dependencies.
151
+ * @returns {object} The controller.
152
+ */
153
+ export function createDiscoveryController(ctx, deps) {
154
+ const openBrowser = typeof deps.openBrowser === "function" ? deps.openBrowser : defaultOpenBrowser
155
+ let runtime = null
156
+ let projectSlug = null
157
+ let unregister = null
158
+ let enabled = false
159
+ let disposed = false
160
+ let warnedAboutWake = false
161
+
162
+ /** Reload the tool list without letting a host failure escape. */
163
+ async function reloadTools() {
164
+ try {
165
+ await ctx?.tool?.reload?.()
166
+ } catch {
167
+ // The next request re-reads the transform.
168
+ }
169
+ }
170
+
171
+ /** The absolute page URL of a plan, or null. */
172
+ function pageUrl(planSlug) {
173
+ return projectSlug ? runtime?.planUrl(projectSlug, planSlug) ?? null : null
174
+ }
175
+
176
+ /** Find the registered project. */
177
+ async function project() {
178
+ if (!runtime) return null
179
+ return runtime.store.findProjectByDirectory(deps.directory)
180
+ }
181
+
182
+ /** Build the wake text for one plan's claimed submissions. */
183
+ async function wakeText(record, claimed) {
184
+ const projectRecord = await runtime.store.getProject(projectSlug)
185
+ const name = projectRecord?.name ?? "project"
186
+ const groups = new Set(claimed.flatMap((submission) => submission.groups))
187
+ const titles = record.groups.filter((group) => groups.has(group.key)).map((group) => group.title)
188
+ const count = claimed.reduce((total, submission) => total + submission.questions.length, 0)
189
+ const noun = count === 1 ? "answer" : "answers"
190
+ return `Discovery: ${count} ${noun} submitted for ${name} › ${record.title} (${titles.join(", ")}). Read them with ${ANSWERS_TOOL}.`
191
+ }
192
+
193
+ /**
194
+ * Wake the plan's owner with one message covering every claimed submission.
195
+ *
196
+ * Returns `woken` only after the prompt resolves and the claims are recorded.
197
+ * A missing session API, an unreachable owner, a live foreign claim, or a
198
+ * rejected prompt all leave the work pending.
199
+ */
200
+ async function deliver(planSlug) {
201
+ if (!runtime || !projectSlug || disposed) return "pending"
202
+ try {
203
+ const record = await runtime.store.getPlan(projectSlug, planSlug)
204
+ if (!record || !record.owner?.sessionID) return "pending"
205
+ const sessionID = record.owner.sessionID
206
+ if (typeof ctx?.session?.get !== "function" || typeof ctx?.session?.prompt !== "function") {
207
+ return "pending"
208
+ }
209
+ try {
210
+ await ctx.session.get({ sessionID })
211
+ } catch {
212
+ return "pending"
213
+ }
214
+ const pending = await runtime.store.pendingDeliveries(projectSlug)
215
+ const entry = pending.find((item) => item.planSlug === planSlug)
216
+ if (!entry) return "pending"
217
+ const claimed = []
218
+ try {
219
+ for (const submission of entry.submissions) {
220
+ if (await runtime.store.claimDelivery(projectSlug, planSlug, submission.id, sessionID)) {
221
+ claimed.push(submission)
222
+ }
223
+ }
224
+ if (claimed.length === 0) return "pending"
225
+ const text = await wakeText(record, claimed)
226
+ await ctx.session.prompt({ sessionID, text, delivery: "queue" })
227
+ } catch {
228
+ for (const submission of claimed) {
229
+ try {
230
+ await runtime.store.releaseDelivery(projectSlug, planSlug, submission.id)
231
+ } catch {
232
+ // A refused file stays claimed until its pid is gone.
233
+ }
234
+ }
235
+ return "pending"
236
+ }
237
+ try {
238
+ await runtime.store.markDelivered(
239
+ projectSlug,
240
+ planSlug,
241
+ claimed.map((submission) => submission.id),
242
+ sessionID,
243
+ )
244
+ for (const submission of claimed) {
245
+ runtime.notify(projectSlug, planSlug, "delivered", { submissionId: submission.id })
246
+ }
247
+ return "woken"
248
+ } catch {
249
+ return "pending"
250
+ }
251
+ } catch {
252
+ return "pending"
253
+ }
254
+ }
255
+
256
+ /** Deliver every plan that has pending work. */
257
+ async function deliverPending() {
258
+ if (!runtime || !projectSlug) return
259
+ try {
260
+ const pending = await runtime.store.pendingDeliveries(projectSlug)
261
+ for (const entry of pending) await deliver(entry.planSlug)
262
+ } catch {
263
+ // Delivery is best effort at every point.
264
+ }
265
+ }
266
+
267
+ /** Turn Discovery on for this project. */
268
+ async function enable() {
269
+ try {
270
+ if (!runtime) {
271
+ const store = createDiscoveryStore({ dataDir: deps.dataDir })
272
+ runtime = getDiscoveryRuntime({
273
+ store,
274
+ assetsDir: deps.assetsDir,
275
+ version: deps.version,
276
+ })
277
+ }
278
+ const branch =
279
+ typeof deps.branchOf === "function" ? deps.branchOf(deps.directory) : null
280
+ const record = await runtime.store.registerProject({
281
+ directory: deps.directory,
282
+ branch,
283
+ repository: {
284
+ id: String(ctx?.location?.project?.id ?? deps.directory),
285
+ canonical: String(ctx?.location?.project?.canonical ?? deps.directory),
286
+ },
287
+ })
288
+ projectSlug = record.slug
289
+ await runtime.ensureListening()
290
+ unregister = runtime.registerProject(deps.directory, record.slug, deliver)
291
+ enabled = true
292
+ if (
293
+ !warnedAboutWake &&
294
+ (typeof ctx?.session?.get !== "function" || typeof ctx?.session?.prompt !== "function")
295
+ ) {
296
+ warnedAboutWake = true
297
+ if (typeof deps.warn === "function") {
298
+ deps.warn("Discovery cannot wake sessions in this OpenCode build.")
299
+ }
300
+ }
301
+ await deliverPending()
302
+ await reloadTools()
303
+ } catch {
304
+ enabled = false
305
+ unregister = null
306
+ }
307
+ }
308
+
309
+ /** Turn Discovery off for this project without blocking the hook. */
310
+ async function disable() {
311
+ if (unregister) {
312
+ try {
313
+ unregister()
314
+ } catch {
315
+ // The registration may already be gone.
316
+ }
317
+ }
318
+ unregister = null
319
+ enabled = false
320
+ projectSlug = null
321
+ if (runtime) {
322
+ try {
323
+ void runtime.closeIfIdle()
324
+ } catch {
325
+ // Nothing is listening.
326
+ }
327
+ }
328
+ await reloadTools()
329
+ }
330
+
331
+ return {
332
+ get runtime() {
333
+ return runtime
334
+ },
335
+
336
+ isEnabled() {
337
+ return enabled
338
+ },
339
+
340
+ async sync() {
341
+ if (disposed) return
342
+ const config = readDiscoveryConfig(deps.directory)
343
+ if (config.enabled === enabled) return
344
+ if (config.enabled) await enable()
345
+ else await disable()
346
+ },
347
+
348
+ async onSessionDeleted(sessionID) {
349
+ if (!runtime || !projectSlug) return
350
+ try {
351
+ await runtime.store.markOrphaned(projectSlug, sessionID)
352
+ } catch {
353
+ // Orphaning is best effort; a later publish can still take over.
354
+ }
355
+ },
356
+
357
+ async dispose() {
358
+ disposed = true
359
+ if (unregister) {
360
+ try {
361
+ unregister()
362
+ } catch {
363
+ // Already unregistered.
364
+ }
365
+ }
366
+ unregister = null
367
+ enabled = false
368
+ await reloadTools()
369
+ if (runtime) await runtime.closeIfIdle()
370
+ },
371
+
372
+ /** Publish a plan round on behalf of the agent. */
373
+ async publish(input, sessionID) {
374
+ try {
375
+ if (!enabled || !runtime) {
376
+ return failure("disabled", "Discovery is disabled for this project.")
377
+ }
378
+ const listening = await runtime.ensureListening()
379
+ if (!listening) {
380
+ return failure("unavailable", "Discovery is unavailable on this machine; ask in chat.")
381
+ }
382
+ const projectRecord = await project()
383
+ if (!projectRecord) return failure("not-found", "This project is not registered.")
384
+ const result = await runtime.store.publish(projectRecord.slug, input, sessionID)
385
+ if (!result.ok) {
386
+ if (result.reason === "invalid") {
387
+ return failure("invalid", "The publish payload is invalid.", {
388
+ errors: result.errors,
389
+ })
390
+ }
391
+ return failure("owned", "This plan is owned by another session.", {
392
+ owner: result.owner,
393
+ })
394
+ }
395
+ const url = pageUrl(result.plan.slug)
396
+ const opened = !runtime.hasViewer(projectRecord.slug, result.plan.slug) && Boolean(openBrowser(url))
397
+ runtime.notify(projectRecord.slug, result.plan.slug, "changed", {
398
+ revision: result.plan.revision,
399
+ })
400
+ await deliver(result.plan.slug)
401
+ return {
402
+ ok: true,
403
+ url,
404
+ plan: {
405
+ slug: result.plan.slug,
406
+ title: result.plan.title,
407
+ kind: result.plan.kind,
408
+ artifact: result.plan.artifact,
409
+ revision: result.plan.revision,
410
+ },
411
+ revision: result.plan.revision,
412
+ opened,
413
+ fallbackPort: listening.fallback ? Number(new URL(listening.url).port) : null,
414
+ warnings: result.warnings,
415
+ }
416
+ } catch {
417
+ return failure("error", "Discovery could not publish this plan.")
418
+ }
419
+ },
420
+
421
+ /** Read the submitted answers for a plan. */
422
+ async answers(input, sessionID) {
423
+ try {
424
+ if (!enabled || !runtime) {
425
+ return failure("disabled", "Discovery is disabled for this project.")
426
+ }
427
+ const projectRecord = await project()
428
+ if (!projectRecord) return failure("not-found", "This project is not registered.")
429
+ const plan = await runtime.store.findPlan(projectRecord.slug, input.kind, input.artifact)
430
+ if (!plan) return failure("not-found", "No published plan matches that artifact.")
431
+ const result = await runtime.store.readAnswers(projectRecord.slug, plan.slug, sessionID)
432
+ if (!result.ok) {
433
+ if (result.reason === "not-found") {
434
+ return failure("not-found", "The plan no longer exists.")
435
+ }
436
+ return failure("owned", "This plan is owned by another session.", {
437
+ owner: result.owner,
438
+ })
439
+ }
440
+ runtime.notify(projectRecord.slug, plan.slug, "changed", {
441
+ revision: result.plan.revision,
442
+ })
443
+ return {
444
+ ok: true,
445
+ plan: {
446
+ slug: result.plan.slug,
447
+ title: result.plan.title,
448
+ revision: result.plan.revision,
449
+ },
450
+ submissions: result.submissions,
451
+ }
452
+ } catch {
453
+ return failure("error", "Discovery could not read the answers.")
454
+ }
455
+ },
456
+
457
+ /** Record or retire questions for a plan. */
458
+ async resolve(input, sessionID) {
459
+ try {
460
+ if (!enabled || !runtime) {
461
+ return failure("disabled", "Discovery is disabled for this project.")
462
+ }
463
+ const projectRecord = await project()
464
+ if (!projectRecord) return failure("not-found", "This project is not registered.")
465
+ const plan = await runtime.store.findPlan(projectRecord.slug, input.kind, input.artifact)
466
+ if (!plan) return failure("not-found", "No published plan matches that artifact.")
467
+ const result = await runtime.store.resolve(projectRecord.slug, plan.slug, sessionID, {
468
+ recorded: input.recorded,
469
+ retire: input.retire,
470
+ })
471
+ if (!result.ok) {
472
+ if (result.reason === "not-found") {
473
+ return failure("not-found", "The plan no longer exists.")
474
+ }
475
+ if (result.reason === "owned") {
476
+ return failure("owned", "This plan is owned by another session.", {
477
+ owner: result.owner,
478
+ })
479
+ }
480
+ return failure("invalid", "The resolve input is invalid.", {
481
+ ...(result.errors === undefined ? {} : { errors: result.errors }),
482
+ })
483
+ }
484
+ runtime.notify(projectRecord.slug, plan.slug, "changed", {
485
+ revision: plan.revision,
486
+ })
487
+ return { ok: true, recorded: result.recorded, retired: result.retired }
488
+ } catch {
489
+ return failure("error", "Discovery could not record the answers.")
490
+ }
491
+ },
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Register the three Discovery tools on a plugin context.
497
+ *
498
+ * The transform adds the tools only while the controller is enabled, so a
499
+ * disabled project behaves exactly as before. Registration never throws and
500
+ * returns a boolean, like the progress tool.
501
+ *
502
+ * @param {unknown} ctx - The plugin context.
503
+ * @param {object} controller - The Discovery controller.
504
+ * @returns {Promise<boolean>} True when the transform completed.
505
+ */
506
+ export async function registerDiscoveryTools(ctx, controller) {
507
+ if (typeof ctx?.tool?.transform !== "function") return false
508
+ try {
509
+ await ctx.tool.transform((tool) => {
510
+ if (!controller.isEnabled()) return
511
+ tool.add({
512
+ name: PUBLISH_TOOL,
513
+ description: DESCRIPTIONS[PUBLISH_TOOL],
514
+ input: DiscoveryPublishSchema,
515
+ async execute(input, toolContext) {
516
+ return { output: await controller.publish(input, toolContext?.sessionID ?? "") }
517
+ },
518
+ })
519
+ tool.add({
520
+ name: ANSWERS_TOOL,
521
+ description: DESCRIPTIONS[ANSWERS_TOOL],
522
+ input: ANSWERS_INPUT,
523
+ async execute(input, toolContext) {
524
+ return { output: await controller.answers(input, toolContext?.sessionID ?? "") }
525
+ },
526
+ })
527
+ tool.add({
528
+ name: RESOLVE_TOOL,
529
+ description: DESCRIPTIONS[RESOLVE_TOOL],
530
+ input: RESOLVE_INPUT,
531
+ async execute(input, toolContext) {
532
+ return { output: await controller.resolve(input, toolContext?.sessionID ?? "") }
533
+ },
534
+ })
535
+ })
536
+ return true
537
+ } catch {
538
+ return false
539
+ }
540
+ }