opencode-anthropic-fix 2.1.1 → 2.2.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/NOTICE +53 -0
- package/README.md +72 -4
- package/docs/opencode-v2-architecture.md +148 -0
- package/docs/opencode-v2.md +198 -0
- package/index.mjs +13 -6578
- package/lib/accounts.mjs +46 -0
- package/lib/host/command-args.mjs +18 -0
- package/lib/host/command-rpc.mjs +192 -0
- package/lib/host/runtime.mjs +6902 -0
- package/lib/host/tool-names.mjs +62 -0
- package/lib/host/v2-context.mjs +45 -0
- package/lib/host/v2-language.mjs +58 -0
- package/lib/host/v2-sdk.mjs +4 -0
- package/lib/host/v2-transport.mjs +374 -0
- package/lib/host/v2.mjs +305 -0
- package/lib/mimicry/request-body.mjs +31 -21
- package/lib/mimicry/request-helpers.mjs +6 -5
- package/lib/mimicry/response-stream.mjs +27 -22
- package/lib/oauth.mjs +29 -9
- package/lib/refresh-lock.mjs +18 -4
- package/lib/session-metrics.mjs +5 -8
- package/lib/unicode-text.mjs +9 -13
- package/licenses/Apache-2.0.txt +201 -0
- package/package.json +21 -1
- package/server.mjs +15 -0
- package/tui.mjs +93 -0
package/lib/accounts.mjs
CHANGED
|
@@ -120,6 +120,8 @@ export class AccountManager {
|
|
|
120
120
|
#config;
|
|
121
121
|
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
122
122
|
#saveTimeout = null;
|
|
123
|
+
#disposed = false;
|
|
124
|
+
#pendingSaves = new Set();
|
|
123
125
|
/**
|
|
124
126
|
* Pending stats deltas per account id, merged into disk values on save.
|
|
125
127
|
* @type {Map<string, StatsDelta>}
|
|
@@ -790,6 +792,7 @@ export class AccountManager {
|
|
|
790
792
|
* Each call resets the debounce timer so the latest state is always persisted.
|
|
791
793
|
*/
|
|
792
794
|
requestSaveToDisk() {
|
|
795
|
+
if (this.#disposed) return;
|
|
793
796
|
if (this.#saveTimeout) clearTimeout(this.#saveTimeout);
|
|
794
797
|
this.#saveTimeout = setTimeout(() => {
|
|
795
798
|
this.#saveTimeout = null;
|
|
@@ -797,6 +800,38 @@ export class AccountManager {
|
|
|
797
800
|
}, 1000);
|
|
798
801
|
}
|
|
799
802
|
|
|
803
|
+
/** Transfer unsaved usage after the old manager has stopped its writes.
|
|
804
|
+
* Disk remains authoritative for membership, enabled state and credentials.
|
|
805
|
+
* @param {AccountManager | null} previous
|
|
806
|
+
*/
|
|
807
|
+
adoptPendingUsageFrom(previous) {
|
|
808
|
+
if (!previous) return;
|
|
809
|
+
for (const [id, delta] of previous.#statsDeltas) {
|
|
810
|
+
const account = this.#accounts.find((candidate) => candidate.id === id);
|
|
811
|
+
if (!account) continue;
|
|
812
|
+
this.#statsDeltas.set(id, { ...delta });
|
|
813
|
+
for (const key of ["requests", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"]) {
|
|
814
|
+
account.stats[key] = (delta.isReset ? 0 : account.stats[key]) + delta[key];
|
|
815
|
+
}
|
|
816
|
+
if (delta.isReset) account.stats.lastReset = delta.resetTimestamp ?? account.stats.lastReset;
|
|
817
|
+
this.requestSaveToDisk();
|
|
818
|
+
}
|
|
819
|
+
previous.#statsDeltas.clear();
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** Flush pending account updates and cancel the debounce when a host unloads. */
|
|
823
|
+
async dispose({ flush = true } = {}) {
|
|
824
|
+
if (this.#disposed) return;
|
|
825
|
+
this.#disposed = true;
|
|
826
|
+
const pending = this.#saveTimeout !== null;
|
|
827
|
+
if (this.#saveTimeout) clearTimeout(this.#saveTimeout);
|
|
828
|
+
this.#saveTimeout = null;
|
|
829
|
+
const saves = await Promise.allSettled(this.#pendingSaves);
|
|
830
|
+
const failure = saves.find((result) => result.status === "rejected");
|
|
831
|
+
if (pending && flush) await this.#persistToDisk();
|
|
832
|
+
if (failure) throw failure.reason;
|
|
833
|
+
}
|
|
834
|
+
|
|
800
835
|
/**
|
|
801
836
|
* Persist current state to disk immediately.
|
|
802
837
|
* Stats use merge-on-save: read disk values, add this instance's deltas,
|
|
@@ -805,6 +840,17 @@ export class AccountManager {
|
|
|
805
840
|
* @returns {Promise<void>}
|
|
806
841
|
*/
|
|
807
842
|
async saveToDisk() {
|
|
843
|
+
if (this.#disposed) return;
|
|
844
|
+
const pending = this.#persistToDisk();
|
|
845
|
+
this.#pendingSaves.add(pending);
|
|
846
|
+
try {
|
|
847
|
+
await pending;
|
|
848
|
+
} finally {
|
|
849
|
+
this.#pendingSaves.delete(pending);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
async #persistToDisk() {
|
|
808
854
|
// Read current disk state to merge stats
|
|
809
855
|
/** @type {Map<string, import('./storage.mjs').AccountMetadata> | null} */
|
|
810
856
|
let diskAccountsById = null;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse command arguments with minimal quote support.
|
|
3
|
+
* Examples: a b "c d" -> ["a", "b", "c d"]; a 'c d' -> ["a", "c d"].
|
|
4
|
+
* Shared by slash dispatch and RPC credential redaction.
|
|
5
|
+
* @param {string} raw
|
|
6
|
+
* @returns {string[]}
|
|
7
|
+
*/
|
|
8
|
+
export function parseCommandArgs(raw) {
|
|
9
|
+
if (!raw || !raw.trim()) return [];
|
|
10
|
+
const parts = [];
|
|
11
|
+
const re = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g;
|
|
12
|
+
let match;
|
|
13
|
+
while ((match = re.exec(raw)) !== null) {
|
|
14
|
+
const token = match[1] ?? match[2] ?? match[3] ?? "";
|
|
15
|
+
parts.push(token.replace(/\\(["'\\])/g, "$1"));
|
|
16
|
+
}
|
|
17
|
+
return parts;
|
|
18
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
import { redactString } from "../redact.mjs";
|
|
3
|
+
import { truncateGraphemes } from "../unicode-text.mjs";
|
|
4
|
+
import { parseCommandArgs } from "./command-args.mjs";
|
|
5
|
+
|
|
6
|
+
const MAX_ARGUMENT_LENGTH = 8_192;
|
|
7
|
+
const MAX_OUTPUT_LENGTH = 65_536;
|
|
8
|
+
const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Portable RPC definition for OpenCode 2.0.21. JSON Schema is a public schema
|
|
12
|
+
* format; Rpc.define() is an identity helper, so no runtime SDK is required.
|
|
13
|
+
* Keep this module importable by both the server and the TUI.
|
|
14
|
+
*/
|
|
15
|
+
export const anthropicCommandRpc = {
|
|
16
|
+
id: "opencode-anthropic-fix",
|
|
17
|
+
methods: {
|
|
18
|
+
execute: {
|
|
19
|
+
input: {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
// Core's portable JSON Schema converter rejects regex patterns;
|
|
23
|
+
// validate the identifier's alphabet in the handler instead.
|
|
24
|
+
sessionID: { type: "string", minLength: 1, maxLength: 128 },
|
|
25
|
+
arguments: { type: "string", maxLength: MAX_ARGUMENT_LENGTH },
|
|
26
|
+
},
|
|
27
|
+
required: ["sessionID", "arguments"],
|
|
28
|
+
additionalProperties: false,
|
|
29
|
+
},
|
|
30
|
+
output: {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: { output: { type: "string", maxLength: MAX_OUTPUT_LENGTH } },
|
|
33
|
+
required: ["output"],
|
|
34
|
+
additionalProperties: false,
|
|
35
|
+
},
|
|
36
|
+
errors: {
|
|
37
|
+
command_failed: { type: "null" },
|
|
38
|
+
invalid_session: { type: "null" },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
events: {},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** @param {unknown} input @returns {string} */
|
|
46
|
+
export function validateCommandArguments(input) {
|
|
47
|
+
if (typeof input !== "string" || input.length > MAX_ARGUMENT_LENGTH) {
|
|
48
|
+
throw new Error("Anthropic command arguments must be a string of at most 8192 characters.");
|
|
49
|
+
}
|
|
50
|
+
// Commands are a single CLI-style line. Reject control characters without
|
|
51
|
+
// echoing the input: login completion arguments may contain an OAuth code.
|
|
52
|
+
if ([...input].some((character) => character < " " && character !== "\t") || input.includes("\u007f")) {
|
|
53
|
+
throw new Error("Anthropic command arguments must not contain control characters.");
|
|
54
|
+
}
|
|
55
|
+
return input;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Remove credentials and terminal escape sequences before crossing the RPC/UI
|
|
60
|
+
* boundary. OAuth authorization URLs remain usable; submitted completion codes
|
|
61
|
+
* must not be echoed, even when an upstream error embeds the original input.
|
|
62
|
+
* @param {unknown} value
|
|
63
|
+
* @param {string} [args]
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
export function sanitizeCommandOutput(value, args = "") {
|
|
67
|
+
let output = stripVTControlCharacters(redactString(value));
|
|
68
|
+
const tokens = typeof args === "string" ? parseCommandArgs(args) : [];
|
|
69
|
+
const completion =
|
|
70
|
+
["login", "reauth"].includes(tokens[0]?.toLowerCase()) && tokens[1]?.toLowerCase() === "complete"
|
|
71
|
+
? tokens.slice(2).join(" ").trim()
|
|
72
|
+
: "";
|
|
73
|
+
if (completion) {
|
|
74
|
+
const secrets = new Set([completion, completion.split("#")[0]]);
|
|
75
|
+
let code;
|
|
76
|
+
try {
|
|
77
|
+
const url = new URL(completion);
|
|
78
|
+
code = url.searchParams.get("code");
|
|
79
|
+
if (code) {
|
|
80
|
+
// Also cover the exact encoded spelling if an error quotes only it.
|
|
81
|
+
const encoded = url.search.match(/(?:\?|&)code=([^&]*)/)?.[1];
|
|
82
|
+
if (encoded) secrets.add(encoded);
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// A plain code#state is not a URL; redact it as a raw completion code.
|
|
86
|
+
code = completion;
|
|
87
|
+
}
|
|
88
|
+
if (code) {
|
|
89
|
+
secrets.add(code);
|
|
90
|
+
secrets.add(code.split("#")[0]);
|
|
91
|
+
}
|
|
92
|
+
for (const secret of [...secrets].sort((a, b) => b.length - a.length)) {
|
|
93
|
+
if (secret) output = output.split(secret).join("[redacted]");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
output = output.replace(
|
|
97
|
+
/(["']?(?:access_?token|refresh_?token|access|refresh|client_secret|code_verifier|authorization_code)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;}]+)/gi,
|
|
98
|
+
"$1[redacted]",
|
|
99
|
+
);
|
|
100
|
+
return truncateGraphemes(output, MAX_OUTPUT_LENGTH, { suffix: "\n[output truncated]" });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Reserve the server-side slash name for clients without the TUI extension.
|
|
105
|
+
* The v2 command API returns no output; running login here would lose its URL
|
|
106
|
+
* and pending-flow instructions. A fixed error directs callers to the RPC and
|
|
107
|
+
* prevents OAuth completion arguments from falling through to a model prompt.
|
|
108
|
+
*
|
|
109
|
+
* @param {{command: {transform: Function}}} context
|
|
110
|
+
* @returns {Promise<{dispose: () => Promise<void>}>}
|
|
111
|
+
*/
|
|
112
|
+
export function registerAnthropicServerCommand(context) {
|
|
113
|
+
return context.command.transform((editor) => {
|
|
114
|
+
editor.add({
|
|
115
|
+
name: "anthropic",
|
|
116
|
+
description: "Manage Anthropic accounts through the TUI or the plugin RPC",
|
|
117
|
+
async execute() {
|
|
118
|
+
throw new Error(
|
|
119
|
+
"Run /anthropic in the OpenCode TUI, or call the opencode-anthropic-fix RPC execute method. " +
|
|
120
|
+
"The server command API cannot display administrative output.",
|
|
121
|
+
);
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Register the administrative command transport. Session lookup uses the
|
|
129
|
+
* server's scoped API, and no prompt/synthetic/message endpoint is called.
|
|
130
|
+
*
|
|
131
|
+
* @param {{rpc: {register: Function}, session: {get: Function}} context
|
|
132
|
+
* @param {(input: {sessionID: string, arguments: string, signal: AbortSignal}) => Promise<string>} runCommand
|
|
133
|
+
* @returns {Promise<{dispose: () => Promise<void>}>}
|
|
134
|
+
*/
|
|
135
|
+
export async function registerAnthropicCommandRpc(context, runCommand) {
|
|
136
|
+
let disposed = false;
|
|
137
|
+
const pending = new Set();
|
|
138
|
+
const registration = await context.rpc.register(anthropicCommandRpc, {
|
|
139
|
+
execute: async (input, call) => {
|
|
140
|
+
if (disposed) return call.error("command_failed", "Anthropic command service is closed.", null);
|
|
141
|
+
let args;
|
|
142
|
+
try {
|
|
143
|
+
args = validateCommandArguments(input?.arguments);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return call.error("command_failed", error.message, null);
|
|
146
|
+
}
|
|
147
|
+
if (typeof input.sessionID !== "string" || !SESSION_ID.test(input.sessionID)) {
|
|
148
|
+
return call.error("invalid_session", "A valid OpenCode session is required.", null);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const controller = new AbortController();
|
|
152
|
+
const abort = () => controller.abort(call.signal.reason);
|
|
153
|
+
if (call.signal.aborted) abort();
|
|
154
|
+
else call.signal.addEventListener("abort", abort, { once: true });
|
|
155
|
+
pending.add(controller);
|
|
156
|
+
try {
|
|
157
|
+
controller.signal.throwIfAborted();
|
|
158
|
+
const session = await context.session.get({ sessionID: input.sessionID }, { signal: controller.signal });
|
|
159
|
+
controller.signal.throwIfAborted();
|
|
160
|
+
if (session?.id !== input.sessionID) {
|
|
161
|
+
return call.error("invalid_session", "The OpenCode session is unavailable.", null);
|
|
162
|
+
}
|
|
163
|
+
const output = await runCommand({ sessionID: input.sessionID, arguments: args, signal: controller.signal });
|
|
164
|
+
controller.signal.throwIfAborted();
|
|
165
|
+
if (typeof output !== "string") {
|
|
166
|
+
return call.error("command_failed", "The Anthropic command returned an invalid response.", null);
|
|
167
|
+
}
|
|
168
|
+
return { output: sanitizeCommandOutput(output, args) };
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (controller.signal.aborted) {
|
|
171
|
+
return call.error("command_failed", "Anthropic command cancelled.", null);
|
|
172
|
+
}
|
|
173
|
+
return call.error(
|
|
174
|
+
"command_failed",
|
|
175
|
+
sanitizeCommandOutput(error?.message || "Anthropic command failed.", args),
|
|
176
|
+
null,
|
|
177
|
+
);
|
|
178
|
+
} finally {
|
|
179
|
+
pending.delete(controller);
|
|
180
|
+
call.signal.removeEventListener("abort", abort);
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
return {
|
|
185
|
+
async dispose() {
|
|
186
|
+
if (disposed) return;
|
|
187
|
+
disposed = true;
|
|
188
|
+
for (const controller of pending) controller.abort();
|
|
189
|
+
await registration.dispose();
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|