@saadnvd1/agent-os 0.3.737 → 0.3.742
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/components/Chat/Activity.test.ts +2 -1
- package/components/Chat/Activity.tsx +102 -64
- package/components/Chat/ChatPanel.tsx +16 -3
- package/components/Chat/LiveOutput.test.ts +36 -0
- package/components/Chat/Messages.test.ts +13 -1
- package/components/Chat/Messages.tsx +19 -2
- package/components/Chat/StepOutcomes.tsx +66 -0
- package/components/Chat/Tools.test.ts +52 -0
- package/components/Chat/Tools.tsx +63 -9
- package/data/chat/useChat.ts +10 -1
- package/lib/chat/drivers/claude-mapper.ts +10 -0
- package/lib/chat/drivers/claude.ts +9 -1
- package/lib/chat/drivers/edit-guard.ts +311 -0
- package/lib/chat/drivers/opencode-config.ts +111 -0
- package/lib/chat/drivers/opencode-mapper.ts +125 -13
- package/lib/chat/drivers/opencode-plugin.ts +78 -0
- package/lib/chat/drivers/opencode-server.ts +7 -2
- package/lib/chat/drivers/opencode.ts +108 -9
- package/lib/chat/drivers/pi-edit-guard.ts +87 -0
- package/lib/chat/drivers/pi-mapper.ts +91 -2
- package/lib/chat/drivers/pi.ts +95 -4
- package/lib/chat/drivers/transient-retry.ts +87 -0
- package/lib/chat/drivers/turn-items.ts +74 -1
- package/lib/chat/drivers/turn-outcome.ts +73 -1
- package/lib/chat/events.ts +17 -1
- package/lib/chat/group.ts +23 -3
- package/lib/chat/model-wait-live.ts +19 -0
- package/lib/chat/model-wait.ts +88 -0
- package/lib/chat/page.ts +3 -0
- package/lib/chat/progress.ts +169 -0
- package/lib/chat/registry.ts +5 -0
- package/lib/chat/runner-watch.ts +68 -0
- package/lib/chat/runner.ts +4 -70
- package/lib/chat/tool-outcome.ts +45 -0
- package/lib/chat/tools.ts +14 -0
- package/package.json +1 -1
- package/scripts/screenshots/chat-verify.ts +68 -0
- package/scripts/screenshots/seed.ts +9 -0
package/data/chat/useChat.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
ChatServerMessage,
|
|
17
17
|
ChatState,
|
|
18
18
|
Compacting,
|
|
19
|
+
ModelWait,
|
|
19
20
|
FileSuggestion,
|
|
20
21
|
QueuedMessage,
|
|
21
22
|
ToolBody,
|
|
@@ -164,6 +165,7 @@ export function useChat(
|
|
|
164
165
|
}, []);
|
|
165
166
|
const [state, setState] = useState<ChatState>("idle");
|
|
166
167
|
const [compacting, setCompacting] = useState<Compacting | null>(null);
|
|
168
|
+
const [modelWait, setModelWait] = useState<ModelWait | null>(null);
|
|
167
169
|
const [connected, setConnected] = useState(false);
|
|
168
170
|
// The chat whose snapshot the items are, once one has landed.
|
|
169
171
|
const [loadedFor, setLoadedFor] = useState<string | null>(null);
|
|
@@ -271,6 +273,7 @@ export function useChat(
|
|
|
271
273
|
setLoadingOlder(false);
|
|
272
274
|
setState(m.state);
|
|
273
275
|
setCompacting(m.compacting ?? null);
|
|
276
|
+
setModelWait(m.modelWait ?? null);
|
|
274
277
|
setQueue(m.queue ?? []);
|
|
275
278
|
setSuggestion(m.suggestion ?? null);
|
|
276
279
|
setLoadedFor(sessionId);
|
|
@@ -295,9 +298,14 @@ export function useChat(
|
|
|
295
298
|
searchRequests.current.delete(m.reqId);
|
|
296
299
|
} else if (m.type === "compacting") {
|
|
297
300
|
setCompacting(m.compacting);
|
|
301
|
+
} else if (m.type === "model_wait") {
|
|
302
|
+
setModelWait(m.wait);
|
|
298
303
|
} else if (m.type === "state") {
|
|
299
304
|
setState(m.state);
|
|
300
|
-
if (m.state === "idle" || m.state === "error")
|
|
305
|
+
if (m.state === "idle" || m.state === "error") {
|
|
306
|
+
setCompacting(null);
|
|
307
|
+
setModelWait(null);
|
|
308
|
+
}
|
|
301
309
|
} else if (m.type === "capabilities") {
|
|
302
310
|
setRefreshingCommands(false);
|
|
303
311
|
setCommands(m.commands);
|
|
@@ -596,6 +604,7 @@ export function useChat(
|
|
|
596
604
|
pinToolBody,
|
|
597
605
|
state,
|
|
598
606
|
compacting,
|
|
607
|
+
modelWait,
|
|
599
608
|
connected,
|
|
600
609
|
commands,
|
|
601
610
|
refreshCommands,
|
|
@@ -233,6 +233,16 @@ export class ClaudeMapper {
|
|
|
233
233
|
case "task_progress":
|
|
234
234
|
case "task_notification":
|
|
235
235
|
return this.task(m);
|
|
236
|
+
case "api_retry": {
|
|
237
|
+
const r = m as { attempt?: number; error_status?: number | null };
|
|
238
|
+
return [
|
|
239
|
+
{
|
|
240
|
+
type: "model_retry",
|
|
241
|
+
attempt: Number(r.attempt) || 1,
|
|
242
|
+
message: r.error_status ? `HTTP ${r.error_status}` : undefined,
|
|
243
|
+
},
|
|
244
|
+
];
|
|
245
|
+
}
|
|
236
246
|
case "status": {
|
|
237
247
|
// "compacting" while the context is summarised, null once it's done.
|
|
238
248
|
const status = (m as { status?: string | null }).status;
|
|
@@ -454,5 +454,13 @@ export function usageEvents(
|
|
|
454
454
|
? "review"
|
|
455
455
|
: "sub";
|
|
456
456
|
const call = callFromAssistant(m.message, kind, agentType, at);
|
|
457
|
-
|
|
457
|
+
if (!call) return [];
|
|
458
|
+
const prompt =
|
|
459
|
+
call.input + call.cacheRead + call.cacheWrite5m + call.cacheWrite1h;
|
|
460
|
+
return [
|
|
461
|
+
{ type: "call_usage", call },
|
|
462
|
+
...(kind === "main" && prompt > 0
|
|
463
|
+
? [{ type: "prompt_tokens" as const, tokens: prompt }]
|
|
464
|
+
: []),
|
|
465
|
+
];
|
|
458
466
|
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { createRequire } from "module";
|
|
4
|
+
import { agentosHome } from "../../agentos-home";
|
|
5
|
+
|
|
6
|
+
// What a model needs after an edit goes wrong, for Pi and OpenCode alike
|
|
7
|
+
// (their edit tools are theirs, so this runs beside them as an extension and
|
|
8
|
+
// a plugin):
|
|
9
|
+
//
|
|
10
|
+
// - an edit whose old text isn't in the file is refused before it runs, with
|
|
11
|
+
// the file's current text around the closest match, and a repeat of an edit
|
|
12
|
+
// that already failed is called out;
|
|
13
|
+
// - after an edit or write to a TypeScript or JavaScript file, new syntax
|
|
14
|
+
// errors and block-scoped names declared twice come back in the tool result.
|
|
15
|
+
//
|
|
16
|
+
// Plain ESM with no imports beyond node's, so Node (Pi) and Bun (OpenCode)
|
|
17
|
+
// both run it. It never throws and never blocks on anything it can't read.
|
|
18
|
+
// Written as a string, like the other extensions, and exported for its test.
|
|
19
|
+
export const EDIT_GUARD_LIB = String.raw`import { createHash } from "node:crypto";
|
|
20
|
+
import { readFileSync, statSync } from "node:fs";
|
|
21
|
+
import { createRequire } from "node:module";
|
|
22
|
+
import { extname, resolve } from "node:path";
|
|
23
|
+
|
|
24
|
+
const SOURCE = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
25
|
+
const MAX_BYTES = 1048576;
|
|
26
|
+
const MAX_REPORTED = 10;
|
|
27
|
+
const EXCERPT_LINES = 40;
|
|
28
|
+
const EXCERPT_CHARS = 3000;
|
|
29
|
+
|
|
30
|
+
const norm = (s) => String(s).replace(/\r\n/g, "\n");
|
|
31
|
+
const trimmed = (s) => norm(s).split("\n").map((l) => l.trim());
|
|
32
|
+
const collapse = (s) => norm(s).replace(/\s+/g, " ").trim();
|
|
33
|
+
|
|
34
|
+
export function readText(file) {
|
|
35
|
+
try {
|
|
36
|
+
if (statSync(file).size > MAX_BYTES) return null;
|
|
37
|
+
return readFileSync(file, "utf8");
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const absolute = (file, cwd) =>
|
|
44
|
+
resolve(cwd || process.cwd(), String(file).replace(/^@/, ""));
|
|
45
|
+
|
|
46
|
+
function needLines(old) {
|
|
47
|
+
const need = trimmed(old);
|
|
48
|
+
while (need.length && need[need.length - 1] === "") need.pop();
|
|
49
|
+
while (need.length && need[0] === "") need.shift();
|
|
50
|
+
return need;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Whether any of the harnesses' matchers could find this text: exact, line by
|
|
54
|
+
// line without indentation, with whitespace collapsed, or by its first and
|
|
55
|
+
// last lines. Only text none of them find is refused, so an edit a harness
|
|
56
|
+
// would have accepted is never blocked here.
|
|
57
|
+
export function isFound(text, old) {
|
|
58
|
+
if (typeof old !== "string" || !old.trim()) return true;
|
|
59
|
+
if (text.includes(old) || norm(text).includes(norm(old))) return true;
|
|
60
|
+
const hay = trimmed(text);
|
|
61
|
+
const need = needLines(old);
|
|
62
|
+
if (!need.length) return true;
|
|
63
|
+
for (let i = 0; i + need.length <= hay.length; i++) {
|
|
64
|
+
let same = true;
|
|
65
|
+
for (let j = 0; j < need.length && same; j++) same = hay[i + j] === need[j];
|
|
66
|
+
if (same) return true;
|
|
67
|
+
}
|
|
68
|
+
if (collapse(text).includes(collapse(old))) return true;
|
|
69
|
+
if (need.length >= 3) {
|
|
70
|
+
const last = need[need.length - 1];
|
|
71
|
+
for (let i = 0; i < hay.length; i++) {
|
|
72
|
+
if (hay[i] !== need[0]) continue;
|
|
73
|
+
const to = Math.min(hay.length, i + need.length + 10);
|
|
74
|
+
for (let k = i + 2; k < to; k++) if (hay[k] === last) return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The file's lines around where the old text most likely was, numbered, or
|
|
81
|
+
// null when nothing in the file resembles it.
|
|
82
|
+
export function closestMatch(text, old) {
|
|
83
|
+
const lines = norm(text).split("\n");
|
|
84
|
+
const hay = lines.map((l) => l.trim());
|
|
85
|
+
const need = trimmed(old);
|
|
86
|
+
const real = need.filter((l) => l.length > 2);
|
|
87
|
+
if (!real.length) return null;
|
|
88
|
+
const anchor = real.reduce((a, b) => (b.length > a.length ? b : a));
|
|
89
|
+
const offset = need.indexOf(anchor);
|
|
90
|
+
let hits = [];
|
|
91
|
+
hay.forEach((l, i) => l === anchor && hits.push(i));
|
|
92
|
+
if (!hits.length) hay.forEach((l, i) => l.length > 2 && (l.includes(anchor) || anchor.includes(l)) && hits.push(i));
|
|
93
|
+
if (!hits.length) {
|
|
94
|
+
const words = (l) => new Set(l.toLowerCase().split(/[^a-z0-9_$]+/).filter(Boolean));
|
|
95
|
+
const want = words(anchor);
|
|
96
|
+
let top = 0.5;
|
|
97
|
+
hay.forEach((l, i) => {
|
|
98
|
+
const have = words(l);
|
|
99
|
+
let shared = 0;
|
|
100
|
+
for (const w of have) if (want.has(w)) shared++;
|
|
101
|
+
const dice = (2 * shared) / (want.size + have.size || 1);
|
|
102
|
+
if (dice > top) {
|
|
103
|
+
top = dice;
|
|
104
|
+
hits = [i];
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (!hits.length) return null;
|
|
109
|
+
let best = { score: -1, start: 0 };
|
|
110
|
+
for (const h of hits.slice(0, 200)) {
|
|
111
|
+
const start = Math.max(0, h - offset);
|
|
112
|
+
let score = 0;
|
|
113
|
+
for (let j = 0; j < need.length; j++) if (hay[start + j] === need[j] && need[j]) score++;
|
|
114
|
+
if (score > best.score) best = { score, start };
|
|
115
|
+
}
|
|
116
|
+
const from = Math.max(0, best.start - 3);
|
|
117
|
+
const to = Math.min(lines.length, best.start + need.length + 3);
|
|
118
|
+
let shown = lines.slice(from, to);
|
|
119
|
+
if (shown.length > EXCERPT_LINES) shown = shown.slice(0, EXCERPT_LINES);
|
|
120
|
+
let excerpt = shown.map((l, i) => (from + i + 1) + "\t" + l).join("\n");
|
|
121
|
+
if (excerpt.length > EXCERPT_CHARS) excerpt = excerpt.slice(0, EXCERPT_CHARS) + "\n…";
|
|
122
|
+
return { from: from + 1, to: from + shown.length, excerpt };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function failureMessage(file, old, text, repeat) {
|
|
126
|
+
const near = closestMatch(text, old);
|
|
127
|
+
const out = [
|
|
128
|
+
"The old text was not found in " + file + ". The file is not what this edit assumes: it changed since you last read it, or the text is not exactly what is there.",
|
|
129
|
+
];
|
|
130
|
+
if (repeat >= 2)
|
|
131
|
+
out.push("This is the same edit (same file, same old text) that has already failed " + (repeat - 1) + " time" + (repeat > 2 ? "s" : "") + ". Do not send it again unchanged.");
|
|
132
|
+
if (near) out.push("The file's current text near the closest match (lines " + near.from + "-" + near.to + "):", near.excerpt);
|
|
133
|
+
else out.push("Nothing in the file resembles that text (" + norm(text).split("\n").length + " lines).");
|
|
134
|
+
out.push("Read " + file + " again and build the edit from what is there now.");
|
|
135
|
+
return out.join("\n");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Failed edits by file and old text, so a repeat can be named. A read, a
|
|
139
|
+
// successful edit or a write of the file starts it over.
|
|
140
|
+
export class FailureLog {
|
|
141
|
+
constructor() {
|
|
142
|
+
this.counts = new Map();
|
|
143
|
+
}
|
|
144
|
+
key(file, old) {
|
|
145
|
+
return file + "\0" + createHash("sha1").update(String(old)).digest("hex");
|
|
146
|
+
}
|
|
147
|
+
record(file, old) {
|
|
148
|
+
const key = this.key(file, old);
|
|
149
|
+
const n = (this.counts.get(key) || 0) + 1;
|
|
150
|
+
this.counts.set(key, n);
|
|
151
|
+
return n;
|
|
152
|
+
}
|
|
153
|
+
clear(file) {
|
|
154
|
+
for (const key of [...this.counts.keys()]) if (key.startsWith(file + "\0")) this.counts.delete(key);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// The refusal for an edit whose old text isn't in the file, or null to let
|
|
159
|
+
// it run. Several edits to one file: the first missing one is named.
|
|
160
|
+
export function guardEdit(log, file, oldTexts, cwd) {
|
|
161
|
+
try {
|
|
162
|
+
const path = absolute(file, cwd);
|
|
163
|
+
const text = readText(path);
|
|
164
|
+
if (text == null) return null;
|
|
165
|
+
const missing = oldTexts.find((o) => !isFound(text, o));
|
|
166
|
+
if (missing === undefined) return null;
|
|
167
|
+
return failureMessage(file, missing, text, log.record(path, missing));
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const tsByRoot = new Map();
|
|
174
|
+
function loadTs(cwd) {
|
|
175
|
+
const root = resolve(cwd || process.cwd());
|
|
176
|
+
const cacheKey = root + "|" + (process.env.AGENTOS_TYPESCRIPT || "");
|
|
177
|
+
if (tsByRoot.has(cacheKey)) return tsByRoot.get(cacheKey);
|
|
178
|
+
let found = null;
|
|
179
|
+
const tries = [
|
|
180
|
+
() => createRequire(resolve(root, "package.json"))("typescript"),
|
|
181
|
+
() => (process.env.AGENTOS_TYPESCRIPT ? createRequire(import.meta.url)(process.env.AGENTOS_TYPESCRIPT) : null),
|
|
182
|
+
];
|
|
183
|
+
for (const t of tries) {
|
|
184
|
+
try {
|
|
185
|
+
const m = t();
|
|
186
|
+
if (m && typeof m.createSourceFile === "function") {
|
|
187
|
+
found = m;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
} catch {}
|
|
191
|
+
}
|
|
192
|
+
tsByRoot.set(cacheKey, found);
|
|
193
|
+
return found;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function bindingNames(ts, name, out) {
|
|
197
|
+
if (ts.isIdentifier(name)) out.push(name);
|
|
198
|
+
else for (const el of name.elements) if (!ts.isOmittedExpression(el)) bindingNames(ts, el.name, out);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function duplicates(ts, sf) {
|
|
202
|
+
const found = [];
|
|
203
|
+
const check = (statements) => {
|
|
204
|
+
const lexical = new Map();
|
|
205
|
+
const hoisted = new Map();
|
|
206
|
+
const add = (map, id, kind) => {
|
|
207
|
+
const line = sf.getLineAndCharacterOfPosition(id.getStart(sf)).line + 1;
|
|
208
|
+
if (!map.has(id.text)) map.set(id.text, []);
|
|
209
|
+
map.get(id.text).push({ kind, line });
|
|
210
|
+
};
|
|
211
|
+
for (const st of statements) {
|
|
212
|
+
if (ts.isVariableStatement(st)) {
|
|
213
|
+
const flags = st.declarationList.flags;
|
|
214
|
+
const kind = flags & ts.NodeFlags.Const ? "const" : flags & ts.NodeFlags.Let ? "let" : "var";
|
|
215
|
+
const ids = [];
|
|
216
|
+
for (const d of st.declarationList.declarations) bindingNames(ts, d.name, ids);
|
|
217
|
+
for (const id of ids) add(kind === "var" ? hoisted : lexical, id, kind);
|
|
218
|
+
} else if (ts.isClassDeclaration(st) && st.name) add(lexical, st.name, "class");
|
|
219
|
+
else if (ts.isFunctionDeclaration(st) && st.name) add(hoisted, st.name, "function");
|
|
220
|
+
}
|
|
221
|
+
for (const [name, at] of lexical) {
|
|
222
|
+
const all = at.concat(hoisted.get(name) || []);
|
|
223
|
+
if (all.length > 1) found.push({ name, kind: at[0].kind, lines: all.map((a) => a.line).sort((a, b) => a - b) });
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
const visit = (node) => {
|
|
227
|
+
if (ts.isSourceFile(node) || ts.isBlock(node) || ts.isModuleBlock(node)) check(node.statements);
|
|
228
|
+
else if (ts.isCaseBlock(node)) check(node.clauses.flatMap((c) => [...c.statements]));
|
|
229
|
+
ts.forEachChild(node, visit);
|
|
230
|
+
};
|
|
231
|
+
visit(sf);
|
|
232
|
+
return found;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function problems(ts, file, text) {
|
|
236
|
+
const out = [];
|
|
237
|
+
const t = ts.transpileModule(text, {
|
|
238
|
+
fileName: file,
|
|
239
|
+
reportDiagnostics: true,
|
|
240
|
+
compilerOptions: { jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.Latest, isolatedModules: true },
|
|
241
|
+
});
|
|
242
|
+
for (const d of t.diagnostics || []) {
|
|
243
|
+
const at = d.file && d.start != null ? d.file.getLineAndCharacterOfPosition(d.start) : null;
|
|
244
|
+
const message = ts.flattenDiagnosticMessageText(d.messageText, " ");
|
|
245
|
+
out.push({ key: message, text: (at ? (at.line + 1) + ":" + (at.character + 1) + " " : "") + message });
|
|
246
|
+
}
|
|
247
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
248
|
+
for (const d of duplicates(ts, sf))
|
|
249
|
+
out.push({
|
|
250
|
+
key: "duplicate " + d.name,
|
|
251
|
+
text: d.kind + " " + d.name + " is declared more than once in the same block (lines " + d.lines.join(", ") + ")",
|
|
252
|
+
});
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// What an edit broke in a source file: the problems it has now that it didn't
|
|
257
|
+
// before, as text for the tool result, or "" when there are none, the file
|
|
258
|
+
// isn't source, or TypeScript can't be loaded. Syntax only, never types.
|
|
259
|
+
export function syntaxReport(file, before, after, cwd) {
|
|
260
|
+
try {
|
|
261
|
+
if (!SOURCE.has(extname(file).toLowerCase()) || typeof after !== "string" || after.length > MAX_BYTES) return "";
|
|
262
|
+
const ts = loadTs(cwd);
|
|
263
|
+
if (!ts) return "";
|
|
264
|
+
const had = new Map();
|
|
265
|
+
for (const p of before == null || before.length > MAX_BYTES ? [] : problems(ts, file, before)) had.set(p.key, (had.get(p.key) || 0) + 1);
|
|
266
|
+
const fresh = [];
|
|
267
|
+
for (const p of problems(ts, file, after)) {
|
|
268
|
+
const n = had.get(p.key) || 0;
|
|
269
|
+
if (n > 0) had.set(p.key, n - 1);
|
|
270
|
+
else fresh.push(p);
|
|
271
|
+
}
|
|
272
|
+
if (!fresh.length) return "";
|
|
273
|
+
const shown = fresh.slice(0, MAX_REPORTED).map((p) => "- " + p.text);
|
|
274
|
+
if (fresh.length > shown.length) shown.push("- and " + (fresh.length - shown.length) + " more");
|
|
275
|
+
return "[AgentOS syntax check] This change left problems in " + file + " that were not there before. Fix them now:\n" + shown.join("\n");
|
|
276
|
+
} catch {
|
|
277
|
+
return "";
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
`;
|
|
281
|
+
|
|
282
|
+
const dir = () => agentosHome("edit-guard");
|
|
283
|
+
|
|
284
|
+
// Owner-only, like the other extension folders.
|
|
285
|
+
export function writeEditGuard(
|
|
286
|
+
name: string,
|
|
287
|
+
source: string,
|
|
288
|
+
folder = dir()
|
|
289
|
+
): string {
|
|
290
|
+
fs.mkdirSync(folder, { recursive: true, mode: 0o700 });
|
|
291
|
+
fs.chmodSync(folder, 0o700);
|
|
292
|
+
for (const [file, text] of [
|
|
293
|
+
["agentos-edit-guard-lib.mjs", EDIT_GUARD_LIB],
|
|
294
|
+
[name, source],
|
|
295
|
+
]) {
|
|
296
|
+
const target = path.join(folder, file);
|
|
297
|
+
if (!fs.existsSync(target) || fs.readFileSync(target, "utf8") !== text)
|
|
298
|
+
fs.writeFileSync(target, text);
|
|
299
|
+
}
|
|
300
|
+
return path.join(folder, name);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The TypeScript this server runs on, for projects that have none of their
|
|
304
|
+
// own, or undefined where there isn't one.
|
|
305
|
+
export function typescriptPath(): string | undefined {
|
|
306
|
+
try {
|
|
307
|
+
return createRequire(import.meta.url).resolve("typescript");
|
|
308
|
+
} catch {
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { pathToFileURL } from "url";
|
|
5
|
+
|
|
6
|
+
type P = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
// How long OpenCode waits for a model server's response headers (its default
|
|
9
|
+
// is 5 minutes). A local model chewing through a long context sends nothing
|
|
10
|
+
// until it has read all of it, and a retry at the same limit fails the same
|
|
11
|
+
// way. The wait between chunks of a streamed reply keeps OpenCode's own 5
|
|
12
|
+
// minute limit, so a stream that started is judged on idle time and a server
|
|
13
|
+
// that never answers still ends the request.
|
|
14
|
+
export const HEADER_TIMEOUT_MS = 1_200_000;
|
|
15
|
+
|
|
16
|
+
const isObject = (v: unknown): v is P =>
|
|
17
|
+
!!v && typeof v === "object" && !Array.isArray(v);
|
|
18
|
+
|
|
19
|
+
// JSON with comments and trailing commas (OpenCode's config allows both),
|
|
20
|
+
// parsed without touching what is inside strings. Null when it isn't JSON.
|
|
21
|
+
export function parseJsonc(text: string): P | null {
|
|
22
|
+
let out = "";
|
|
23
|
+
for (let i = 0; i < text.length; i++) {
|
|
24
|
+
const c = text[i];
|
|
25
|
+
if (c === '"') {
|
|
26
|
+
let j = i + 1;
|
|
27
|
+
while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
|
|
28
|
+
out += text.slice(i, j + 1);
|
|
29
|
+
i = j;
|
|
30
|
+
} else if (c === "/" && text[i + 1] === "/") {
|
|
31
|
+
while (i < text.length && text[i] !== "\n") i++;
|
|
32
|
+
out += "\n";
|
|
33
|
+
} else if (c === "/" && text[i + 1] === "*") {
|
|
34
|
+
const end = text.indexOf("*/", i + 2);
|
|
35
|
+
i = end < 0 ? text.length : end + 1;
|
|
36
|
+
} else out += c;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const v: unknown = JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
|
|
40
|
+
return isObject(v) ? v : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function read(file: string): P | null {
|
|
47
|
+
try {
|
|
48
|
+
return parseJsonc(fs.readFileSync(file, "utf8"));
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function userConfigs(cwd: string, env: NodeJS.ProcessEnv): P[] {
|
|
55
|
+
const home = env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
56
|
+
const files = [
|
|
57
|
+
...["opencode.json", "opencode.jsonc", "config.json"].map((f) =>
|
|
58
|
+
path.join(home, "opencode", f)
|
|
59
|
+
),
|
|
60
|
+
...(env.OPENCODE_CONFIG ? [env.OPENCODE_CONFIG] : []),
|
|
61
|
+
...["opencode.json", "opencode.jsonc"].flatMap((f) => [
|
|
62
|
+
path.join(cwd, f),
|
|
63
|
+
path.join(cwd, ".opencode", f),
|
|
64
|
+
]),
|
|
65
|
+
];
|
|
66
|
+
return files.map(read).filter((c): c is P => !!c);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The OPENCODE_CONFIG_CONTENT for one conversation: the user's own value
|
|
70
|
+
// (never replaced, their providers are in it) plus the edit guard plugin and
|
|
71
|
+
// a longer header wait for each provider in their config and for the
|
|
72
|
+
// conversation's model, unless they set one themselves.
|
|
73
|
+
export function openCodeConfigContent(o: {
|
|
74
|
+
cwd: string;
|
|
75
|
+
env: NodeJS.ProcessEnv;
|
|
76
|
+
// "provider/model", the conversation's.
|
|
77
|
+
model?: string;
|
|
78
|
+
pluginFile?: string;
|
|
79
|
+
}): string {
|
|
80
|
+
const given = o.env.OPENCODE_CONFIG_CONTENT;
|
|
81
|
+
const base = given ? parseJsonc(given) : {};
|
|
82
|
+
if (!base) return given as string;
|
|
83
|
+
|
|
84
|
+
const own = new Set<string>();
|
|
85
|
+
const set = new Set<string>();
|
|
86
|
+
const slash = o.model?.indexOf("/") ?? -1;
|
|
87
|
+
if (o.model && slash > 0) set.add(o.model.slice(0, slash));
|
|
88
|
+
for (const c of [...userConfigs(o.cwd, o.env), base]) {
|
|
89
|
+
if (!isObject(c.provider)) continue;
|
|
90
|
+
for (const [id, p] of Object.entries(c.provider)) {
|
|
91
|
+
set.add(id);
|
|
92
|
+
const options = isObject(p) && isObject(p.options) ? p.options : {};
|
|
93
|
+
if (options.headerTimeout !== undefined) own.add(id);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const provider: P = isObject(base.provider) ? { ...base.provider } : {};
|
|
98
|
+
for (const id of set) {
|
|
99
|
+
if (own.has(id)) continue;
|
|
100
|
+
const p = isObject(provider[id]) ? (provider[id] as P) : {};
|
|
101
|
+
const options = isObject(p.options) ? p.options : {};
|
|
102
|
+
provider[id] = {
|
|
103
|
+
...p,
|
|
104
|
+
options: { ...options, headerTimeout: HEADER_TIMEOUT_MS },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const plugin = Array.isArray(base.plugin) ? [...base.plugin] : [];
|
|
109
|
+
if (o.pluginFile) plugin.push(pathToFileURL(o.pluginFile).href);
|
|
110
|
+
return JSON.stringify({ ...base, provider, plugin });
|
|
111
|
+
}
|