opencode-deja 0.21.4 → 0.21.5

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.
Files changed (5) hide show
  1. package/README.md +18 -1
  2. package/index.js +139 -72
  3. package/lib.js +85 -3
  4. package/package.json +4 -2
  5. package/server.js +122 -0
package/README.md CHANGED
@@ -20,6 +20,14 @@ embeddings, no network path unless you ask for one.
20
20
  }
21
21
  ```
22
22
 
23
+ That is opencode 1.x. On 2.x the list is `plugins`:
24
+
25
+ ```json
26
+ {
27
+ "plugins": ["opencode-deja"]
28
+ }
29
+ ```
30
+
23
31
  opencode installs the package on start. The deja binary comes with it, but a
24
32
  deja you installed yourself always wins — see [Which binary](#which-binary).
25
33
 
@@ -45,7 +53,8 @@ Six tools the model can call:
45
53
  And recall nobody has to ask for:
46
54
 
47
55
  - the project's recent sessions are pushed onto the system prompt once per
48
- session, with a one-time toast so you know memory arrived;
56
+ session, with a one-time toast so you know memory arrived (1.x only: a
57
+ 2.x server plugin has no channel to the TUI);
49
58
  - each prompt gets a relevance pass of its own — silent when nothing matches;
50
59
  - a spawned agent gets recall in its own instructions, since it never sees the
51
60
  system prompt or a user prompt;
@@ -63,6 +72,14 @@ And recall nobody has to ask for:
63
72
  }
64
73
  ```
65
74
 
75
+ On 2.x:
76
+
77
+ ```json
78
+ {
79
+ "plugins": [{ "package": "opencode-deja", "options": { "autoRecall": false, "tools": true } }]
80
+ }
81
+ ```
82
+
66
83
  - `autoRecall` (default `true`) — the hooks above. Turn off to keep only
67
84
  the tools.
68
85
  - `tools` (default `true`) — the six tools. They are skipped on their own when
package/index.js CHANGED
@@ -5,6 +5,9 @@
5
5
  // this machine, including months before deja existed. The index is deja's; this
6
6
  // file is the seam: six tools the model can call, plus recall that arrives
7
7
  // without being asked for.
8
+ //
9
+ // This is the 1.x plugin. opencode 2.x loads server.js, which runs this same
10
+ // function and wires its hooks to 2.x's seams.
8
11
 
9
12
  import { createRequire } from "node:module"
10
13
  import { execFile } from "node:child_process"
@@ -22,6 +25,8 @@ import {
22
25
  contributions,
23
26
  lastUserText,
24
27
  mcpWired,
28
+ TOOL_SPECS,
29
+ zodTools,
25
30
  } from "./lib.js"
26
31
 
27
32
  const require = createRequire(import.meta.url)
@@ -99,6 +104,9 @@ async function run(bin, args, input, cwd, timeout = 20000) {
99
104
  maxBuffer: 8 * 1024 * 1024,
100
105
  })
101
106
  if (input !== undefined) {
107
+ // deja can exit before it reads stdin; unhandled, the EPIPE that
108
+ // follows would take opencode down. The generated plugin does the same.
109
+ child.child.stdin.on("error", () => {})
102
110
  child.child.stdin.end(input)
103
111
  }
104
112
  const { stdout } = await child
@@ -158,6 +166,27 @@ export const DejaPlugin = async ({ client, directory }, options = {}) => {
158
166
  // again. Counted, so a store that really is empty is still only asked a few
159
167
  // times.
160
168
  const empties = new Map()
169
+ // Every session the per-prompt hook stamped live. opencode never says a
170
+ // session ended, so without ending them here the stamp sat out its whole
171
+ // window and the next session's MCP recall left a finished one out (#4546).
172
+ const live = new Set()
173
+ const endSession = (id) => ask(["hook-session-end"], JSON.stringify({ session_id: id }), 5000)
174
+ // The session that spawned each one, asked of opencode once. A task
175
+ // sub-agent's digest and recall led with its parent, which is live and
176
+ // asking through it (#4548).
177
+ const parents = new Map()
178
+ const parentOf = async (id) => {
179
+ if (!id) return ""
180
+ if (!parents.has(id)) {
181
+ let parent = ""
182
+ try {
183
+ const res = await client?.session?.get?.({ path: { id } })
184
+ parent = res?.data?.parentID || ""
185
+ } catch {}
186
+ parents.set(id, parent)
187
+ }
188
+ return parents.get(id)
189
+ }
161
190
 
162
191
  const hooks = {}
163
192
 
@@ -169,74 +198,80 @@ export const DejaPlugin = async ({ client, directory }, options = {}) => {
169
198
  // copy `deja install` keeps current; this package fills the gaps.
170
199
  const adds = contributions(installerWiring(), config)
171
200
 
172
- if (adds.tools && tool) {
173
- const schema = tool.schema
174
- hooks.tool = {
175
- deja_recall: tool({
176
- description:
177
- "Search this machine's own past AI coding sessions — every agent used on it, including months before deja was installed. Use before debugging an error or re-implementing anything that may already exist. Match on the most specific token available: an exact error string, function name, file path or flag.",
178
- args: {
179
- query: schema.string().describe("Specific tokens to match. Several words are ANDed."),
180
- limit: schema.number().optional().describe("How many sessions to return. Default 5."),
181
- },
182
- async execute(args) {
183
- const limit = String(clampLimit(args.limit))
184
- return answer(await ask(argv("search", ["--json", "--limit", limit], args.query)))
185
- },
186
- }),
187
- deja_session: tool({
188
- description:
189
- "A full digest of the single best-matching past session — what was tried, what was decided, what it cost. Use after deja_recall when the reasoning behind an earlier decision matters, not just that it happened.",
190
- args: {
191
- query: schema.string().describe("A query, or a session id prefix returned by deja_recall."),
192
- },
193
- async execute(args) {
194
- return answer(await ask(argv("ctx", [], args.query)))
195
- },
196
- }),
197
- deja_blame: tool({
198
- description:
199
- "The past sessions that discussed a file, so you know why it is shaped the way it is before editing, refactoring or deleting it. Session history, not git authorship.",
200
- args: {
201
- path: schema.string().describe("Path to the file, absolute or relative to the project."),
202
- },
203
- async execute(args) {
204
- return answer(await ask(argv("blame", ["--json"], args.path)))
205
- },
206
- }),
207
- deja_fix: tool({
208
- description:
209
- "What this machine ran after that same error before, in the sessions where the error did not come back. Paste the failing output verbatim rather than a paraphrase — the match is on the error's own words.",
210
- args: {
211
- error: schema.string().describe("The failing output, copied as it was printed."),
212
- },
213
- async execute(args) {
214
- return answer(await ask(argv("fix", [], args.error)))
215
- },
216
- }),
217
- deja_how: tool({
218
- description:
219
- "The real invocation this machine uses for a build, test, deploy or script, with the flags it actually ran, ordered by how many sessions ran it. A guessed command is plausible and fails on this setup.",
220
- args: {
221
- what: schema.string().describe("The thing to run: a tool, a task, a script name."),
222
- },
223
- async execute(args) {
224
- return answer(await ask(argv("how", [], args.what)))
225
- },
226
- }),
227
- deja_remember: tool({
228
- description:
229
- "Store one durable decision once it is settled, as a single self-contained fact that will make sense months later. Not transcripts, not a summary of the conversation, and not anything already obvious from the code.",
230
- args: {
231
- text: schema.string().describe("The decision, in one or two sentences, with the reason it was taken."),
232
- },
233
- async execute(args) {
234
- const written = await ask(argv("remember", [], args.text))
235
- if (!installed) return MISSING
236
- return written || "deja did not record that."
237
- },
238
- }),
239
- }
201
+ // The six tools, described once. opencode 1.x takes them through its zod
202
+ // helper and 2.x as JSON Schema (server.js), so the arguments are plain
203
+ // data and each side builds its own.
204
+ const specs = {
205
+ deja_recall: {
206
+ description:
207
+ "Search this machine's own past AI coding sessions — every agent used on it, including months before deja was installed. Use before debugging an error or re-implementing anything that may already exist. Match on the most specific token available: an exact error string, function name, file path or flag.",
208
+ args: {
209
+ query: { type: "string", description: "Specific tokens to match. Several words are ANDed." },
210
+ limit: { type: "number", optional: true, description: "How many sessions to return. Default 5." },
211
+ },
212
+ async execute(args) {
213
+ const limit = String(clampLimit(args.limit))
214
+ return answer(await ask(argv("search", ["--json", "--limit", limit], args.query)))
215
+ },
216
+ },
217
+ deja_session: {
218
+ description:
219
+ "A full digest of the single best-matching past session — what was tried, what was decided, what it cost. Use after deja_recall when the reasoning behind an earlier decision matters, not just that it happened.",
220
+ args: {
221
+ query: { type: "string", description: "A query, or a session id prefix returned by deja_recall." },
222
+ },
223
+ async execute(args) {
224
+ return answer(await ask(argv("ctx", [], args.query)))
225
+ },
226
+ },
227
+ deja_blame: {
228
+ description:
229
+ "The past sessions that discussed a file, so you know why it is shaped the way it is before editing, refactoring or deleting it. Session history, not git authorship.",
230
+ args: {
231
+ path: { type: "string", description: "Path to the file, absolute or relative to the project." },
232
+ },
233
+ async execute(args) {
234
+ return answer(await ask(argv("blame", ["--json"], args.path)))
235
+ },
236
+ },
237
+ deja_fix: {
238
+ description:
239
+ "What this machine ran after that same error before, in the sessions where the error did not come back. Paste the failing output verbatim rather than a paraphrase — the match is on the error's own words.",
240
+ args: {
241
+ error: { type: "string", description: "The failing output, copied as it was printed." },
242
+ },
243
+ async execute(args) {
244
+ return answer(await ask(argv("fix", [], args.error)))
245
+ },
246
+ },
247
+ deja_how: {
248
+ description:
249
+ "The real invocation this machine uses for a build, test, deploy or script, with the flags it actually ran, ordered by how many sessions ran it. A guessed command is plausible and fails on this setup.",
250
+ args: {
251
+ what: { type: "string", description: "The thing to run: a tool, a task, a script name." },
252
+ },
253
+ async execute(args) {
254
+ return answer(await ask(argv("how", [], args.what)))
255
+ },
256
+ },
257
+ deja_remember: {
258
+ description:
259
+ "Store one durable decision once it is settled, as a single self-contained fact that will make sense months later. Not transcripts, not a summary of the conversation, and not anything already obvious from the code.",
260
+ args: {
261
+ text: { type: "string", description: "The decision, in one or two sentences, with the reason it was taken." },
262
+ },
263
+ async execute(args) {
264
+ const written = await ask(argv("remember", [], args.text))
265
+ if (!installed) return MISSING
266
+ return written || "deja did not record that."
267
+ },
268
+ },
269
+ }
270
+
271
+ if (adds.tools) {
272
+ // Under a symbol, so 1.x, which reads its hooks by name, never sees it.
273
+ hooks[TOOL_SPECS] = specs
274
+ if (tool) hooks.tool = zodTools(tool, specs)
240
275
  }
241
276
 
242
277
  if (!adds.recall) return hooks
@@ -248,7 +283,12 @@ export const DejaPlugin = async ({ client, directory }, options = {}) => {
248
283
  try {
249
284
  const key = input?.sessionID || "default"
250
285
  if (!digests.has(key)) {
251
- const { context, receipt } = contextText(await ask(["hook-context"], undefined, 30000))
286
+ // The session id, as the plugin `deja install opencode-auto` writes
287
+ // sends it: without it deja hands the session its own work back and
288
+ // never reads the digest cached for it (#4273). The parent rides
289
+ // along so the digest leaves it out too.
290
+ const payload = { session_id: input?.sessionID || "", parent_session_id: await parentOf(input?.sessionID), cwd }
291
+ const { context, receipt } = contextText(await ask(["hook-context"], JSON.stringify(payload), 30000))
252
292
  digests.set(key, context)
253
293
  // The receipt is the only sign the user gets that memory arrived. Once
254
294
  // per session: repeating it every turn is wallpaper. The hook's own
@@ -296,13 +336,18 @@ export const DejaPlugin = async ({ client, directory }, options = {}) => {
296
336
  hooks["experimental.chat.messages.transform"] = async (input, output) => {
297
337
  try {
298
338
  const { parts, prompt, sessionID } = lastUserText(output?.messages)
299
- if (!prompt) return
339
+ // A turn with no text, an image alone, still goes to hook-prompt: that
340
+ // call is what stamps the session live again after session.idle ended
341
+ // it, and without it the turn's MCP recall could return the session to
342
+ // itself (#4573). sessionID is empty when there is no user message.
343
+ if (!prompt && !sessionID) return
300
344
  // The session id lets recall skip what it already showed this session.
301
345
  // Without it every message re-injects the same block — measured on a real
302
346
  // store, half of all injections were a word-for-word repeat.
303
347
  const key = input?.sessionID || sessionID || ""
304
- const raw = await ask(["hook-prompt"], JSON.stringify({ prompt, session_id: key, cwd }))
305
- if (!raw) return
348
+ if (key) live.add(key)
349
+ const raw = await ask(["hook-prompt"], JSON.stringify({ prompt, session_id: key, parent_session_id: await parentOf(key), cwd }))
350
+ if (!prompt || !raw) return
306
351
  const extra = JSON.parse(raw)?.hookSpecificOutput?.additionalContext
307
352
  if (!extra) return
308
353
  parts[parts.length - 1].text += "\n\n" + extra
@@ -399,5 +444,27 @@ export const DejaPlugin = async ({ client, directory }, options = {}) => {
399
444
  }
400
445
  }
401
446
 
447
+ // A turn is over when opencode publishes session.idle, and the next prompt
448
+ // stamps the session again before the model can ask anything, so the session
449
+ // is history to everyone else in between.
450
+ hooks.event = async ({ event }) => {
451
+ try {
452
+ const id = event?.type === "session.idle" ? event.properties?.sessionID : ""
453
+ if (!id) return
454
+ await endSession(id)
455
+ // Ended, so dispose does not spawn deja for it again on the way out.
456
+ live.delete(id)
457
+ } catch {
458
+ // memory is optional: never break the session over it
459
+ }
460
+ }
461
+
462
+ // `opencode run` exits without waiting for the event above, and it does wait
463
+ // for dispose: a one-shot run ends what it started here.
464
+ hooks.dispose = async () => {
465
+ for (const id of live) await endSession(id)
466
+ live.clear()
467
+ }
468
+
402
469
  return hooks
403
470
  }
package/lib.js CHANGED
@@ -65,10 +65,13 @@ export function configPaths(env, home) {
65
65
  export function mcpWired(text) {
66
66
  try {
67
67
  const config = JSON.parse(stripJSONComments(String(text || "")))
68
- const entry = config && config.mcp && config.mcp.deja
68
+ // 1.x keeps servers directly under `mcp`, 2.x under `mcp.servers`.
69
+ const mcp = (config && config.mcp) || {}
70
+ const entry = mcp.deja || (mcp.servers && mcp.servers.deja)
69
71
  // A server kept in the file but switched off offers nothing, so the
70
- // tools here are not a second copy of it.
71
- return Boolean(entry) && entry.enabled !== false
72
+ // tools here are not a second copy of it. 1.x switches it off with
73
+ // `enabled: false`, 2.x with `disabled: true`.
74
+ return Boolean(entry) && entry.enabled !== false && entry.disabled !== true
72
75
  } catch {
73
76
  return false
74
77
  }
@@ -145,3 +148,82 @@ export function clampLimit(value, fallback = 5) {
145
148
  if (!Number.isFinite(asked)) return fallback
146
149
  return Math.min(20, Math.max(1, Math.trunc(asked)))
147
150
  }
151
+
152
+ // TOOL_SPECS is where the plugin leaves its tool list on the hooks it returns,
153
+ // for the 2.x entry to register. A symbol, so 1.x never reads it as a hook.
154
+ export const TOOL_SPECS = Symbol.for("opencode-deja.tools")
155
+
156
+ // zodTools builds the 1.x tool table from the specs, through opencode's own
157
+ // helper and the zod it re-exports.
158
+ export function zodTools(tool, specs) {
159
+ const schema = tool.schema
160
+ const out = {}
161
+ for (const [name, spec] of Object.entries(specs)) {
162
+ const args = {}
163
+ for (const [key, arg] of Object.entries(spec.args)) {
164
+ let field = arg.type === "number" ? schema.number() : schema.string()
165
+ if (arg.optional) field = field.optional()
166
+ args[key] = field.describe(arg.description)
167
+ }
168
+ out[name] = tool({ description: spec.description, args, execute: spec.execute })
169
+ }
170
+ return out
171
+ }
172
+
173
+ // jsonSchema is a spec's arguments in the form 2.x takes a tool's input.
174
+ export function jsonSchema(args) {
175
+ const properties = {}
176
+ const required = []
177
+ for (const [key, arg] of Object.entries(args)) {
178
+ properties[key] = { type: arg.type, description: arg.description }
179
+ if (!arg.optional) required.push(key)
180
+ }
181
+ return { type: "object", properties, required }
182
+ }
183
+
184
+ // turnEnded is the session whose turn a 2.x event says is over, or "". 2.0.22
185
+ // ends a turn with session.execution.succeeded, .failed or .interrupted and
186
+ // never publishes 1.x's session.idle, which its schema still carries (#4571).
187
+ export function turnEnded(event) {
188
+ switch (event?.type) {
189
+ case "session.execution.succeeded":
190
+ case "session.execution.failed":
191
+ case "session.execution.interrupted":
192
+ case "session.idle":
193
+ return event.data?.sessionID || ""
194
+ }
195
+ return ""
196
+ }
197
+
198
+ // v1Messages shows 2.x's request messages in the 1.x shape lastUserText reads.
199
+ // The parts are 2.x's own content objects, so text appended to one lands in
200
+ // the request.
201
+ export function v1Messages(messages, sessionID) {
202
+ const list = Array.isArray(messages) ? messages : []
203
+ return list.map((m) => ({
204
+ info: { role: m?.role, sessionID: sessionID || "" },
205
+ parts: Array.isArray(m?.content) ? m.content : [],
206
+ }))
207
+ }
208
+
209
+ // resultText is the text a finished 2.x tool call returned: `content` is a
210
+ // string or a list of blocks, and a tool may leave only `output`.
211
+ export function resultText(result) {
212
+ const r = result || {}
213
+ if (typeof r.content === "string") return r.content
214
+ if (Array.isArray(r.content)) {
215
+ return r.content
216
+ .filter((c) => c?.type === "text")
217
+ .map((c) => c.text)
218
+ .join("\n")
219
+ }
220
+ return typeof r.output === "string" ? r.output : ""
221
+ }
222
+
223
+ // v1ToolName is the 1.x name for a 2.x tool, where the two differ in a way the
224
+ // hooks look at: the spawn tool and the shell.
225
+ export function v1ToolName(name) {
226
+ if (name === "subagent") return "task"
227
+ if (name === "shell") return "bash"
228
+ return name
229
+ }
package/package.json CHANGED
@@ -1,15 +1,17 @@
1
1
  {
2
2
  "name": "opencode-deja",
3
- "version": "0.21.4",
3
+ "version": "0.21.5",
4
4
  "description": "deja-vu memory for opencode: the session history of thirty-four other coding agents, searchable and recalled on every request — a local index, no LLM.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {
8
8
  ".": "./index.js",
9
+ "./server": "./server.js",
9
10
  "./package.json": "./package.json"
10
11
  },
11
12
  "files": [
12
13
  "index.js",
14
+ "server.js",
13
15
  "lib.js",
14
16
  "README.md",
15
17
  "LICENSE"
@@ -50,7 +52,7 @@
50
52
  },
51
53
  "dependencies": {
52
54
  "@opencode-ai/plugin": ">=1.0.0",
53
- "@vshulcz/deja-vu": "^0.21.4"
55
+ "@vshulcz/deja-vu": "^0.21.5"
54
56
  },
55
57
  "scripts": {
56
58
  "test": "node --test test/*.test.js"
package/server.js ADDED
@@ -0,0 +1,122 @@
1
+ // The entry opencode loads as `opencode-deja/server`.
2
+ //
3
+ // opencode 2.x imports only a default export and requires `{ id, setup }` (or
4
+ // `effect`) on it; the named export in index.js fails its check with "Plugin
5
+ // must export a default definition with an id and an effect or setup
6
+ // function" (#4151). 1.x from 1.3.4 resolves the same `./server` subpath and
7
+ // wants `{ server }` on the default export, so this object carries both. 1.x
8
+ // before 1.3.4 never looks here and keeps loading index.js, which calls every
9
+ // export as a plugin and would choke on an object.
10
+ //
11
+ // setup runs the 1.x plugin and wires its hooks to 2.x's seams, so both majors
12
+ // run the same code: the session digest and the per-prompt recall go in on
13
+ // `session` "context", the pre-compaction index on "compaction", the spawn and
14
+ // after-tool lines on `tool` "execute.before" and "execute.after", the end of
15
+ // a turn on `event` "subscribe" and dispose on the cleanup setup returns.
16
+
17
+ import { DejaPlugin } from "./index.js"
18
+ import { jsonSchema, resultText, TOOL_SPECS, turnEnded, v1Messages, v1ToolName } from "./lib.js"
19
+
20
+ // A 2.x server plugin has no channel to the TUI, so the toasts go nowhere.
21
+ const noToast = { tui: { showToast: async () => {} } }
22
+
23
+ async function setup(ctx) {
24
+ const directory = ctx.location?.directory || process.cwd()
25
+ const hooks = await DejaPlugin({ client: noToast, directory }, ctx.options || {})
26
+
27
+ const specs = hooks[TOOL_SPECS]
28
+ if (specs) {
29
+ await ctx.tool.transform((tools) => {
30
+ for (const [name, spec] of Object.entries(specs)) {
31
+ tools.add({
32
+ name,
33
+ // Only a codemode: false tool is offered to the model directly; the
34
+ // rest sit behind the code-mode `execute` tool, as built-ins do not.
35
+ options: { codemode: false },
36
+ description: spec.description,
37
+ input: jsonSchema(spec.args),
38
+ execute: async (input) => ({ content: await spec.execute(input || {}) }),
39
+ })
40
+ }
41
+ })
42
+ }
43
+
44
+ const system = hooks["experimental.chat.system.transform"]
45
+ const prompt = hooks["experimental.chat.messages.transform"]
46
+ if (system || prompt) {
47
+ await ctx.session.hook("context", async (event) => {
48
+ try {
49
+ if (system) {
50
+ const out = { system: event.system.map((p) => p.text) }
51
+ await system({ sessionID: event.sessionID }, out)
52
+ if (event.system.length) {
53
+ if (out.system[0] !== event.system[0].text) event.system[0] = { ...event.system[0], text: out.system[0] }
54
+ } else if (out.system.length) {
55
+ event.system.push({ type: "text", text: out.system[0] })
56
+ }
57
+ }
58
+ if (prompt) {
59
+ await prompt({ sessionID: event.sessionID }, { messages: v1Messages(event.messages, event.sessionID) })
60
+ }
61
+ } catch {
62
+ // memory is optional: never break the session over it
63
+ }
64
+ })
65
+ }
66
+
67
+ const compacting = hooks["experimental.session.compacting"]
68
+ if (compacting) {
69
+ await ctx.session.hook("compaction", async () => {
70
+ await compacting()
71
+ })
72
+ }
73
+
74
+ const before = hooks["tool.execute.before"]
75
+ if (before) {
76
+ await ctx.tool.hook("execute.before", async (event) => {
77
+ const args = { ...(event.input || {}) }
78
+ await before({ tool: v1ToolName(event.tool), sessionID: event.sessionID }, { args })
79
+ if (args.prompt !== event.input?.prompt) event.input = args
80
+ })
81
+ }
82
+
83
+ const after = hooks["tool.execute.after"]
84
+ if (after) {
85
+ await ctx.tool.hook("execute.after", async (event) => {
86
+ if (event.status !== "completed") return
87
+ const text = resultText(event.result)
88
+ const output = { output: text }
89
+ await after({ tool: v1ToolName(event.tool), sessionID: event.sessionID, args: event.input || {} }, output)
90
+ if (output.output === text || !output.output.startsWith(text)) return
91
+ const extra = output.output.slice(text.length).trim()
92
+ const result = event.result || {}
93
+ const content = Array.isArray(result.content)
94
+ ? result.content
95
+ : typeof result.content === "string"
96
+ ? [{ type: "text", text: result.content }]
97
+ : text
98
+ ? [{ type: "text", text }]
99
+ : []
100
+ event.result = { ...result, content: [...content, { type: "text", text: extra }] }
101
+ })
102
+ }
103
+
104
+ // 2.x has no event hook or dispose in a plugin's table: the events come from
105
+ // ctx.event and the exit is the cleanup setup returns, which `opencode run`
106
+ // awaits. Without them a session stamped live was never ended (#4571).
107
+ const stop = new AbortController()
108
+ if (hooks.event && typeof ctx.event?.subscribe === "function") {
109
+ ;(async () => {
110
+ for await (const event of ctx.event.subscribe({ signal: stop.signal })) {
111
+ const id = turnEnded(event)
112
+ if (id) await hooks.event({ event: { type: "session.idle", properties: { sessionID: id } } })
113
+ }
114
+ })().catch(() => {})
115
+ }
116
+ return async () => {
117
+ stop.abort()
118
+ await hooks.dispose?.()
119
+ }
120
+ }
121
+
122
+ export default { id: "opencode-deja", setup, server: DejaPlugin }