@te-river/opencode-team-mode 1.5.13 → 1.5.15
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 +24 -5
- package/README.zh-CN.md +20 -4
- package/dist/agents.d.ts.map +1 -1
- package/dist/agents.js +4 -2
- package/dist/agents.js.map +1 -1
- package/dist/capabilities.d.ts +66 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +165 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/host-hooks.d.ts.map +1 -1
- package/dist/host-hooks.js +1 -0
- package/dist/host-hooks.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +35 -0
- 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 +51 -0
- 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 +32 -6
- package/dist/prompts/shared.js.map +1 -1
- package/dist/tm/args-schema.d.ts.map +1 -1
- package/dist/tm/args-schema.js +8 -0
- package/dist/tm/args-schema.js.map +1 -1
- package/dist/tm/browser.d.ts +25 -0
- package/dist/tm/browser.d.ts.map +1 -1
- package/dist/tm/browser.js +90 -2
- package/dist/tm/browser.js.map +1 -1
- package/dist/tm/cache.d.ts +68 -0
- package/dist/tm/cache.d.ts.map +1 -0
- package/dist/tm/cache.js +157 -0
- package/dist/tm/cache.js.map +1 -0
- package/dist/tm/config.d.ts +43 -0
- package/dist/tm/config.d.ts.map +1 -1
- package/dist/tm/config.js +10 -0
- package/dist/tm/config.js.map +1 -1
- package/dist/tm/dispatch.d.ts +84 -2
- package/dist/tm/dispatch.d.ts.map +1 -1
- package/dist/tm/dispatch.js +397 -39
- package/dist/tm/dispatch.js.map +1 -1
- package/dist/tm/dupe-guard.d.ts +58 -0
- package/dist/tm/dupe-guard.d.ts.map +1 -0
- package/dist/tm/dupe-guard.js +105 -0
- package/dist/tm/dupe-guard.js.map +1 -0
- package/dist/tm/index.d.ts +5 -0
- package/dist/tm/index.d.ts.map +1 -1
- package/dist/tm/index.js +29 -2
- package/dist/tm/index.js.map +1 -1
- package/dist/tm/perm-ask.d.ts +1 -0
- package/dist/tm/perm-ask.d.ts.map +1 -1
- package/dist/tm/perm-ask.js +15 -1
- package/dist/tm/perm-ask.js.map +1 -1
- package/dist/tm/pipelines.d.ts.map +1 -1
- package/dist/tm/pipelines.js +11 -0
- package/dist/tm/pipelines.js.map +1 -1
- package/dist/tm/search.d.ts +5 -0
- package/dist/tm/search.d.ts.map +1 -1
- package/dist/tm/search.js +44 -4
- package/dist/tm/search.js.map +1 -1
- package/dist/tm/stats.d.ts +147 -0
- package/dist/tm/stats.d.ts.map +1 -0
- package/dist/tm/stats.js +383 -0
- package/dist/tm/stats.js.map +1 -0
- package/dist/tm/store.d.ts +3 -0
- package/dist/tm/store.d.ts.map +1 -1
- package/dist/tm/store.js +6 -1
- package/dist/tm/store.js.map +1 -1
- package/dist/tm/webfetch.d.ts +19 -0
- package/dist/tm/webfetch.d.ts.map +1 -1
- package/dist/tm/webfetch.js +70 -4
- package/dist/tm/webfetch.js.map +1 -1
- package/package.json +4 -2
package/dist/tm/dispatch.js
CHANGED
|
@@ -27,11 +27,26 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { tmError, toToolResult } from "./result.js";
|
|
29
29
|
import { unwrapClientResult } from "./client-unwrap.js";
|
|
30
|
+
import { askUserForTarget } from "./perm-ask.js";
|
|
30
31
|
import { detectContentType } from "./preview.js";
|
|
31
32
|
import { shorten } from "./config.js";
|
|
32
33
|
/** The five dispatchable specialists — "team" is deliberately NOT in the
|
|
33
34
|
* list: a lead spawning a lead is the nesting T3 closed. */
|
|
34
35
|
export const DISPATCH_TARGETS = ["architect", "implementer", "reviewer", "tester", "researcher"];
|
|
36
|
+
export function openHostTodos(todos) {
|
|
37
|
+
if (!Array.isArray(todos))
|
|
38
|
+
return [];
|
|
39
|
+
const open = [];
|
|
40
|
+
for (const t of todos) {
|
|
41
|
+
const status = String(t?.status ?? "").toLowerCase();
|
|
42
|
+
if (!status || status === "completed" || status === "cancelled" || status === "done")
|
|
43
|
+
continue;
|
|
44
|
+
const content = String(t?.content ?? "").trim();
|
|
45
|
+
if (content)
|
|
46
|
+
open.push({ content: content.slice(0, 120), status });
|
|
47
|
+
}
|
|
48
|
+
return open;
|
|
49
|
+
}
|
|
35
50
|
/** Resolve the host session API defensively — a missing namespace means the
|
|
36
51
|
* host does not speak this protocol, and the caller must degrade. */
|
|
37
52
|
export function sessionApiOf(client) {
|
|
@@ -43,13 +58,57 @@ export function sessionApiOf(client) {
|
|
|
43
58
|
return null;
|
|
44
59
|
return api;
|
|
45
60
|
}
|
|
61
|
+
/** The title `tm_dispatch` gives every child session.
|
|
62
|
+
*
|
|
63
|
+
* The shape is the host's OWN subagent convention — the built-in task tool
|
|
64
|
+
* titles its children `${description} (@${agent} subagent)` (verified in the
|
|
65
|
+
* desktop binary), because that is what the renderer's `taskSession()`
|
|
66
|
+
* heuristic reads to colour a child row and to resolve it from a tool card.
|
|
67
|
+
* A plugin cannot register a tool renderer (the desktop's ToolRegistry holds
|
|
68
|
+
* exactly the built-in names), so matching the convention is the only way our
|
|
69
|
+
* children look like sub-agent sessions in the sidebar instead of a mystery
|
|
70
|
+
* row. ` ·tm` is our own marker and it is LOAD-BEARING: it is what keeps a
|
|
71
|
+
* `task`-spawned child out of `parseDispatchTitle`, so tm_join never claims a
|
|
72
|
+
* reply that belongs to the host's dispatcher. */
|
|
73
|
+
export function dispatchTitle(agent, label) {
|
|
74
|
+
return `${label} (@${agent} subagent${DISPATCH_TITLE_MARKER})`;
|
|
75
|
+
}
|
|
76
|
+
/** Suffix tm_dispatch appends inside the host's `(@<agent> subagent)` shape. */
|
|
77
|
+
export const DISPATCH_TITLE_MARKER = " ·tm";
|
|
78
|
+
/** The pre-1.5.15 title (`tm:<agent>:<label>`). Still accepted on the way in
|
|
79
|
+
* so a child created before the upgrade is not orphaned by restart recovery. */
|
|
80
|
+
export function parseDispatchTitle(title, targets) {
|
|
81
|
+
const t = typeof title === "string" ? title.trim() : "";
|
|
82
|
+
if (t.startsWith("tm:")) {
|
|
83
|
+
const rest = t.slice(3);
|
|
84
|
+
const colon = rest.indexOf(":");
|
|
85
|
+
const legacyAgent = (colon < 0 ? rest : rest.slice(0, colon)).trim().toLowerCase();
|
|
86
|
+
if (!legacyAgent || !targets.includes(legacyAgent))
|
|
87
|
+
return null;
|
|
88
|
+
return { agent: legacyAgent, label: (colon < 0 ? "" : rest.slice(colon + 1)).trim().slice(0, 40) };
|
|
89
|
+
}
|
|
90
|
+
// The marker is required here — without it a built-in task child would read
|
|
91
|
+
// as ours, which is the one thing the restart path must never do.
|
|
92
|
+
const m = /^(.*?) \(@([^\s()]+) subagent ·tm\)$/.exec(t);
|
|
93
|
+
if (!m)
|
|
94
|
+
return null;
|
|
95
|
+
const agent = m[2].trim().toLowerCase();
|
|
96
|
+
if (!agent || !targets.includes(agent))
|
|
97
|
+
return null;
|
|
98
|
+
return { agent, label: m[1].trim().slice(0, 40) };
|
|
99
|
+
}
|
|
46
100
|
/**
|
|
47
|
-
* Pull the assistant reply text
|
|
101
|
+
* Pull the assistant reply text (and, when the host records it, the moment
|
|
102
|
+
* that reply completed) out of a `session.messages` payload.
|
|
48
103
|
* Shapes accepted (host has renamed things before): {info,parts} pairs or a
|
|
49
104
|
* bare Part[] — the LAST assistant message is the answer, everything earlier
|
|
50
105
|
* is that agent's own working transcript.
|
|
106
|
+
*
|
|
107
|
+
* `completedAt` is what lets a rebuilt registry tell "still running" from
|
|
108
|
+
* "finished while we were not listening": AssistantMessage.time.completed is
|
|
109
|
+
* only set once the message is done.
|
|
51
110
|
*/
|
|
52
|
-
export function
|
|
111
|
+
export function lastAssistantMessage(messages) {
|
|
53
112
|
const list = Array.isArray(messages) ? messages : [];
|
|
54
113
|
for (let i = list.length - 1; i >= 0; i--) {
|
|
55
114
|
const entry = list[i];
|
|
@@ -57,12 +116,17 @@ export function lastAssistantText(messages) {
|
|
|
57
116
|
const role = typeof info?.role === "string" ? String(info.role).toLowerCase() : "";
|
|
58
117
|
if (role && role !== "assistant")
|
|
59
118
|
continue;
|
|
119
|
+
const completed = Number(info?.time?.completed);
|
|
120
|
+
const completedAt = Number.isFinite(completed) && completed > 0 ? completed : undefined;
|
|
121
|
+
// an assistant turn that errored carries the reason on info.error — the
|
|
122
|
+
// lead must see it, or "no reply" reads as "the agent had nothing to say"
|
|
123
|
+
const error = info?.error ? describeHostError(info.error, 200) : undefined;
|
|
60
124
|
const parts = entry?.parts;
|
|
61
125
|
if (!Array.isArray(parts)) {
|
|
62
126
|
// a bare message list (no {info,parts} envelope) — take any text part
|
|
63
127
|
const one = entry ?? {};
|
|
64
128
|
if (one.type === "text" && typeof one.text === "string")
|
|
65
|
-
return one.text;
|
|
129
|
+
return { text: one.text, ...(completedAt ? { completedAt } : {}), ...(error ? { error } : {}) };
|
|
66
130
|
continue;
|
|
67
131
|
}
|
|
68
132
|
const texts = parts
|
|
@@ -71,16 +135,66 @@ export function lastAssistantText(messages) {
|
|
|
71
135
|
return pp && pp.type === "text" && typeof pp.text === "string" && pp.text.trim() !== "";
|
|
72
136
|
})
|
|
73
137
|
.map((p) => String(p.text));
|
|
138
|
+
// keys are added only when present so a deep-equality assertion sees the
|
|
139
|
+
// shape the caller actually got (no `error: undefined` noise)
|
|
74
140
|
if (texts.length)
|
|
75
|
-
return texts.join("\n");
|
|
141
|
+
return { text: texts.join("\n"), ...(completedAt ? { completedAt } : {}), ...(error ? { error } : {}) };
|
|
142
|
+
if (error)
|
|
143
|
+
return { text: "", ...(completedAt ? { completedAt } : {}), error };
|
|
76
144
|
}
|
|
77
|
-
return "";
|
|
145
|
+
return { text: "" };
|
|
146
|
+
}
|
|
147
|
+
/** Back-compat seam: the reply text alone. */
|
|
148
|
+
export function lastAssistantText(messages) {
|
|
149
|
+
return lastAssistantMessage(messages).text;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Turn whatever the host puts in an error slot into ONE readable line.
|
|
153
|
+
*
|
|
154
|
+
* The live host nests them (`{name, data:{message, ref}}`, `{error:{message}}`,
|
|
155
|
+
* a bare string — sometimes several at once), and `String(obj)` is
|
|
156
|
+
* "[object Object]". That is what a lead reported to a user for three child
|
|
157
|
+
* sessions that died at launch: the one diagnostic the whole flow had was
|
|
158
|
+
* destroyed by our own rendering. Never again: dig the known fields, then
|
|
159
|
+
* fall back to a compact key dump — never a bare object stringify.
|
|
160
|
+
*/
|
|
161
|
+
export function describeHostError(err, max = 220) {
|
|
162
|
+
if (typeof err === "string")
|
|
163
|
+
return shorten(err.trim() || "session.error", max);
|
|
164
|
+
if (err === null || err === undefined)
|
|
165
|
+
return "session.error";
|
|
166
|
+
if (typeof err !== "object")
|
|
167
|
+
return shorten(String(err), max);
|
|
168
|
+
const o = err;
|
|
169
|
+
const box = (v) => v && typeof v === "object" ? v : undefined;
|
|
170
|
+
const data = box(o.data);
|
|
171
|
+
const inner = box(o.error);
|
|
172
|
+
const pick = (...cands) => {
|
|
173
|
+
for (const c of cands)
|
|
174
|
+
if (typeof c === "string" && c.trim())
|
|
175
|
+
return c.trim();
|
|
176
|
+
return "";
|
|
177
|
+
};
|
|
178
|
+
const message = pick(o.message, data?.message, inner?.message, inner?.name, data?.ref ? `ref=${String(data.ref)}` : "");
|
|
179
|
+
const name = pick(o.name, inner?.name, data?.name);
|
|
180
|
+
const ref = pick(data?.ref, o.ref);
|
|
181
|
+
if (message || name) {
|
|
182
|
+
const head = name && message && !message.startsWith(name) ? `${name}: ${message}` : message || name;
|
|
183
|
+
return shorten(ref && !head.includes(ref) ? `${head}(ref=${ref})` : head, max);
|
|
184
|
+
}
|
|
185
|
+
// nothing recognised — dump the SHAPE with short values, which is still
|
|
186
|
+
// infinitely more useful than "[object Object]"
|
|
187
|
+
const flat = Object.entries(o)
|
|
188
|
+
.slice(0, 6)
|
|
189
|
+
.map(([k, v]) => `${k}=${typeof v === "object" && v !== null ? Object.keys(v).slice(0, 4).join("+") : String(v).slice(0, 60)}`)
|
|
190
|
+
.join(" ");
|
|
191
|
+
return shorten(flat || "session.error", max);
|
|
78
192
|
}
|
|
79
193
|
/** One child's status line — the lead reads these, so keep them terse. */
|
|
80
194
|
export function renderChildLine(c, elapsedMs) {
|
|
81
195
|
const secs = Math.round(elapsedMs / 1000);
|
|
82
196
|
const tag = c.state === "running" ? `运行中 ${secs}s` : c.state === "error" ? `失败:${shorten(c.error ?? "", 80)}` : `已完成 ${secs}s`;
|
|
83
|
-
return `${c.sessionID} · ${c.agent} · "${c.label}" · ${tag}`;
|
|
197
|
+
return `${c.sessionID} · ${c.agent} · "${c.label}" · ${tag}${c.adopted ? " ·(本进程重启后由宿主会话树接管)" : ""}`;
|
|
84
198
|
}
|
|
85
199
|
/** Verdicts tm_join can actually observe (issue #7's "leader keeps control"). */
|
|
86
200
|
export function summarizeStates(children) {
|
|
@@ -108,10 +222,37 @@ function sleep(ms) {
|
|
|
108
222
|
t.unref?.();
|
|
109
223
|
});
|
|
110
224
|
}
|
|
225
|
+
/** `ids` arrives in three shapes on the live host: a real array, a
|
|
226
|
+
* JSON-array STRING (`"[\"ses_x\"]"` — what a model actually sent on
|
|
227
|
+
* 2026-09-19), or a comma-separated list. Array.isArray alone silently
|
|
228
|
+
* ignored the string form and returned EVERY child when the lead asked for
|
|
229
|
+
* one, which is the same class of bug as the old `Boolean("false")` headless
|
|
230
|
+
* trap: the arg is trusted because it "looks" like the declared type. */
|
|
231
|
+
export function parseIdList(raw) {
|
|
232
|
+
if (Array.isArray(raw))
|
|
233
|
+
return raw.map((x) => String(x ?? "").trim()).filter(Boolean);
|
|
234
|
+
const s = typeof raw === "string" ? raw.trim() : "";
|
|
235
|
+
if (!s)
|
|
236
|
+
return null;
|
|
237
|
+
if (s.startsWith("[") && s.endsWith("]")) {
|
|
238
|
+
try {
|
|
239
|
+
const parsed = JSON.parse(s);
|
|
240
|
+
if (Array.isArray(parsed))
|
|
241
|
+
return parsed.map((x) => String(x ?? "").trim()).filter(Boolean);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
/* fall through to the comma split */
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return s.split(",").map((x) => x.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
248
|
+
}
|
|
111
249
|
export function buildDispatchTools(deps) {
|
|
112
250
|
const leadAgent = deps.leadAgent ?? "team";
|
|
113
251
|
const targets = deps.targets ?? DISPATCH_TARGETS;
|
|
114
252
|
const maxWaitMs = deps.maxWaitMs ?? 300_000;
|
|
253
|
+
const askBeforeSpawn = deps.askBeforeSpawn !== false;
|
|
254
|
+
const maxDepth = Math.max(0, Number.isFinite(deps.maxDepth) ? deps.maxDepth : 1);
|
|
255
|
+
const parallel = deps.parallelDispatch !== "off";
|
|
115
256
|
const now = deps.now ?? (() => Date.now());
|
|
116
257
|
const store = deps.pipelines.store;
|
|
117
258
|
const log = (e) => store.appendTrajectory({ tool: "tm_dispatch", ...e });
|
|
@@ -122,12 +263,15 @@ export function buildDispatchTools(deps) {
|
|
|
122
263
|
* dispatched before a plugin restart, or a session.idle that never
|
|
123
264
|
* arrived). */
|
|
124
265
|
async function refreshFromStatus(directory) {
|
|
125
|
-
|
|
126
|
-
if (typeof statusApi !== "function")
|
|
266
|
+
if (typeof api?.status !== "function")
|
|
127
267
|
return;
|
|
128
268
|
let un;
|
|
129
269
|
try {
|
|
130
|
-
|
|
270
|
+
// PROPERTY-ACCESS CALL — the SDK's endpoints need their receiver (see
|
|
271
|
+
// approval-gate.ts's replyCapableFn note); `const f = api.status` then
|
|
272
|
+
// `f(...)` threw "Cannot read properties of undefined (reading
|
|
273
|
+
// 'client')" on the live host and killed every dispatch.
|
|
274
|
+
un = unwrapClientResult(await api.status(directory ? { query: { directory } } : {}));
|
|
131
275
|
}
|
|
132
276
|
catch {
|
|
133
277
|
return;
|
|
@@ -151,29 +295,159 @@ export function buildDispatchTools(deps) {
|
|
|
151
295
|
}
|
|
152
296
|
function fetchReply(sid, directory) {
|
|
153
297
|
return (async () => {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
return "";
|
|
298
|
+
if (typeof api?.messages !== "function")
|
|
299
|
+
return { text: "" };
|
|
157
300
|
try {
|
|
158
|
-
const un = unwrapClientResult(await messages({ path: { id: sid }, ...(directory ? { query: { directory } } : {}) }));
|
|
159
|
-
return un.ok ?
|
|
301
|
+
const un = unwrapClientResult(await api.messages({ path: { id: sid }, ...(directory ? { query: { directory } } : {}) }));
|
|
302
|
+
return un.ok ? lastAssistantMessage(un.data) : { text: "" };
|
|
160
303
|
}
|
|
161
304
|
catch {
|
|
162
|
-
return "";
|
|
305
|
+
return { text: "" };
|
|
163
306
|
}
|
|
164
307
|
})();
|
|
165
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* The model the CHILD should run. A child session carries no model of its
|
|
311
|
+
* own, and `prompt_async` accepts `model:{providerID,modelID}` — the
|
|
312
|
+
* built-in `task` tool supplies one, we did not, and three dispatched
|
|
313
|
+
* children died at launch with no readable reply (2026-09-19). Inherit the
|
|
314
|
+
* lead's own last-used model from the parent transcript; when nothing can
|
|
315
|
+
* be resolved, dispatch anyway and say so in the reply, so a failure is
|
|
316
|
+
* never mysterious.
|
|
317
|
+
*/
|
|
318
|
+
async function parentModel(parent, directory) {
|
|
319
|
+
if (typeof api?.messages !== "function" || !parent)
|
|
320
|
+
return undefined;
|
|
321
|
+
try {
|
|
322
|
+
const un = unwrapClientResult(await api.messages({ path: { id: parent }, ...(directory ? { query: { directory } } : {}) }));
|
|
323
|
+
if (!un.ok || !Array.isArray(un.data))
|
|
324
|
+
return undefined;
|
|
325
|
+
for (let i = un.data.length - 1; i >= 0; i--) {
|
|
326
|
+
const info = un.data[i]?.info;
|
|
327
|
+
if (!info || String(info.role ?? "").toLowerCase() !== "assistant")
|
|
328
|
+
continue;
|
|
329
|
+
const providerID = typeof info.providerID === "string" ? info.providerID.trim() : "";
|
|
330
|
+
const modelID = typeof info.modelID === "string" ? info.modelID.trim() : "";
|
|
331
|
+
if (providerID && modelID)
|
|
332
|
+
return { providerID, modelID };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
/* no transcript readable — dispatch without a model and report it */
|
|
337
|
+
}
|
|
338
|
+
return undefined;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Adopt the children the HOST lists under this parent but this process never
|
|
342
|
+
* dispatched: the plugin instance restarted (or the lead resumed a session
|
|
343
|
+
* an earlier instance created) while a specialist was still working — or had
|
|
344
|
+
* already answered into a registry that no longer exists. Without this,
|
|
345
|
+
* tm_join's honest answer would be "没有待收集的派发" while a finished report
|
|
346
|
+
* sits in the host, which is exactly the silent loss issue #7 was about.
|
|
347
|
+
*
|
|
348
|
+
* The discriminator is the title tm_dispatch itself writes (`dispatchTitle`
|
|
349
|
+
* + a known agent), so a child spawned by the built-in `task` tool is never
|
|
350
|
+
* claimed as ours.
|
|
351
|
+
*/
|
|
352
|
+
async function adoptFromHost(parent, directory, only) {
|
|
353
|
+
if (typeof api?.children !== "function" || !parent)
|
|
354
|
+
return 0;
|
|
355
|
+
let un;
|
|
356
|
+
try {
|
|
357
|
+
un = unwrapClientResult(await api.children({ path: { id: parent }, ...(directory ? { query: { directory } } : {}) }));
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return 0;
|
|
361
|
+
}
|
|
362
|
+
if (!un.ok || !Array.isArray(un.data))
|
|
363
|
+
return 0;
|
|
364
|
+
let adopted = 0;
|
|
365
|
+
for (const raw of un.data) {
|
|
366
|
+
const sid = String(raw?.id ?? "").trim();
|
|
367
|
+
if (!sid || children.has(sid))
|
|
368
|
+
continue;
|
|
369
|
+
if (only && !only.has(sid))
|
|
370
|
+
continue;
|
|
371
|
+
const parsed = parseDispatchTitle(raw?.title, targets);
|
|
372
|
+
if (!parsed)
|
|
373
|
+
continue;
|
|
374
|
+
const created = Number(raw?.time?.created);
|
|
375
|
+
const rec = {
|
|
376
|
+
sessionID: sid,
|
|
377
|
+
agent: parsed.agent,
|
|
378
|
+
label: parsed.label || parsed.agent,
|
|
379
|
+
parentSessionID: parent,
|
|
380
|
+
startedAt: Number.isFinite(created) && created > 0 ? created : now(),
|
|
381
|
+
state: "running",
|
|
382
|
+
adopted: true,
|
|
383
|
+
};
|
|
384
|
+
children.set(sid, rec);
|
|
385
|
+
// the child's own protected reads must still open the official dialog
|
|
386
|
+
deps.onChildSession?.(sid, parsed.agent);
|
|
387
|
+
adopted++;
|
|
388
|
+
log({ step_id: "join", event: "adopt", child: sid, agent: parsed.agent, label: rec.label });
|
|
389
|
+
}
|
|
390
|
+
return adopted;
|
|
391
|
+
}
|
|
392
|
+
/** Settle verdict for adopted rows: `AssistantMessage.time.completed` is
|
|
393
|
+
* only set once the reply is done, so it answers "finished while nobody
|
|
394
|
+
* was listening?" without waiting for an event that already fired. */
|
|
395
|
+
async function settleAdopted(records, directory) {
|
|
396
|
+
for (const rec of records) {
|
|
397
|
+
if (!rec.adopted || rec.state !== "running")
|
|
398
|
+
continue;
|
|
399
|
+
const reply = await fetchReply(rec.sessionID, directory);
|
|
400
|
+
if (typeof reply.completedAt === "number") {
|
|
401
|
+
rec.state = "idle";
|
|
402
|
+
rec.finishedAt = reply.completedAt;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* How deep the caller's session sits: the host's task tool counts the
|
|
408
|
+
* parentID chain and refuses at `subagent_depth` (default 1 = no nested
|
|
409
|
+
* subagents). That check lives in the TOOL, not in the session API, so a
|
|
410
|
+
* plugin-side dispatcher that skipped it would quietly let a user's nesting
|
|
411
|
+
* limit be escaped. Walk it the same way; an unreachable session (no `get`
|
|
412
|
+
* endpoint, deleted) stops the walk at what we know rather than allowing.
|
|
413
|
+
*/
|
|
414
|
+
async function sessionDepth(sessionID, directory) {
|
|
415
|
+
if (typeof api?.get !== "function" || !sessionID)
|
|
416
|
+
return 0;
|
|
417
|
+
let depth = 0;
|
|
418
|
+
let current = sessionID;
|
|
419
|
+
for (let hop = 0; hop < 16; hop++) {
|
|
420
|
+
try {
|
|
421
|
+
const un = unwrapClientResult(await api.get({ path: { id: current }, ...(directory ? { query: { directory } } : {}) }));
|
|
422
|
+
const parentID = un.ok ? String(un.data?.parentID ?? "").trim() : "";
|
|
423
|
+
if (!parentID)
|
|
424
|
+
break;
|
|
425
|
+
depth++;
|
|
426
|
+
current = parentID;
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return depth;
|
|
433
|
+
}
|
|
166
434
|
const dispatch = {
|
|
167
435
|
description: `Fire-and-continue sub-agent dispatch — the lead's parallelism lever (the built-in task tool BLOCKS you until the child finishes; this one does not).
|
|
168
|
-
- { agent: ${targets.join("|")} , task: "<self-contained brief>",
|
|
436
|
+
- { agent: ${targets.join("|")} , task: "<self-contained brief>", description: "<short slug>" } → starts the specialist in its OWN child session (parented to yours) and returns the child session id IMMEDIATELY. You keep working the same round: plan the next step, do a cheap read of your own, or fire more dispatches — independent work runs concurrently instead of serially.
|
|
437
|
+
- The child is a real session: it appears under yours in the host's session tree (titled "<description> (@<agent> subagent ·tm)"), so the user can open it and watch it work. Its tool card is NOT expandable — the host only renders bodies for its own built-in tools — so say out loud which child is running and what it is for.
|
|
169
438
|
- The brief must stand alone: the child has not seen this conversation, does not know what you tried, and cannot ask you mid-run. Say what to do, WHY it matters, which files/paths are its territory, what "done" looks like, and how much thoroughness you expect — the child still answers the mandatory STATUS/CHANGES/FINDINGS/EVIDENCE/HANDOFF skeleton.
|
|
170
439
|
- Collect with tm_join (no wait = status snapshot, waitMs = bounded wait). Collected replies ride the offload pipeline: a long report arrives as a handle + preview, not a context flood.
|
|
171
440
|
- Division of labour: bulk reading / searching / aggregation burns the CHILD's context and comes back as a skeleton; your own context is the scarce resource — keep it for routing, decisions and the final merge.
|
|
172
|
-
- Only the team lead may call this; a child never dispatches (no nested teams). If the host exposes no async session API the call fails with an explicit "use the built-in task tool" directive
|
|
441
|
+
- Only the team lead may call this; a child never dispatches (no nested teams). If the host exposes no async session API the call fails with an explicit "use the built-in task tool" directive.${parallel
|
|
442
|
+
? ""
|
|
443
|
+
: `
|
|
444
|
+
- TM_PARALLEL_DISPATCH=off is set: at most ONE child may run at a time, so a second dispatch while one is live is refused. Dispatch, tm_join it, then dispatch the next — and tell the user why the team is serial.`}`,
|
|
173
445
|
args: {
|
|
174
446
|
agent: { descriptor: `agent: ${targets.join("|")} (required)` },
|
|
175
447
|
task: { descriptor: "task: string (required — the full self-contained brief the child works from)" },
|
|
176
|
-
|
|
448
|
+
description: {
|
|
449
|
+
descriptor: "description: string (optional ≤40-char slug — the child session's title and the line the UI shows for this call; defaults to the task's first words)",
|
|
450
|
+
},
|
|
177
451
|
},
|
|
178
452
|
execute: async (rawArgs, ctx) => {
|
|
179
453
|
const tool = "tm_dispatch";
|
|
@@ -199,41 +473,94 @@ export function buildDispatchTools(deps) {
|
|
|
199
473
|
if (!api) {
|
|
200
474
|
return toToolResult(tmError(tool, "client", `此宿主未暴露异步会话 API(client.session.create/promptAsync),tm_dispatch 不可用——改用内置 task 工具(会阻塞你,但功能等价)。`));
|
|
201
475
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
476
|
+
// `description` is the host's own name for this field (the task tool
|
|
477
|
+
// takes prompt + description), and the desktop reads exactly that key
|
|
478
|
+
// as a tool card's subtitle — so the dispatch shows as
|
|
479
|
+
// "Called tm_dispatch · 修登录页" instead of a raw-JSON chip.
|
|
480
|
+
const label = (typeof args.description === "string" && args.description.trim()
|
|
481
|
+
? args.description.trim()
|
|
482
|
+
: task.split(/\s+/).slice(0, 4).join(" ")).slice(0, 40);
|
|
483
|
+
const directory = typeof c.directory === "string" && c.directory ? c.directory : undefined;
|
|
484
|
+
// --- governance the built-in task tool applies and we must not lose -
|
|
485
|
+
// (1) nesting ceiling, same 口径 as the host's subagent_depth
|
|
486
|
+
const depth = await sessionDepth(parent, directory);
|
|
487
|
+
if (depth >= maxDepth) {
|
|
488
|
+
return toToolResult(tmError(tool, "governance", `已达子代理嵌套上限(当前深度 ${depth},TM_SUBAGENT_DEPTH=${maxDepth},与宿主 subagent_depth 同口径)。` +
|
|
489
|
+
`这与 T3「子代理不再派子代理」同源:把这项工作留在你自己这一层,或让宿主配置放宽。`));
|
|
490
|
+
}
|
|
491
|
+
// (1b) TM_PARALLEL_DISPATCH=off — one child at a time. Refused BEFORE
|
|
492
|
+
// the dialog (same ordering as tm_pty's concurrency cap): never ask the
|
|
493
|
+
// user to approve a spawn the policy has already declined.
|
|
494
|
+
if (!parallel) {
|
|
495
|
+
const live = [...children.values()].filter((r) => r.state === "running" && r.parentSessionID === parent);
|
|
496
|
+
if (live.length) {
|
|
497
|
+
const who = live.map((r) => `${r.agent}「${r.label}」`).slice(0, 4).join("、");
|
|
498
|
+
log({ step_id: "dispatch", event: "serial_refused", agent, label, live: live.length });
|
|
499
|
+
return toToolResult(tmError(tool, "governance", `TM_PARALLEL_DISPATCH=off:一次只跑一个子代理,而你这轮已有 ${live.length} 个在跑(${who})。` +
|
|
500
|
+
`先 tm_join 收掉再派 ${agent}。注意:并行才是 Team 的意义——串行是你选的模式,` +
|
|
501
|
+
`要恢复默认值就删掉 TM_PARALLEL_DISPATCH 或设为 on。`));
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// (2) the user decides which sub-agent gets spawned — the task tool
|
|
505
|
+
// asks ctx.ask({permission:"task", patterns:[subagent_type]}) and we
|
|
506
|
+
// bypass that tool, so we ask on our own permission name. No bridge ⇒
|
|
507
|
+
// refuse (never a silent pass), same rule as tm_pty / evaluate_script.
|
|
508
|
+
if (askBeforeSpawn) {
|
|
509
|
+
const outcome = await askUserForTarget(ctx, {
|
|
510
|
+
permission: tool,
|
|
511
|
+
patterns: [agent],
|
|
512
|
+
metadata: { tool, subagent_type: agent, description: label },
|
|
513
|
+
});
|
|
514
|
+
log({ step_id: "dispatch", event: "consent", agent, label, verdict: outcome });
|
|
515
|
+
if (outcome !== "approved") {
|
|
516
|
+
return toToolResult(tmError(tool, "permission", outcome === "unavailable"
|
|
517
|
+
? `tm_dispatch 需要官方确认窗批准派工,本宿主没有 ask 桥——已拒绝。改用内置 task 工具(会阻塞你,但由宿主治理),或 TM_DISPATCH_ASK=off。`
|
|
518
|
+
: `派工未获批准(${agent}),已拒绝创建子会话。请改用内置 task 工具,或向用户确认后再派。`));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (typeof api?.create !== "function" || typeof api?.promptAsync !== "function") {
|
|
206
522
|
return toToolResult(tmError(tool, "client", "此宿主的 client.session 缺少 create/promptAsync——改用内置 task 工具。"));
|
|
207
523
|
}
|
|
208
|
-
|
|
524
|
+
// PROPERTY-ACCESS CALLS (api.create / api.promptAsync), never a
|
|
525
|
+
// captured function reference — see refreshFromStatus above.
|
|
526
|
+
const created = unwrapClientResult(await api.create({ body: { parentID: parent, title: dispatchTitle(agent, label) } }));
|
|
209
527
|
if (!created.ok)
|
|
210
528
|
return toToolResult(tmError(tool, "client", `创建子会话失败:${shorten(created.message ?? "", 120)}`));
|
|
211
529
|
const sid = String(created.data?.id ?? "").trim();
|
|
212
530
|
if (!sid)
|
|
213
531
|
return toToolResult(tmError(tool, "client", "子会话创建后未返回 id"));
|
|
532
|
+
const model = await parentModel(parent, directory);
|
|
214
533
|
const promptBody = {
|
|
215
534
|
agent,
|
|
216
535
|
noReply: false,
|
|
536
|
+
...(model ? { model } : {}),
|
|
217
537
|
parts: [{ type: "text", text: task }],
|
|
218
538
|
};
|
|
219
|
-
const
|
|
220
|
-
const sent = unwrapClientResult(await promptApi({ path: { id: sid }, ...(directory ? { query: { directory } } : {}), body: promptBody }));
|
|
539
|
+
const sent = unwrapClientResult(await api.promptAsync({ path: { id: sid }, ...(directory ? { query: { directory } } : {}), body: promptBody }));
|
|
221
540
|
if (!sent.ok) {
|
|
222
541
|
return toToolResult(tmError(tool, "client", `子会话 ${sid} 已创建但提示未能启动:${shorten(sent.message ?? "", 120)}——可对该 id 直接用内置 task,或本次改用 task 工具。`));
|
|
223
542
|
}
|
|
224
543
|
const rec = { sessionID: sid, agent, label, parentSessionID: parent, startedAt: now(), state: "running" };
|
|
225
544
|
children.set(sid, rec);
|
|
226
545
|
deps.onChildSession?.(sid, agent);
|
|
227
|
-
log({ step_id: "dispatch", event: "start", child: sid, parent: parent, agent, label });
|
|
546
|
+
log({ step_id: "dispatch", event: "start", child: sid, parent: parent, agent, label, model: model ? `${model.providerID}/${model.modelID}` : "host-default" });
|
|
547
|
+
// The child's tool card is a one-liner the user cannot open (a plugin
|
|
548
|
+
// cannot register a renderer), so the toast is the only thing that
|
|
549
|
+
// points at the live session — without it a dispatch just looks like a
|
|
550
|
+
// stalled tool call.
|
|
551
|
+
deps.notify?.(`子代理已启动:${agent}「${label}」— 在左侧会话树里展开本会话即可实时查看(子会话 ${sid})`);
|
|
228
552
|
return toToolResult([
|
|
229
553
|
`已派发(非阻塞):${agent} · "${label}" · 子会话 ${sid}`,
|
|
554
|
+
model
|
|
555
|
+
? `模型沿用你当前的:${model.providerID}/${model.modelID}(子会话不自带模型)。`
|
|
556
|
+
: `⚠ 未能从本会话记录里解析出模型,子会话将由宿主决定——若它启动即失败,改用内置 task 工具。`,
|
|
230
557
|
`你现在可以继续:编排下一步、做廉价检查、或继续派发其它独立任务。`,
|
|
231
558
|
`收集结果:tm_join(立即看状态)或 tm_join { waitMs: 60000 }。所有派发完成前不要提交结论。`,
|
|
232
559
|
].join("\n"));
|
|
233
560
|
}
|
|
234
561
|
catch (err) {
|
|
235
562
|
const e = err;
|
|
236
|
-
return toToolResult(tmError(tool, "execute", `tm_dispatch 失败:${
|
|
563
|
+
return toToolResult(tmError(tool, "execute", `tm_dispatch 失败:${describeHostError(e?.message ?? err, 160)}`));
|
|
237
564
|
}
|
|
238
565
|
},
|
|
239
566
|
};
|
|
@@ -242,6 +569,7 @@ export function buildDispatchTools(deps) {
|
|
|
242
569
|
- No args: status snapshot of every open child of THIS session — running / idle(done) / error, with elapsed seconds. Cheap and non-blocking: use it to decide whether to keep working or start merging.
|
|
243
570
|
- { waitMs: 30000 }: bounded wait (capped at ${Math.round(maxWaitMs / 1000)}s) until every child settles, then returns each one's reply skeleton. Never wait for a child whose result you do not need — abort it instead ({ cancel: true }).
|
|
244
571
|
- { ids: [...] }: restrict to those child sessions; { cancel: true }: abort still-running ones (a runaway child is yours to stop, not the user's problem).
|
|
572
|
+
- A plugin/host restart does NOT orphan a dispatch: children the host still lists under your session are re-adopted automatically (their rows are marked 接管), and one that answered while nobody was listening is reported 已完成, not lost.
|
|
245
573
|
- Replies come back through the offload pipeline: a long sub-agent report arrives as a handle + ≤80-token preview (page it with tm_fetch), so five parallel dispatches do not multiply your context. STATUS: blocked/failed children are surfaced first, always.`,
|
|
246
574
|
args: {
|
|
247
575
|
ids: { descriptor: "ids: string[] (optional — only these child sessions)" },
|
|
@@ -260,13 +588,20 @@ export function buildDispatchTools(deps) {
|
|
|
260
588
|
}
|
|
261
589
|
const parent = String(c.sessionID ?? "").trim();
|
|
262
590
|
const directory = typeof c.directory === "string" && c.directory ? c.directory : undefined;
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
591
|
+
const idList = parseIdList(args.ids);
|
|
592
|
+
const idFilter = idList && idList.length ? new Set(idList) : null;
|
|
593
|
+
const owned = () => [...children.values()].filter((r) => (!parent || r.parentSessionID === parent) && (!idFilter || idFilter.has(r.sessionID)));
|
|
594
|
+
let mine = owned();
|
|
595
|
+
// This process has never seen some (or all) of what the lead is asking
|
|
596
|
+
// for — the host's session tree, not our memory, is the authority.
|
|
597
|
+
if (mine.length < (idFilter ? idFilter.size : 1)) {
|
|
598
|
+
if (await adoptFromHost(parent, directory, idFilter))
|
|
599
|
+
mine = owned();
|
|
600
|
+
}
|
|
267
601
|
if (!mine.length) {
|
|
268
|
-
return toToolResult(`没有待收集的派发${idFilter ? "(ids
|
|
602
|
+
return toToolResult(`没有待收集的派发${idFilter ? "(ids 未匹配到本会话的子代理,宿主会话树里也没有 tm: 标题的子会话)" : ""}。刚派发过却看不到?说明那次派发没成功——回到 tm_dispatch 的返回值检查。`);
|
|
269
603
|
}
|
|
604
|
+
await settleAdopted(mine, directory);
|
|
270
605
|
const waitMs = Math.max(0, Math.min(maxWaitMs, Number(args.waitMs) || 0));
|
|
271
606
|
const deadline = now() + waitMs;
|
|
272
607
|
let settled = false;
|
|
@@ -280,11 +615,10 @@ export function buildDispatchTools(deps) {
|
|
|
280
615
|
await sleep(Math.min(1000, Math.max(100, deadline - now())));
|
|
281
616
|
}
|
|
282
617
|
const stillRunning = mine.filter((r) => r.state === "running");
|
|
283
|
-
const abortApi = api?.abort;
|
|
284
618
|
if (args.cancel === true || args.cancel === "true") {
|
|
285
619
|
for (const r of stillRunning) {
|
|
286
|
-
if (typeof
|
|
287
|
-
const un = unwrapClientResult(await
|
|
620
|
+
if (typeof api?.abort === "function") {
|
|
621
|
+
const un = unwrapClientResult(await api.abort({ path: { id: r.sessionID } }));
|
|
288
622
|
r.error = un.ok ? "aborted on request" : `abort failed: ${shorten(un.message ?? "", 60)}`;
|
|
289
623
|
}
|
|
290
624
|
else {
|
|
@@ -300,13 +634,35 @@ export function buildDispatchTools(deps) {
|
|
|
300
634
|
`派发汇总:${sums.idle} 完成 / ${sums.running} 运行中 / ${sums.error} 失败${settled ? "(全部已结算)" : "(未等到全部结算,可再次 tm_join)"}`,
|
|
301
635
|
...mine.map((r) => renderChildLine(r, (r.finishedAt ?? now()) - r.startedAt)),
|
|
302
636
|
];
|
|
637
|
+
// Goal tripwire, at the exact moment a lead tends to wrap up: every
|
|
638
|
+
// child settled says nothing about the USER'S goal, and the host's own
|
|
639
|
+
// todo list does. Read-only (GET /session/{id}/todo has no write
|
|
640
|
+
// body), so this can remind but never rewrite.
|
|
641
|
+
if (settled && typeof api?.todo === "function" && parent) {
|
|
642
|
+
try {
|
|
643
|
+
const un = unwrapClientResult(await api.todo({ path: { id: parent }, ...(directory ? { query: { directory } } : {}) }));
|
|
644
|
+
const open = un.ok ? openHostTodos(un.data) : [];
|
|
645
|
+
if (open.length) {
|
|
646
|
+
header.push(`⚠ 目标未达成:宿主 todolist 还有 ${open.length} 项未完成 —— ${open.slice(0, 6).map((t) => `「${t.content}」(${t.status})`).join("、")}` +
|
|
647
|
+
(open.length > 6 ? ` …+${open.length - 6}` : "") +
|
|
648
|
+
`\n按目标指令:要么继续做掉,要么向用户写明哪一条被什么卡住;不要把这轮当成收尾。`);
|
|
649
|
+
log({ step_id: "join", event: "goal_open", count: open.length });
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
catch {
|
|
653
|
+
/* the todo endpoint is an extra, never a reason to fail a join */
|
|
654
|
+
}
|
|
655
|
+
}
|
|
303
656
|
const wantText = !(args.includeText === false || args.includeText === "false");
|
|
304
657
|
const collectible = mine.filter((r) => r.state !== "running");
|
|
305
658
|
const blocks = [];
|
|
306
659
|
if (wantText) {
|
|
307
660
|
for (const r of collectible) {
|
|
308
|
-
const
|
|
309
|
-
|
|
661
|
+
const reply = await fetchReply(r.sessionID, directory);
|
|
662
|
+
const text = reply.text;
|
|
663
|
+
blocks.push(`--- ${r.agent} "${r.label}" (${r.sessionID}) ---\n${text.trim() ||
|
|
664
|
+
`(该子会话没有可读的助手回复${r.error ? `;宿主错误:${r.error}` : reply.error ? `;宿主错误:${reply.error}` : ""})` +
|
|
665
|
+
`——子会话不是文件,tm_read 读不到它:在宿主的会话面板里看这条子会话,或改用内置 task 工具重做这一步。`}`);
|
|
310
666
|
}
|
|
311
667
|
}
|
|
312
668
|
const body = [...header, ...(blocks.length ? ["", blocks.join("\n")] : [])].join("\n");
|
|
@@ -318,7 +674,7 @@ export function buildDispatchTools(deps) {
|
|
|
318
674
|
}
|
|
319
675
|
catch (err) {
|
|
320
676
|
const e = err;
|
|
321
|
-
return toToolResult(tmError(tool, "execute", `tm_join 失败:${
|
|
677
|
+
return toToolResult(tmError(tool, "execute", `tm_join 失败:${describeHostError(e?.message ?? err, 160)}`));
|
|
322
678
|
}
|
|
323
679
|
},
|
|
324
680
|
};
|
|
@@ -344,7 +700,9 @@ export function buildDispatchTools(deps) {
|
|
|
344
700
|
if (type === "session.error") {
|
|
345
701
|
rec.state = "error";
|
|
346
702
|
rec.finishedAt = now();
|
|
347
|
-
|
|
703
|
+
// describeHostError, NOT String(err): the host nests its errors and the
|
|
704
|
+
// bare stringify produced "[object Object]" for a real launch failure
|
|
705
|
+
rec.error = describeHostError(props?.error ?? "session.error", 200);
|
|
348
706
|
log({ step_id: "events", event: "error", child: sid, agent: rec.agent, reason: rec.error });
|
|
349
707
|
return;
|
|
350
708
|
}
|