bacluc-opencode-completion-check-command 0.4.1 → 0.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bacluc-opencode-completion-check-command",
3
- "version": "v0.4.1",
3
+ "version": "v0.6.0",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "opencode",
@@ -1,6 +1,7 @@
1
1
  import type { Hooks, Plugin, PluginInput } from '@opencode-ai/plugin'
2
2
  import type { Event } from '@opencode-ai/sdk'
3
3
  import { promises as fs } from 'fs'
4
+ import { exec } from 'child_process'
4
5
 
5
6
  export const DEFAULT_MAX_RETRIES = 10
6
7
 
@@ -78,21 +79,34 @@ export interface CommandResult {
78
79
  stderr: string
79
80
  }
80
81
 
81
- export async function executeCommand($: PluginInput['$'], command: string, cwd: string): Promise<CommandResult> {
82
- try {
83
- const result = await $`${command}`.nothrow().quiet().cwd(cwd)
84
- return {
85
- exitCode: result.exitCode,
86
- stdout: result.text(),
87
- stderr: result.stderr.toString(),
88
- }
89
- } catch (error) {
90
- return {
91
- exitCode: 1,
92
- stdout: '',
93
- stderr: error instanceof Error ? error.message : String(error),
94
- }
95
- }
82
+ /**
83
+ * Runs the completion check command in a real system shell (`/bin/sh -c`).
84
+ *
85
+ * The previous implementation used opencode's built-in Bun shell via
86
+ * `` $`${command}` ``. Bun interpolates the whole command string as a single
87
+ * quoted argument and resolves binaries against its own restricted PATH, which
88
+ * breaks commands such as `docker compose run ...` with errors like
89
+ * "Bun: command not found: docker" even though `docker` is on the user's PATH.
90
+ *
91
+ * Using `child_process.exec` runs the command through the real `/bin/sh`, so
92
+ * the command line is parsed normally and binaries are resolved against the
93
+ * inherited PATH exactly like in a normal terminal (including `/sbin`,
94
+ * `/usr/sbin`, etc.).
95
+ */
96
+ export async function executeCommand(command: string, cwd: string): Promise<CommandResult> {
97
+ return new Promise((resolve) => {
98
+ exec(command, { cwd, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
99
+ let exitCode = 0
100
+ if (error) {
101
+ exitCode = typeof error.code === 'number' ? error.code : 1
102
+ }
103
+ resolve({
104
+ exitCode,
105
+ stdout: stdout.toString(),
106
+ stderr: stderr.toString(),
107
+ })
108
+ })
109
+ })
96
110
  }
97
111
 
98
112
  export function buildFailureMessage(result: CommandResult): string {
@@ -109,6 +123,60 @@ export function buildFailureMessage(result: CommandResult): string {
109
123
  return message
110
124
  }
111
125
 
126
+ /**
127
+ * Detects whether an error attached to an assistant message means the provider's
128
+ * usage limit was used up (rate limit, quota or credits exhausted). When that
129
+ * happens the agent did not actually finish its task — it was cut off — so the
130
+ * completion check must not run and the agent must not be re-prompted (which
131
+ * would immediately hit the same limit again and burn the retry budget).
132
+ */
133
+ export function isUsageLimitError(error: unknown): boolean {
134
+ if (!error || typeof error !== 'object') {
135
+ return false
136
+ }
137
+ const { name, data } = error as { name?: string; data?: Record<string, unknown> }
138
+ const statusCode = typeof data?.statusCode === 'number' ? data.statusCode : undefined
139
+
140
+ // HTTP 429 from the provider always means rate / usage limit reached.
141
+ if (name === 'APIError' && statusCode === 429) {
142
+ return true
143
+ }
144
+
145
+ // Otherwise fall back to matching the human-readable message / response body,
146
+ // which is how quota/credit exhaustion surfaces across providers.
147
+ const message = typeof data?.message === 'string' ? data.message : ''
148
+ const responseBody = typeof data?.responseBody === 'string' ? data.responseBody : ''
149
+ const haystack = `${message} ${responseBody}`.toLowerCase()
150
+
151
+ return /usage limit|rate limit|quota|too many requests|credit balance|out of credits|insufficient (?:credit|balance|funds|quota)/.test(
152
+ haystack,
153
+ )
154
+ }
155
+
156
+ /**
157
+ * Returns true when the session's most recent assistant message ended with a
158
+ * usage-limit error, i.e. the model ran out of usage rather than finishing.
159
+ */
160
+ export async function sessionHitUsageLimit(client: PluginInput['client'], sessionID: string): Promise<boolean> {
161
+ try {
162
+ const response = await client.session.messages({ path: { id: sessionID } })
163
+ const messages = response?.data
164
+ if (!Array.isArray(messages)) {
165
+ return false
166
+ }
167
+ for (let i = messages.length - 1; i >= 0; i--) {
168
+ const info = messages[i]?.info
169
+ if (info?.role === 'assistant') {
170
+ return isUsageLimitError(info.error)
171
+ }
172
+ }
173
+ return false
174
+ } catch {
175
+ // If we cannot determine the state, fall back to the normal behaviour.
176
+ return false
177
+ }
178
+ }
179
+
112
180
  export async function readDefaultCommandFromAgentsMd(directory: string): Promise<string | null> {
113
181
  try {
114
182
  const content = await fs.readFile(`${directory}/AGENTS.md`, 'utf-8')
@@ -123,7 +191,7 @@ export async function readDefaultCommandFromAgentsMd(directory: string): Promise
123
191
  }
124
192
 
125
193
  export const CompletionCheckCommandPlugin: Plugin = async (input, options) => {
126
- const { client, $ } = input
194
+ const { client } = input
127
195
  const maxRetries = typeof options?.maxRetries === 'number' ? options.maxRetries : DEFAULT_MAX_RETRIES
128
196
  const store = new CompletionCheckStore(maxRetries)
129
197
 
@@ -225,7 +293,27 @@ export const CompletionCheckCommandPlugin: Plugin = async (input, options) => {
225
293
  processing.add(sessionID)
226
294
 
227
295
  try {
228
- const result = await executeCommand($, command, input.directory)
296
+ if (await sessionHitUsageLimit(client, sessionID)) {
297
+ // The agent was cut off by the provider's usage limit rather than
298
+ // finishing. Skip the completion check (and the re-prompt) so we don't
299
+ // immediately hit the limit again. The command stays registered, so the
300
+ // check still runs once the session is able to continue.
301
+ try {
302
+ await client.tui.showToast({
303
+ body: {
304
+ title: 'Completion Check',
305
+ message: 'Skipped the completion check because the usage limit was reached.',
306
+ variant: 'warning',
307
+ duration: 10000,
308
+ },
309
+ })
310
+ } catch {
311
+ // Ignore feedback errors
312
+ }
313
+ return
314
+ }
315
+
316
+ const result = await executeCommand(command, input.directory)
229
317
 
230
318
  if (result.exitCode === 0) {
231
319
  store.delete(sessionID)
package/src/index.ts CHANGED
@@ -5,5 +5,7 @@ export {
5
5
  CompletionCheckStore,
6
6
  executeCommand,
7
7
  buildFailureMessage,
8
+ isUsageLimitError,
9
+ sessionHitUsageLimit,
8
10
  } from './completion-check-command.js'
9
11
  export type { CommandResult } from './completion-check-command.js'