backeer-opencode 0.2.3 → 0.2.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backeer-opencode",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Backeer lifecycle notifications and remote permission decisions for OpenCode",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -20,7 +20,8 @@
20
20
  ],
21
21
  "scripts": {
22
22
  "build": "esbuild src/index.js --bundle --platform=node --format=esm --target=node18 --outfile=dist/backeer-permission.js --legal-comments=none",
23
- "check": "node --check bin/backeer-opencode.js && npm test",
23
+ "check": "npm run lint && node --check bin/backeer-opencode.js && npm test",
24
+ "lint": "eslint src bin test eslint.config.js --max-warnings=0",
24
25
  "pretest": "npm run build",
25
26
  "test": "node --test",
26
27
  "pack:check": "npm pack --dry-run",
@@ -50,7 +51,10 @@
50
51
  "access": "public"
51
52
  },
52
53
  "devDependencies": {
53
- "esbuild": "^0.28.2"
54
+ "@eslint/js": "^9.39.5",
55
+ "esbuild": "^0.28.2",
56
+ "eslint": "^9.39.5",
57
+ "globals": "^16.5.0"
54
58
  },
55
59
  "license": "SEE LICENSE IN LICENSE"
56
60
  }
@@ -0,0 +1,75 @@
1
+ import { rememberBounded } from "./utilities.js"
2
+
3
+ export function createDeliveryTracker() {
4
+ const states = new Map()
5
+
6
+ return {
7
+ begin(key) {
8
+ if (states.has(key)) return false
9
+ rememberBounded(states, key, "publishing")
10
+ return true
11
+ },
12
+
13
+ succeed(key) {
14
+ if (states.get(key) === "publishing") {
15
+ rememberBounded(states, key, "published")
16
+ }
17
+ },
18
+
19
+ fail(key) {
20
+ if (states.get(key) === "publishing") states.delete(key)
21
+ },
22
+
23
+ clear() {
24
+ states.clear()
25
+ },
26
+ }
27
+ }
28
+
29
+ export function createCompletionTracker() {
30
+ const states = new Map()
31
+
32
+ function stateFor(key) {
33
+ let state = states.get(key)
34
+ if (!state) {
35
+ state = { complete: false, publishing: false, revision: 0 }
36
+ rememberBounded(states, key, state)
37
+ }
38
+ return state
39
+ }
40
+
41
+ return {
42
+ update(key, complete) {
43
+ const state = stateFor(key)
44
+ if (!complete) {
45
+ state.complete = false
46
+ state.publishing = false
47
+ state.revision++
48
+ return
49
+ }
50
+ if (state.complete || state.publishing) return
51
+
52
+ state.publishing = true
53
+ state.revision++
54
+ return { key, revision: state.revision }
55
+ },
56
+
57
+ succeed(attempt) {
58
+ const state = states.get(attempt?.key)
59
+ if (!state || state.revision !== attempt.revision) return
60
+ state.complete = true
61
+ state.publishing = false
62
+ },
63
+
64
+ fail(attempt) {
65
+ const state = states.get(attempt?.key)
66
+ if (!state || state.revision !== attempt.revision) return
67
+ state.complete = false
68
+ state.publishing = false
69
+ },
70
+
71
+ clear() {
72
+ states.clear()
73
+ },
74
+ }
75
+ }
@@ -0,0 +1,110 @@
1
+ import { limit, rememberBounded } from "./utilities.js"
2
+
3
+ const MAX_QUEUED_MESSAGES = 20
4
+
5
+ export class FeedbackSessionStore {
6
+ constructor({ send, reporter }) {
7
+ this.send = send
8
+ this.reporter = reporter
9
+ this.sessions = new Map()
10
+ this.disposed = false
11
+ }
12
+
13
+ stateFor(sessionID) {
14
+ let state = this.sessions.get(sessionID)
15
+ if (!state) {
16
+ state = {
17
+ idle: false,
18
+ flushing: false,
19
+ blockers: new Set(),
20
+ queue: [],
21
+ queued: new Set(),
22
+ queueOverflowReported: false,
23
+ }
24
+ rememberBounded(this.sessions, sessionID, state)
25
+ }
26
+ return state
27
+ }
28
+
29
+ hasReadyMessage(state) {
30
+ return !this.disposed && state.idle &&
31
+ state.blockers.size === 0 && state.queue.length > 0
32
+ }
33
+
34
+ async flush(sessionID, state) {
35
+ if (state.flushing || !this.hasReadyMessage(state)) return
36
+ state.flushing = true
37
+ try {
38
+ while (this.hasReadyMessage(state)) {
39
+ const item = state.queue[0]
40
+ try {
41
+ await this.send(sessionID, item.kind, item.message)
42
+ state.queue.shift()
43
+ state.queued.delete(item.key)
44
+ } catch (error) {
45
+ state.idle = false
46
+ this.reporter.debug("Conversation feedback could not be displayed", error)
47
+ }
48
+ }
49
+ } finally {
50
+ state.flushing = false
51
+ }
52
+ }
53
+
54
+ enqueue(kind, sessionID, message) {
55
+ if (this.disposed || typeof sessionID !== "string" || !sessionID || !message) return
56
+ const state = this.stateFor(sessionID)
57
+ const key = `${sessionID}:${kind}:${message}`
58
+ if (state.queued.has(key)) return
59
+
60
+ state.queue.push({ kind, message: limit(message, 300), key })
61
+ state.queued.add(key)
62
+ if (state.queue.length > MAX_QUEUED_MESSAGES) {
63
+ const removed = state.queue.shift()
64
+ state.queued.delete(removed.key)
65
+ if (!state.queueOverflowReported) {
66
+ state.queueOverflowReported = true
67
+ this.reporter.warning("Conversation feedback queue full — oldest message discarded")
68
+ }
69
+ }
70
+ if (state.idle && state.blockers.size === 0) void this.flush(sessionID, state)
71
+ }
72
+
73
+ setIdle(sessionID, idle) {
74
+ if (typeof sessionID !== "string" || !sessionID) return
75
+ const state = this.stateFor(sessionID)
76
+ state.idle = idle
77
+ if (idle) void this.flush(sessionID, state)
78
+ }
79
+
80
+ beginInteraction(sessionID, key) {
81
+ const state = this.stateFor(sessionID)
82
+ state.idle = false
83
+ state.blockers.add(key)
84
+ }
85
+
86
+ endInteraction(sessionID, key, kind) {
87
+ const state = this.stateFor(sessionID)
88
+ if (!state.blockers.delete(key)) {
89
+ const fallback = [...state.blockers]
90
+ .find((blocker) => blocker.startsWith(`${kind}:`))
91
+ if (fallback) state.blockers.delete(fallback)
92
+ }
93
+ state.idle = false
94
+ }
95
+
96
+ failSession(sessionID) {
97
+ const state = this.stateFor(sessionID)
98
+ state.blockers.clear()
99
+ state.idle = false
100
+ }
101
+
102
+ delete(sessionID) {
103
+ this.sessions.delete(sessionID)
104
+ }
105
+
106
+ dispose() {
107
+ this.disposed = true
108
+ this.sessions.clear()
109
+ }
110
+ }
@@ -1,6 +1,8 @@
1
1
  import { postJsonWithRetry } from "./http.js"
2
2
  import { limit, stableHash } from "./utilities.js"
3
3
 
4
+ // Intentional sanitization of non-printable control bytes before logging.
5
+ // eslint-disable-next-line no-control-regex
4
6
  const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g
5
7
  const SECRET_ASSIGNMENT = /\b(token|secret|password|api[-_ ]?key)\s*[:=]\s*[^\s,;]+/gi
6
8
  const BEARER_TOKEN = /\bBearer\s+[^\s,;]+/gi
@@ -0,0 +1,60 @@
1
+ import { HttpError } from "./http.js"
2
+
3
+ export const ALLOWED_DECISIONS = new Set(["once", "always", "reject"])
4
+
5
+ const DECISION_LABELS = {
6
+ once: "Allow once",
7
+ always: "Always allow",
8
+ reject: "Reject",
9
+ }
10
+
11
+ export function validatePermissionCommand(command, now = Date.now()) {
12
+ if (!ALLOWED_DECISIONS.has(command.decision)) {
13
+ return {
14
+ status: "rejected",
15
+ decision: command.decision,
16
+ errorCode: "invalid_decision",
17
+ }
18
+ }
19
+
20
+ const expiresAt = Date.parse(command.expiresAt)
21
+ if (!Number.isFinite(expiresAt) || expiresAt <= now) {
22
+ return { status: "expired", decision: command.decision }
23
+ }
24
+
25
+ if (command.decision === "always" && command.confirmed !== true) {
26
+ return {
27
+ status: "rejected",
28
+ decision: command.decision,
29
+ errorCode: "confirmation_required",
30
+ }
31
+ }
32
+ }
33
+
34
+ export function failureAcknowledgement(command, error) {
35
+ const stale = error instanceof HttpError && error.status === 404
36
+ return {
37
+ status: stale ? "stale" : "failed",
38
+ decision: command.decision,
39
+ errorCode: stale ? "permission_not_found" : "opencode_reply_failed",
40
+ }
41
+ }
42
+
43
+ export function reportAcknowledgement(acknowledgement, pending, reporter, conversation) {
44
+ const messages = {
45
+ applied: `Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`,
46
+ expired: "Decision expired before it could be applied",
47
+ stale: "Permission was already answered locally",
48
+ rejected: "Decision was rejected by validation",
49
+ }
50
+ const message = messages[acknowledgement.status]
51
+ if (!message) return
52
+
53
+ if (acknowledgement.status === "applied") {
54
+ reporter.applied(message)
55
+ conversation?.applied(pending.sessionID, message)
56
+ return
57
+ }
58
+ reporter.warning(message)
59
+ conversation?.warning(pending.sessionID, message)
60
+ }
@@ -1,14 +1,12 @@
1
1
  import { HttpError, postJsonWithRetry } from "./http.js"
2
+ import {
3
+ failureAcknowledgement,
4
+ reportAcknowledgement,
5
+ validatePermissionCommand,
6
+ } from "./permission-command.js"
2
7
  import { runCommandStream } from "./sse.js"
3
8
  import { rememberBounded } from "./utilities.js"
4
9
 
5
- const ALLOWED_DECISIONS = new Set(["once", "always", "reject"])
6
- const DECISION_LABELS = {
7
- once: "Allow once",
8
- always: "Always allow",
9
- reject: "Reject",
10
- }
11
-
12
10
  async function replyToOpenCode(client, pending, decision) {
13
11
  const result = await client.postSessionIdPermissionsPermissionId({
14
12
  path: { id: pending.sessionID, permissionID: pending.requestID },
@@ -41,7 +39,6 @@ export function createPermissionController({
41
39
  const externalIDByRequestID = new Map()
42
40
  const processingCommands = new Set()
43
41
  const completedCommands = new Map()
44
- const publishedPermissions = new Map()
45
42
  let commandStream
46
43
  let disposed = false
47
44
 
@@ -74,36 +71,18 @@ export function createPermissionController({
74
71
  if (!pending) return
75
72
 
76
73
  processingCommands.add(command.id)
77
- let acknowledgement
74
+ let acknowledgement = validatePermissionCommand(command)
78
75
  try {
79
- if (!ALLOWED_DECISIONS.has(command.decision)) {
80
- acknowledgement = { status: "rejected", errorCode: "invalid_decision" }
81
- } else {
82
- const expiresAt = Date.parse(command.expiresAt)
83
- if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
84
- acknowledgement = { status: "expired", decision: command.decision }
85
- } else if (command.decision === "always" && command.confirmed !== true) {
86
- acknowledgement = {
87
- status: "rejected",
88
- decision: command.decision,
89
- errorCode: "confirmation_required",
90
- }
91
- } else {
92
- await replyToOpenCode(client, pending, command.decision)
93
- acknowledgement = { status: "applied", decision: command.decision }
94
- removePendingByRequestID(pending.requestID)
95
- }
76
+ if (!acknowledgement) {
77
+ await replyToOpenCode(client, pending, command.decision)
78
+ acknowledgement = { status: "applied", decision: command.decision }
79
+ removePendingByRequestID(pending.requestID)
96
80
  }
97
81
  } catch (error) {
98
- const stale = error instanceof HttpError && error.status === 404
99
- acknowledgement = {
100
- status: stale ? "stale" : "failed",
101
- decision: ALLOWED_DECISIONS.has(command.decision)
102
- ? command.decision
103
- : undefined,
104
- errorCode: stale ? "permission_not_found" : "opencode_reply_failed",
82
+ acknowledgement = failureAcknowledgement(command, error)
83
+ if (acknowledgement.status === "stale") {
84
+ removePendingByRequestID(pending.requestID)
105
85
  }
106
- if (stale) removePendingByRequestID(pending.requestID)
107
86
  reporter.error("Decision could not be applied", error)
108
87
  conversation?.error(pending.sessionID, "Decision could not be applied")
109
88
  } finally {
@@ -118,24 +97,7 @@ export function createPermissionController({
118
97
  }
119
98
  rememberBounded(completedCommands, command.id, acknowledgement)
120
99
 
121
- if (acknowledgement.status === "applied") {
122
- reporter.applied(
123
- `Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`,
124
- )
125
- conversation?.applied(
126
- pending.sessionID,
127
- `Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`,
128
- )
129
- } else if (acknowledgement.status === "expired") {
130
- reporter.warning("Decision expired before it could be applied")
131
- conversation?.warning(pending.sessionID, "Decision expired before it could be applied")
132
- } else if (acknowledgement.status === "stale") {
133
- reporter.warning("Permission was already answered locally")
134
- conversation?.warning(pending.sessionID, "Permission was already answered locally")
135
- } else if (acknowledgement.status === "rejected") {
136
- reporter.warning("Decision was rejected by validation")
137
- conversation?.warning(pending.sessionID, "Decision was rejected by validation")
138
- }
100
+ reportAcknowledgement(acknowledgement, pending, reporter, conversation)
139
101
 
140
102
  await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
141
103
  reporter.warning("Decision receipt could not be sent", error)
@@ -167,10 +129,6 @@ export function createPermissionController({
167
129
  pendingByExternalID.set(identity.externalId, { ...identity, directory })
168
130
  externalIDByRequestID.set(identity.requestID, identity.externalId)
169
131
  ensureCommandStream()
170
-
171
- if (publishedPermissions.has(identity.externalId)) return false
172
- rememberBounded(publishedPermissions, identity.externalId, true)
173
- return true
174
132
  },
175
133
 
176
134
  resolveLocally(requestID) {
@@ -198,7 +156,6 @@ export function createPermissionController({
198
156
  externalIDByRequestID.clear()
199
157
  processingCommands.clear()
200
158
  completedCommands.clear()
201
- publishedPermissions.clear()
202
159
  },
203
160
  }
204
161
  }
package/src/plugin.js CHANGED
@@ -1,4 +1,8 @@
1
1
  import { loadConfig } from "./config.js"
2
+ import {
3
+ createCompletionTracker,
4
+ createDeliveryTracker,
5
+ } from "./delivery-tracker.js"
2
6
  import {
3
7
  permissionIdentity,
4
8
  publishPermission,
@@ -18,7 +22,6 @@ import {
18
22
  createUpdateChecker,
19
23
  createUpdateNoticeHook,
20
24
  } from "./updates.js"
21
- import { rememberBounded } from "./utilities.js"
22
25
 
23
26
  /**
24
27
  * OpenCode server plugin entrypoint.
@@ -58,8 +61,9 @@ export default async function BackeerPlugin({ client, directory }, options = {})
58
61
  directory,
59
62
  conversation,
60
63
  })
61
- const todoCompletionBySessionID = new Map()
62
- const publishedQuestions = new Map()
64
+ const permissionPublications = createDeliveryTracker()
65
+ const questionPublications = createDeliveryTracker()
66
+ const todoCompletions = createCompletionTracker()
63
67
  const updateChecker = createUpdateChecker()
64
68
  const handleBackeerCommand = createBackeerCommandHandler({
65
69
  client,
@@ -83,7 +87,8 @@ export default async function BackeerPlugin({ client, directory }, options = {})
83
87
  reporter.error("Permission event is missing correlation IDs")
84
88
  return
85
89
  }
86
- if (!permissionController.register(identity)) return
90
+ permissionController.register(identity)
91
+ if (!permissionPublications.begin(identity.externalId)) return
87
92
 
88
93
  void publishPermission(
89
94
  config,
@@ -92,10 +97,12 @@ export default async function BackeerPlugin({ client, directory }, options = {})
92
97
  directory,
93
98
  identity,
94
99
  conversation,
95
- ).catch((error) => {
96
- reporter.error("Permission request could not be sent", error)
97
- conversation.error(identity.sessionID, "Permission request could not be sent")
98
- })
100
+ ).then(() => permissionPublications.succeed(identity.externalId))
101
+ .catch((error) => {
102
+ permissionPublications.fail(identity.externalId)
103
+ reporter.error("Permission request could not be sent", error)
104
+ conversation.error(identity.sessionID, "Permission request could not be sent")
105
+ })
99
106
  }
100
107
 
101
108
  async function handleTodoUpdated(event) {
@@ -107,9 +114,8 @@ export default async function BackeerPlugin({ client, directory }, options = {})
107
114
 
108
115
  const isComplete = todos.length > 0 &&
109
116
  todos.every((todo) => todo?.status === "completed")
110
- const wasComplete = todoCompletionBySessionID.get(sessionID) === true
111
- rememberBounded(todoCompletionBySessionID, sessionID, isComplete)
112
- if (!isComplete || wasComplete) return
117
+ const attempt = todoCompletions.update(sessionID, isComplete)
118
+ if (!attempt) return
113
119
 
114
120
  void publishTodoCompletion(
115
121
  config,
@@ -119,10 +125,12 @@ export default async function BackeerPlugin({ client, directory }, options = {})
119
125
  client,
120
126
  directory,
121
127
  conversation,
122
- ).catch((error) => {
123
- reporter.error("Todo completion could not be sent", error)
124
- conversation.error(sessionID, "Todo completion could not be sent")
125
- })
128
+ ).then(() => todoCompletions.succeed(attempt))
129
+ .catch((error) => {
130
+ todoCompletions.fail(attempt)
131
+ reporter.error("Todo completion could not be sent", error)
132
+ conversation.error(sessionID, "Todo completion could not be sent")
133
+ })
126
134
  }
127
135
 
128
136
  async function handleQuestionAsked(event) {
@@ -131,8 +139,7 @@ export default async function BackeerPlugin({ client, directory }, options = {})
131
139
  reporter.error("Question event is missing correlation IDs")
132
140
  return
133
141
  }
134
- if (publishedQuestions.has(identity.externalId)) return
135
- rememberBounded(publishedQuestions, identity.externalId, true)
142
+ if (!questionPublications.begin(identity.externalId)) return
136
143
 
137
144
  void publishQuestion(
138
145
  config,
@@ -142,10 +149,12 @@ export default async function BackeerPlugin({ client, directory }, options = {})
142
149
  client,
143
150
  directory,
144
151
  conversation,
145
- ).catch((error) => {
146
- reporter.error("Question could not be sent", error)
147
- conversation.error(identity.sessionID, "Question could not be sent")
148
- })
152
+ ).then(() => questionPublications.succeed(identity.externalId))
153
+ .catch((error) => {
154
+ questionPublications.fail(identity.externalId)
155
+ reporter.error("Question could not be sent", error)
156
+ conversation.error(identity.sessionID, "Question could not be sent")
157
+ })
149
158
  }
150
159
 
151
160
  return {
@@ -191,8 +200,9 @@ export default async function BackeerPlugin({ client, directory }, options = {})
191
200
  dispose: async () => {
192
201
  await permissionController.dispose()
193
202
  lifecycleController.dispose()
194
- todoCompletionBySessionID.clear()
195
- publishedQuestions.clear()
203
+ permissionPublications.clear()
204
+ questionPublications.clear()
205
+ todoCompletions.clear()
196
206
  conversation.dispose()
197
207
  },
198
208
  }