Skip to content

Latest commit

 

History

History
87 lines (79 loc) · 83.8 KB

File metadata and controls

87 lines (79 loc) · 83.8 KB

Opencode-TeamMode — Agent Guide

What this is

OpenCode Desktop plugin that injects a multi-agent team (6 agents, 6 commands) into the user's workspace. Published as @te-river/opencode-team-mode on npm.

Design goals (business context)

  1. A complete "Team" mode — one lead + five specialists orchestrated by a deterministic routing table, a count-based approval gate, and structured STATUS/CHANGES/FINDINGS/EVIDENCE/HANDOFF handoffs.
  2. Parallel efficiency in medium/large projects — independent dispatches batch into the same round; parallel implementers interoperate through verbatim data contracts; adaptive review escalates to 3 parallel dimensions only for high-risk changes.
  3. Kill the many-bash-round-trips inefficiency (效率至上 — rounds are the unit everything is priced in) — reads/search/enumeration go through the governed tm_bash (read-only allowlist) instead of ad-hoc shell ping-pong, and tm_ptc_run runs one async program that makes N governed tm_* calls with ZERO LLM round-trips, returning only an aggregation summary. The same rule is now a prompt mandate in the lead (BEFORE the routing table, so it cannot be re-litigated per dispatch) and in SHARED_RULES for all five specialists: one wide call beats three narrow ones, independent calls batch into the same round, ≥3 probes collapse into one tm_ptc_run, and no step is re-run to watch it pass again — with the boundary in the same breath, because an efficiency rule that outranks the evidence rule makes an unverified "done" the cheap option (principles 9-10, pinned by test-blackboard).
  4. JIT context governance / token economy — tool outputs above the token threshold never enter the context window (offload store + 80-token content-aware previews + HMAC expiring handles fetched via tm_fetch); sub-agent context stays minimal (no README re-reads, tm_*-only file access), saving user tokens.
  5. Web access with a fixed tool priority ladder (network roles: team lead + researcher ONLY; tester carries tm_browser for UI verification) — ① governed tm_search (multi-engine search front: auto default — query-classified parallel fan-out, host+path dedup, weighted-RRF fusion — over seven engines bing / stackoverflow / hn / bilibili / moegirl HTML+API + npm / github JSON, all no-API-key + CN-reachable; extracted title+URL(+snippet) hit lists, SO keyless quota tracked with auto-swap to bing, CJK phrase protection + github qualifier folding, empty results name alternative engines; sogou/so/baidu/bing-int REMOVED after the 2026-09-14 anti-bot-shell benchmark) / tm_browser (interactive; playwright-core primary on Node >= 20, auto-degrade to cdp-legacy; 18 snapshot-first verbs + 5 compat verbs + 1 gate verb allow_host) / tm_webfetch (single fetch; SERP pages auto-extract to hit lists); OUT-OF-ALLOWLIST targets ask via ctx.ask -- the host's official dialog (verified 1.18.30: plugin tool ctx carries ask(req) bridging PermissionV2.ask; findLast makes the explicit {"": "ask"} rule beat the tm_ wildcard allow, so the dialog always pops; R6 env-file/scheme lines stay hard, never consentable) → ② user MCP/plugin tools → ③ report as gap, never simulate; tm_browser drives the user's own Chromium headful via CDP pipe with an isolated profile (a real browser data dir is REFUSED before spawn — browsing as the user while a force-kill reaper owns the process is not a neutral choice) and a network-layer domain allowlist; architect/implementer/reviewer have no network grant. All governed tools are PARALLEL-SAFE (sync step counter on a single-threaded loop → distinct step ids; per-step payload files → no clobbering) — pinned by the §6p parallel test. THE GENERAL ladder (all agents, every task) is ① the user's own MCP/plugin tools → ② tm_* → ③ the model's own reasoning (reported as a gap, never fabricated); the web channel above is the ONE stated exception — governed tm_* stays first there because it is the only path carrying the allowlist, the per-request dialog and the R6 red lines, so swapping in a user fetcher for the same page would silently skip all three plus the offload.
  6. A governed tool must not overstate what happened — this is the product, not a polish item. Every claim a tool makes about itself has to be checkable by the one party who pays for being wrong (the user), and this is what the live sessions keep testing: 已确认关闭 only when an OS pid was actually verified gone (进程未核验 when no pid was obtained, 警告:关闭未完全成功 when leftovers remain — and the warning may not then tell the user to close a window whose process is already dead); 已点击 only after the page shows a delta, never for "playwright delivered a mouse event"; a blank page is attributed to OUR gate, to a 验证墙, or to the site — never collapsed into "该网站没有内容"; an off-allowlist page that loaded says WHETHER it was the static seed, a dialog the user just answered, or a saved "always" that answered in 30 ms without asking anybody (an approval is not an allowlist — a saved rule is project-wide and the per-agent consent it bypasses never existed); 缓存命中 and the arrival format are stated rather than assumed; and a window somebody left open is REPORTED (tm_join's lease tripwire covers the settled children AND the caller's own browser) rather than silently reaped 180 s later. The reason is economic, not aesthetic: an unverified "done" costs the user the round AND the bug, which is the one outcome a throughput-mandated team is not allowed to produce.

Commands

Action Command
Build npm run build (tsc -> dist/)
Test npm test (runs scripts/run-tests.mjs: tsc first, then the 7 test-*.mjs suites CONCURRENTLY — cap min(4, cpus−1), the two real-browser suites (test-browser + test-tm-tools §6o) serialized by an IN-PROCESS boolean (browserBusy), NOT a lock file so a crashed suite cannot strand the next one, per-suite buffered output, markdown summary table, exit code aggregated; measured 151 s wall vs 401 s serial. Caveat: output is buffered per suite, so piping a run through head kills the writer mid-suite and looks like a hang that belongs to no product code — read a file instead. Filters: npm test -- browser stats is node scripts/run-tests.mjs browser; npm run test:serial for one-at-a-time debugging; npm run test:one <filter> skips tsc)
Dev watch npm run dev

All test suites must pass before committing.

Project structure

File Role
src/agents.ts + src/prompts/ Agent definitions (structure: roles, modes, colors, temperatures, tool-whitelist matrix incl. tm_ptc_run all-allow + tm_board_write in TM_TOOLS so all six roles carry the board writer (the three without any file tool included: it is board-scoped, never an overwrite, never outside <board-root> + tm_webfetch/tm_search/tm_browser lead+researcher grants + tester browser-only grant (governed UI verification — the tester prompt carries a dedicated "UI verification (tm_browser)" section; open web stays with the two roles) + lead-only task/todowrite/question grants (T3 task-reclaim: architect & reviewer DROPPED task -- only the lead dispatches, no sub-agent spawns a sub-agent) backing the TodoList/blocking-question prompt mandates + lead DELEGATION prompt section (the host's task is the only sub-agent channel, background-vs-sync is a rule, and a wait must be announced before it blocks; pinned by test-blackboard) + lead GOAL DIRECTIVE (state GOAL: in the user's terms + checkable ACCEPTANCE: criteria at Step 0; the run ends when the criteria are met, not when it is convenient — the only legitimate stops are "blocked on the user, naming the criterion" or "provably unachievable, naming the attempts"; reframing a partial result as the deliverable is called out; the goal may be neither shrunk nor grown unilaterally; user-stated boundaries outrank the goal) + LEDGER rule (every new ask becomes a list item BEFORE the work; an interruption is an insertion, not a replacement; blocked is a state, not an exit; on resume/compaction re-read the list first) — the prompt strings live verbatim in src/prompts/lead.ts, src/prompts/specialists.ts, src/prompts/shared.ts (REPLY_CONTRACT + SHARED_RULES) — pinned by test-blackboard.mjs
src/commands.ts Slash command templates (/team-plan, /team-run, etc.)
src/blackboard.ts Shared blackboard + TTL auto-cleanup sweeper
src/envprotect.ts + src/envprotect/ R6 env protection -- facade re-export + 5 modules: patterns.ts (the R6_ENV_BASH_ASK / R2_DANGER_BASH_ASK ask sets, bashAskPatterns(mode, envFace) dead-popup guard, mode parsing), bash-classify.ts (bash/PS env commands incl. pathed /usr/bin/env + cmd /c heads, $env:/${VAR}/$ALLCAPS expansion, env-file tokens), path-classify.ts (env-file paths), gate-predicates.ts (isAskGatedEnvCommand() BYTE-EXACT glob = dialog guaranteed -> defers, loose/armed categorize keeps hard throw; per-session scope via deferToApproval(sessionID)), hook.ts (categorizePermission() real permission.asked shape: tool in permission, patterns[]/metadata.command inference; tool routing; audit log); tm_* tools alias onto this surface (anti-backdoor, never deferred); the session-wide env-approved pass ("always") NEVER covers env-FILE reads (no dialog ever backs them -- they keep hard-throwing)
src/approval-gate.ts Unified approval gate Layer 2 -- one shared timer over the live host's permission.asked/permission.replied (+ notify hook: every asked fires ONE toast message via index.ts's tui.showToast wiring, deduped by request id, so the user learns a dialog is waiting) (legacy permission.updated spelling accepted): auto-REJECTS an unanswered R6/R2 dialog after TM_ASK_TIMEOUT_MIN (default 1 min); replied (which may carry ONLY {sessionID}) cancels the session's WHOLE pending set + tombstones ids (ghost asked replays never re-arm, no dead-id double-reject); v1 {error} envelope unwrapped -> real SDK failures flip degraded; the plugin NEVER self-allows (reject-only reply); classifyReplyFailure splits a failed reply -- 404/NotFound -> benign already-closed (no degrade flip), 400/BadRequest -> rejected-shape-bug (flips), else transport -> degraded; the closed-request tombstone map ({sid,cat,at}) lets a reply landing AFTER the auto-reject audit as late-<verdict> (observability only -- the reject stands, never resurrected); env-face asks + exec-role session registration (registerExecSession, revokeExecSession -- index.ts revokes when a user prompt routes to a NON-exec agent, closing the mixed-agent stale window; canDefer(sid)) gate deferral per-session; armed only when R6 is on AND the client can reply
src/tm/ JIT layer-2 tools (tm_read/tm_grep/tm_bash/tm_fetch/tm_memory/tm_search/tm_webfetch/tm_browser/tm_ptc_run/tm_join/tm_pty/tm_stats/tm_board_write) -- config.ts env knobs + CJK-aware token口径 (CJK ≈ 1 token, other chars/4; capTokens truncates by the same cost basis) + shared allowlist parser + DEFAULT_WEBFETCH_DOMAINS (TWENTY-TWO seeded hosts: baidu.com (parent, covers baike.)/bdimg.com (Baidu's OWN script + asset CDN, measured: baike.baidu.com renders 0 addressable nodes behind the gate and 260 with this host seeded -- same-site can NEVER infer a CDN on an unrelated brand domain, so such a host must be seeded or the agent gets a blank page it then reports as "no content")/moegirl.org.cn (parent, covers mzh.)/bilibili.com (parent)/sogou/so/bing CN+int/zhihu/juejin/csdn/cnblogs/gitee/github api+raw+gist+site/ghproxy.net mirror/stackoverflow/npmjs.org/pypi.org/learn.microsoft.com — 22 hosts; webfetch.ts seedWebfetchDomains appends the api.stackexchange.com + hn.algolia.com engine seeds ONLY while the default seed is in play (24 effective; a custom TM_WEBFETCH_ALLOWED_DOMAINS replaces the seed verbatim)), git-aware store dirs (AUTO = <repo>/.git/opencode-team/{blackboard,trajectory}, tmpdir fallback SHARDED per workspace — workspaceStoreKey() is a sha256 of the resolved path, not a slug, because CJK paths like D:/扒取数据 and D:/文档 both strip to one letter and would collide; memories/ deliberately stays on the shared base since its project tier is already slug-keyed, so relocating it would strand written memories. pruneStaleStoreShards() runs at boot for non-git workspaces and reclaims a sibling w-* idle past the TTL (never the live shard, never via a bare rmSync -- see fs-safe), because the shard is created lazily per workspace and would otherwise accumulate one directory per throwaway temp dir. reclaimLegacyStoreBuckets() does the same for the UPGRADE orphan — the pre-shard blackboard/ (runs + webcache) and trajectory/ at the tmpdir base, which nothing reads or sweeps any more (measured 503 MB / 2654 runs); it removes only TTL-expired entries (a fresh run may belong to a session that started before the upgrade) and drops the shell only when nothing lives under it — and that emptiness test must treat a FILE as non-empty, because the first version read readdir's throw as "empty" and deleted a live run dir along with its shell (caught by test §6q). Both passes sit behind TM_STORE_RECLAIM (default on), and scripts/run-tests.mjs sets it to off: a suite boots real runtimes in temp dirs, and before the knob existed one test run drained the developer's actual Temp bucket mid-suite — correct product behaviour, entirely wrong as a test side effect. Explicit TM_*_DIR keeps absolute/project-relative semantics), refs.ts run ids + HMAC handles, store.ts run store + append-only trajectory + TTL sweep, preview.ts 5-branch previews (80-token cap; collectPathLineRefs is a BOUNDED LINEAR scan -- the old global-regex matchAll was O(n^2) on long word-char runs, i.e. a hang on minified/huge single-line payloads: 100K chars = 23s, 2.5MB = forever) + structure summaries, guard.ts P2 path scope + P3 readonly allowlist (rejections guide to the bash confirmation dialog; tasklist + ps + findstr are seeded into DEFAULT_BASH_READONLY_ALLOWED because "已确认关闭" is only checkable if the agent can ask the OS for the pid — a user-facing verification with no read path is the same defect as a lie, and this was hit live; findstr joined for the same reason (#84, from the host export: `tasklist
scripts/run-tests.mjs The npm test runner — tsc first (suites import dist/, a stale build produces phantom failures), then the test-*.mjs suites in parallel with a per-suite output buffer and an aggregated exit code. The two suites that launch a REAL browser never overlap; positional args filter which suites run
docs/installation.md Agent-consumable install/update/uninstall guide (bilingual; README "let your agent install it" path points here — config locations, plugin entry, restart, verification checks, three-path update recipe (re-run installer / agent prompt / manual cache purge), troubleshooting)
scripts/install.sh + scripts/install.ps1 Idempotent installers — patching the config AND purging the stale plugin cache AND re-resolving npm-installed copies, so RE-RUNNING the installer IS the update (OpenCode never re-resolves @latest on its own). Both then enable the host's VISIBLE sub-agent: OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true via setx (User scope, survives reboot) / launchctl setenv / systemctl --user set-environment, falling back to printing the export line — a plugin cannot set a host-process flag, and the host rejects task {background:true} without it. Opt out with -NoBackgroundSubagents or TEAMMODE_SKIP_BACKGROUND_SUBAGENTS=1; each path prints its revert command and reads the value back instead of trusting the tool's exit code. CACHE LAYOUT NOTE: packages/@te-river/<name>@latest/ is a WRAPPER whose executed code is the NESTED node_modules/@te-river/<name>/dist — overwriting the wrapper root or ~/.config/opencode/node_modules does nothing
src/tm/dispatch.ts tm_join ONLY — the COLLECT side of sub-agent work. tm_dispatch is REMOVED (v1.5.16, user decision): a child a plugin creates is a session the user can neither open from a card nor stop from the interface, and "the lead can cancel:true it" is no substitute. Delegation therefore goes through the host's own task / task { background: true } (visible card, host-managed permission set, host kill switch — see src/task-offload.ts for the context-budget half and the installers for OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS). tm_dispatch is not registered at all (src/tm/index.ts), and permission["tm_dispatch"] = "deny" for all six agents INCLUDING the lead — the key stays in the matrix so the tm_* wildcard can never hand a dispatcher back, and the runtime ctx.agent !== "team" check remains as the second lock on what is left. WHAT tm_join DOES: keeps an in-process registry of children this plugin process has seen; when it has nothing for the caller it falls back to client.session.children({path:{id:parent}}) and adopts ONLY titles that parseDispatchTitle recognises — <label> (@<agent> subagent ·tm) (our old dispatched shape) or the pre-1.5.15 tm:<agent>:<label>; the ·tm marker is LOAD-BEARING because it is what keeps a host task child out of speculative adoption, and legacy titles still parse so an upgrade never orphans a live child. claimNamedChild is the ONE place an explicitly named id may be a host task child, and only after session.get verifies parentID === the caller (a foreign session stays a miss — never claim what is not yours). Settle via the event hook's session.idle/session.error/session.status with session.status polling behind it, plus AssistantMessage.time.completed for adopted rows ("finished while nobody was listening"); session.abort implements cancel: true; collected text rides pipelines.govern, so a fat child reply arrives as an offload handle + ≤80-token preview. CALL ACCOUNTING: tm_join writes {tool:"tm_join",event:"call"} at entry (after the lead lock, so a refused call is never counted) — the token table reads calls off that line, and without it tm_join showed "0 调用 / 2 结果", which looks like a broken counter rather than a tool that ran. The join/wait/adopt/cancel events keep the historical tool:"tm_dispatch" label so a 1.5.15 run and this one stay comparable. WAIT DISCIPLINE (from a session that parked 19 minutes): joinBudget(waitMs, maxWaitMs, prev) is a pure function — a first bounded wait clamped to TM_JOIN_MAX_WAIT_MS (default 60 000, was 300 000), a wait FOLLOWING a wait that settled nothing cut to REPEAT_WAIT_MS (10 000) with a 别再等 directive naming the three real options, a snapshot (waitMs 0) neither extends nor resets the streak; every wait logs {event:"wait", asked_ms, budget_ms, waited_ms, still_running, repeat} so tm_stats can show the lead's blocked time. An unsettled round answers with the (未等到全部结算 · 本次已等 Xs) header and a "这一轮不是交付" line rather than a table that reads like a delivery. GOAL TRIPWIRE: GET /session/{id}/todo is READ-ONLY in the plugin surface (writing stays todowrite's job), so a settled round with open host todos appends ⚠ 目标未达成 … plus the escape hatch ("若这些项其实已完成——先用 todowrite 更新状态,再收尾"). LEASE TRIPWIRE (#80): the same shape applied to a fact -- deps.browserLeases() (wired in tm/index.ts to browserTool.leases(), the ONE lease table) filtered to children that already settled OR the CALLER itself (#86 -- the live recheck opened a browser as the lead, kept it, and tm_join said nothing, because owner in settled children could never name the lead's own window; leaseTripwire(held, callerSessionID) now splits two groups: a settled child's window is a violation to bounce, the caller's own is a reminder to close or explain, never an accusation since keeping one across rounds is legitimate), rendered by the pure leaseTripwire(held, caller?), appended as ⚠ N 个浏览器还开着(子代理已结算,但它没 close) with id + owning role + idle seconds, and logged as event:"lease_held"; a throwing lease read is swallowed because the warning is an extra, never a reason to fail a join. #87: the tripwire is computed ONCE (leaseLine(), which also does the logging) BEFORE any early return, so the "没有待收集的派发" answer carries it too -- the live re-check of #86 used a SYNCHRONOUS host task, which the host collects inline and this registry therefore never sees, and the lease check sitting in the settled-round header assembly never ran. That empty-round line also stopped diagnosing a failure it had not measured ("那说明派发生本身没成功" named a dispatch that had completed fine) and now states the real reason: only task {background:true} (or an explicit ids) needs tm_join. Also describeHostError (a nested session.error must never render as [object Object] — that destroyed the only diagnostic in a real session) and parseIdList (models send ids as a JSON-array STRING; Array.isArray alone silently degraded tm_join to "report every child"). CALLING RULE (live-host, cost a release): every SDK endpoint MUST be a property-access call (api.create({...})); capturing the reference (const f = api.create then f({...})) drops the receiver and threw Cannot read properties of undefined (reading 'client') on the desktop — the same class approval-gate.ts:replyCapableFn documents and pty.ts avoids; the §10 test fake asserts this.__sdkNamespace on every method so it cannot come back. VISIBILITY FORENSICS (still true, and the reason the removal is not a regression): the desktop's renderer registry holds only its ~14 built-in tool names and GenericTool never reads props.output, so a plugin tool call is a one-line, non-expandable row, and its subtitle comes from state.input's `description
src/capabilities.ts Host-capability probe — the "fail loud, not silent" seam. The plugin's features each lean on a host surface with no stability promise (ctx.ask, permission.asked, session.create/promptAsync/children/status/abort, client.pty, permission.reply, tui.showToast, input.$, the hook set, ToolResult.attachments); every row is classified ok (observed live: a hook fired, an event arrived, an ask bridge was handed to a tool) / declared (exists, unused) / not-seen (registered, host hasn't called it — NOT a break) / missing (gone — the named feature is off) / unverified (we emit the contract, only a human sees the result — attachments). createCapabilityProbe writes ONE trajectory line at boot and raises exactly ONE toast when a REQUIRED seam is missing; renderCapabilityMatrix is the markdown table tm_stats shows. setAskBridgeObserver (in tm/perm-ask.ts) is how askFnOf reports what each real tool ctx actually carried. Nothing throws, nothing probes the network
src/tm/stats.ts tm_stats — reads this plugin's own steps.jsonl back (it used to be write-only, which made the throughput mandate unfalsifiable). listTrajectoryRuns (newest-first, one run dir per plugin process, so the window spans restarts) → parseTrajectoryJsonl (torn tail line = skip) → summarizeEvents → renderStats: tokens kept out of the context per tool (offload tokens minus the preview_tokens that DID arrive; 80-token fallback for older events), dispatch overlap saving (Σ each child's own ms, else settle-ts − start-ts, minus the wall window between the first start and the last settle; a single dispatch claims nothing), PTC roll-up, governance counts (blocked subresources + distinct hosts, tm_pty refusals, bash timeout clamps, web cache hits, evaluate_script redactions), engine degradations, and the capability matrix. { recent: N } appends the call-by-call recap (recentCalls + renderRecent, newest-first over event:"result" lines, joined against the run's index.jsonl via RunStore.indexFile() so an offloaded result prints BOTH its tm_fetch handle AND the absolute payload path) — the answer to "the host gives plugin tools no expandable card": the details are on disk and this names where. Read-only over files we wrote; the reply is deliberately NOT offloaded (a table you have to page through defeats it) and the 口径 caveat is the first line
src/tm/cache.ts URL-level TTL cache for the governed web channel (TM_WEB_CACHE_TTL_SEC, default 300, 0 = off), ONE instance built in tm/index.ts and shared by tm_webfetch, tm_search's engine legs and the PTC bridge (keyed by URL, so the sharing is exact). Entries are sha256-hash-named and never store the URL (a query string can carry the token the allowlist exists to protect); atomic-ish write (tmp + unlink + rename), expired entry deleted by the read that found it dead, entry cap pruned by mtime, every fs op best-effort. GOVERNANCE: fetchWebText consults and writes the cache only when that hop passed the STATIC allowlist (staticAllow), so a hit can neither resurrect a removed host nor substitute for per-request dialog consent — dialog-approved bodies are never cached (pinned by test); a served page appends 缓存命中 with its age. fresh: true skips the READ, never the WRITE (#82) -- passing no cache object at all made a fresh fetch store nothing, so the next caller paid for the same network trip again (measured in the host export: two calls, 7 408 bytes each, no 缓存命中)
src/tm/dupe-guard.ts The "the engine ignored you" detector, wired into both tm_search paths (auto + explicit engine). hitSignature (sorted host+path, www-insensitive → a re-rank is the same answer) and hitsShareAnyQueryTerm (CJK bigram 口径, empty hit list = not judged irrelevant) feed createDupeGuard().observe(engine, query, hits, terms), which flags collapse (identical set under a DIFFERENT query) and irrelevant (zero query-token overlap), counts repeats, and returns a directive rather than a shrug: split one concept per query / use auto for a vote, and at the 3rd repeat hand it to the host's task (a researcher spends its own context on the multi-round attempt). Trajectory events collapse / irrelevant. Born from a live session: 66 tool calls / 25 steps / 2.8M input tokens on a three-term lookup because bing answered two different quoted queries with a byte-identical 舞-dictionary list and nothing said so
src/tm/perm-ask.ts The ONE door to the host's OFFICIAL dialog, shared by every ask site (tm_webfetch pre-flight + redirect re-check, tm_search, tm_browser navigation + evaluate_script consent, tm_pty, the PTC web bridge) — so no caller can forget a guard the leaf already applies. askFnOf feeds the capability probe (setAskBridgeObserver); askUserForTarget returns FOUR verdicts — approved / rejected / timed-out / unavailable — and askRefusalNote(outcome) is the single source of the sentence the agent reads (the timeout line states 等满了 N 秒 — a measurement, not the configured cap, because an agent that cannot tell a wait from a fast failure goes and times it itself), because "the user said no" (stop asking) and "nobody answered" (go look at the screen) are different next moves. The wait is bounded HERE: a bare await ask() hung until the user interrupted the whole turn (live: an off-allowlist tm_webfetch ending as Tool execution aborted), since the approval gate's TM_ASK_TIMEOUT_MIN auto-reject is armed ONLY when R6 is on and the client can reply. setAskWaitMs(resolveAskTimeoutMs(env) + ASK_GRACE_MS) is wired once at boot (index.ts) so the gate's authoritative reject still wins when it can fire; a floor keeps a mistyped env from making the dialog un-answerable. AN APPROVAL IS NOT AN ALLOWLIST (#81, from the 1.6.0 host export): askUserForTargetDetailed additionally reports answeredInMs and autoGranted (< ASK_AUTO_GRANT_MS = 1500, a documented HEURISTIC because the host gives no mode back), and askGrantNote(res) is the single sentence every caller appends -- a page that arrived on a saved rule says so ("宿主按已记住的规则直接准了…它按项目生效,对所有 agent 会话都算"), a page that arrived on a real click says that instead. This exists because a researcher saw no dialog, wrote 免弹窗直接成功 in its deliverable, and INFERRED the host was allowlisted: a saved "always" from any session answers for the whole project, so per-owner browser consent is never consulted. tm_browser's navigation gate and tm_webfetch's pre-flight both use it, and the trajectory separates silent_grant from dialog_approved. Never throws, never self-allows
src/tm/bash-timeout.ts Built-in bash timeout clamp (issue #6: the host resolves flags.bashDefaultTimeoutMs ?? 2*60*1e3 — 120 s — and models pass 120000+ for a Get-ChildItem, so serialised probes cost minutes of dead air). Mutates output.args.timeout in the official tool.execute.before hook, which is explicitly mutable ({args:any}); TM_BASH_TIMEOUT_PROBE_MS (default 60 000) applies ONLY to a command classifyReadonlyCommand already accepts, TM_BASH_TIMEOUT_MAX_MS (default 0 = off) is the opt-in global ceiling. It never invents a timeout the model omitted, never rewrites the command, never widens what may run, and cannot throw into the hook. Composed with R6 in src/index.ts (clamp first, then envProtectHook) because the host gives each plugin ONE tool.execute.before slot
src/tool-coerce.ts Repairs the built-in tool args the host validates strictly and models send as text (live: task {background:"True"} → "true" → two rejected calls before a real boolean; the host's schema is Schema.Boolean). Rides the same mutable tool.execute.before args as the bash timeout clamp and runs FIRST in the composed hook: one known boolean per known tool (BOOLEAN_ARGS), only `/^(true
src/task-offload.ts Plan B — the host's own background sub-agent, kept inside our budget. task {background:true} is the ONLY sub-agent the desktop can show (its card links to the child session; a plugin cannot register a renderer), and on completion the host injects the child's FULL reply into the parent via ops.prompt({parts:[{synthetic:true, text:<task …>}]}). We take the other half: chat.message hands plugins the parts array BEFORE the host persists it, so createTaskOffload swaps an oversized body for a preview + tm_join {ids:[…]} to pull the whole reply. THREE LOCKS, all required, else the part is left byte-exact: part.synthetic === true (a typed message never is), the text must match the host's own <task id="…" state="completed"> envelope exactly, and the body must exceed the text-tier offload threshold — verified live 2026-09-20 (the injection IS a user-role message whose part carries exactly that envelope, and a one-sentence child reply stays under the threshold by design). EVERY recognised envelope logs `{event:"envelope", action:"offloaded"
src/host-hooks.ts Host-hook leverage beyond the tool surface, all additive + individually switchable + never throwing: applyToolDefinition (TM_TOOL_HINTS, default on) APPENDS our call-site discipline to the built-in bash/task descriptions via tool.definition (idempotent marker check, host text never replaced — the timeout rule lands where the model decides, not thousands of tokens earlier); takes an overrides table so the task footer changes with the host's background-subagent flag (TASK_HINT_BACKGROUND); applyChatParams (TM_AGENT_TEMPERATURE, default off so the "all agents 0.2" invariant stands unless the user opts in, or passes its own reviewer=0.05;team=0.4 table) touches ONLY temperature; applySessionCompacting (TM_COMPACTION_CONTEXT, on) pushes the must-survive list (reply skeleton / offload handles / OPEN sub-agent session ids from the host task tool / provenance / board paths) into output.context and NEVER sets output.prompt; applyCompactionAutoContinue (default on = hands-off; off pauses instead of silently resuming); applyShellEnv (TM_SHELL_NO_COLOR on + TM_SHELL_ENV allowlisted K=V;K2=V2 passthrough) injects NO_COLOR/TERM=dumb and never overwrites what the host already set, so it cannot become a parent-env side channel. experimental.chat.messages.transform deliberately NOT implemented: rewriting outgoing messages means guessing the live shape of tool results at that layer, and a wrong guess deletes EVIDENCE silently.
src/tm/pty.ts tm_pty — non-blocking command execution on the host’s OWN terminal sessions (client.pty.create/get/list/remove; `Pty={id,title,command,args,cwd,status:"running"
src/tm/ptc.ts tm_ptc_run M1-M3 (shipped in the published dist since v1.5.2) -- args schema (program/label/tighten-only budgets clamped to TM_PTC_*), three engines behind the frozen PtcEngine seam: WorkerEngine (primary, worker_threads + MessagePort RPC + env:{} + terminate(), bootstrap as build-time string), InlineVmEngine (fallback, node:vm runInNewContext -- script timeout kills PRE-await sync busy-loops; a POST-await busy loop blocks the host event loop irrecoverably, documented -- prefer worker/auto), InlineSequentialEngine (M1 legacy, test-only); `TM_PTC_ENGINE=auto
src/index.ts + src/host/v1.ts + src/host/note.ts index.ts is the dual-personality BARREL (the v2 port, 1.7.0 line): its default export is {id:"team-mode", server}, and server is createV1Personality moved verbatim into src/host/v1.ts (331 body lines, de-indented diff = empty, proven in the M1 commit — a v1 host must not observe ONE behavioural difference, and test-.mjs is the oracle). blackboardNote went to src/host/note.ts because both personalities need it. The v2 half lands in src/host/v2.ts as setup(ctx) — Plugin.define in @opencode/plugin@2.0.16 is literally plugin => plugin, so the plain object IS the definition and the v2 SDK never becomes a runtime dependency (devDependency for a separate typecheck only; @opencode/plugin must not appear in dependencies). The v1 loader ignores setup; the v2 host ignores server; "V1 plugin implementations do not run in V2" is why both must ship in one package. ── v1 entry: server() + config hook (injects agents/commands + escalates execution-role bash to the ask pattern object, tracks injected exec agents) + ONE composed tool.execute.before hook (the host gives each plugin a single slot: bash-timeout clamp runs FIRST, then R6 env protection wired to the gate's session-scoped deferToApproval(sessionID)) + event hook (feeds session.idle/session.error/session.status to the async dispatcher, routes message.updated user prompts into the gate's session registry -- exec-role agents register, any other agent revokes -- and permission events to the timer) + chat.message (secondary registration + symmetric revoke) + ASYNC dispose (awaits the tm_browser close; a fire-and-forget teardown left a window on screen) + FIVE more host hooks wired in src/host-hooks.ts (tool.definition, chat.params, experimental.session.compacting, experimental.compaction.autocontinue, shell.env) + the src/capabilities.ts probe built after the tm runtime (declared BEFORE it so capabilities: () => capabilityProbe.snapshot() breaks the cycle, setAskBridgeObserver fed from perm-ask, observeEvent on every event, observeHook inside each hook body, report() once at boot = one trajectory line + one toast only if a REQUIRED seam is gone) + tool segment (tm_ registration incl. tm_ptc_run M3 + tm_join/tm_pty/tm_stats — tm_dispatch is deliberately NOT registered), id "team-mode"
src/types.ts Loader-contract type definitions (1.18.x) incl. PendingPermission / PermissionEvent / HostEvent + Hooks.event/Hooks["chat.message"]/Hooks.dispose
test-blackboard.mjs Blackboard + prompt contract assertions (incl. THE STATIC-DATE GUARD: no injected agent prompt or command template may contain an absolute date, since a long-lived process cannot re-render one; + every role's rules naming tm_board_write and the stale "architect / reviewer" cannot-write list being gone, + the lead's ## Hybrid blackboard passing the session folder down because a bash-less role cannot stamp one; incl. bash ask-object slot matrix on a reply-capable client + dead-popup no-R6-face check; T3 lead Dispatch-concurrency + pre-commit-hygiene anchors; T1 shared ## Memory tiers, dedup and compaction anchor incl. compact dry-run/apply:true + .compact-backup)
test-default-agent.mjs Default-agent promotion + isolation tests (§6 = permission/ask-object deep-equality incl. tm_board_write in the explicit TM_TOOLS set every agent allows incl. bare git/publish keys + no-gate dead-popup guard)
test-envprotect.mjs R6 pattern/mode/audit assertions (incl. privacy red line, path/cmd-head dumps) + §7 unified approval gate (REAL host asked/replied shapes, session-wide cancel + ghost tombstones, scoped-deferral registry, timeout parse, ask patterns, expressible/inexpressible + byte-exact split, categorize inference, timer reject-only, never-self-allow, SDK {error}-envelope fail-closed, hook session-scoping, dead-popup guard, tm_bash guidance)
test-tm-tools.mjs JIT tool assertions (incl. §12 tm_board_write: the pure path pieces (stamp shape, slug sanitising that turns auth/design into auth-design and .env into env, ordinal, findFamily keeping a revision in its family's NN with the next round) plus end to end on a temp board root -- path in the reply and NEVER the content, bytes on disk, board_write trajectory line, revision as a new -r2 file with the first file's bytes untouched, ordinal advancing per task dir, the filename role taken from ctx and not from an argument, an explicit session joining the lead's folder instead of forking a second board, and the refusals that must write nothing (empty content, over-cap, ../../ task shape that still resolves under the root, the per-session file quota naming the TTL path, a symlinked task dir -- the symlink leg skips, never fails, where the OS account cannot create one); threshold boundary, previews, HMAC/refs, P3, R6 reuse, degradation, trajectory append-only, §6g2 EMPTY RESULT SELF-REPORT (bash prints "stdout 为空 = 0 行输出 · cwd=…" + the tm_read pointer, grep prints "(0 命中)pattern=… · 范围=…" and only suggests widening when a path WAS given; a non-empty result stays byte-exact), §6m-ask an unanswered dialog returns 无人应答 within its own deadline instead of hanging the turn, §6k shared $ shell-bridge fallback, §6m tm_webfetch allowlist/red-lines/offload/SERP auto-extraction, §16 RECENCY ANCHOR (searchDateLine = today's date on BOTH hit-list headers — the per-engine renderer and the auto fused one that builds its own line; a month boundary changes the string, so it cannot be a captured constant), §15 ACCEPT NEGOTIATION (tm_search's shared default Accept byte-exact, tm_webfetch leads with text/markdown while keeping the Chrome-shaped tail, a markdown body passes through unstripped AND is labelled in the reply, one cache entry per URL regardless of negotiation, the HTML extractor still runs and kills <script>), §14 redirect trail + Retry-After (both hops named in order and the stopping hop counted; 429 carries 30; an HTTP date is not laundered into seconds; a single-hop fetch stays quiet), §13 THE NETWORK EGRESS RED LINE (pure matrix: metadata/link-local/multicast/reserved/benchmark + IPv6 special-use, loopback/RFC1918/ULA/CGNAT/.localhost askable, public untouched, [::1]/trailing-dot/case normalization, unparseable quad is not mistaken for private; then wired through checkWebUrl: * cannot open 169.254.169.254 and it is NOT askable, loopback stays askable), §6m-s tm_search (7-engine + auto-route table all-allowlisted, SERP→hit list with tracker/chrome exclusion, SO/HN snippet + npm/github structured renders, SO-quota→bing swap, switch-engine hint, single-quote+entity extractor), §6q AUTO STORE ISOLATION (two non-git workspaces get different trajectory roots; the shard key is a path hash so CJK dirs cannot collide; the memory tree stays on the shared base), §6p PARALLEL SAFETY (4 concurrent searches → isolated hit lists; 2 concurrent offloads → distinct step ids + isolated payloads; concurrent tm_fetch page-ins), §6n tm_memory add/update/search/list/forget (incl. slug-collision guard) + fs-safe non-ASCII rm regression, §6o tm_browser headless matrix/discovery override (incl. DEFAULT-browser registry/xdg resolution + Chromium-family filter)/pre-spawn allowlist/live round-trip) + §9 PTC (config, budgets, arg schema, staticPscan, five statuses + retry (incl. NO retry past the deadline), composite step numbers, trajectory shapes, summary shape pin, tool registration with the twelve-tool set (tm_join/tm_pty/tm_stats — and NOT tm_dispatch), §10 tm_join as the collect side (tm_dispatch NOT REGISTERED — asserted by name, and tm_join refusing a non-lead ctx at runtime; adoption of leftovers from both title shapes with a host task child never claimed on speculation; claimNamedChild verifying host parentage before adopting a named id; status + session.idle settle; cancel; fat-reply offload; the joinBudget matrix — first wait clamped, chained wait cut to REPEAT_WAIT_MS, snapshot neither extends nor resets; an unsettled round refusing to read as a delivery; the goal tripwire over the read-only host todo endpoint with its todowrite escape; and the pure helpers parseIdList / describeHostError / hostBackgroundSubagentsEnabled / parseDispatchTitle), §11 bash timeout clamp (probe-only default, opt-in ceiling, never invents a timeout, composed with R6), §9j REAL engine runs: WorkerEngine RPC/throw/terminate/env-isolation/strict + InlineVmEngine compile-timeout kill; 6m no-body regression pins the bounded ref scan (text()-only response truncated at the byte cap)
test-hosthooks.mjs Host-hook adapters (tool.definition append-only + idempotent + off-switch, chat.params off-by-default and single-field, compaction additive with output.prompt never replaced, shell.env never clobbering + allowlisted passthrough only) + tm_pty governance (R6/R2 refused BEFORE the dialog, no ask bridge = refuse, dialog pattern = the exact command line, argv passed separately not a shell string, foreign ids not killable, TM_PTY_MAX cap) + capability probe (missing vs declared vs not-seen vs unverified, one-shot required-seam toast, matrix as a markdown table, the askFnOf observer seam) + tm_stats (token saving net of the preview, dispatch overlap from each child's own duration, governance/degrade roll-ups, lead blocked-in-tm_join time, live tool over a real trajectory store, {recent:N} recap naming the handle AND the payload path, newest-first, no placeholder leakage) + plan B offload (envelope parses only the host's own shape; three locks — synthetic / exact envelope / threshold — each verified, a typed message never touched even when it contains a perfect envelope; assistant parts untouched; under-threshold verbatim; off restores; hook never throws; no disk copy; the trajectory count renders and a zero explains itself)
test-memory.mjs tm_memory three-tier (global / project / session) + near-duplicate dedup + bloat guards + compact assertions (T1): session transience & per-session isolation, TTL expiry (clock seam) + PERSIST mirror + boot/lazy sweep, dedupKey + Jaccard merge (no second file), MAX_ENTRIES cap -> args error pointing at compact/forget, [stale Nd] tagging + staleDays=0 off, compact dry-run default vs apply:true + .compact-backup rollback
test-browser.mjs T5 browser engine split: engine selection + auto-degrade (playwright preference + failing playwright-core import / node<20 lands on cdp-legacy; pins never import), discovery + pre-spawn allowlist gate, the 18 chrome-devtools-mcp verb mappings + ariaSnapshot [uid] handling against a MOCK playwright module, evaluate_script FUNCTION-SOURCE pins (a "() => …" source reaches page.evaluate as "(() => …)()", an already-invoked or plain expression passes byte-exact, and renderEvalResult separates undefined / null / "" / array(0) into four different sentences), §24 the launch-pid scan (our child + our executable; a row whose command line is a DIFFERENT exe is never adopted even when its image name matches; ambiguity → 0), the identity gate and the tree-kill command shapes (win32 /T /F, POSIX deepest-first), §25 the three close verdicts (an unverifiable close may not say 已确认关闭), §26 click effect verification (pure describeClickChange/clickVerdict pins + four end-to-end shapes: effective, inert-then-retried, dialog-open-never-retried, navigation-detached), §27 per-caller browser leases (id minted + echoed, two callers = two launches, omitted id refused once ambiguous, another caller's id refused WITH the owner named, unknown id lists what is live, uid namespaces independent, close and close-all scoped to the caller, dialog consent not shared), §28 the five defects one live session exported (empty window keeps its lease, closed page ≠ dead browser, the version sentence only when the accessor is missing, multi-match wait_for takes the first and SAYS so, the three no-change click causes), §30 the pid-query retry (a transient CIM failure may not forfeit the orphan ledger or close's verification, and a throwing lookup may not fail the launch), and §29 the blank-page attribution (a thin page with blocked script hosts blames OUR gate and names the host + allow_host + re-navigate; challengeWallOf labels a 安全验证/Cloudflare/access-denied wall and only fires on a thin page, so an article mentioning 验证码 is never mislabelled; allow_host end to end — one dialog with patterns:[host], the grant clears that host's script for the approver and STILL aborts for another owner, an allowlisted host asks nothing, malformed hosts die before the dialog, and the trajectory records host_approved). The MOCK mirrors playwright-core 1.63 — no process() accessor unless a test passes browserPid — and makeTool injects listChildren, so no unit test ever scans the developer's real process table or adopts a live browser's pid. The REAL playwright leg (gated on the dep being installed; skipped — never failed — while absent) is not a smoke: it asserts a real new_page/list_pages/close_page round-trip, that close's 已确认关闭 names a pid that is really gone together with its whole descendant tree (enumerated by PPID on win32), and that the orphan reaper's DEFAULT kill path — no injected alive, no injected kill — takes a real browser and its children down

Code conventions

  • TypeScript, ES modules -- all relative imports use .js suffix
  • Agent prompts are template literals; specialists get REPLY_CONTRACT + SHARED_RULES appended programmatically
  • dist/ is gitignored -- never commit build output
  • Prompt edits: preserve the subtraction philosophy (v1.4.7) -- deterministic routing, structured skeleton, count-based gate, hybrid blackboard

Development rules

  • Do not bump version or publish unless explicitly asked
  • Every release from 1.6.0 on ships a signed git tag AND a GitHub Release (standing instruction, 2026-09-25; the repo had zero releases before it). Order is fixed: CHANGELOG date → npm publish → git tag -s vX.Y.Z + git push --follow-tags → gh release create vX.Y.Z --notes-file <that CHANGELOG section> → re-run scripts/install.ps1. Never --generate-notes: dumping commit titles next to the CHANGELOG gives the same version two contradictory public descriptions. A tag and a release are the parts you cannot quietly take back, so they come after the user's explicit "发".
  • Version lines: 1.6.0 (published 2026-09-25) is the LAST v1-only release; every OpenCode-v2-based release continues from 1.7.0 (user decision, 2026-09-25). So the v2 port (Plugin.define dual-personality export — see the v2 section of the project memory and docs/ for the hook map) lands as 1.7.0, and 1.7.x/1.8.x stay v2-capable. Do not spend a version number on a v1-only refinement after 1.7.0 exists. During the port the v1 personality is frozen: src/host/v1.ts may not change behaviour at all — a move must be provable as a move (de-indent diff empty) and npm test must stay 7/7 with the same output; if a v1 assertion goes red because of a "harmless" refactor, the refactor is wrong.
  • Unified approval gate (R6 + R2): dangerous operations (delete / git push+commit / network fetch / package install+publish / process+system / privilege commands) and wildcard-expressible env reads are gated by the host's OFFICIAL confirmation dialog with a hard timeout -- TM_ASK_TIMEOUT_MIN (default 1 min) then auto-REJECT; the plugin NEVER self-allows (only rejects). Deferral to the dialog is per-session (canDefer(sessionID): only sessions registered via exec-role prompts or env-faced asks; stock sessions keep the hard throw) and byte-exact on the defer side. Wildcards-inexpressible shapes and the whole tm_* channel keep the code-level hard throw. Host "always" replies generalize far beyond the command (observed Get-ChildItem env:PATH -> Get-ChildItem *) -- the READMEs tell users to prefer "once". The timeout carries a hard 1-minute floor (MIN_ASK_TIMEOUT_MIN = 1, tunable via TM_ASK_TIMEOUT_FLOOR_MIN): short values are safe because a reply that races the auto-reject is classified benign already-closed (closed request -> host 404, no degrade flip) and a late user reply only audits as late-<verdict> -- the observed ~120 s is the host->plugin event-bus delivery lag, not click-resolution latency. Do not weaken either path without an approved spec.
  • R6 privacy red line: the env-protection audit log (team-mode-env-protect) records ONLY tool name + pattern category + approval verdict (ask / allowed-once / allowed-always / rejected / timeout-rejected / degraded / already-closed / rejected-shape-bug / late-<verdict>) -- never command text, paths, variable names or values; never weaken the interception patterns without an approved spec
  • Commit messages: feat(scope): ... / fix(scope): ... / docs(scope): ...
  • CHANGELOG.md: Keep a Changelog style; git-only changes go under [Unreleased]
  • README.md + README.zh-CN.md: keep the bilingual pair in sync when user-facing features, commands, or config change (both ship inside the npm package); if a feature is merged on main but not yet published, mark it as such in the READMEs
  • AGENTS.md: keep this file in sync when commands, structure, or conventions change
  • When adding a new prompt rule, add a matching assertion in test-blackboard.mjs
  • When changing agent/command injection, update test-default-agent.mjs
  • When changing env-protection patterns, approval-gate behaviour, or modes, add a matching assertion in test-envprotect.mjs

Prompt design principles (v1.4.7)

  1. Deterministic routing table -- no free-form scheduling deliberation
  2. Structured reply skeleton (STATUS/CHANGES/FINDINGS/EVIDENCE/HANDOFF) -- primary inter-agent channel
  3. Count-based approval gate -- >=2 dispatches -> plan + wait for user
  4. Hybrid blackboard -- files only for >~50 line deliverables
  5. Adaptive review -- 1 reviewer default; 3 dimensions only for high-risk
  6. Static verification -- build/typecheck/lint/tests; no improvised browser automation
  7. All agents at temperature 0.2
  8. Repo hygiene -- scratch/temp files deleted before reporting done (throwaway work goes to the OS temp dir); pinned in test-blackboard.mjs
  9. 效率至上 -- rounds are the unit of cost (one wide call beats three narrow ones, independent calls batch into one round, >=3 probes collapse into one tm_ptc_run, never re-run a check to watch it pass); stated in the LEAD prompt (## Efficiency first, BEFORE ## Routing table so it cannot be re-litigated per dispatch) and in SHARED_RULES for the five specialists, each with the boundary spelled out: efficiency never buys its way out of the evidence rule. Pinned in test-blackboard.mjs
  10. Reply language = the USER's language, never the tool's -- the prompts are English while 555 Chinese literal lines in src/tm/ flow into context on every tool call, so an agent mirrors the tool and answers an English request in Chinese. SHARED_RULES ## Reply language and the lead's counterpart say: write prose in the user's language, and quote a Chinese string VERBATIM only where it IS the evidence (a close verdict, a refusal line, 无人应答) -- a translated verdict is a claim nobody can re-check. Deliberately the PROMPT layer only: localising the tool strings themselves would rewrite ~414 assertions in test-*.mjs that pin the exact user-visible sentences, and is tracked as its own item. Pinned in test-blackboard.mjs (incl. the verbatim-quote assertion)