@te-river/opencode-team-mode 1.5.0 → 1.5.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.
Files changed (54) hide show
  1. package/README.md +157 -53
  2. package/README.zh-CN.md +137 -53
  3. package/dist/agents.d.ts.map +1 -1
  4. package/dist/agents.js +128 -26
  5. package/dist/agents.js.map +1 -1
  6. package/dist/approval-gate.d.ts +147 -0
  7. package/dist/approval-gate.d.ts.map +1 -0
  8. package/dist/approval-gate.js +471 -0
  9. package/dist/approval-gate.js.map +1 -0
  10. package/dist/commands.js +3 -3
  11. package/dist/commands.js.map +1 -1
  12. package/dist/envprotect.d.ts +234 -0
  13. package/dist/envprotect.d.ts.map +1 -0
  14. package/dist/envprotect.js +820 -0
  15. package/dist/envprotect.js.map +1 -0
  16. package/dist/index.d.ts +5 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +112 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/tm/config.d.ts +105 -0
  21. package/dist/tm/config.d.ts.map +1 -0
  22. package/dist/tm/config.js +118 -0
  23. package/dist/tm/config.js.map +1 -0
  24. package/dist/tm/guard.d.ts +56 -0
  25. package/dist/tm/guard.d.ts.map +1 -0
  26. package/dist/tm/guard.js +249 -0
  27. package/dist/tm/guard.js.map +1 -0
  28. package/dist/tm/index.d.ts +41 -0
  29. package/dist/tm/index.d.ts.map +1 -0
  30. package/dist/tm/index.js +93 -0
  31. package/dist/tm/index.js.map +1 -0
  32. package/dist/tm/preview.d.ts +46 -0
  33. package/dist/tm/preview.d.ts.map +1 -0
  34. package/dist/tm/preview.js +354 -0
  35. package/dist/tm/preview.js.map +1 -0
  36. package/dist/tm/ptc.d.ts +279 -0
  37. package/dist/tm/ptc.d.ts.map +1 -0
  38. package/dist/tm/ptc.js +839 -0
  39. package/dist/tm/ptc.js.map +1 -0
  40. package/dist/tm/refs.d.ts +37 -0
  41. package/dist/tm/refs.d.ts.map +1 -0
  42. package/dist/tm/refs.js +58 -0
  43. package/dist/tm/refs.js.map +1 -0
  44. package/dist/tm/store.d.ts +95 -0
  45. package/dist/tm/store.d.ts.map +1 -0
  46. package/dist/tm/store.js +222 -0
  47. package/dist/tm/store.js.map +1 -0
  48. package/dist/tm/tools.d.ts +84 -0
  49. package/dist/tm/tools.d.ts.map +1 -0
  50. package/dist/tm/tools.js +710 -0
  51. package/dist/tm/tools.js.map +1 -0
  52. package/dist/types.d.ts +155 -0
  53. package/dist/types.d.ts.map +1 -1
  54. package/package.json +3 -2
@@ -0,0 +1,710 @@
1
+ /**
2
+ * JIT layer-2 tools — the four governed pipelines (T1.2 + T1.3 + T1.4).
3
+ *
4
+ * tm_read — governed passthrough of client.file.read (T0.4-verified API)
5
+ * tm_grep — governed passthrough of client.find.text (T0.4-verified API)
6
+ * tm_bash — read-only allowlisted shell via the host `$` bridge
7
+ * tm_fetch — paged/structured retrieval of offloaded payloads (handles)
8
+ *
9
+ * Shared governance (every tool):
10
+ * - ToolResult contract (official tool.d.ts:39-46): execute() returns
11
+ * {output: string} — ALWAYS. Handles, structured errors, pages and
12
+ * degraded warnings are formatted INTO the output text; a bare object
13
+ * return crashes the host result pipeline (`c.split`) and the model
14
+ * would never see the handle it needs to drive tm_fetch.
15
+ * - R6 reuse: env-file paths / env commands are refused through the SAME
16
+ * envprotect matchers the global hook uses (anti-backdoor requirement);
17
+ * the hook layer additionally aliases tm_* onto read/grep/bash so even a
18
+ * future tool-code regression cannot bypass R6.
19
+ * - Threshold offload: estimateTokens(content) >= TM_OFFLOAD_THRESHOLD
20
+ * (default 2000; == threshold also offloads) -> full payload to the run
21
+ * store + trajectory, handle {offloaded, ref, access_token, expire_at,
22
+ * tokens, preview} back. Below -> content inline.
23
+ * - Structured errors (T1.4): pipelines still produce
24
+ * {error: {tool, phase, message, line?}} internally — never a bare
25
+ * throw — and execute() renders them readably into output; shell errors
26
+ * carry a line when one can be extracted and stdout noise is stripped.
27
+ * - Degradation: an offload (store) failure returns truncated content plus
28
+ * a warning — a governance fault must never fail the task.
29
+ */
30
+ import { ENV_PROTECT_MESSAGE, classifyBashCommand, classifyPathFields } from "../envprotect.js";
31
+ import { estimateTokens, shouldOffload, shorten } from "./config.js";
32
+ import { isExpired, parseRef as parseTmRef, verifyToken } from "./refs.js";
33
+ import { buildPreview, buildStructureSummary, contentTypeForPath, detectContentType, } from "./preview.js";
34
+ import { assertReadablePath, classifyReadonlyCommand } from "./guard.js";
35
+ export function tmError(tool, phase, message, line) {
36
+ const body = { tool, phase, message };
37
+ if (typeof line === "number" && Number.isFinite(line))
38
+ body.line = line;
39
+ return { error: body };
40
+ }
41
+ /** Fixed invalid-handle message (spec-pinned wording for tm_fetch). */
42
+ export const HANDLE_INVALID_MESSAGE = "载荷已清理或 run 不匹配,建议重跑原工具。";
43
+ // ---------- ToolResult rendering (official tool.d.ts:39-46 contract) ----------
44
+ function isTmErrorBody(res) {
45
+ return Boolean(res) && typeof res === "object" && "error" in res;
46
+ }
47
+ /**
48
+ * Render one pipeline result as the host ToolResult: `{output: string}`.
49
+ * The model can ONLY see this text — so the handle fields it needs to drive
50
+ * tm_fetch (ref/access_token/expire_at), the preview, paging hints, error
51
+ * phase/message and degraded warnings are all formatted INTO the output.
52
+ */
53
+ function toToolResult(res) {
54
+ if (typeof res === "string")
55
+ return { output: res };
56
+ if (!res || typeof res !== "object")
57
+ return { output: String(res ?? "") };
58
+ if (isTmErrorBody(res)) {
59
+ const e = res.error;
60
+ const head = `[${e.tool} 失败 · phase=${e.phase}` + (typeof e.line === "number" ? ` · line=${e.line}` : "") + "]";
61
+ return { output: `${head}\n${e.message}` };
62
+ }
63
+ const o = res;
64
+ // tm_fetch structure page
65
+ if (o.mode === "structure") {
66
+ return {
67
+ output: [
68
+ `ref: ${String(o.ref)}`,
69
+ `mode: structure | total_lines: ${String(o.total_lines)}${o.expire_at != null ? ` | expire_at: ${String(o.expire_at)}` : ""}`,
70
+ "结构摘要:",
71
+ String(o.summary ?? ""),
72
+ ].join("\n"),
73
+ };
74
+ }
75
+ // tm_fetch lines page — hint already carries total/returned/remaining/next offset
76
+ if (o.mode === "lines") {
77
+ return {
78
+ output: `ref: ${String(o.ref)}\n${String(o.hint ?? "")}\n--- 内容 ---\n${String(o.content ?? "")}`,
79
+ };
80
+ }
81
+ // offload handle — the model's ONLY window onto ref + token + preview
82
+ if (o.offloaded === true) {
83
+ return {
84
+ output: [
85
+ `结果过大(约 ${String(o.tokens)} tokens),已卸载到 run 存储。以下句柄是取回全文的唯一入口:`,
86
+ `ref: ${String(o.ref)}`,
87
+ `access_token: ${String(o.access_token)}`,
88
+ `expire_at: ${String(o.expire_at)}`,
89
+ `tokens: ${String(o.tokens)}`,
90
+ `content_type: ${String(o.content_type ?? "text")}`,
91
+ "preview:",
92
+ String(o.preview ?? ""),
93
+ `用 tm_fetch(ref, access_token) 分页取回;大载荷先试 mode:"structure"。`,
94
+ ].join("\n"),
95
+ };
96
+ }
97
+ // degraded path — store failure never fails the task
98
+ if (o.degraded === true) {
99
+ return {
100
+ output: `[警告] ${String(o.warning ?? "")}\n--- 内容(截断) ---\n${String(o.content ?? "")}`,
101
+ };
102
+ }
103
+ try {
104
+ return { output: JSON.stringify(res, null, 2) };
105
+ }
106
+ catch {
107
+ return { output: String(res) };
108
+ }
109
+ }
110
+ /**
111
+ * Client result unwrapping — TWO live shapes + the legacy test shape:
112
+ * - 1.18.x REAL host (tester-probed): `{ data: <payload>, request, response }`
113
+ * with NO `ok` field — file.read data = {type:"text", content},
114
+ * find.text data = Match[]. Absent `ok` is NOT a failure; `data`
115
+ * presence decides success.
116
+ * - T0.4 legacy: `{ ok: true, data }` (what the fake clients in tests emit).
117
+ * RequestResult hygiene: ok === true can STILL carry the error envelope
118
+ * {error: {name, data: {message, ref}}} — self-check `data.error`. A
119
+ * top-level error / ok===false fails even when a data payload rides along.
120
+ */
121
+ function unwrapClientResult(res) {
122
+ if (!res || typeof res !== "object") {
123
+ return { ok: false, message: "客户端返回为空或格式异常" };
124
+ }
125
+ const r = res;
126
+ if (r.ok === false || r.error != null) {
127
+ const e = r.error;
128
+ const msg = e?.message ?? e?.data?.message ?? e?.name;
129
+ return { ok: false, message: msg ? String(msg) : `客户端返回 ok=${String(r.ok)}` };
130
+ }
131
+ if ("data" in r) {
132
+ const data = r.data;
133
+ if (data && typeof data === "object") {
134
+ const errVal = data.error;
135
+ if (errVal != null) {
136
+ if (typeof errVal === "string")
137
+ return { ok: false, message: errVal };
138
+ const envelope = errVal;
139
+ const msg = envelope?.data?.message ?? envelope?.name ?? "unknown client error envelope";
140
+ return { ok: false, message: String(msg) };
141
+ }
142
+ }
143
+ return { ok: true, data };
144
+ }
145
+ if (r.ok === true)
146
+ return { ok: true, data: undefined };
147
+ return { ok: false, message: "客户端返回格式异常(缺少 data 字段)" };
148
+ }
149
+ /**
150
+ * One client.find.text match object -> ripgrep-style `path:line: text` lines.
151
+ * Key table (live-probed shape; tester re-verifies each key against real
152
+ * host data):
153
+ * path — file path of the hit (aliases: file, file_path, filePath)
154
+ * lines — matched lines, string[] (aliases: text, content, match, line_text,
155
+ * line-as-string)
156
+ * line — 1-based line number (aliases: line_number, lineNumber; must be
157
+ * numeric — a textual `line` value falls through to the text keys)
158
+ * Returns null when neither a path nor any hit text is found.
159
+ */
160
+ function matchObjectText(m) {
161
+ const p = [m.path, m.file, m.file_path, m.filePath].find((v) => typeof v === "string" && v.length > 0);
162
+ let texts = [];
163
+ for (const key of ["lines", "text", "content", "match", "line_text", "line"]) {
164
+ const v = m[key];
165
+ if (Array.isArray(v)) {
166
+ texts = v.filter((x) => typeof x === "string");
167
+ if (texts.length > 0)
168
+ break;
169
+ }
170
+ else if (typeof v === "string" && v.length > 0) {
171
+ texts = [v];
172
+ break;
173
+ }
174
+ }
175
+ const numKeys = ["line_number", "lineNumber", "line"]
176
+ .map((k) => Number(m[k]))
177
+ .find((n) => Number.isInteger(n) && Number.isFinite(n));
178
+ if (!p && texts.length === 0)
179
+ return null;
180
+ const prefix = p ? (typeof numKeys === "number" ? `${p}:${numKeys}` : p) : "";
181
+ if (texts.length === 0)
182
+ return prefix;
183
+ return texts.map((t) => (prefix ? `${prefix}: ${t}` : t)).join("\n");
184
+ }
185
+ /** Best-effort text extraction from verified client payloads. */
186
+ function extractText(data) {
187
+ if (typeof data === "string")
188
+ return data;
189
+ if (data == null)
190
+ return "";
191
+ if (Array.isArray(data)) {
192
+ // find.text real-host shape: an array of match objects. Render each as
193
+ // `path:line: text` so path/line/hit structure survives into previews
194
+ // (the log branch lifts file:line retrieval clues) and aggregation.
195
+ if (data.length > 0 && data.every((x) => x !== null && typeof x === "object" && !Array.isArray(x))) {
196
+ const rendered = data.map((x) => matchObjectText(x));
197
+ if (rendered.some((s) => s !== null)) {
198
+ return rendered.filter((s) => s !== null).join("\n");
199
+ }
200
+ // Key-table mismatch — fall back to JSON so nothing is silently dropped.
201
+ }
202
+ else {
203
+ return data.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join("\n");
204
+ }
205
+ }
206
+ const o = data;
207
+ for (const key of ["content", "text", "output", "stdout"]) {
208
+ if (typeof o[key] === "string")
209
+ return o[key];
210
+ }
211
+ try {
212
+ return JSON.stringify(data, null, 2);
213
+ }
214
+ catch {
215
+ return String(data);
216
+ }
217
+ }
218
+ // ---------- shell bridge ----------
219
+ /**
220
+ * Run a command through the host `$` bridge. The REAL host `$` is a
221
+ * Bun-shell tagged template (probed keys: Shell/ShellPromise/ShellError/
222
+ * braces/escape) — both `$(["cmd"])` and `$("cmd")` are rejected with
223
+ * "Please use '$' as a tagged template function", which failed every
224
+ * tm_bash call at phase=execute. A tagged-template call desugars to
225
+ * `$(Object.assign([command], { raw: [command] }))` — tester-verified OK on
226
+ * the real host — so that shape goes FIRST; the legacy call shapes stay as
227
+ * fallbacks for other hosts and the test fakes (which only need *some*
228
+ * call to return `{text}`). Returns stdout text.
229
+ */
230
+ async function runShellCommand($, command) {
231
+ if (typeof $ !== "function") {
232
+ throw new Error("宿主 shell 桥($)不可用");
233
+ }
234
+ const shell = $;
235
+ let proc;
236
+ let lastErr;
237
+ for (const attempt of [
238
+ () => shell(Object.assign([command], { raw: [command] })), // tagged-template desugar: $`command`
239
+ () => shell([command]), // plain-array host variant
240
+ () => shell(command), // plain-string host variant
241
+ ]) {
242
+ try {
243
+ proc = attempt();
244
+ // a call shape may return undefined WITHOUT throwing — keep trying the
245
+ // next shape instead of breaking with lastErr still undefined
246
+ if (proc != null)
247
+ break;
248
+ }
249
+ catch (err) {
250
+ lastErr = err;
251
+ }
252
+ }
253
+ if (proc == null) {
254
+ // rethrow the ORIGINAL error (Error or shell-error object) — wrapping it
255
+ // through String() would destroy stderr/stdout before cleanShellError
256
+ // can extract line numbers
257
+ throw lastErr ?? new Error("shell 桥调用失败");
258
+ }
259
+ const p = proc;
260
+ if (typeof p.text === "function")
261
+ return String(await p.text());
262
+ if (p.stdout !== undefined)
263
+ return String(p.stdout);
264
+ throw new Error("shell 结果不可读");
265
+ }
266
+ /** Strip ANSI + empty-line noise, cap length, best-effort line number. */
267
+ function cleanShellError(err) {
268
+ const e = err;
269
+ const raw = [e?.stderr, e?.message, e?.stdout]
270
+ .filter((v) => typeof v === "string" && v.length > 0)
271
+ .join("\n");
272
+ let msg = String(raw || e?.message || String(err))
273
+ .replace(/\x1b\[[0-9;]*[A-Za-z]/g, "")
274
+ .split(/\r?\n/)
275
+ .map((l) => l.trimEnd())
276
+ .filter((l) => l.trim().length > 0)
277
+ .slice(0, 6)
278
+ .join("\n");
279
+ if (msg.length > 400)
280
+ msg = msg.slice(0, 400) + " …";
281
+ const m = /(?:line|行)\s*[:#]?\s*(\d+)/i.exec(raw);
282
+ return { message: msg || "shell 执行失败", line: m ? Number(m[1]) : undefined };
283
+ }
284
+ // ---------- args coercion ----------
285
+ function strArg(v) {
286
+ return typeof v === "string" ? v : v == null ? "" : String(v);
287
+ }
288
+ function intArg(v, def) {
289
+ const n = typeof v === "number" ? v : Number.parseInt(String(v ?? ""), 10);
290
+ return Number.isFinite(n) ? Math.trunc(n) : def;
291
+ }
292
+ // ---------- descriptions ----------
293
+ // Deliberately explicit about dialect + governance: the model must prefer the
294
+ // governed tools over raw bash equivalents (see /#14791 for tm_grep) and must
295
+ // learn the handle protocol from the description alone.
296
+ const TM_READ_DESCRIPTION = `Read a file inside the project (governed passthrough of the built-in read).
297
+ - Read scope (P2, fail-closed): project root + blackboard dir + trajectory dir; anything outside is rejected, and so is a nonexistent/unresolvable path (realpath-verified).
298
+ - R6 applies: env files (.env, *.env, .bashrc family) are refused — same interception source as the built-in read; tm_* is NOT a bypass.
299
+ - Governance: results up to TM_OFFLOAD_THRESHOLD tokens (default 2000, chars/4 estimate) return inline; larger payloads are offloaded to a handle {offloaded, ref, access_token, expire_at, tokens, preview} — page through with tm_fetch (try mode:"structure" first).
300
+ - The preview is content-aware (JSON / CSV / log / code / binary branches, hard-capped at 80 tokens) and embeds retrieval clues.`;
301
+ const TM_GREP_DESCRIPTION = `Full-text regex search inside the project (governed passthrough of the host's ripgrep index). PREFER this over running rg inside tm_bash: it uses the host's search index and auto-governs oversized results instead of flooding the context.
302
+ - Args: pattern (required, regex), path (optional scope directory, default project root).
303
+ - P2 fail-closed: a path argument that does not exist (or resolves outside project root + blackboard + trajectory) is rejected outright.
304
+ - R6 applies: pattern/path naming env files (*.env, .bashrc family) are refused — same source as the built-in grep.
305
+ - Governance: results up to TM_OFFLOAD_THRESHOLD tokens return inline; larger ones are offloaded to a handle {offloaded, ref, access_token, expire_at, tokens, preview} whose preview carries match clues (file:line). Page through with tm_fetch.
306
+ - Strategy: aggregate first (narrow pattern, counts), fetch raw lines only when needed.`;
307
+ const TM_BASH_DESCRIPTION = `Run a READ-ONLY shell command in the project (governed passthrough of the built-in bash). Dialect: bash on POSIX, PowerShell-like on Windows (Get-Content / Get-ChildItem / Select-String work; ls/cat/dir are aliased).
308
+ - Allowlist only (P3): ls cat head tail grep rg find awk sort uniq wc cut dir Get-Content Get-ChildItem Select-String Measure-Object — extend via TM_BASH_READONLY_ALLOWED. Anything else is rejected with a read-only suggestion or an ask to HUMAN.
309
+ - R6 still applies on top: env dumps (env/printenv/set, $env:) and env-file paths are blocked exactly like the built-in bash — two layers, non-conflicting (R6 forbids, the allowlist permits).
310
+ - Escapes rejected: output redirection (> >>), command substitution ($(), backticks, <() >()), find -delete/-exec, tail -f, Get-Content/Get-ChildItem -Wait, awk system(), rg --pre.
311
+ - Governance: output up to TM_OFFLOAD_THRESHOLD tokens returns inline; larger output is offloaded to a tm_fetch handle. Use for aggregations (count/sort/uniq); for plain search prefer tm_grep (host index, auto-governed).`;
312
+ const TM_FETCH_DESCRIPTION = `Page through an offloaded tool result by its handle (tm_read / tm_grep / tm_bash return handles for oversized payloads).
313
+ - Args: ref (required, tm://runs/{run_id}/steps/{step_id}/result), access_token (required — from the handle; may also ride the ref fragment), offset (0-based line), limit (lines per fetch, capped at TM_FETCH_MAX_LINES, default 2000), mode ("lines" default | "structure" — a ~100-token TOC / key-tree / error-line map; try it FIRST on big payloads).
314
+ - Handles are run-scoped and HMAC-signed: a foreign-run, tampered or expired handle (TM_BLACKBOARD_TTL, default 7 days) is rejected with "payload cleared or run mismatch — rerun the original tool".
315
+ - Returns the ref, a paging hint (total/returned/remaining/next offset) and the content slice. Aggregate first; fetch further slices only when needed.`;
316
+ export function buildPipelines(deps) {
317
+ const { cfg, store } = deps;
318
+ let stepCounter = 0;
319
+ const nextStepId = () => `s${String(++stepCounter).padStart(4, "0")}`;
320
+ function ctxDir(ctx) {
321
+ const d = ctx?.directory;
322
+ return typeof d === "string" && d ? d : process.cwd();
323
+ }
324
+ /**
325
+ * Threshold governance. Inline under the threshold; otherwise store full
326
+ * content + append trajectory and return the handle. A store failure
327
+ * degrades to truncated content + warning (never fails the task).
328
+ */
329
+ function govern(stepId, tool, content, opts) {
330
+ const tokens = estimateTokens(content);
331
+ if (!shouldOffload(tokens, cfg.offloadThreshold)) {
332
+ store.appendTrajectory({ tool, step_id: stepId, event: "result", offloaded: false, tokens });
333
+ return content;
334
+ }
335
+ const preview = buildPreview(content, opts.contentType, {
336
+ lines: cfg.previewLines,
337
+ maxTokens: cfg.previewMaxTokens,
338
+ clue: opts.clue,
339
+ });
340
+ try {
341
+ const stored = store.writeResult(stepId, {
342
+ tool,
343
+ content,
344
+ tokens,
345
+ contentType: opts.contentType,
346
+ preview,
347
+ expireAt: deps.expireAt,
348
+ });
349
+ store.appendTrajectory({
350
+ tool,
351
+ step_id: stepId,
352
+ seq: stored.seq,
353
+ event: "result",
354
+ offloaded: true,
355
+ tokens,
356
+ ref: stored.ref,
357
+ });
358
+ return {
359
+ offloaded: true,
360
+ ref: stored.ref,
361
+ access_token: deps.accessToken,
362
+ expire_at: stored.expireAt,
363
+ content_type: opts.contentType,
364
+ tokens,
365
+ preview,
366
+ };
367
+ }
368
+ catch (err) {
369
+ return {
370
+ offloaded: false,
371
+ degraded: true,
372
+ truncated: true,
373
+ warning: `结果治理(黑板卸载)失败,已降级为截断返回:${shorten(err?.message ?? err, 120)}。` +
374
+ `治理故障不使任务失败;需要全文请重跑原工具或缩小查询范围。`,
375
+ content: content.slice(0, Math.max(0, cfg.offloadThreshold) * 4),
376
+ tokens_estimate: tokens,
377
+ };
378
+ }
379
+ }
380
+ // ---------- tm_read ----------
381
+ async function tmRead(rawArgs, ctx) {
382
+ const tool = "tm_read";
383
+ try {
384
+ const args = rawArgs ?? {};
385
+ const requested = strArg(args.path).trim();
386
+ if (!requested)
387
+ return tmError(tool, "args", "缺少 path 参数");
388
+ // R6 reuse — same matcher source as the global hook (anti-backdoor).
389
+ const r6 = classifyPathFields(args, deps.mode, deps.extra);
390
+ if (r6)
391
+ return tmError(tool, "permission", `${ENV_PROTECT_MESSAGE} [category=${r6}]`);
392
+ const stepId = nextStepId();
393
+ store.appendTrajectory({ tool, step_id: stepId, event: "call" });
394
+ const scope = assertReadablePath(ctxDir(ctx), requested, [
395
+ store.blackboardRoot,
396
+ store.trajectoryRoot,
397
+ ]);
398
+ if (!scope.ok)
399
+ return tmError(tool, "permission", scope.message);
400
+ const client = deps.client;
401
+ if (!client || typeof client.file?.read !== "function") {
402
+ return tmError(tool, "client", "宿主 client 不可用(client.file.read 缺失)");
403
+ }
404
+ // METHOD call (property-access site keeps the SDK `this` binding) —
405
+ // a fetched-and-unbound call dies synchronously on the real host
406
+ // (same P0 class as the approval-gate reply bug; §7-style mocks pin it)
407
+ const res = await client.file.read({
408
+ query: { path: scope.abs, directory: ctxDir(ctx) },
409
+ });
410
+ const unwrapped = unwrapClientResult(res);
411
+ if (!unwrapped.ok)
412
+ return tmError(tool, "client", unwrapped.message);
413
+ const content = extractText(unwrapped.data);
414
+ return govern(stepId, tool, content, {
415
+ contentType: detectContentType(content, contentTypeForPath(scope.abs)),
416
+ clue: `path=${shorten(scope.abs, 120)}`,
417
+ });
418
+ }
419
+ catch (err) {
420
+ const info = cleanShellError(err);
421
+ return tmError(tool, "execute", info.message, info.line);
422
+ }
423
+ }
424
+ // ---------- tm_grep ----------
425
+ async function tmGrep(rawArgs, ctx) {
426
+ const tool = "tm_grep";
427
+ try {
428
+ const args = rawArgs ?? {};
429
+ const pattern = strArg(args.pattern).trim();
430
+ if (!pattern)
431
+ return tmError(tool, "args", "缺少 pattern 参数");
432
+ const r6 = classifyPathFields(args, deps.mode, deps.extra);
433
+ if (r6)
434
+ return tmError(tool, "permission", `${ENV_PROTECT_MESSAGE} [category=${r6}]`);
435
+ const stepId = nextStepId();
436
+ store.appendTrajectory({ tool, step_id: stepId, event: "call" });
437
+ let scopeDir = ctxDir(ctx);
438
+ const requested = strArg(args.path).trim();
439
+ if (requested) {
440
+ const scope = assertReadablePath(ctxDir(ctx), requested, [
441
+ store.blackboardRoot,
442
+ store.trajectoryRoot,
443
+ ]);
444
+ if (!scope.ok)
445
+ return tmError(tool, "permission", scope.message);
446
+ scopeDir = scope.abs;
447
+ }
448
+ const client = deps.client;
449
+ if (!client || typeof client.find?.text !== "function") {
450
+ return tmError(tool, "client", "宿主 client 不可用(client.find.text 缺失)");
451
+ }
452
+ // METHOD call — see the tm_read note above (unbound SDK call = dead)
453
+ const res = await client.find.text({
454
+ query: { pattern, directory: scopeDir },
455
+ });
456
+ const unwrapped = unwrapClientResult(res);
457
+ if (!unwrapped.ok)
458
+ return tmError(tool, "client", unwrapped.message);
459
+ const content = extractText(unwrapped.data);
460
+ const matchLines = content ? content.split(/\r?\n/).filter((l) => l.trim()).length : 0;
461
+ return govern(stepId, tool, content, {
462
+ contentType: detectContentType(content),
463
+ clue: `pattern=${shorten(pattern, 60)}, 命中 ${matchLines} 行, dir=${shorten(scopeDir, 80)}`,
464
+ });
465
+ }
466
+ catch (err) {
467
+ const info = cleanShellError(err);
468
+ return tmError(tool, "execute", info.message, info.line);
469
+ }
470
+ }
471
+ // ---------- tm_bash ----------
472
+ async function tmBash(rawArgs, ctx) {
473
+ const tool = "tm_bash";
474
+ try {
475
+ const args = rawArgs ?? {};
476
+ const command = strArg(args.command).trim();
477
+ if (!command)
478
+ return tmError(tool, "args", "缺少 command 参数");
479
+ // Layer 1 — R6 (forbidden set, same source as the built-in bash hook).
480
+ const r6 = classifyBashCommand(command, deps.mode, deps.extra);
481
+ if (r6)
482
+ return tmError(tool, "permission", `${ENV_PROTECT_MESSAGE} [category=${r6}]`);
483
+ // Layer 2 — P3 allowlist (permitted set, read-only).
484
+ const verdict = classifyReadonlyCommand(command, cfg.bashReadonlyAllowed);
485
+ if (!verdict.ok) {
486
+ return tmError(tool, "permission", `${verdict.reason ?? "命令被拒绝"}。${verdict.suggestion ?? ""}`.trim());
487
+ }
488
+ const stepId = nextStepId();
489
+ store.appendTrajectory({ tool, step_id: stepId, event: "call" });
490
+ const output = await runShellCommand(deps.$, command);
491
+ return govern(stepId, tool, output, {
492
+ contentType: detectContentType(output),
493
+ clue: `cmd=${shorten(command, 60)}, dir=${shorten(ctxDir(ctx), 80)}`,
494
+ });
495
+ }
496
+ catch (err) {
497
+ const info = cleanShellError(err);
498
+ return tmError(tool, "execute", info.message, info.line);
499
+ }
500
+ }
501
+ // ---------- tm_fetch ----------
502
+ async function tmFetch(rawArgs, _ctx) {
503
+ const tool = "tm_fetch";
504
+ try {
505
+ const args = rawArgs ?? {};
506
+ const ref = strArg(args.ref).trim();
507
+ if (!ref)
508
+ return tmError(tool, "args", "缺少 ref 参数(offload 句柄中的 ref)");
509
+ const parsed = parseTmRef(ref);
510
+ if (!parsed) {
511
+ return tmError(tool, "args", "ref 格式无效,期望 tm://runs/{run_id}/steps/{step_id}/result");
512
+ }
513
+ let token = strArg(args.access_token).trim();
514
+ if (!token && parsed.token)
515
+ token = parsed.token;
516
+ if (!token)
517
+ return tmError(tool, "args", "缺少 access_token(offload 句柄中携带)");
518
+ // Auth: run must match the current run AND the token must verify.
519
+ let reason;
520
+ if (parsed.runId !== deps.runId) {
521
+ reason = "run 不匹配";
522
+ }
523
+ else if (!verifyToken(deps.hmacKey, deps.runId, token)) {
524
+ reason = "token 校验失败";
525
+ }
526
+ else {
527
+ reason = "";
528
+ }
529
+ if (reason) {
530
+ return tmError(tool, "permission", `句柄校验失败(${reason})。${HANDLE_INVALID_MESSAGE}`);
531
+ }
532
+ const entry = store.findIndexEntry(ref);
533
+ const now = Date.now();
534
+ if (entry && isExpired(entry.expire_at, now)) {
535
+ return tmError(tool, "permission", `句柄已过期(expire_at=${entry.expire_at})。${HANDLE_INVALID_MESSAGE}`);
536
+ }
537
+ const loaded = store.readStepFile(parsed.stepId);
538
+ if (!loaded) {
539
+ return tmError(tool, "store", `找不到载荷文件。${HANDLE_INVALID_MESSAGE}`);
540
+ }
541
+ const lines = loaded.content.split(/\r?\n/);
542
+ const mode = strArg(args.mode).trim() === "structure" ? "structure" : "lines";
543
+ store.appendTrajectory({ tool, step_id: parsed.stepId, event: "fetch", ref, mode });
544
+ if (mode === "structure") {
545
+ return {
546
+ ref,
547
+ mode: "structure",
548
+ total_lines: lines.length,
549
+ summary: buildStructureSummary(loaded.content, entry?.content_type ?? "text"),
550
+ expire_at: entry?.expire_at ?? null,
551
+ };
552
+ }
553
+ let offset = intArg(args.offset, 0);
554
+ if (offset < 0)
555
+ offset = 0;
556
+ let limit = intArg(args.limit, cfg.fetchMaxLines);
557
+ if (limit <= 0)
558
+ limit = cfg.fetchMaxLines;
559
+ if (limit > cfg.fetchMaxLines)
560
+ limit = cfg.fetchMaxLines;
561
+ const slice = lines.slice(offset, offset + limit);
562
+ // offset past EOF must not produce a negative remaining (#9)
563
+ const remaining = Math.max(0, lines.length - (offset + slice.length));
564
+ return {
565
+ ref,
566
+ mode: "lines",
567
+ offset,
568
+ limit,
569
+ total_lines: lines.length,
570
+ returned_lines: slice.length,
571
+ remaining_lines: remaining,
572
+ next_offset: remaining > 0 ? offset + slice.length : null,
573
+ hint: remaining > 0
574
+ ? `共 ${lines.length} 行,已返回 ${slice.length} 行(offset=${offset}),剩余 ${remaining} 行;继续取回用 offset=${offset + slice.length};先聚合再决定是否翻页。`
575
+ : `共 ${lines.length} 行,已返回 ${slice.length} 行(offset=${offset}),已到末尾。`,
576
+ content: slice.join("\n"),
577
+ };
578
+ }
579
+ catch (err) {
580
+ const info = cleanShellError(err);
581
+ return tmError(tool, "execute", info.message, info.line);
582
+ }
583
+ }
584
+ return { nextStepId, govern, tmRead, tmGrep, tmBash, tmFetch };
585
+ }
586
+ // ---------- tool set assembly ----------
587
+ /**
588
+ * Load zod when the host environment provides it (opencode ships it as a
589
+ * peer); fall back to plain arg descriptors so the plugin never hard-depends
590
+ * on it. execute() is defensive either way — it coerces raw args itself.
591
+ */
592
+ async function loadZod() {
593
+ try {
594
+ const spec = "zod";
595
+ const mod = await import(spec);
596
+ const z = mod?.z ??
597
+ mod?.default?.z ??
598
+ null;
599
+ return z && typeof z.object === "function"
600
+ ? z
601
+ : null;
602
+ }
603
+ catch {
604
+ return null;
605
+ }
606
+ }
607
+ function describe(z, schema, fallback) {
608
+ if (z && typeof z.string === "function") {
609
+ return schema;
610
+ }
611
+ return { descriptor: fallback };
612
+ }
613
+ /**
614
+ * Per-tool `args` — a ZodRawShape (plain `{key: validator}` object, official
615
+ * tool.d.ts:47-50), NOT a z.object(): the host serializes the raw shape into
616
+ * the LLM parameter spec. Wrapping it in z.object produced `{def:{command:
617
+ * ...}}` garbage args in real sessions (the model wrapped its args 3/3
618
+ * times), even though flat args reached execute fine.
619
+ */
620
+ async function buildArgsSchemas() {
621
+ const z = await loadZod();
622
+ if (!z) {
623
+ return {
624
+ tm_read: describe(null, null, "path: string (required)"),
625
+ tm_grep: describe(null, null, "pattern: string (required), path: string (optional)"),
626
+ tm_bash: describe(null, null, "command: string (required, read-only allowlisted)"),
627
+ tm_fetch: describe(null, null, "ref: string (required), access_token: string, offset: int, limit: int, mode: lines|structure"),
628
+ };
629
+ }
630
+ const zz = z;
631
+ // RAW SHAPES — no zz.object() wrapper (BUG#3).
632
+ return {
633
+ tm_read: {
634
+ path: zz
635
+ .string()
636
+ .describe("File path, relative to the project root (or absolute within it). Env files (.env, *.env, .bashrc family) are rejected — same R6 source as the built-in read."),
637
+ },
638
+ tm_grep: {
639
+ pattern: zz.string().describe("Regex to search (ripgrep syntax)."),
640
+ path: zz
641
+ .string()
642
+ .describe("Optional directory scope, relative to the project root.")
643
+ .optional(),
644
+ },
645
+ tm_bash: {
646
+ command: zz
647
+ .string()
648
+ .describe("Read-only shell command (allowlisted heads only). bash on POSIX, PowerShell-like on Windows."),
649
+ },
650
+ tm_fetch: {
651
+ ref: zz
652
+ .string()
653
+ .describe("Offload handle ref: tm://runs/{run_id}/steps/{step_id}/result"),
654
+ access_token: zz
655
+ .string()
656
+ .describe("HMAC token from the offload handle (may also ride the ref fragment).")
657
+ .optional(),
658
+ offset: zz
659
+ .number()
660
+ .int()
661
+ .min(0)
662
+ .describe("0-based line offset of the first line to return.")
663
+ .optional(),
664
+ limit: zz
665
+ .number()
666
+ .int()
667
+ .min(1)
668
+ .describe("Max lines per fetch, capped at TM_FETCH_MAX_LINES (default 2000).")
669
+ .optional(),
670
+ mode: zz
671
+ .enum(["lines", "structure"])
672
+ .describe("structure = ~100-token TOC / key tree / error-line map; try it first.")
673
+ .optional(),
674
+ },
675
+ };
676
+ }
677
+ /**
678
+ * Build the four tool definitions. Async only because of the optional zod
679
+ * load; every pipeline itself is runtime-defensive against raw args. Every
680
+ * execute() funnels through toToolResult so the return value ALWAYS satisfies
681
+ * the host ToolResult contract ({output: string}) — handles, structured
682
+ * errors, pages and degraded warnings included.
683
+ */
684
+ export async function buildTmTools(deps) {
685
+ const pipelines = buildPipelines(deps);
686
+ const argsSchemas = await buildArgsSchemas();
687
+ return {
688
+ tm_read: {
689
+ description: TM_READ_DESCRIPTION,
690
+ args: argsSchemas.tm_read,
691
+ execute: async (args, ctx) => toToolResult(await pipelines.tmRead(args ?? {}, ctx)),
692
+ },
693
+ tm_grep: {
694
+ description: TM_GREP_DESCRIPTION,
695
+ args: argsSchemas.tm_grep,
696
+ execute: async (args, ctx) => toToolResult(await pipelines.tmGrep(args ?? {}, ctx)),
697
+ },
698
+ tm_bash: {
699
+ description: TM_BASH_DESCRIPTION,
700
+ args: argsSchemas.tm_bash,
701
+ execute: async (args, ctx) => toToolResult(await pipelines.tmBash(args ?? {}, ctx)),
702
+ },
703
+ tm_fetch: {
704
+ description: TM_FETCH_DESCRIPTION,
705
+ args: argsSchemas.tm_fetch,
706
+ execute: async (args, ctx) => toToolResult(await pipelines.tmFetch(args ?? {}, ctx)),
707
+ },
708
+ };
709
+ }
710
+ //# sourceMappingURL=tools.js.map