@te-river/opencode-team-mode 1.6.0 → 1.6.2
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/README.md +80 -15
- package/README.zh-CN.md +67 -18
- package/dist/host/note.d.ts +7 -0
- package/dist/host/note.d.ts.map +1 -0
- package/dist/host/note.js +31 -0
- package/dist/host/note.js.map +1 -0
- package/dist/host/v1.d.ts +14 -0
- package/dist/host/v1.d.ts.map +1 -0
- package/dist/host/v1.js +340 -0
- package/dist/host/v1.js.map +1 -0
- package/dist/host/v2-browser-gate.d.ts +45 -0
- package/dist/host/v2-browser-gate.d.ts.map +1 -0
- package/dist/host/v2-browser-gate.js +319 -0
- package/dist/host/v2-browser-gate.js.map +1 -0
- package/dist/host/v2-capabilities.d.ts +78 -0
- package/dist/host/v2-capabilities.d.ts.map +1 -0
- package/dist/host/v2-capabilities.js +173 -0
- package/dist/host/v2-capabilities.js.map +1 -0
- package/dist/host/v2-client.d.ts +53 -0
- package/dist/host/v2-client.d.ts.map +1 -0
- package/dist/host/v2-client.js +193 -0
- package/dist/host/v2-client.js.map +1 -0
- package/dist/host/v2-events.d.ts +56 -0
- package/dist/host/v2-events.d.ts.map +1 -0
- package/dist/host/v2-events.js +158 -0
- package/dist/host/v2-events.js.map +1 -0
- package/dist/host/v2-guard.d.ts +101 -0
- package/dist/host/v2-guard.d.ts.map +1 -0
- package/dist/host/v2-guard.js +199 -0
- package/dist/host/v2-guard.js.map +1 -0
- package/dist/host/v2-offload.d.ts +148 -0
- package/dist/host/v2-offload.d.ts.map +1 -0
- package/dist/host/v2-offload.js +288 -0
- package/dist/host/v2-offload.js.map +1 -0
- package/dist/host/v2-permissions.d.ts +123 -0
- package/dist/host/v2-permissions.d.ts.map +1 -0
- package/dist/host/v2-permissions.js +190 -0
- package/dist/host/v2-permissions.js.map +1 -0
- package/dist/host/v2-probe.d.ts +85 -0
- package/dist/host/v2-probe.d.ts.map +1 -0
- package/dist/host/v2-probe.js +268 -0
- package/dist/host/v2-probe.js.map +1 -0
- package/dist/host/v2-scope.d.ts +51 -0
- package/dist/host/v2-scope.d.ts.map +1 -0
- package/dist/host/v2-scope.js +58 -0
- package/dist/host/v2-scope.js.map +1 -0
- package/dist/host/v2-session-client.d.ts +105 -0
- package/dist/host/v2-session-client.d.ts.map +1 -0
- package/dist/host/v2-session-client.js +390 -0
- package/dist/host/v2-session-client.js.map +1 -0
- package/dist/host/v2-session.d.ts +85 -0
- package/dist/host/v2-session.d.ts.map +1 -0
- package/dist/host/v2-session.js +171 -0
- package/dist/host/v2-session.js.map +1 -0
- package/dist/host/v2-subagent.d.ts +157 -0
- package/dist/host/v2-subagent.d.ts.map +1 -0
- package/dist/host/v2-subagent.js +286 -0
- package/dist/host/v2-subagent.js.map +1 -0
- package/dist/host/v2-tool.d.ts +67 -0
- package/dist/host/v2-tool.d.ts.map +1 -0
- package/dist/host/v2-tool.js +209 -0
- package/dist/host/v2-tool.js.map +1 -0
- package/dist/host/v2-transcript.d.ts +73 -0
- package/dist/host/v2-transcript.d.ts.map +1 -0
- package/dist/host/v2-transcript.js +151 -0
- package/dist/host/v2-transcript.js.map +1 -0
- package/dist/host/v2-types.d.ts +229 -0
- package/dist/host/v2-types.d.ts.map +1 -0
- package/dist/host/v2-types.js +17 -0
- package/dist/host/v2-types.js.map +1 -0
- package/dist/host/v2.d.ts +42 -0
- package/dist/host/v2.d.ts.map +1 -0
- package/dist/host/v2.js +853 -0
- package/dist/host/v2.js.map +1 -0
- package/dist/index.d.ts +23 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -366
- package/dist/index.js.map +1 -1
- package/dist/prompts/lead.d.ts +1 -1
- package/dist/prompts/lead.d.ts.map +1 -1
- package/dist/prompts/lead.js +456 -426
- package/dist/prompts/lead.js.map +1 -1
- package/dist/prompts/shared.d.ts +2 -2
- package/dist/prompts/shared.d.ts.map +1 -1
- package/dist/prompts/shared.js +38 -11
- package/dist/prompts/shared.js.map +1 -1
- package/dist/prompts/specialists.d.ts +2 -2
- package/dist/prompts/specialists.d.ts.map +1 -1
- package/dist/prompts/specialists.js +7 -6
- package/dist/prompts/specialists.js.map +1 -1
- package/dist/task-offload.d.ts +16 -0
- package/dist/task-offload.d.ts.map +1 -1
- package/dist/task-offload.js +51 -0
- package/dist/task-offload.js.map +1 -1
- package/dist/tm/browser.d.ts.map +1 -1
- package/dist/tm/browser.js +41 -10
- package/dist/tm/browser.js.map +1 -1
- package/dist/tm/dispatch.d.ts +86 -1
- package/dist/tm/dispatch.d.ts.map +1 -1
- package/dist/tm/dispatch.js +360 -39
- package/dist/tm/dispatch.js.map +1 -1
- package/dist/tm/dupe-guard.d.ts +16 -3
- package/dist/tm/dupe-guard.d.ts.map +1 -1
- package/dist/tm/dupe-guard.js +27 -7
- package/dist/tm/dupe-guard.js.map +1 -1
- package/dist/tm/index.d.ts +40 -1
- package/dist/tm/index.d.ts.map +1 -1
- package/dist/tm/index.js +40 -11
- package/dist/tm/index.js.map +1 -1
- package/dist/tm/ledger.d.ts +114 -0
- package/dist/tm/ledger.d.ts.map +1 -0
- package/dist/tm/ledger.js +308 -0
- package/dist/tm/ledger.js.map +1 -0
- package/dist/tm/perm-ask.d.ts +1 -3
- package/dist/tm/perm-ask.d.ts.map +1 -1
- package/dist/tm/perm-ask.js +10 -1
- package/dist/tm/perm-ask.js.map +1 -1
- package/dist/tm/preview.d.ts +55 -0
- package/dist/tm/preview.d.ts.map +1 -1
- package/dist/tm/preview.js +225 -0
- package/dist/tm/preview.js.map +1 -1
- package/dist/tm/search.d.ts.map +1 -1
- package/dist/tm/search.js +40 -10
- package/dist/tm/search.js.map +1 -1
- package/dist/tm/serp-loop.d.ts +45 -0
- package/dist/tm/serp-loop.d.ts.map +1 -0
- package/dist/tm/serp-loop.js +80 -0
- package/dist/tm/serp-loop.js.map +1 -0
- package/dist/tm/stats.d.ts +29 -0
- package/dist/tm/stats.d.ts.map +1 -1
- package/dist/tm/stats.js +278 -4
- package/dist/tm/stats.js.map +1 -1
- package/dist/tm/webfetch.d.ts +4 -0
- package/dist/tm/webfetch.d.ts.map +1 -1
- package/dist/tm/webfetch.js +75 -8
- package/dist/tm/webfetch.js.map +1 -1
- package/docs/installation-v2.md +445 -0
- package/docs/installation.md +12 -0
- package/docs/research/agent-data-exchange.md +117 -0
- package/docs/research/browser-pane.md +89 -0
- package/docs/research/host-http-api.md +132 -0
- package/docs/research/host-subagent-injection.md +187 -0
- package/docs/research/plugin-loader-contract.md +261 -0
- package/docs/research/v2-builtin-tools.md +447 -0
- package/docs/v2-port-notes.md +413 -0
- package/index.js +7 -0
- package/package.json +64 -61
- package/scripts/gen-v2-config.mjs +304 -0
- package/scripts/lib/config-surgery.cjs +239 -0
- package/scripts/lib/fake-ctx.mjs +169 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Project the team's roles AND slash commands into OpenCode v2 config files.
|
|
4
|
+
*
|
|
5
|
+
* A v2 plugin cannot create an agent — `AgentEditor` exposes only
|
|
6
|
+
* list/get/default/update/remove (docs/v2/build/plugins, Agent reference) — and
|
|
7
|
+
* `ctx.command.transform.add` was measured NOT to reach the UI on 2.0.16, so
|
|
8
|
+
* both have to live where the built-in Build and Plan agents do:
|
|
9
|
+
*
|
|
10
|
+
* ~/.config/opencode/agents/<name>.md frontmatter + body = system prompt
|
|
11
|
+
* ~/.config/opencode/commands/<name>.md frontmatter + body = prompt template
|
|
12
|
+
*
|
|
13
|
+
* Everything here is a projection of `src/agents.ts` / `src/commands.ts`
|
|
14
|
+
* through the built `dist/`, including the prompt strings, so the markdown
|
|
15
|
+
* cannot drift from what the v1 personality injects.
|
|
16
|
+
*
|
|
17
|
+
* ONE THING THIS CANNOT VERIFY: the v1 commands carry `agent: architect` (etc.)
|
|
18
|
+
* and that works on v1. v2 documents `mode: subagent` as "runs ONLY in a child
|
|
19
|
+
* session through the subagent tool", so a command selecting a subagent-only
|
|
20
|
+
* role may not run as that role. If the live host says so, the fix is one value
|
|
21
|
+
* in src/agents.ts — `mode: "all"` runs either way, and T3 ("only the lead
|
|
22
|
+
* dispatches") is held by the specialists' `subagent: deny` rule rather than by
|
|
23
|
+
* `mode`, so switching costs the picker five extra entries and nothing else.
|
|
24
|
+
*
|
|
25
|
+
* Usage:
|
|
26
|
+
* node scripts/gen-v2-config.mjs [--dir <path>] [--print] [--force]
|
|
27
|
+
*
|
|
28
|
+
* `--dir` is the base; `agents/` and `commands/` are created under it.
|
|
29
|
+
* Refuses to touch a file it did not generate unless --force is given.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"
|
|
33
|
+
import { join } from "node:path"
|
|
34
|
+
import { homedir } from "node:os"
|
|
35
|
+
|
|
36
|
+
const MARKER = "generated by @te-river/opencode-team-mode"
|
|
37
|
+
|
|
38
|
+
const argv = process.argv.slice(2)
|
|
39
|
+
const flag = (name) => argv.includes(`--${name}`)
|
|
40
|
+
const opt = (name) => {
|
|
41
|
+
const i = argv.indexOf(`--${name}`)
|
|
42
|
+
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[i + 1] : undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const baseDir = opt("dir") ?? join(homedir(), ".config", "opencode")
|
|
46
|
+
const printOnly = flag("print")
|
|
47
|
+
const force = flag("force")
|
|
48
|
+
|
|
49
|
+
const { agents } = await import(new URL("../dist/agents.js", import.meta.url).href)
|
|
50
|
+
const { commands } = await import(new URL("../dist/commands.js", import.meta.url).href)
|
|
51
|
+
const { triplesFromAgentPermission, V1_ONLY_TOOLS } = await import(
|
|
52
|
+
new URL("../dist/host/v2-permissions.js", import.meta.url).href
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
/** YAML is a superset of JSON, so a JSON scalar is a valid YAML scalar —
|
|
56
|
+
* and the descriptions are full of em-dashes and colons that a hand-rolled
|
|
57
|
+
* quote would get wrong. */
|
|
58
|
+
const scalar = (v) => JSON.stringify(String(v))
|
|
59
|
+
|
|
60
|
+
function agentFrontmatter(cfg, triples) {
|
|
61
|
+
const lines = [`---`, `# ${MARKER} — edit src/agents.ts and re-run the generator instead.`]
|
|
62
|
+
if (cfg.description) lines.push(`description: ${scalar(cfg.description)}`)
|
|
63
|
+
lines.push(`mode: ${scalar(cfg.mode ?? "subagent")}`)
|
|
64
|
+
if (cfg.color) lines.push(`color: ${scalar(cfg.color)}`)
|
|
65
|
+
if (triples.length) {
|
|
66
|
+
lines.push("permissions:")
|
|
67
|
+
for (const t of triples) {
|
|
68
|
+
lines.push(` - action: ${scalar(t.action)}`)
|
|
69
|
+
lines.push(` resource: ${scalar(t.resource)}`)
|
|
70
|
+
lines.push(` effect: ${scalar(t.effect)}`)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
lines.push("---", "")
|
|
74
|
+
return lines.join("\n")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** v2's command markdown takes `description` / `agent` / `model` / `subagent` in
|
|
78
|
+
* frontmatter and the template as the body — deliberately NO `template` key,
|
|
79
|
+
* which the docs forbid because the body supplies it. */
|
|
80
|
+
function commandFrontmatter(cfg) {
|
|
81
|
+
const lines = [`---`, `# ${MARKER} — edit src/commands.ts and re-run the generator instead.`]
|
|
82
|
+
if (cfg.description) lines.push(`description: ${scalar(cfg.description)}`)
|
|
83
|
+
if (cfg.agent) lines.push(`agent: ${scalar(cfg.agent)}`)
|
|
84
|
+
lines.push("---", "")
|
|
85
|
+
return lines.join("\n")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const body = (prompt) => {
|
|
89
|
+
const text = String(prompt ?? "").trimEnd()
|
|
90
|
+
// A body that opens with `---` would read as a second frontmatter block.
|
|
91
|
+
return text.startsWith("---") ? text.replace(/^---/, "\\---") : text
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The personality fork, in one place.
|
|
96
|
+
*
|
|
97
|
+
* v2 does not register `tm_ptc_run` — the host's own `execute` (Code Mode)
|
|
98
|
+
* already runs "one program, N tool calls, zero round-trips, only the aggregate
|
|
99
|
+
* entering the context", and a live session proved the governed tm_* results
|
|
100
|
+
* still come back offloaded through it. v1 has no Code Mode, so it keeps the
|
|
101
|
+
* tool and keeps these sentences untouched.
|
|
102
|
+
*
|
|
103
|
+
* A prompt that names a tool the model cannot call is not a style problem — it
|
|
104
|
+
* costs the round the mandate exists to save — so the v2 markdown says what v2
|
|
105
|
+
* can actually do. Every key below MUST be found in the shared source or the
|
|
106
|
+
* generator fails: a silent no-op here would ship the v1 sentence to a v2 model
|
|
107
|
+
* and look like success. `test-v2-adapter` group 8 pins both halves.
|
|
108
|
+
*/
|
|
109
|
+
const V2_TEXT = [
|
|
110
|
+
["tm_ptc_run program, keeping only the handle.", "`execute` (the host's Code Mode tool) program, keeping only the handle."],
|
|
111
|
+
["Batch the recon in one tm_ptc_run program", "Batch the recon in one `execute` program"],
|
|
112
|
+
["batch recon via tm_ptc_run,", "batch recon via `execute`,"],
|
|
113
|
+
["is ONE tm_ptc_run, not a chain.", "is ONE `execute` (Code Mode) call, not a chain."],
|
|
114
|
+
["multi-file batch recon → tm_ptc_run (one program,", "multi-file batch recon → `execute` (one program,"],
|
|
115
|
+
["your FIRST move is ONE tm_ptc_run program:", "your FIRST move is ONE `execute` (Code Mode) program:"],
|
|
116
|
+
["## Recon batching (PTC-first)", "## Recon batching (Code Mode first)"],
|
|
117
|
+
["Local-repo recon is PTC-first:", "Local-repo recon is Code-Mode-first:"],
|
|
118
|
+
["cross-referencing searches → ONE tm_ptc_run program", "cross-referencing searches → ONE `execute` program"],
|
|
119
|
+
/*
|
|
120
|
+
* The delegation half of the lead prompt. On v2 the host's tool is named
|
|
121
|
+
* `subagent` — measured, not inferred: a live dispatch reached
|
|
122
|
+
* `execute.before {tool:"subagent"}` and `permission.evaluate {action:"subagent"}`.
|
|
123
|
+
* A mandate that names `task` points at a tool the role cannot call, which costs
|
|
124
|
+
* the round it exists to save (the tm_ptc_run lesson, same shape). And on v2
|
|
125
|
+
* `background` needs no operator flag — the plugin sets it on every dispatch —
|
|
126
|
+
* so the "pick the shape by rule" instruction is not a choice the model has.
|
|
127
|
+
*/
|
|
128
|
+
["## Delegation — the host's task tool, and what you do while it runs", "## Delegation — the host's subagent tool, and what you do while it runs"],
|
|
129
|
+
["delegation goes through the host's `task` — governed, visible, killable.", "delegation goes through the host's `subagent` — governed, visible, killable."],
|
|
130
|
+
["you will keep following up while they run → `task { background: true }`: the", "you will keep following up while they run → `subagent { background: true }`: the"],
|
|
131
|
+
["`task`. Do not choose background when you would only park waiting for it.",
|
|
132
|
+
"`subagent`. On this host the plugin forces `background: true` on EVERY dispatch, so this is not a choice you make, and the operator flag v1 needed does not exist here."],
|
|
133
|
+
["Background needs the operator flag `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true`;",
|
|
134
|
+
"Assume the card is asynchronous: say who is running instead of pretending to block."],
|
|
135
|
+
["without it `task` only blocks, so say so rather than pretending to overlap.",
|
|
136
|
+
"There is no flag to check and no synchronous fallback to fall back on."],
|
|
137
|
+
["a background `task` names its own session id", "a background `subagent` names its own session id"],
|
|
138
|
+
["a synchronous `task` call, so chaining waits", "a synchronous `subagent` call, so chaining waits"],
|
|
139
|
+
["blocking on `task`", "blocking on `subagent`"],
|
|
140
|
+
/*
|
|
141
|
+
* The file ladder (the tm_read / tm_grep / tm_bash retirement). v2 does not
|
|
142
|
+
* register those three, so every sentence that routes a role through them has
|
|
143
|
+
* to name what is actually on the surface instead — and the native tools are
|
|
144
|
+
* NOT ungoverned here: `execute.after` offloads their oversized results, and
|
|
145
|
+
* the address / R6 red lines ride `permission.evaluate` (both landed BEFORE
|
|
146
|
+
* this fork, which is the ordering the retirement requires).
|
|
147
|
+
*
|
|
148
|
+
* Two words are host-vocabulary, not ours: v2 names the shell tool `shell`,
|
|
149
|
+
* and its `background` argument is documented on the native tool (AGENTS.md,
|
|
150
|
+
* "native shell … has background:true"). The pty line is rewritten around
|
|
151
|
+
* that, NOT around anything I have measured in a live v2 session — a v2
|
|
152
|
+
* session has yet to make a background shell call, and #8/#9 are where that
|
|
153
|
+
* gets checked. Until then the sentence says only "returns at once".
|
|
154
|
+
*/
|
|
155
|
+
["tm_grep / tm_read first, a second lookup only for what it genuinely missed.", "grep / read first, a second lookup only for what it genuinely missed."],
|
|
156
|
+
["All file reads / searches / enumeration go through tm_read / tm_grep / tm_bash.\nThe built-in read/grep/glob/list tools are removed from the tool surface —\nretrying them only wastes a turn. Built-in bash exists only where granted\n(team / implementer / reviewer / tester run commands: build / test / git);\narchitect and researcher have no bash at all — one-off read-only commands\ngo through tm_bash or are reported as a gap.",
|
|
157
|
+
"File reads / searches / enumeration go through the built-in read / grep /\nglob tools — on this host they ARE the governed path: an oversized result comes\nback as a short preview plus a handle, and `tm_fetch` pages the rest, so a wide\ncall is cheap here and still the right move. Built-in shell exists only where\ngranted (team / implementer / reviewer / tester run commands: build / test /\ngit); architect and researcher have no shell at all — a one-off read-only\ncommand they cannot run is reported as a gap, not retried."],
|
|
158
|
+
["code search → tm_grep · enumeration and quick\n probes → tm_bash · multi-file", "code search → grep · enumeration and quick\n probes → glob / shell where granted · multi-file"],
|
|
159
|
+
["use the **built-in bash** tool — not tm_bash. tm_bash hard-blocks them with\nno dialog; built-in bash triggers the official confirmation dialog (once /\nalways / reject). Dangerous commands (rm / git push / npm publish / etc.)\nalways trigger the dialog regardless of tool.",
|
|
160
|
+
"there is no second, ungoverned shell to fall back to on this host: the one\n**shell** tool IS the R6 surface, and its classifier only ever makes a rule\nstricter — an env dump or a delete asks the host, which opens its own dialog\n(once / always / reject). Dangerous commands (rm / git push / npm publish /\netc.) ask regardless of what the config said."],
|
|
161
|
+
["probes toward one goal — tm_read / tm_grep / tm_bash\nOR built-in bash alike — your FIRST move is ONE", "probes toward one goal — read / grep / glob / shell alike — your FIRST move is ONE"],
|
|
162
|
+
["Plain shell probes the governed channel cannot run\n(e.g. env-path checks, which the tm_* channel hard-blocks by\ndesign) go as ONE compound built-in bash command (`a; b; c` in a\nsingle call)",
|
|
163
|
+
"Several cheap probes of one kind go as ONE compound `shell`\ncommand (`a; b; c` in a\nsingle call)"],
|
|
164
|
+
["searches are PTC work;", "searches are one-program work;"],
|
|
165
|
+
["(tm.read / tm.grep / tm.bash ride inside; ALWAYS `return` the", "(the built-in read / grep / shell tools are callable inside it; ALWAYS `return` the"],
|
|
166
|
+
["(or one PTC program), never in two.", "(or one `execute` program), never in two."],
|
|
167
|
+
["run one cheap tm_grep. Do NOT pull big payloads", "run one cheap grep. Do NOT pull big payloads"],
|
|
168
|
+
["or tm_grep + tm_bash yourself)", "or grep + shell yourself)"],
|
|
169
|
+
["into its OWN tm_pty session (returns at once, the user sees the terminal,\n every start passes the official dialog) rather than being chained with\n `;` behind one long bash call. tm_pty returns no transcript — tee it to\n a log and read that log when it reports exited.",
|
|
170
|
+
"into its OWN separate `shell` call rather than being chained with\n `;` behind one long shell call — and when the step is genuinely slow, use\n `shell {background:true}`: the ack gives a shell ID plus the file its output\n streams to, and the host notifies on exit (do NOT poll for it). Have it write\n its own log file and read that log when it reports exited — do not re-run the\n command to see its output."],
|
|
171
|
+
["probes via bash where granted) BEFORE answering from memory.", "probes via shell where granted) BEFORE answering from memory."],
|
|
172
|
+
["FIRST there, because that is the only path with the domain allowlist, the\nper-request dialog and the R6 red lines; an MCP fetcher of the same page\nsilently skips all three (and dumps raw HTML into your context). Fall to a",
|
|
173
|
+
"FIRST there, because that is the only path with the address red line (no\nmetadata / private-range fetch) and the threshold offload; an MCP fetcher of the\nsame page silently skips both (and dumps raw HTML into your context). Fall to a"],
|
|
174
|
+
["- Files/docs → tm_read · code search →", "- Files/docs → read · code search →"],
|
|
175
|
+
[" --help) → built-in bash where granted ·", " --help) → `shell` where granted ·"],
|
|
176
|
+
["## PTC batch orchestration", "## Batch orchestration (Code Mode first)"],
|
|
177
|
+
["file tool at all (architect and researcher have no write/edit/bash; reviewer\n has only the read-only bash, which refuses redirection). Pass the `session`",
|
|
178
|
+
"file tool at all (architect and researcher have no write / edit / shell, and\n reviewer's shell is the host's own, classified per command). Pass the `session`"],
|
|
179
|
+
["- The host stops a bash command after 120 s unless you pass a larger", "- The host stops a shell command after 120 s unless you pass a larger"],
|
|
180
|
+
/*
|
|
181
|
+
* The LEDGER. v2 gives a plugin no `todowrite` (V2_ONLY_ACTIONS names it as a
|
|
182
|
+
* key with no v2 counterpart), so the mandate's tool is the one this plugin
|
|
183
|
+
* registers instead — `tm_ledger`, stored in the host's own ctx.storage. A v2
|
|
184
|
+
* lead told to "create a todo list" without a named tool either invents a
|
|
185
|
+
* MANIFEST.md (the prompt forbids that, correctly) or keeps the list in prose
|
|
186
|
+
* and loses it at the next compaction, which is the failure the rule exists to
|
|
187
|
+
* prevent. The status words move with it: `in_progress`/`completed` are
|
|
188
|
+
* todowrite's enum, and tm_ledger's are open/doing/done/blocked.
|
|
189
|
+
*/
|
|
190
|
+
["## Hard rule — TodoList discipline (non-negotiable)\nBefore you touch anything on a medium-or-larger task you MUST create a todo\nlist.",
|
|
191
|
+
"## Hard rule — LEDGER discipline (`tm_ledger`, non-negotiable)\nBefore you touch anything on a medium-or-larger task you MUST put the list on\nrecord: `tm_ledger { action:\"add\", text:\"…\" }` per work package."],
|
|
192
|
+
["- Keep it LIVE: an item is `in_progress` while you or a dispatched child is\n actually working it, and `completed` only after its work is verified —",
|
|
193
|
+
"- Keep it LIVE: an item is `doing` while you or a dispatched child is\n actually working it, and `done` only after its work is verified —"],
|
|
194
|
+
["genuinely are in_progress at once; that is the intended shape, not a\n violation of single-task focus (one worker, one in_progress).",
|
|
195
|
+
"genuinely are `doing` at once; that is the intended shape, not a\n violation of single-task focus (one worker, one `doing`)."],
|
|
196
|
+
["- The goal travels with the work: it goes in the todo list, into every dispatch",
|
|
197
|
+
"- The goal travels with the work: it goes in `tm_ledger`, into every dispatch"],
|
|
198
|
+
["NO MANIFEST.md — your state memory is the todo list.", "NO MANIFEST.md — your state memory is `tm_ledger`."],
|
|
199
|
+
[" todo list, dispatched to `implementer` with the exact finding text.",
|
|
200
|
+
" ledger, dispatched to `implementer` with the exact finding text."],
|
|
201
|
+
[" list, lay out the merge structure,", " ledger (`tm_ledger { action:\"list\" }` marks what the round finished), lay out the merge structure,"],
|
|
202
|
+
["Do NOT store task state or oversized content there — todo list and\nboard files own those.",
|
|
203
|
+
"Do NOT store task state or oversized content there — the lead's `tm_ledger`\nand board files own those."],
|
|
204
|
+
["dropped: the list is the user's audit surface (pending / in_progress /\n completed / blocked)", "dropped: the list is the user's audit surface (open / doing /\n done / blocked — `tm_ledger` keeps the states, and `blocked` carries the note\n naming what blocked it)"],
|
|
205
|
+
/* tm_pty is v1-only on purpose: `client.pty` does not exist on the v2 plugin
|
|
206
|
+
* ctx, so the tool could only ever answer "pty 接口不可用". The v2 rules now DO name a
|
|
207
|
+
* replacement, because #28 measured it out of the 2.0.16 build
|
|
208
|
+
* (docs/research/v2-builtin-tools.md §2.5): `shell` takes `background:true`, the ack is
|
|
209
|
+
* "Command moved to the background (shell ID: X)" plus the file the output streams to,
|
|
210
|
+
* the host says DO NOT poll, and completion returns later as a synthetic
|
|
211
|
+
* `<shell id=… state=… command=…>` message. `shell` and `subagent` are the ONLY two
|
|
212
|
+
* built-ins with a background parameter, which is also why tm_pty stays retired: the pty
|
|
213
|
+
* REST surface is still absent, and the native path has no per-command dialog (and its
|
|
214
|
+
* completion envelope re-enters context carrying the command text — an R6 touchpoint). */
|
|
215
|
+
["either: give each its own call, or run it through tm_pty (non-blocking,\n where granted) and check `status` later. A tm_pty session writes no\n transcript back to you, so tee its output to a file (`<cmd> 2>&1 | tee\n <log>`) and read that file for EVIDENCE once it reports exited.",
|
|
216
|
+
"either: give each its own call, or run it through `shell` with\n `background:true` (it returns at once with a shell ID and the file its output\n streams to, and the host notifies you when it exits — so do NOT poll, and never\n re-run the command to watch it pass again). Either way tee the output\n (`<cmd> 2>&1 | tee\n <log>`) and read that log for EVIDENCE."],
|
|
217
|
+
/* Option A (user decision 2026-09-25): interactive browsing goes to the host's own
|
|
218
|
+
* browser_* tools, because that is the only browser the desktop renders in its side
|
|
219
|
+
* panel (docs/research/browser-pane.md). Governance is NOT taken off the table: the gate
|
|
220
|
+
* lives in src/host/v2-browser-gate.ts (execute.before + a leak-detected fallback that
|
|
221
|
+
* replaces the page with the refusal), and snapshots stay addressable through
|
|
222
|
+
* capKeepingAddressing. tm_browser stays named as the fallback for a host with no
|
|
223
|
+
* desktop browser (CLI / standalone server). Every key below must match the v1 prompt
|
|
224
|
+
* byte-exactly: assertForkApplied() throws when one stops matching. */
|
|
225
|
+
[" URLs → tm_webfetch, JS-rendered pages → tm_browser (network roles only).", " URLs → tm_webfetch, JS-rendered pages → the host's own browser_* tools (they drive\n the side panel the user can actually see), falling back to tm_browser when no desktop browser is connected (network roles only)."],
|
|
226
|
+
["- a visual state (a rendered UI, a chart) → tm_browser", "- a visual state (a rendered UI, a chart) → the host's browser_* tools (browser_tabs_open → browser_snapshot → browser_screenshot), or tm_browser when they are not offered"],
|
|
227
|
+
["## UI verification (tm_browser — you carry it)", "## UI verification (the host's browser_* tools — you carry them)"],
|
|
228
|
+
["For user-visible frontend changes, verify through the governed tm_browser", "For user-visible frontend changes, verify through the host's own browser_* tools (tm_browser only when they are absent)"],
|
|
229
|
+
["If tm_browser is unavailable on this host, the action you need is not", "If neither browser_* nor tm_browser is available on this host, the action you need is not"],
|
|
230
|
+
[" via your governed tm_search / tm_browser / tm_webfetch first,", " via your governed tm_search / tm_webfetch and the host's browser_* tools (or tm_browser when no desktop browser is connected) first,"],
|
|
231
|
+
[" lookups: governed tm_search / tm_browser / tm_webfetch first, then user MCP tools.", " lookups: governed tm_search / tm_webfetch + the host's browser_* tools first, then user MCP tools."],
|
|
232
|
+
]
|
|
233
|
+
|
|
234
|
+
function forkBody(text, hits) {
|
|
235
|
+
let out = text
|
|
236
|
+
for (const [from, to] of V2_TEXT) {
|
|
237
|
+
if (!out.includes(from)) continue
|
|
238
|
+
out = out.split(from).join(to)
|
|
239
|
+
hits.add(from)
|
|
240
|
+
}
|
|
241
|
+
return out
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Every key must land in at least one role, or the fork is silently stale. */
|
|
245
|
+
function assertForkApplied(hits) {
|
|
246
|
+
const dead = V2_TEXT.map(([from]) => from).filter((from) => !hits.has(from))
|
|
247
|
+
if (dead.length) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`v2 提示词分叉有 ${dead.length} 条没命中任何角色(源文改了,这里没跟上):\n - ` +
|
|
250
|
+
dead.join("\n - "),
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const written = []
|
|
256
|
+
const skipped = []
|
|
257
|
+
|
|
258
|
+
function emit(dir, name, content) {
|
|
259
|
+
const file = join(dir, `${name}.md`)
|
|
260
|
+
if (existsSync(file)) {
|
|
261
|
+
const current = readFileSync(file, "utf8")
|
|
262
|
+
if (!current.includes(MARKER) && !force) {
|
|
263
|
+
skipped.push(`${file} — 不是我生成的文件,未覆盖(--force 强制)`)
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
if (current === content) {
|
|
267
|
+
skipped.push(`${file} — 已是最新`)
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (printOnly) {
|
|
272
|
+
console.log(`=== ${file}\n${content.slice(0, 700)}\n… (${content.length} 字节)`)
|
|
273
|
+
written.push(file)
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
mkdirSync(dir, { recursive: true })
|
|
277
|
+
writeFileSync(file, content, "utf8")
|
|
278
|
+
written.push(file)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const agentsDir = join(baseDir, "agents")
|
|
282
|
+
const forkHits = new Set()
|
|
283
|
+
for (const [id, cfg] of Object.entries(agents)) {
|
|
284
|
+
const { triples } = triplesFromAgentPermission(cfg.permission, { escalateShellAsk: false, agentName: id })
|
|
285
|
+
// An `allow` for a tool v2 never registers would claim a capability that does
|
|
286
|
+
// not exist; V1_ONLY_TOOLS is the same set v2.ts skips when registering.
|
|
287
|
+
const v2Triples = triples.filter((t) => !V1_ONLY_TOOLS.has(t.action))
|
|
288
|
+
const text = forkBody(body(cfg.prompt), forkHits)
|
|
289
|
+
emit(agentsDir, id, agentFrontmatter(cfg, v2Triples) + text + "\n")
|
|
290
|
+
}
|
|
291
|
+
assertForkApplied(forkHits)
|
|
292
|
+
|
|
293
|
+
const commandsDir = join(baseDir, "commands")
|
|
294
|
+
for (const [name, cfg] of Object.entries(commands)) {
|
|
295
|
+
emit(commandsDir, name, commandFrontmatter(cfg) + body(cfg.template) + "\n")
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
console.log(`目标:${agentsDir} 与 ${commandsDir}`)
|
|
299
|
+
for (const f of written) console.log(printOnly ? `将写入 ${f}` : `已写入 ${f}`)
|
|
300
|
+
for (const s of skipped) console.log(`跳过 ${s}`)
|
|
301
|
+
console.log(
|
|
302
|
+
`\n共 ${written.length} 个文件(角色 ${Object.keys(agents).length} + 命令 ${Object.keys(commands).length})` +
|
|
303
|
+
`${printOnly ? "(--print,未落盘)" : ""}。重启桌面端(或 opencode reload)后 Team 出现在 agent 选择器、/team-* 出现在命令列表。`,
|
|
304
|
+
)
|
|
@@ -0,0 +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
|
+
}
|