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 +1 -1
- package/src/completion-check-command.ts +105 -17
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
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
|
-
|
|
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)
|