@te-river/opencode-team-mode 1.7.6 → 1.7.8

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.
Files changed (59) hide show
  1. package/README.md +1010 -1009
  2. package/README.zh-CN.md +907 -906
  3. package/dist/host/v2-browser-gate.js +2 -2
  4. package/dist/host/v2-guard.d.ts +135 -0
  5. package/dist/host/v2-guard.d.ts.map +1 -1
  6. package/dist/host/v2-guard.js +227 -2
  7. package/dist/host/v2-guard.js.map +1 -1
  8. package/dist/host/v2-offload.js +6 -6
  9. package/dist/host/v2-retry.d.ts +9 -1
  10. package/dist/host/v2-retry.d.ts.map +1 -1
  11. package/dist/host/v2-retry.js +10 -2
  12. package/dist/host/v2-retry.js.map +1 -1
  13. package/dist/host/v2-subagent.d.ts +21 -0
  14. package/dist/host/v2-subagent.d.ts.map +1 -1
  15. package/dist/host/v2-subagent.js +55 -9
  16. package/dist/host/v2-subagent.js.map +1 -1
  17. package/dist/host/v2.d.ts.map +1 -1
  18. package/dist/host/v2.js +77 -1
  19. package/dist/host/v2.js.map +1 -1
  20. package/dist/prompts/specialists.js +286 -286
  21. package/dist/task-offload.js +1 -1
  22. package/dist/tm/config-layers.d.ts +5 -2
  23. package/dist/tm/config-layers.d.ts.map +1 -1
  24. package/dist/tm/config-layers.js +10 -6
  25. package/dist/tm/config-layers.js.map +1 -1
  26. package/dist/tm/config.d.ts +3 -0
  27. package/dist/tm/config.d.ts.map +1 -1
  28. package/dist/tm/config.js +7 -1
  29. package/dist/tm/config.js.map +1 -1
  30. package/dist/tm/index.d.ts +5 -1
  31. package/dist/tm/index.d.ts.map +1 -1
  32. package/dist/tm/index.js +1 -1
  33. package/dist/tm/index.js.map +1 -1
  34. package/dist/tm/ledger.js +5 -5
  35. package/dist/tm/memory.js +9 -9
  36. package/dist/tm/search.d.ts +3 -0
  37. package/dist/tm/search.d.ts.map +1 -1
  38. package/dist/tm/search.js +67 -15
  39. package/dist/tm/search.js.map +1 -1
  40. package/dist/tm/stats.d.ts.map +1 -1
  41. package/dist/tm/stats.js +23 -0
  42. package/dist/tm/stats.js.map +1 -1
  43. package/dist/tm/tools.js +3 -3
  44. package/dist/tm/webfetch.d.ts +94 -5
  45. package/dist/tm/webfetch.d.ts.map +1 -1
  46. package/dist/tm/webfetch.js +285 -64
  47. package/dist/tm/webfetch.js.map +1 -1
  48. package/docs/installation-v2.md +2 -2
  49. package/docs/installation.md +505 -505
  50. package/docs/research/agent-data-exchange.md +117 -117
  51. package/docs/research/browser-pane.md +94 -94
  52. package/docs/research/host-http-api.md +132 -132
  53. package/docs/research/host-subagent-injection.md +187 -187
  54. package/docs/research/plugin-loader-contract.md +261 -261
  55. package/docs/research/v2-builtin-tools.md +447 -447
  56. package/docs/v2-port-notes.md +121 -78
  57. package/index.js +7 -7
  58. package/package.json +64 -64
  59. package/scripts/lib/config-surgery.cjs +239 -239
@@ -1,239 +1,239 @@
1
- // 2.x config surgery, shared by scripts/install.sh and scripts/install.ps1 so the two
2
- // installers cannot drift into two different ideas of what "installed" means.
3
- //
4
- // Adds the plugin entry and/or sets default_agent, then READS THE VALUE BACK from disk
5
- // before reporting success. Comments are masked to spaces at identical offsets, so a
6
- // commented-out key can never be mistaken for a live one (the read-back would pass
7
- // while the config said nothing).
8
- //
9
- // Why the key is `plugins` and not `plugin`, why one entry may not sit in two files,
10
- // and why a directory target needs a root index.js: docs/research/plugin-loader-contract.md
11
- // usage: node config-surgery.cjs <file> <plugins|default-agent> <value> [otherFileToScrub]
12
- const fs = require("fs");
13
- const [file, mode, value, other] = process.argv.slice(2);
14
- if (!file || !mode || !value) {
15
- console.error("ERR usage: node this.js <file> <plugins|default-agent> <value> [otherFileToScrub]");
16
- process.exit(1);
17
- }
18
-
19
- const mask = (s) => {
20
- let out = "", i = 0, inStr = false, esc = false;
21
- while (i < s.length) {
22
- const c = s[i];
23
- if (inStr) {
24
- out += c;
25
- if (esc) esc = false; else if (c === "\\") esc = true; else if (c === '"') inStr = false;
26
- i++; continue;
27
- }
28
- if (c === '"') { inStr = true; out += c; i++; continue; }
29
- if (c === "/" && s[i + 1] === "/") { while (i < s.length && s[i] !== "\n") { out += " "; i++; } continue; }
30
- if (c === "/" && s[i + 1] === "*") {
31
- while (i < s.length && !(s[i] === "*" && s[i + 1] === "/")) { out += s[i] === "\n" ? "\n" : " "; i++; }
32
- out += " "; i += 2; continue;
33
- }
34
- out += c; i++;
35
- }
36
- return out;
37
- };
38
-
39
- const lineOf = (s, idx) => s.slice(0, idx).split("\n").length;
40
- let src = fs.readFileSync(file, "utf8");
41
- const open = mask(src).indexOf("{");
42
- if (open < 0) { console.error("ERR no top-level object in " + file); process.exit(1); }
43
- const nextNonWs = (s, i) => { while (i < s.length && /\s/.test(s[i])) i++; return i; };
44
- /** insert one member right after the object's opening brace (top level by
45
- * definition: in a JSONC config the first '{' IS the document's own). */
46
- const insertMember = (s, member) => {
47
- const after = nextNonWs(s, open + 1);
48
- const comma = s[after] === "}" ? "" : ","; // an empty object takes none
49
- return s.slice(0, open + 1) + "\n " + member + comma + s.slice(open + 1);
50
- };
51
- const lineNo = (s, idx) => " (line " + lineOf(s, idx) + " of " + file + ")";
52
-
53
- if (mode === "plugins") {
54
- // OpenCode 2.x reads `config.plugins` (PLURAL). The singular `plugin` key is the
55
- // 1.18.x spelling; a 2.x host never reads it, so an entry written there looks like
56
- // an installed plugin and is nothing. It is left in place (a user may still share
57
- // the file with a 1.18.x install) but REPORTED.
58
- //
59
- // Exactly ONE entry for our package, in either spelling: the host loads every entry
60
- // it finds, and the same plugin loaded twice means two personalities registering the
61
- // same tools and hooks. So a previous entry of OURS (npm specifier vs ./vendor path)
62
- // is REPLACED, never duplicated; entries naming anything else are untouched.
63
- const lit = JSON.stringify(value);
64
- // Case-insensitive, hyphen-optional and slash-tolerant, because a local install is
65
- // spelled with the working tree's own directory name (`D:/Github/Opencode-TeamMode`,
66
- // no hyphen between team and mode) while the npm entry is `opencode-team-mode`. An
67
- // entry we fail to recognise as ours is one we would ADD next to the existing one —
68
- // and two entries is two loads, which is the defect this function exists to prevent.
69
- const ours = (t) => /opencode[-_]?team[-_]?mode|vendor[/\\]team-mode/i.test(t);
70
- const findArray = (mk, key) => {
71
- const m = new RegExp('"' + key + '"\\s*:\\s*\\[').exec(mk);
72
- if (!m) return null;
73
- let i = m.index + m[0].length, depth = 1;
74
- while (i < mk.length) {
75
- const c = mk[i];
76
- if (c === '"') { const j = mk.indexOf('"', i + 1); if (j < 0) return null; i = j + 1; continue; }
77
- if (c === "[" || c === "{") depth++;
78
- else if (c === "]" || c === "}") { depth--; if (depth === 0) return { head: m.index + m[0].length, end: i }; }
79
- i++;
80
- }
81
- return null;
82
- };
83
- /** [start,end) spans of the array's own elements in [head,end); commas and whitespace
84
- * are skipped, so a trailing comma — which a hand-edited or host-written config often
85
- * carries — cannot end up producing ", ,", which would break the parse. */
86
- const elements = (mk, head, end) => {
87
- const out = []; let i = head;
88
- while (i < end) {
89
- const c = mk[i];
90
- if (c === "," || /\s/.test(c)) { i++; continue; }
91
- if (c === '"') { const j = mk.indexOf('"', i + 1); if (j < 0 || j >= end) break; out.push([i, j + 1]); i = j + 1; continue; }
92
- if (c === "{" || c === "[") {
93
- let d = 0, k = i;
94
- for (; k < end; k++) {
95
- const q = mk[k];
96
- if (q === '"') { k = mk.indexOf('"', k + 1); if (k < 0 || k >= end) { k = end; break } continue; }
97
- if (q === "{" || q === "[") d++;
98
- else if (q === "}" || q === "]") { d--; if (!d) { k++; break; } }
99
- }
100
- out.push([i, k]); i = k; continue;
101
- }
102
- let k = i; while (k < end && !/[,}\]\s]/.test(mk[k])) k++;
103
- out.push([i, k]); i = k;
104
- }
105
- return out;
106
- };
107
- const view = (text) => { const mk = mask(text); const a = findArray(mk, "plugins"); return { mk, a, els: a ? elements(mk, a.head, a.end) : [] }; };
108
-
109
- const v0 = view(src);
110
- if (findArray(v0.mk, "plugin")) {
111
- console.log("NOTE this file also has a top-level \"plugin\" (singular) key — OpenCode 2.x loads" +
112
- "\n config.plugins (plural) and never reads the singular one. Left untouched; if 1.18.x\n" +
113
- " is gone from this machine, the key and its entries are dead weight you can delete.");
114
- }
115
- if (!v0.a) {
116
- fs.writeFileSync(file, insertMember(src, '"plugins": [\n ' + lit + "\n ]"));
117
- console.log("OK created the plugins array with " + lit);
118
- } else {
119
- const same = v0.els.filter(([a, b]) => v0.mk.slice(a, b) === lit);
120
- const dupe = v0.els.filter(([a, b]) => ours(v0.mk.slice(a, b)) && v0.mk.slice(a, b) !== lit);
121
- if (dupe.length) {
122
- // Replace the first, drop the rest: two entries of ours = two loaded personalities.
123
- let out = src;
124
- for (let n = dupe.length - 1; n >= 0; n--) {
125
- const [a, b] = dupe[n];
126
- out = n === 0 ? out.slice(0, a) + lit + out.slice(b) : out.slice(0, a) + out.slice(b);
127
- }
128
- fs.writeFileSync(file, out);
129
- console.log("OK replaced " + dupe.length + " previous Team entry/entries with " + lit);
130
- } else if (same.length) {
131
- console.log("OK plugins already lists " + lit +
132
- (same.length > 1 ? " — " + same.length + " times, which loads the plugin twice" : ""));
133
- } else {
134
- const last = v0.els[v0.els.length - 1];
135
- const at = last ? last[1] : v0.a.head;
136
- const pad = last ? ",\n " : "\n ";
137
- fs.writeFileSync(file, src.slice(0, at) + pad + lit + src.slice(at));
138
- console.log("OK added " + lit + " alongside " + v0.els.length + " existing entr" + (v0.els.length === 1 ? "y" : "ies"));
139
- }
140
- }
141
- // Read it back off the disk. The host's loader skips a plugin directory whose
142
- // entrypoint it cannot resolve with NO message at all, and a key nobody reads is
143
- // indistinguishable from an install — so the only claim made here is the measured one.
144
- const v1 = view(fs.readFileSync(file, "utf8"));
145
- const got = v1.els.filter(([a, b]) => v1.mk.slice(a, b) === lit).length;
146
- if (!got) {
147
- console.error("ERR read-back: plugins does not hold " + lit + " (array " + (v1.a ? "present" : "ABSENT") + ")");
148
- console.error(" Add it by hand at the top level of " + file + ": \"plugins\": [" + lit + "]");
149
- process.exit(1);
150
- }
151
- const stillOurs = v1.els.filter(([a, b]) => ours(v1.mk.slice(a, b))).length;
152
- console.log("OK read back: plugins holds " + lit + " (" + v1.els.length + " entries, " +
153
- stillOurs + " of them Team)");
154
- if (got > 1 || stillOurs > 1) {
155
- console.error("ERR the array carries our package more than once — the host would load it twice");
156
- process.exit(1);
157
- }
158
- // Reclaim our entry from the OTHER global config file. The two files are merged at
159
- // parse time and the host dedupes only on an identical string, so leaving our entry
160
- // in opencode.json while writing this one would register the plugin twice — two
161
- // personalities, the same tools and hooks bound twice, and a boot log that reads as
162
- // one healthy install. Foreign entries (another plugin, any other key) are untouched.
163
- if (other && other !== file && fs.existsSync(other)) {
164
- const osrc = fs.readFileSync(other, "utf8");
165
- const ov = view(osrc);
166
- const mine = ov.els.filter(([a, b]) => ours(ov.mk.slice(a, b)));
167
- if (mine.length) {
168
- let out = osrc;
169
- for (let n = mine.length - 1; n >= 0; n--) {
170
- const [a, b] = mine[n];
171
- // Take the comma with the element, and pick the RIGHT side: dropping a trailing
172
- // comma when the element is the last one would leave `"x",\n ]`, which only
173
- // parses because the host reads JSONC with allowTrailingComma — and a config that
174
- // is valid solely under a lenient parser is a trap for the next hand edit.
175
- let prev = a - 1;
176
- while (prev >= 0 && /\s/.test(out[prev])) prev--;
177
- if (prev >= 0 && out[prev] === ",") {
178
- out = out.slice(0, prev) + out.slice(b); // not first: remove prev comma + element
179
- } else {
180
- let next = b;
181
- while (next < out.length && /\s/.test(out[next])) next++;
182
- out = out.slice(0, a) + out.slice(out[next] === "," ? next + 1 : b); // first: remove element + its comma
183
- }
184
- }
185
- fs.writeFileSync(other, out);
186
- // [Q, T,] with T removed keeps a comma either way (a separator on one side, a
187
- // trailing one on the other), so a single element + comma delete cannot produce
188
- // strict JSON. Strip a comma that now dangles immediately before THIS array's own
189
- // closing bracket — that region and no other, because the rest of the file is not
190
- // ours to reformat.
191
- const after = view(fs.readFileSync(other, "utf8"));
192
- if (after.a) {
193
- let p = after.a.end - 1;
194
- while (p > after.a.head && /\s/.test(out[p])) p--;
195
- if (out[p] === ",") {
196
- out = out.slice(0, p) + out.slice(p + 1);
197
- fs.writeFileSync(other, out);
198
- }
199
- }
200
- const chk = view(fs.readFileSync(other, "utf8"));
201
- const left = chk.els.filter(([a, b]) => ours(chk.mk.slice(a, b))).length;
202
- if (left) {
203
- console.error("ERR " + other + " still lists Team after the reclaim — the plugin would load twice");
204
- process.exit(1);
205
- }
206
- console.log("OK reclaimed " + mine.length + " Team entr" + (mine.length === 1 ? "y" : "ies") +
207
- " from " + other + " (a second copy means a second load)");
208
- }
209
- }
210
- } else if (mode === "default-agent") {
211
- const lit = JSON.stringify(value);
212
- const re = /"default_agent"(\s*:\s*)("(?:[^"\\]|\\.)*"|[^,}\s][^,}\n]*)/;
213
- const m = re.exec(mask(src));
214
- if (m) {
215
- const vStart = m.index + m[0].length - m[2].length;
216
- if (m[2].trim() === lit) {
217
- console.log("OK default_agent already " + lit + lineNo(src, m.index));
218
- } else {
219
- fs.writeFileSync(file, src.slice(0, vStart) + lit + src.slice(vStart + m[2].length));
220
- console.log("OK default_agent rewritten (was " + m[2].trim() + ")");
221
- }
222
- } else {
223
- fs.writeFileSync(file, insertMember(src, '"default_agent": ' + lit));
224
- console.log("OK default_agent added");
225
- }
226
- // Read it back — a fresh read of the file from disk, comments masked again.
227
- src = fs.readFileSync(file, "utf8");
228
- const chk = re.exec(mask(src));
229
- const got = chk ? chk[2].trim() : null;
230
- if (got !== lit) {
231
- console.error("ERR default_agent read back as " + (got === null ? "(key absent)" : got) + ", not " + lit);
232
- console.error(" Add it by hand at the top level of " + file + ": \"default_agent\": " + lit);
233
- process.exit(1);
234
- }
235
- console.log("OK read back: " + lit + lineNo(src, chk.index));
236
- } else {
237
- console.error("ERR unknown mode '" + mode + "'");
238
- process.exit(1);
239
- }
1
+ // 2.x config surgery, shared by scripts/install.sh and scripts/install.ps1 so the two
2
+ // installers cannot drift into two different ideas of what "installed" means.
3
+ //
4
+ // Adds the plugin entry and/or sets default_agent, then READS THE VALUE BACK from disk
5
+ // before reporting success. Comments are masked to spaces at identical offsets, so a
6
+ // commented-out key can never be mistaken for a live one (the read-back would pass
7
+ // while the config said nothing).
8
+ //
9
+ // Why the key is `plugins` and not `plugin`, why one entry may not sit in two files,
10
+ // and why a directory target needs a root index.js: docs/research/plugin-loader-contract.md
11
+ // usage: node config-surgery.cjs <file> <plugins|default-agent> <value> [otherFileToScrub]
12
+ const fs = require("fs");
13
+ const [file, mode, value, other] = process.argv.slice(2);
14
+ if (!file || !mode || !value) {
15
+ console.error("ERR usage: node this.js <file> <plugins|default-agent> <value> [otherFileToScrub]");
16
+ process.exit(1);
17
+ }
18
+
19
+ const mask = (s) => {
20
+ let out = "", i = 0, inStr = false, esc = false;
21
+ while (i < s.length) {
22
+ const c = s[i];
23
+ if (inStr) {
24
+ out += c;
25
+ if (esc) esc = false; else if (c === "\\") esc = true; else if (c === '"') inStr = false;
26
+ i++; continue;
27
+ }
28
+ if (c === '"') { inStr = true; out += c; i++; continue; }
29
+ if (c === "/" && s[i + 1] === "/") { while (i < s.length && s[i] !== "\n") { out += " "; i++; } continue; }
30
+ if (c === "/" && s[i + 1] === "*") {
31
+ while (i < s.length && !(s[i] === "*" && s[i + 1] === "/")) { out += s[i] === "\n" ? "\n" : " "; i++; }
32
+ out += " "; i += 2; continue;
33
+ }
34
+ out += c; i++;
35
+ }
36
+ return out;
37
+ };
38
+
39
+ const lineOf = (s, idx) => s.slice(0, idx).split("\n").length;
40
+ let src = fs.readFileSync(file, "utf8");
41
+ const open = mask(src).indexOf("{");
42
+ if (open < 0) { console.error("ERR no top-level object in " + file); process.exit(1); }
43
+ const nextNonWs = (s, i) => { while (i < s.length && /\s/.test(s[i])) i++; return i; };
44
+ /** insert one member right after the object's opening brace (top level by
45
+ * definition: in a JSONC config the first '{' IS the document's own). */
46
+ const insertMember = (s, member) => {
47
+ const after = nextNonWs(s, open + 1);
48
+ const comma = s[after] === "}" ? "" : ","; // an empty object takes none
49
+ return s.slice(0, open + 1) + "\n " + member + comma + s.slice(open + 1);
50
+ };
51
+ const lineNo = (s, idx) => " (line " + lineOf(s, idx) + " of " + file + ")";
52
+
53
+ if (mode === "plugins") {
54
+ // OpenCode 2.x reads `config.plugins` (PLURAL). The singular `plugin` key is the
55
+ // 1.18.x spelling; a 2.x host never reads it, so an entry written there looks like
56
+ // an installed plugin and is nothing. It is left in place (a user may still share
57
+ // the file with a 1.18.x install) but REPORTED.
58
+ //
59
+ // Exactly ONE entry for our package, in either spelling: the host loads every entry
60
+ // it finds, and the same plugin loaded twice means two personalities registering the
61
+ // same tools and hooks. So a previous entry of OURS (npm specifier vs ./vendor path)
62
+ // is REPLACED, never duplicated; entries naming anything else are untouched.
63
+ const lit = JSON.stringify(value);
64
+ // Case-insensitive, hyphen-optional and slash-tolerant, because a local install is
65
+ // spelled with the working tree's own directory name (`D:/Github/Opencode-TeamMode`,
66
+ // no hyphen between team and mode) while the npm entry is `opencode-team-mode`. An
67
+ // entry we fail to recognise as ours is one we would ADD next to the existing one —
68
+ // and two entries is two loads, which is the defect this function exists to prevent.
69
+ const ours = (t) => /opencode[-_]?team[-_]?mode|vendor[/\\]team-mode/i.test(t);
70
+ const findArray = (mk, key) => {
71
+ const m = new RegExp('"' + key + '"\\s*:\\s*\\[').exec(mk);
72
+ if (!m) return null;
73
+ let i = m.index + m[0].length, depth = 1;
74
+ while (i < mk.length) {
75
+ const c = mk[i];
76
+ if (c === '"') { const j = mk.indexOf('"', i + 1); if (j < 0) return null; i = j + 1; continue; }
77
+ if (c === "[" || c === "{") depth++;
78
+ else if (c === "]" || c === "}") { depth--; if (depth === 0) return { head: m.index + m[0].length, end: i }; }
79
+ i++;
80
+ }
81
+ return null;
82
+ };
83
+ /** [start,end) spans of the array's own elements in [head,end); commas and whitespace
84
+ * are skipped, so a trailing comma — which a hand-edited or host-written config often
85
+ * carries — cannot end up producing ", ,", which would break the parse. */
86
+ const elements = (mk, head, end) => {
87
+ const out = []; let i = head;
88
+ while (i < end) {
89
+ const c = mk[i];
90
+ if (c === "," || /\s/.test(c)) { i++; continue; }
91
+ if (c === '"') { const j = mk.indexOf('"', i + 1); if (j < 0 || j >= end) break; out.push([i, j + 1]); i = j + 1; continue; }
92
+ if (c === "{" || c === "[") {
93
+ let d = 0, k = i;
94
+ for (; k < end; k++) {
95
+ const q = mk[k];
96
+ if (q === '"') { k = mk.indexOf('"', k + 1); if (k < 0 || k >= end) { k = end; break } continue; }
97
+ if (q === "{" || q === "[") d++;
98
+ else if (q === "}" || q === "]") { d--; if (!d) { k++; break; } }
99
+ }
100
+ out.push([i, k]); i = k; continue;
101
+ }
102
+ let k = i; while (k < end && !/[,}\]\s]/.test(mk[k])) k++;
103
+ out.push([i, k]); i = k;
104
+ }
105
+ return out;
106
+ };
107
+ const view = (text) => { const mk = mask(text); const a = findArray(mk, "plugins"); return { mk, a, els: a ? elements(mk, a.head, a.end) : [] }; };
108
+
109
+ const v0 = view(src);
110
+ if (findArray(v0.mk, "plugin")) {
111
+ console.log("NOTE this file also has a top-level \"plugin\" (singular) key — OpenCode 2.x loads" +
112
+ "\n config.plugins (plural) and never reads the singular one. Left untouched; if 1.18.x\n" +
113
+ " is gone from this machine, the key and its entries are dead weight you can delete.");
114
+ }
115
+ if (!v0.a) {
116
+ fs.writeFileSync(file, insertMember(src, '"plugins": [\n ' + lit + "\n ]"));
117
+ console.log("OK created the plugins array with " + lit);
118
+ } else {
119
+ const same = v0.els.filter(([a, b]) => v0.mk.slice(a, b) === lit);
120
+ const dupe = v0.els.filter(([a, b]) => ours(v0.mk.slice(a, b)) && v0.mk.slice(a, b) !== lit);
121
+ if (dupe.length) {
122
+ // Replace the first, drop the rest: two entries of ours = two loaded personalities.
123
+ let out = src;
124
+ for (let n = dupe.length - 1; n >= 0; n--) {
125
+ const [a, b] = dupe[n];
126
+ out = n === 0 ? out.slice(0, a) + lit + out.slice(b) : out.slice(0, a) + out.slice(b);
127
+ }
128
+ fs.writeFileSync(file, out);
129
+ console.log("OK replaced " + dupe.length + " previous Team entry/entries with " + lit);
130
+ } else if (same.length) {
131
+ console.log("OK plugins already lists " + lit +
132
+ (same.length > 1 ? " — " + same.length + " times, which loads the plugin twice" : ""));
133
+ } else {
134
+ const last = v0.els[v0.els.length - 1];
135
+ const at = last ? last[1] : v0.a.head;
136
+ const pad = last ? ",\n " : "\n ";
137
+ fs.writeFileSync(file, src.slice(0, at) + pad + lit + src.slice(at));
138
+ console.log("OK added " + lit + " alongside " + v0.els.length + " existing entr" + (v0.els.length === 1 ? "y" : "ies"));
139
+ }
140
+ }
141
+ // Read it back off the disk. The host's loader skips a plugin directory whose
142
+ // entrypoint it cannot resolve with NO message at all, and a key nobody reads is
143
+ // indistinguishable from an install — so the only claim made here is the measured one.
144
+ const v1 = view(fs.readFileSync(file, "utf8"));
145
+ const got = v1.els.filter(([a, b]) => v1.mk.slice(a, b) === lit).length;
146
+ if (!got) {
147
+ console.error("ERR read-back: plugins does not hold " + lit + " (array " + (v1.a ? "present" : "ABSENT") + ")");
148
+ console.error(" Add it by hand at the top level of " + file + ": \"plugins\": [" + lit + "]");
149
+ process.exit(1);
150
+ }
151
+ const stillOurs = v1.els.filter(([a, b]) => ours(v1.mk.slice(a, b))).length;
152
+ console.log("OK read back: plugins holds " + lit + " (" + v1.els.length + " entries, " +
153
+ stillOurs + " of them Team)");
154
+ if (got > 1 || stillOurs > 1) {
155
+ console.error("ERR the array carries our package more than once — the host would load it twice");
156
+ process.exit(1);
157
+ }
158
+ // Reclaim our entry from the OTHER global config file. The two files are merged at
159
+ // parse time and the host dedupes only on an identical string, so leaving our entry
160
+ // in opencode.json while writing this one would register the plugin twice — two
161
+ // personalities, the same tools and hooks bound twice, and a boot log that reads as
162
+ // one healthy install. Foreign entries (another plugin, any other key) are untouched.
163
+ if (other && other !== file && fs.existsSync(other)) {
164
+ const osrc = fs.readFileSync(other, "utf8");
165
+ const ov = view(osrc);
166
+ const mine = ov.els.filter(([a, b]) => ours(ov.mk.slice(a, b)));
167
+ if (mine.length) {
168
+ let out = osrc;
169
+ for (let n = mine.length - 1; n >= 0; n--) {
170
+ const [a, b] = mine[n];
171
+ // Take the comma with the element, and pick the RIGHT side: dropping a trailing
172
+ // comma when the element is the last one would leave `"x",\n ]`, which only
173
+ // parses because the host reads JSONC with allowTrailingComma — and a config that
174
+ // is valid solely under a lenient parser is a trap for the next hand edit.
175
+ let prev = a - 1;
176
+ while (prev >= 0 && /\s/.test(out[prev])) prev--;
177
+ if (prev >= 0 && out[prev] === ",") {
178
+ out = out.slice(0, prev) + out.slice(b); // not first: remove prev comma + element
179
+ } else {
180
+ let next = b;
181
+ while (next < out.length && /\s/.test(out[next])) next++;
182
+ out = out.slice(0, a) + out.slice(out[next] === "," ? next + 1 : b); // first: remove element + its comma
183
+ }
184
+ }
185
+ fs.writeFileSync(other, out);
186
+ // [Q, T,] with T removed keeps a comma either way (a separator on one side, a
187
+ // trailing one on the other), so a single element + comma delete cannot produce
188
+ // strict JSON. Strip a comma that now dangles immediately before THIS array's own
189
+ // closing bracket — that region and no other, because the rest of the file is not
190
+ // ours to reformat.
191
+ const after = view(fs.readFileSync(other, "utf8"));
192
+ if (after.a) {
193
+ let p = after.a.end - 1;
194
+ while (p > after.a.head && /\s/.test(out[p])) p--;
195
+ if (out[p] === ",") {
196
+ out = out.slice(0, p) + out.slice(p + 1);
197
+ fs.writeFileSync(other, out);
198
+ }
199
+ }
200
+ const chk = view(fs.readFileSync(other, "utf8"));
201
+ const left = chk.els.filter(([a, b]) => ours(chk.mk.slice(a, b))).length;
202
+ if (left) {
203
+ console.error("ERR " + other + " still lists Team after the reclaim — the plugin would load twice");
204
+ process.exit(1);
205
+ }
206
+ console.log("OK reclaimed " + mine.length + " Team entr" + (mine.length === 1 ? "y" : "ies") +
207
+ " from " + other + " (a second copy means a second load)");
208
+ }
209
+ }
210
+ } else if (mode === "default-agent") {
211
+ const lit = JSON.stringify(value);
212
+ const re = /"default_agent"(\s*:\s*)("(?:[^"\\]|\\.)*"|[^,}\s][^,}\n]*)/;
213
+ const m = re.exec(mask(src));
214
+ if (m) {
215
+ const vStart = m.index + m[0].length - m[2].length;
216
+ if (m[2].trim() === lit) {
217
+ console.log("OK default_agent already " + lit + lineNo(src, m.index));
218
+ } else {
219
+ fs.writeFileSync(file, src.slice(0, vStart) + lit + src.slice(vStart + m[2].length));
220
+ console.log("OK default_agent rewritten (was " + m[2].trim() + ")");
221
+ }
222
+ } else {
223
+ fs.writeFileSync(file, insertMember(src, '"default_agent": ' + lit));
224
+ console.log("OK default_agent added");
225
+ }
226
+ // Read it back — a fresh read of the file from disk, comments masked again.
227
+ src = fs.readFileSync(file, "utf8");
228
+ const chk = re.exec(mask(src));
229
+ const got = chk ? chk[2].trim() : null;
230
+ if (got !== lit) {
231
+ console.error("ERR default_agent read back as " + (got === null ? "(key absent)" : got) + ", not " + lit);
232
+ console.error(" Add it by hand at the top level of " + file + ": \"default_agent\": " + lit);
233
+ process.exit(1);
234
+ }
235
+ console.log("OK read back: " + lit + lineNo(src, chk.index));
236
+ } else {
237
+ console.error("ERR unknown mode '" + mode + "'");
238
+ process.exit(1);
239
+ }