@te-river/opencode-team-mode 1.5.14 → 1.6.0
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 +788 -674
- package/README.zh-CN.md +712 -618
- package/dist/agents.d.ts +5 -3
- package/dist/agents.d.ts.map +1 -1
- package/dist/agents.js +19 -8
- package/dist/agents.js.map +1 -1
- package/dist/capabilities.d.ts +1 -1
- package/dist/capabilities.js +2 -2
- package/dist/capabilities.js.map +1 -1
- package/dist/host-hooks.d.ts +13 -2
- package/dist/host-hooks.d.ts.map +1 -1
- package/dist/host-hooks.js +19 -5
- package/dist/host-hooks.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +54 -14
- 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 +135 -17
- 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 +94 -16
- package/dist/prompts/shared.js.map +1 -1
- package/dist/prompts/specialists.d.ts +2 -2
- package/dist/prompts/specialists.d.ts.map +1 -1
- package/dist/prompts/specialists.js +43 -10
- package/dist/prompts/specialists.js.map +1 -1
- package/dist/task-offload.d.ts +67 -0
- package/dist/task-offload.d.ts.map +1 -0
- package/dist/task-offload.js +123 -0
- package/dist/task-offload.js.map +1 -0
- package/dist/tm/args-schema.d.ts +4 -0
- package/dist/tm/args-schema.d.ts.map +1 -1
- package/dist/tm/args-schema.js +44 -3
- package/dist/tm/args-schema.js.map +1 -1
- package/dist/tm/board.d.ts +113 -0
- package/dist/tm/board.d.ts.map +1 -0
- package/dist/tm/board.js +275 -0
- package/dist/tm/board.js.map +1 -0
- package/dist/tm/browser.d.ts +325 -6
- package/dist/tm/browser.d.ts.map +1 -1
- package/dist/tm/browser.js +1329 -135
- package/dist/tm/browser.js.map +1 -1
- package/dist/tm/config.d.ts +36 -1
- package/dist/tm/config.d.ts.map +1 -1
- package/dist/tm/config.js +29 -1
- package/dist/tm/config.js.map +1 -1
- package/dist/tm/dispatch.d.ts +118 -31
- package/dist/tm/dispatch.d.ts.map +1 -1
- package/dist/tm/dispatch.js +385 -131
- package/dist/tm/dispatch.js.map +1 -1
- package/dist/tm/dupe-guard.js +1 -1
- package/dist/tm/dupe-guard.js.map +1 -1
- package/dist/tm/egress.d.ts +44 -0
- package/dist/tm/egress.d.ts.map +1 -0
- package/dist/tm/egress.js +191 -0
- package/dist/tm/egress.js.map +1 -0
- package/dist/tm/guard.js +1 -1
- package/dist/tm/guard.js.map +1 -1
- package/dist/tm/index.d.ts +28 -2
- package/dist/tm/index.d.ts.map +1 -1
- package/dist/tm/index.js +198 -10
- package/dist/tm/index.js.map +1 -1
- package/dist/tm/perm-ask.d.ts +47 -5
- package/dist/tm/perm-ask.d.ts.map +1 -1
- package/dist/tm/perm-ask.js +98 -7
- package/dist/tm/perm-ask.js.map +1 -1
- package/dist/tm/pipelines.d.ts.map +1 -1
- package/dist/tm/pipelines.js +24 -2
- package/dist/tm/pipelines.js.map +1 -1
- package/dist/tm/ptc/tool.d.ts.map +1 -1
- package/dist/tm/ptc/tool.js +2 -4
- package/dist/tm/ptc/tool.js.map +1 -1
- package/dist/tm/pty.d.ts.map +1 -1
- package/dist/tm/pty.js +6 -3
- package/dist/tm/pty.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 +19 -17
- package/dist/tm/search.js.map +1 -1
- package/dist/tm/stats.d.ts +46 -0
- package/dist/tm/stats.d.ts.map +1 -1
- package/dist/tm/stats.js +124 -7
- package/dist/tm/stats.js.map +1 -1
- 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 +52 -0
- package/dist/tm/webfetch.d.ts.map +1 -1
- package/dist/tm/webfetch.js +128 -22
- package/dist/tm/webfetch.js.map +1 -1
- package/dist/tool-coerce.d.ts +24 -0
- package/dist/tool-coerce.d.ts.map +1 -0
- package/dist/tool-coerce.js +63 -0
- package/dist/tool-coerce.js.map +1 -0
- package/dist/types.d.ts +7 -1
- package/dist/types.d.ts.map +1 -1
- package/docs/installation.md +69 -2
- package/package.json +61 -59
package/dist/tm/dispatch.js
CHANGED
|
@@ -1,29 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* tm_join — the COLLECT side of sub-agent work (issue #7 of 2026-09-18; the
|
|
3
|
+
* dispatch side was removed in 1.5.16).
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
5
|
+
* What is gone and why: this module used to own a plugin-side dispatcher,
|
|
6
|
+
* `tm_dispatch`, which bought the lead real overlap by creating child sessions
|
|
7
|
+
* through `client.session.create` + `promptAsync`. It was removed on the
|
|
8
|
+
* user's instruction, because a child a plugin creates is a session the user
|
|
9
|
+
* can neither open from the tool card nor stop from the interface — the host
|
|
10
|
+
* renders only its own built-in tools, and only its own `task` card links to a
|
|
11
|
+
* live child session. "The lead can `cancel:true` it" is not the same thing as
|
|
12
|
+
* the user being able to. Delegation therefore goes back to the host's `task`
|
|
13
|
+
* (and `task { background: true }`, which the installers switch on), and what
|
|
14
|
+
* stays here is the half that is still ours to do: observing and collecting
|
|
15
|
+
* children, which is also how the lead reads back a reply that
|
|
16
|
+
* `src/task-offload.ts` replaced with a pointer.
|
|
14
17
|
*
|
|
15
18
|
* Contract kept from T3 (no sub-agent spawns a sub-agent):
|
|
16
|
-
* - `
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* - children are the five named specialists, never "team";
|
|
20
|
-
* -
|
|
21
|
-
* host
|
|
19
|
+
* - `tm_join` executes ONLY for the team lead (ctx.agent check here, plus an
|
|
20
|
+
* explicit deny for the five specialists in agents.ts — the tm_* wildcard
|
|
21
|
+
* would otherwise hand it to everyone);
|
|
22
|
+
* - the children it knows about are the five named specialists, never "team";
|
|
23
|
+
* - everything it touches is parented to the calling session (parentID), and
|
|
24
|
+
* parentage is READ BACK from the host before an id is claimed — this module
|
|
25
|
+
* never assumes a session belongs to the caller.
|
|
22
26
|
*
|
|
23
27
|
* Context economy (design goal #4): tm_join renders each child's reply
|
|
24
|
-
* through the SAME governance pipeline as every other tm_* tool, so
|
|
25
|
-
*
|
|
26
|
-
*
|
|
28
|
+
* through the SAME governance pipeline as every other tm_* tool, so a long
|
|
29
|
+
* sub-agent report arrives as a handle + an 80-token preview the lead can page
|
|
30
|
+
* through with tm_fetch — not kilotokens dumped inline.
|
|
27
31
|
*/
|
|
28
32
|
import { tmError, toToolResult } from "./result.js";
|
|
29
33
|
import { unwrapClientResult } from "./client-unwrap.js";
|
|
@@ -32,35 +36,131 @@ import { shorten } from "./config.js";
|
|
|
32
36
|
/** The five dispatchable specialists — "team" is deliberately NOT in the
|
|
33
37
|
* list: a lead spawning a lead is the nesting T3 closed. */
|
|
34
38
|
export const DISPATCH_TARGETS = ["architect", "implementer", "reviewer", "tester", "researcher"];
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
export function openHostTodos(todos) {
|
|
40
|
+
if (!Array.isArray(todos))
|
|
41
|
+
return [];
|
|
42
|
+
const open = [];
|
|
43
|
+
for (const t of todos) {
|
|
44
|
+
const status = String(t?.status ?? "").toLowerCase();
|
|
45
|
+
if (!status || status === "completed" || status === "cancelled" || status === "done")
|
|
46
|
+
continue;
|
|
47
|
+
const content = String(t?.content ?? "").trim();
|
|
48
|
+
if (content)
|
|
49
|
+
open.push({ content: content.slice(0, 120), status });
|
|
50
|
+
}
|
|
51
|
+
return open;
|
|
52
|
+
}
|
|
53
|
+
/** #80: the line tm_join adds when a SETTLED child still owns a browser window.
|
|
54
|
+
* Takes only the leases already filtered to settled children, so the ownership
|
|
55
|
+
* rule stays where the child registry is; this function's whole job is to say
|
|
56
|
+
* it in the one shape that survives the lead's skimming — and to refuse to say
|
|
57
|
+
* it for a window that was never ours. */
|
|
58
|
+
export function leaseTripwire(held, callerSessionID) {
|
|
59
|
+
if (!held.length)
|
|
60
|
+
return null;
|
|
61
|
+
const isMine = (l) => !!callerSessionID && l.owner === callerSessionID;
|
|
62
|
+
const mine = held.filter(isMine);
|
|
63
|
+
const theirs = held.filter((l) => !isMine(l));
|
|
64
|
+
const at = (l) => `空闲 ${Math.round(l.idleMs / 1000)}s`;
|
|
65
|
+
const parts = [];
|
|
66
|
+
if (theirs.length) {
|
|
67
|
+
parts.push(`⚠ ${theirs.length} 个浏览器还开着(子代理已结算,但它没 close):` +
|
|
68
|
+
theirs.map((l) => `${l.id}(${l.agent || "未知角色"} · ${at(l)})`).join("、") +
|
|
69
|
+
`\n要么让它 close 并引用工具自己的三种裁决之一(已确认关闭 / 进程未核验 / 警告:关闭未完全成功),` +
|
|
70
|
+
`要么由你向用户写明为什么留着。不要替它写"已关闭"——那是把没人核实过的结论交付出去。`);
|
|
71
|
+
}
|
|
72
|
+
// The caller's own window is the one the user is looking at, and the live
|
|
73
|
+
// recheck proved this branch was the missing one: a lead that keeps a browser
|
|
74
|
+
// across rounds is legitimate, so this is a reminder, not an accusation —
|
|
75
|
+
// but silence is the defect.
|
|
76
|
+
for (const l of mine) {
|
|
77
|
+
parts.push(`(你自己还占着 ${l.id},${at(l)}。收尾前 action:"close" 并引用工具的裁决句;` +
|
|
78
|
+
`如果确实要跨轮留着,就在回复里向用户说明为什么。)`);
|
|
79
|
+
}
|
|
80
|
+
return parts.join("\n");
|
|
81
|
+
}
|
|
82
|
+
/** Resolve the host session API defensively — `messages` is what tm_join
|
|
83
|
+
* cannot work without (the collected reply); a host without it means the
|
|
84
|
+
* caller must degrade, and `create`/`promptAsync` are deliberately NOT part
|
|
85
|
+
* of this contract any more: nothing here spawns sessions. */
|
|
37
86
|
export function sessionApiOf(client) {
|
|
38
87
|
const session = client?.session;
|
|
39
88
|
if (!session || typeof session !== "object")
|
|
40
89
|
return null;
|
|
41
90
|
const api = session;
|
|
42
|
-
if (typeof api.
|
|
91
|
+
if (typeof api.messages !== "function")
|
|
43
92
|
return null;
|
|
44
93
|
return api;
|
|
45
94
|
}
|
|
46
|
-
/** The
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
52
|
-
|
|
95
|
+
/** The host's OWN visible-and-non-blocking sub-agent path: its built-in `task`
|
|
96
|
+
* takes `background: true`, and because that card is rendered by the host's
|
|
97
|
+
* own `task` renderer it links to the live child session — which a plugin tool
|
|
98
|
+
* card structurally cannot do. Gated behind a runtime flag (verified in the
|
|
99
|
+
* desktop binary): `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS`, falling back
|
|
100
|
+
* to the umbrella `OPENCODE_EXPERIMENTAL`. We read the SAME env the host
|
|
101
|
+
* reads — our plugin runs inside that process — so this is a fact, not a
|
|
102
|
+
* guess. */
|
|
103
|
+
export function hostBackgroundSubagentsEnabled(env = process.env) {
|
|
104
|
+
const truthy = (v) => {
|
|
105
|
+
const s = typeof v === "string" ? v.trim().toLowerCase() : "";
|
|
106
|
+
if (!s)
|
|
107
|
+
return null;
|
|
108
|
+
return s === "1" || s === "true" || s === "yes" || s === "on";
|
|
109
|
+
};
|
|
110
|
+
return truthy(env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS) ?? truthy(env.OPENCODE_EXPERIMENTAL) ?? false;
|
|
111
|
+
}
|
|
112
|
+
/** A wait budget spelled the way a human reads it (never "0s" for 300 ms). */
|
|
113
|
+
function waitLabel(ms) {
|
|
114
|
+
return ms >= 1000 ? `${Math.round(ms / 1000)}s` : `${ms}ms`;
|
|
53
115
|
}
|
|
116
|
+
/** What a tm_join wait may actually cost. The FIRST bounded wait gets the
|
|
117
|
+
* requested budget (clamped to TM_JOIN_MAX_WAIT_MS); a wait that follows a
|
|
118
|
+
* wait which settled nothing gets cut to REPEAT_WAIT_MS, because the second
|
|
119
|
+
* one is the shape of a lead parking instead of working — measured live at
|
|
120
|
+
* 2 × 300 s of nothing while six children ran. */
|
|
121
|
+
export const REPEAT_WAIT_MS = 10_000;
|
|
122
|
+
export function joinBudget(waitMs, maxWaitMs, prev) {
|
|
123
|
+
const clamped = Math.max(0, Math.min(maxWaitMs, waitMs));
|
|
124
|
+
const repeat = !!prev && prev.stillRunning > 0 && clamped > 0;
|
|
125
|
+
const streak = clamped === 0 ? (prev?.streak ?? 0) : repeat ? (prev?.streak ?? 1) + 1 : 1;
|
|
126
|
+
return { budget: repeat ? Math.min(clamped, REPEAT_WAIT_MS) : clamped, repeat, streak };
|
|
127
|
+
}
|
|
128
|
+
/** Recognise the title a `tm_dispatch` child used to carry — the tool is no
|
|
129
|
+
* longer registered (a plugin-spawned child is a session the user can neither
|
|
130
|
+
* open from a card nor stop from the UI), but children created before the
|
|
131
|
+
* change are still live in the host's session tree and tm_join must still
|
|
132
|
+
* collect them.
|
|
133
|
+
*
|
|
134
|
+
* The shape was the host's OWN subagent convention — the built-in task tool
|
|
135
|
+
* titles its children `${description} (@${agent} subagent)` (verified in the
|
|
136
|
+
* desktop binary), which is what the renderer's `taskSession()` heuristic
|
|
137
|
+
* reads to colour a child row. Ours added a ` ·tm` marker inside that shape,
|
|
138
|
+
* and the marker is LOAD-BEARING in both directions: it is what tells a
|
|
139
|
+
* leftover dispatch apart from a host `task` child, so an automatic tree walk
|
|
140
|
+
* never claims a reply that belongs to the host's dispatcher. The pre-1.5.15
|
|
141
|
+
* shape (`tm:<agent>:<label>`) still parses for the same reason — a session
|
|
142
|
+
* that exists must stay collectable. A child named explicitly by id takes a
|
|
143
|
+
* different path (`claimNamedChild`), where the lock is the host's own
|
|
144
|
+
* parentage rather than the title. */
|
|
54
145
|
export function parseDispatchTitle(title, targets) {
|
|
55
146
|
const t = typeof title === "string" ? title.trim() : "";
|
|
56
|
-
if (
|
|
147
|
+
if (t.startsWith("tm:")) {
|
|
148
|
+
const rest = t.slice(3);
|
|
149
|
+
const colon = rest.indexOf(":");
|
|
150
|
+
const legacyAgent = (colon < 0 ? rest : rest.slice(0, colon)).trim().toLowerCase();
|
|
151
|
+
if (!legacyAgent || !targets.includes(legacyAgent))
|
|
152
|
+
return null;
|
|
153
|
+
return { agent: legacyAgent, label: (colon < 0 ? "" : rest.slice(colon + 1)).trim().slice(0, 40) };
|
|
154
|
+
}
|
|
155
|
+
// The marker is required here — without it a built-in task child would read
|
|
156
|
+
// as ours, which is the one thing the restart path must never do.
|
|
157
|
+
const m = /^(.*?) \(@([^\s()]+) subagent ·tm\)$/.exec(t);
|
|
158
|
+
if (!m)
|
|
57
159
|
return null;
|
|
58
|
-
const
|
|
59
|
-
const colon = rest.indexOf(":");
|
|
60
|
-
const agent = (colon < 0 ? rest : rest.slice(0, colon)).trim().toLowerCase();
|
|
160
|
+
const agent = m[2].trim().toLowerCase();
|
|
61
161
|
if (!agent || !targets.includes(agent))
|
|
62
162
|
return null;
|
|
63
|
-
return { agent, label:
|
|
163
|
+
return { agent, label: m[1].trim().slice(0, 40) };
|
|
64
164
|
}
|
|
65
165
|
/**
|
|
66
166
|
* Pull the assistant reply text (and, when the host records it, the moment
|
|
@@ -83,12 +183,15 @@ export function lastAssistantMessage(messages) {
|
|
|
83
183
|
continue;
|
|
84
184
|
const completed = Number(info?.time?.completed);
|
|
85
185
|
const completedAt = Number.isFinite(completed) && completed > 0 ? completed : undefined;
|
|
186
|
+
// an assistant turn that errored carries the reason on info.error — the
|
|
187
|
+
// lead must see it, or "no reply" reads as "the agent had nothing to say"
|
|
188
|
+
const error = info?.error ? describeHostError(info.error, 200) : undefined;
|
|
86
189
|
const parts = entry?.parts;
|
|
87
190
|
if (!Array.isArray(parts)) {
|
|
88
191
|
// a bare message list (no {info,parts} envelope) — take any text part
|
|
89
192
|
const one = entry ?? {};
|
|
90
193
|
if (one.type === "text" && typeof one.text === "string")
|
|
91
|
-
return { text: one.text, completedAt };
|
|
194
|
+
return { text: one.text, ...(completedAt ? { completedAt } : {}), ...(error ? { error } : {}) };
|
|
92
195
|
continue;
|
|
93
196
|
}
|
|
94
197
|
const texts = parts
|
|
@@ -97,8 +200,12 @@ export function lastAssistantMessage(messages) {
|
|
|
97
200
|
return pp && pp.type === "text" && typeof pp.text === "string" && pp.text.trim() !== "";
|
|
98
201
|
})
|
|
99
202
|
.map((p) => String(p.text));
|
|
203
|
+
// keys are added only when present so a deep-equality assertion sees the
|
|
204
|
+
// shape the caller actually got (no `error: undefined` noise)
|
|
100
205
|
if (texts.length)
|
|
101
|
-
return { text: texts.join("\n"), completedAt };
|
|
206
|
+
return { text: texts.join("\n"), ...(completedAt ? { completedAt } : {}), ...(error ? { error } : {}) };
|
|
207
|
+
if (error)
|
|
208
|
+
return { text: "", ...(completedAt ? { completedAt } : {}), error };
|
|
102
209
|
}
|
|
103
210
|
return { text: "" };
|
|
104
211
|
}
|
|
@@ -106,6 +213,48 @@ export function lastAssistantMessage(messages) {
|
|
|
106
213
|
export function lastAssistantText(messages) {
|
|
107
214
|
return lastAssistantMessage(messages).text;
|
|
108
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* Turn whatever the host puts in an error slot into ONE readable line.
|
|
218
|
+
*
|
|
219
|
+
* The live host nests them (`{name, data:{message, ref}}`, `{error:{message}}`,
|
|
220
|
+
* a bare string — sometimes several at once), and `String(obj)` is
|
|
221
|
+
* "[object Object]". That is what a lead reported to a user for three child
|
|
222
|
+
* sessions that died at launch: the one diagnostic the whole flow had was
|
|
223
|
+
* destroyed by our own rendering. Never again: dig the known fields, then
|
|
224
|
+
* fall back to a compact key dump — never a bare object stringify.
|
|
225
|
+
*/
|
|
226
|
+
export function describeHostError(err, max = 220) {
|
|
227
|
+
if (typeof err === "string")
|
|
228
|
+
return shorten(err.trim() || "session.error", max);
|
|
229
|
+
if (err === null || err === undefined)
|
|
230
|
+
return "session.error";
|
|
231
|
+
if (typeof err !== "object")
|
|
232
|
+
return shorten(String(err), max);
|
|
233
|
+
const o = err;
|
|
234
|
+
const box = (v) => v && typeof v === "object" ? v : undefined;
|
|
235
|
+
const data = box(o.data);
|
|
236
|
+
const inner = box(o.error);
|
|
237
|
+
const pick = (...cands) => {
|
|
238
|
+
for (const c of cands)
|
|
239
|
+
if (typeof c === "string" && c.trim())
|
|
240
|
+
return c.trim();
|
|
241
|
+
return "";
|
|
242
|
+
};
|
|
243
|
+
const message = pick(o.message, data?.message, inner?.message, inner?.name, data?.ref ? `ref=${String(data.ref)}` : "");
|
|
244
|
+
const name = pick(o.name, inner?.name, data?.name);
|
|
245
|
+
const ref = pick(data?.ref, o.ref);
|
|
246
|
+
if (message || name) {
|
|
247
|
+
const head = name && message && !message.startsWith(name) ? `${name}: ${message}` : message || name;
|
|
248
|
+
return shorten(ref && !head.includes(ref) ? `${head}(ref=${ref})` : head, max);
|
|
249
|
+
}
|
|
250
|
+
// nothing recognised — dump the SHAPE with short values, which is still
|
|
251
|
+
// infinitely more useful than "[object Object]"
|
|
252
|
+
const flat = Object.entries(o)
|
|
253
|
+
.slice(0, 6)
|
|
254
|
+
.map(([k, v]) => `${k}=${typeof v === "object" && v !== null ? Object.keys(v).slice(0, 4).join("+") : String(v).slice(0, 60)}`)
|
|
255
|
+
.join(" ");
|
|
256
|
+
return shorten(flat || "session.error", max);
|
|
257
|
+
}
|
|
109
258
|
/** One child's status line — the lead reads these, so keep them terse. */
|
|
110
259
|
export function renderChildLine(c, elapsedMs) {
|
|
111
260
|
const secs = Math.round(elapsedMs / 1000);
|
|
@@ -138,14 +287,47 @@ function sleep(ms) {
|
|
|
138
287
|
t.unref?.();
|
|
139
288
|
});
|
|
140
289
|
}
|
|
290
|
+
/** `ids` arrives in three shapes on the live host: a real array, a
|
|
291
|
+
* JSON-array STRING (`"[\"ses_x\"]"` — what a model actually sent on
|
|
292
|
+
* 2026-09-19), or a comma-separated list. Array.isArray alone silently
|
|
293
|
+
* ignored the string form and returned EVERY child when the lead asked for
|
|
294
|
+
* one, which is the same class of bug as the old `Boolean("false")` headless
|
|
295
|
+
* trap: the arg is trusted because it "looks" like the declared type. */
|
|
296
|
+
export function parseIdList(raw) {
|
|
297
|
+
if (Array.isArray(raw))
|
|
298
|
+
return raw.map((x) => String(x ?? "").trim()).filter(Boolean);
|
|
299
|
+
const s = typeof raw === "string" ? raw.trim() : "";
|
|
300
|
+
if (!s)
|
|
301
|
+
return null;
|
|
302
|
+
if (s.startsWith("[") && s.endsWith("]")) {
|
|
303
|
+
try {
|
|
304
|
+
const parsed = JSON.parse(s);
|
|
305
|
+
if (Array.isArray(parsed))
|
|
306
|
+
return parsed.map((x) => String(x ?? "").trim()).filter(Boolean);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
/* fall through to the comma split */
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return s.split(",").map((x) => x.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
313
|
+
}
|
|
141
314
|
export function buildDispatchTools(deps) {
|
|
142
315
|
const leadAgent = deps.leadAgent ?? "team";
|
|
143
316
|
const targets = deps.targets ?? DISPATCH_TARGETS;
|
|
144
|
-
const maxWaitMs = deps.maxWaitMs ??
|
|
317
|
+
const maxWaitMs = deps.maxWaitMs ?? 60_000;
|
|
145
318
|
const now = deps.now ?? (() => Date.now());
|
|
146
319
|
const store = deps.pipelines.store;
|
|
320
|
+
// The trajectory label stays `tm_dispatch` after the tool's death on purpose:
|
|
321
|
+
// tm_stats reads it, and adopt/cancel/wait events from a 1.5.15 run must stay
|
|
322
|
+
// comparable with this one. Renaming would split the history in two.
|
|
147
323
|
const log = (e) => store.appendTrajectory({ tool: "tm_dispatch", ...e });
|
|
148
324
|
const children = new Map();
|
|
325
|
+
/** Per-parent memory of the LAST join's outcome. A wait is not parallelism
|
|
326
|
+
* — while tm_join is in flight the lead's turn is blocked exactly like a
|
|
327
|
+
* synchronous `task` call — so a SECOND wait that inherited a first wait
|
|
328
|
+
* which settled nothing is the pattern a live session burned ten minutes
|
|
329
|
+
* on. It gets cut short and answered with what to do instead. */
|
|
330
|
+
const lastJoin = new Map();
|
|
149
331
|
const api = sessionApiOf(deps.client);
|
|
150
332
|
/** Refresh a child's state from the host's own status map (the event bus
|
|
151
333
|
* is the fast path; this is the belt when an event was missed — a child
|
|
@@ -203,9 +385,11 @@ export function buildDispatchTools(deps) {
|
|
|
203
385
|
* tm_join's honest answer would be "没有待收集的派发" while a finished report
|
|
204
386
|
* sits in the host, which is exactly the silent loss issue #7 was about.
|
|
205
387
|
*
|
|
206
|
-
* The discriminator is the title
|
|
207
|
-
*
|
|
208
|
-
*
|
|
388
|
+
* The discriminator is the title shape our removed `tm_dispatch` used to
|
|
389
|
+
* write (a recognised agent + the ` ·tm` marker) or the pre-1.5.15
|
|
390
|
+
* `tm:<agent>:<label>`, so a child spawned by the built-in `task` tool is
|
|
391
|
+
* never claimed as ours on speculation — see `claimNamedChild` for the one
|
|
392
|
+
* path that may take a host child, and the parentage check it must pass.
|
|
209
393
|
*/
|
|
210
394
|
async function adoptFromHost(parent, directory, only) {
|
|
211
395
|
if (typeof api?.children !== "function" || !parent)
|
|
@@ -247,6 +431,42 @@ export function buildDispatchTools(deps) {
|
|
|
247
431
|
}
|
|
248
432
|
return adopted;
|
|
249
433
|
}
|
|
434
|
+
/** Claim ONE explicitly named child of the calling session — the host's own
|
|
435
|
+
* `task` child included, which `adoptFromHost` must never pick up on its
|
|
436
|
+
* own. The lock is the host's parentage (`parentID === the caller`), read
|
|
437
|
+
* back rather than assumed: an id belonging to someone else stays a miss. */
|
|
438
|
+
async function claimNamedChild(sid, parent, directory) {
|
|
439
|
+
if (typeof api?.get !== "function" || !sid || !parent || children.has(sid))
|
|
440
|
+
return false;
|
|
441
|
+
let un;
|
|
442
|
+
try {
|
|
443
|
+
un = unwrapClientResult(await api.get({ path: { id: sid }, ...(directory ? { query: { directory } } : {}) }));
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
if (!un.ok)
|
|
449
|
+
return false;
|
|
450
|
+
const info = un.data;
|
|
451
|
+
if (String(info?.parentID ?? "").trim() !== parent)
|
|
452
|
+
return false;
|
|
453
|
+
const rawAgent = String(info?.agent ?? "").trim().toLowerCase();
|
|
454
|
+
const agent = targets.includes(rawAgent) ? rawAgent : "task";
|
|
455
|
+
const title = String(info?.title ?? "").trim();
|
|
456
|
+
const created = Number(info?.time?.created);
|
|
457
|
+
children.set(sid, {
|
|
458
|
+
sessionID: sid,
|
|
459
|
+
agent,
|
|
460
|
+
label: (title || "host task").slice(0, 40),
|
|
461
|
+
parentSessionID: parent,
|
|
462
|
+
startedAt: Number.isFinite(created) && created > 0 ? created : now(),
|
|
463
|
+
state: "running",
|
|
464
|
+
adopted: true,
|
|
465
|
+
});
|
|
466
|
+
deps.onChildSession?.(sid, agent);
|
|
467
|
+
log({ step_id: "join", event: "claim_host_task", child: sid, agent });
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
250
470
|
/** Settle verdict for adopted rows: `AssistantMessage.time.completed` is
|
|
251
471
|
* only set once the reply is done, so it answers "finished while nobody
|
|
252
472
|
* was listening?" without waiting for an event that already fired. */
|
|
@@ -261,90 +481,16 @@ export function buildDispatchTools(deps) {
|
|
|
261
481
|
}
|
|
262
482
|
}
|
|
263
483
|
}
|
|
264
|
-
const dispatch = {
|
|
265
|
-
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).
|
|
266
|
-
- { agent: ${targets.join("|")} , task: "<self-contained brief>", label: "<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.
|
|
267
|
-
- 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.
|
|
268
|
-
- 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.
|
|
269
|
-
- 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.
|
|
270
|
-
- 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.`,
|
|
271
|
-
args: {
|
|
272
|
-
agent: { descriptor: `agent: ${targets.join("|")} (required)` },
|
|
273
|
-
task: { descriptor: "task: string (required — the full self-contained brief the child works from)" },
|
|
274
|
-
label: { descriptor: "label: string (optional short slug for this dispatch, ≤40 chars)" },
|
|
275
|
-
},
|
|
276
|
-
execute: async (rawArgs, ctx) => {
|
|
277
|
-
const tool = "tm_dispatch";
|
|
278
|
-
try {
|
|
279
|
-
const args = (rawArgs ?? {});
|
|
280
|
-
const c = (ctx ?? {});
|
|
281
|
-
const caller = String(c.agent ?? "").trim();
|
|
282
|
-
if (caller && caller !== leadAgent) {
|
|
283
|
-
return toToolResult(tmError(tool, "governance", `只有 ${leadAgent} 可以派发子代理(当前 agent="${shorten(caller, 24)}")。T3 规则:子代理不再派子代理——把需要并行的工作交给 lead 编排。`));
|
|
284
|
-
}
|
|
285
|
-
const agent = String(args.agent ?? "").trim().toLowerCase();
|
|
286
|
-
if (!targets.includes(agent)) {
|
|
287
|
-
return toToolResult(tmError(tool, "args", `未知派发目标 "${shorten(agent, 30)}"——可用: ${targets.join(", ")}`));
|
|
288
|
-
}
|
|
289
|
-
const task = typeof args.task === "string" ? args.task.trim() : "";
|
|
290
|
-
if (task.length < 20) {
|
|
291
|
-
return toToolResult(tmError(tool, "args", "task 太短:子代理看不到本会话,派发说明必须自包含(目标、涉及文件、完成判据、期望深度)。"));
|
|
292
|
-
}
|
|
293
|
-
const parent = String(c.sessionID ?? "").trim();
|
|
294
|
-
if (!parent) {
|
|
295
|
-
return toToolResult(tmError(tool, "client", "缺少 sessionID(宿主未把会话上下文传给插件),无法建立父子会话。"));
|
|
296
|
-
}
|
|
297
|
-
if (!api) {
|
|
298
|
-
return toToolResult(tmError(tool, "client", `此宿主未暴露异步会话 API(client.session.create/promptAsync),tm_dispatch 不可用——改用内置 task 工具(会阻塞你,但功能等价)。`));
|
|
299
|
-
}
|
|
300
|
-
const label = (typeof args.label === "string" && args.label.trim() ? args.label.trim() : task.split(/\s+/).slice(0, 4).join(" ")).slice(0, 40);
|
|
301
|
-
if (typeof api?.create !== "function" || typeof api?.promptAsync !== "function") {
|
|
302
|
-
return toToolResult(tmError(tool, "client", "此宿主的 client.session 缺少 create/promptAsync——改用内置 task 工具。"));
|
|
303
|
-
}
|
|
304
|
-
// PROPERTY-ACCESS CALLS (api.create / api.promptAsync), never a
|
|
305
|
-
// captured function reference — see refreshFromStatus above.
|
|
306
|
-
const created = unwrapClientResult(await api.create({ body: { parentID: parent, title: dispatchTitle(agent, label) } }));
|
|
307
|
-
if (!created.ok)
|
|
308
|
-
return toToolResult(tmError(tool, "client", `创建子会话失败:${shorten(created.message ?? "", 120)}`));
|
|
309
|
-
const sid = String(created.data?.id ?? "").trim();
|
|
310
|
-
if (!sid)
|
|
311
|
-
return toToolResult(tmError(tool, "client", "子会话创建后未返回 id"));
|
|
312
|
-
const promptBody = {
|
|
313
|
-
agent,
|
|
314
|
-
noReply: false,
|
|
315
|
-
parts: [{ type: "text", text: task }],
|
|
316
|
-
};
|
|
317
|
-
const directory = typeof c.directory === "string" && c.directory ? c.directory : undefined;
|
|
318
|
-
const sent = unwrapClientResult(await api.promptAsync({ path: { id: sid }, ...(directory ? { query: { directory } } : {}), body: promptBody }));
|
|
319
|
-
if (!sent.ok) {
|
|
320
|
-
return toToolResult(tmError(tool, "client", `子会话 ${sid} 已创建但提示未能启动:${shorten(sent.message ?? "", 120)}——可对该 id 直接用内置 task,或本次改用 task 工具。`));
|
|
321
|
-
}
|
|
322
|
-
const rec = { sessionID: sid, agent, label, parentSessionID: parent, startedAt: now(), state: "running" };
|
|
323
|
-
children.set(sid, rec);
|
|
324
|
-
deps.onChildSession?.(sid, agent);
|
|
325
|
-
log({ step_id: "dispatch", event: "start", child: sid, parent: parent, agent, label });
|
|
326
|
-
return toToolResult([
|
|
327
|
-
`已派发(非阻塞):${agent} · "${label}" · 子会话 ${sid}`,
|
|
328
|
-
`你现在可以继续:编排下一步、做廉价检查、或继续派发其它独立任务。`,
|
|
329
|
-
`收集结果:tm_join(立即看状态)或 tm_join { waitMs: 60000 }。所有派发完成前不要提交结论。`,
|
|
330
|
-
].join("\n"));
|
|
331
|
-
}
|
|
332
|
-
catch (err) {
|
|
333
|
-
const e = err;
|
|
334
|
-
return toToolResult(tmError(tool, "execute", `tm_dispatch 失败:${shorten(e?.message ?? err, 160)}`));
|
|
335
|
-
}
|
|
336
|
-
},
|
|
337
|
-
};
|
|
338
484
|
const join = {
|
|
339
|
-
description: `Collect
|
|
485
|
+
description: `Collect the sub-agent work attached to THIS session — the read-back side of delegation. You reach for it in two situations: the host's background \`task\` finished and TeamMode replaced its injected reply with a preview (pull the whole thing back with \`{ ids: ["<child session>"] }\`), or a dispatched child from before this build is still open and needs collecting or cancelling.
|
|
340
486
|
- 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.
|
|
341
|
-
- { waitMs: 30000 }: bounded wait (capped at ${
|
|
342
|
-
- { ids: [...] }: restrict to those child sessions
|
|
343
|
-
- A plugin/host restart does NOT orphan a
|
|
344
|
-
- 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
|
|
487
|
+
- { waitMs: 30000 }: bounded wait (capped at ${waitLabel(maxWaitMs)}) until every child settles. A wait BLOCKS YOU — your turn is parked in this tool call, so it is the synchronous \`task\` experience with none of its visibility. Wait once, briefly, and only when the very next step needs the answer; a second consecutive wait after nothing settled is cut to 10s and answered with what to do instead. Never wait for a child whose result you do not need — abort it instead ({ cancel: true }).
|
|
488
|
+
- { ids: [...] }: restrict to those child sessions — a host \`task\` child is collectable when you NAME it (its parentage is verified against the host's own session tree, never assumed); { cancel: true }: abort still-running ones.
|
|
489
|
+
- A plugin/host restart does NOT orphan a child: those 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.
|
|
490
|
+
- 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 a batch of parallel work does not multiply your context. STATUS: blocked/failed children are surfaced first, always.`,
|
|
345
491
|
args: {
|
|
346
492
|
ids: { descriptor: "ids: string[] (optional — only these child sessions)" },
|
|
347
|
-
waitMs: { descriptor:
|
|
493
|
+
waitMs: { descriptor: `waitMs: number (optional 0..${maxWaitMs} — bounded wait; a SECOND consecutive wait is cut to 10s, because waiting is not parallel work)` },
|
|
348
494
|
cancel: { descriptor: "cancel: true (abort the still-running children in this set)" },
|
|
349
495
|
includeText: { descriptor: "includeText: false to get only the status table (default true)" },
|
|
350
496
|
},
|
|
@@ -358,10 +504,13 @@ export function buildDispatchTools(deps) {
|
|
|
358
504
|
return toToolResult(tmError(tool, "governance", `只有 ${leadAgent} 能收集派发结果。`));
|
|
359
505
|
}
|
|
360
506
|
const parent = String(c.sessionID ?? "").trim();
|
|
507
|
+
// tm_stats counts calls from `event:"call"`, and tm_join only ever
|
|
508
|
+
// wrote its governed result — so the token table read "0 调用 / 2 结果",
|
|
509
|
+
// which looks like a broken counter rather than a tool that ran.
|
|
510
|
+
store.appendTrajectory({ tool, step_id: "join", event: "call" });
|
|
361
511
|
const directory = typeof c.directory === "string" && c.directory ? c.directory : undefined;
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
: null;
|
|
512
|
+
const idList = parseIdList(args.ids);
|
|
513
|
+
const idFilter = idList && idList.length ? new Set(idList) : null;
|
|
365
514
|
const owned = () => [...children.values()].filter((r) => (!parent || r.parentSessionID === parent) && (!idFilter || idFilter.has(r.sessionID)));
|
|
366
515
|
let mine = owned();
|
|
367
516
|
// This process has never seen some (or all) of what the lead is asking
|
|
@@ -369,13 +518,66 @@ export function buildDispatchTools(deps) {
|
|
|
369
518
|
if (mine.length < (idFilter ? idFilter.size : 1)) {
|
|
370
519
|
if (await adoptFromHost(parent, directory, idFilter))
|
|
371
520
|
mine = owned();
|
|
521
|
+
// Plan B: a NAMED id may be the host's own `task` child (no ·tm
|
|
522
|
+
// marker, so the tree walk above never claims it). Collecting it is
|
|
523
|
+
// how the lead reads back a result we replaced with a pointer — the
|
|
524
|
+
// host's parentage check below replaces the title discriminator, and
|
|
525
|
+
// only an explicit request reaches this path.
|
|
526
|
+
if (idFilter) {
|
|
527
|
+
for (const sid of idFilter) {
|
|
528
|
+
if (children.has(sid))
|
|
529
|
+
continue;
|
|
530
|
+
if (await claimNamedChild(sid, parent, directory))
|
|
531
|
+
mine = owned();
|
|
532
|
+
}
|
|
533
|
+
}
|
|
372
534
|
}
|
|
535
|
+
// #87: computed BEFORE any early return. The live regression of #86 was
|
|
536
|
+
// a lead holding a browser while tm_join answered "nothing to collect" —
|
|
537
|
+
// a SYNCHRONOUS host task never registers in this table (the host
|
|
538
|
+
// collects it inline), so the header assembly below never ran, and the
|
|
539
|
+
// window the user was looking at went unreported again. A forgotten
|
|
540
|
+
// browser has to surface on EVERY answer this tool gives.
|
|
541
|
+
const leaseLine = () => {
|
|
542
|
+
if (typeof deps.browserLeases !== "function")
|
|
543
|
+
return "";
|
|
544
|
+
let held = [];
|
|
545
|
+
try {
|
|
546
|
+
const done = new Set(mine.filter((r) => r.state !== "running").map((r) => r.sessionID));
|
|
547
|
+
held = deps.browserLeases().filter((l) => done.has(l.owner) || l.owner === parent);
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
/* the lease table is an extra, never a reason to fail a join */
|
|
551
|
+
return "";
|
|
552
|
+
}
|
|
553
|
+
const line = leaseTripwire(held, parent) ?? "";
|
|
554
|
+
if (line) {
|
|
555
|
+
log({
|
|
556
|
+
step_id: "join",
|
|
557
|
+
event: "lease_held",
|
|
558
|
+
count: held.length,
|
|
559
|
+
ids: held.map((l) => l.id).join(","),
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
return line;
|
|
563
|
+
};
|
|
373
564
|
if (!mine.length) {
|
|
374
|
-
|
|
565
|
+
const lease = leaseLine();
|
|
566
|
+
return toToolResult(`没有待收集的派发${idFilter ? "(ids 未匹配到本会话的子代理,宿主会话树里也没有可认领的子会话)" : ""}。` +
|
|
567
|
+
// The old line asserted the dispatch had failed. Measured live: it
|
|
568
|
+
// had not — a completed host `task` with a full reply was sitting in
|
|
569
|
+
// the transcript, and the honest reason is that a synchronous host
|
|
570
|
+
// task never enters this registry at all.
|
|
571
|
+
`如果你用的是同步 \`task\`:宿主自己就把结果收回来了,它从不登记在这里,这不是派发失败。` +
|
|
572
|
+
`只有后台 \`task {background:true}\`(或显式带 ids)需要 tm_join 来取。` +
|
|
573
|
+
(lease ? `\n${lease}` : ""));
|
|
375
574
|
}
|
|
376
575
|
await settleAdopted(mine, directory);
|
|
377
576
|
const waitMs = Math.max(0, Math.min(maxWaitMs, Number(args.waitMs) || 0));
|
|
378
|
-
const
|
|
577
|
+
const prev = lastJoin.get(parent);
|
|
578
|
+
const { budget, repeat: repeatWait, streak } = joinBudget(waitMs, maxWaitMs, prev);
|
|
579
|
+
const waitStart = now();
|
|
580
|
+
const deadline = waitStart + budget;
|
|
379
581
|
let settled = false;
|
|
380
582
|
for (;;) {
|
|
381
583
|
await refreshFromStatus(directory);
|
|
@@ -387,6 +589,16 @@ export function buildDispatchTools(deps) {
|
|
|
387
589
|
await sleep(Math.min(1000, Math.max(100, deadline - now())));
|
|
388
590
|
}
|
|
389
591
|
const stillRunning = mine.filter((r) => r.state === "running");
|
|
592
|
+
lastJoin.set(parent, { at: now(), stillRunning: stillRunning.length, streak });
|
|
593
|
+
log({
|
|
594
|
+
step_id: "join",
|
|
595
|
+
event: "wait",
|
|
596
|
+
asked_ms: waitMs,
|
|
597
|
+
budget_ms: budget,
|
|
598
|
+
waited_ms: Math.max(0, now() - waitStart),
|
|
599
|
+
still_running: stillRunning.length,
|
|
600
|
+
repeat: repeatWait,
|
|
601
|
+
});
|
|
390
602
|
if (args.cancel === true || args.cancel === "true") {
|
|
391
603
|
for (const r of stillRunning) {
|
|
392
604
|
if (typeof api?.abort === "function") {
|
|
@@ -403,9 +615,48 @@ export function buildDispatchTools(deps) {
|
|
|
403
615
|
}
|
|
404
616
|
const sums = summarizeStates(mine);
|
|
405
617
|
const header = [
|
|
406
|
-
`派发汇总:${sums.idle} 完成 / ${sums.running} 运行中 / ${sums.error} 失败${settled ? "(全部已结算)" :
|
|
618
|
+
`派发汇总:${sums.idle} 完成 / ${sums.running} 运行中 / ${sums.error} 失败${settled ? "(全部已结算)" : `(未等到全部结算 · 本次已等 ${Math.round((now() - waitStart) / 1000)}s)`}`,
|
|
407
619
|
...mine.map((r) => renderChildLine(r, (r.finishedAt ?? now()) - r.startedAt)),
|
|
408
620
|
];
|
|
621
|
+
if (stillRunning.length && !settled) {
|
|
622
|
+
header.push(`⏱ 还有 ${stillRunning.length} 个子代理在跑 —— 先告诉用户你在等谁、在等什么、这一轮不是交付,再决定是等还是去干活。` +
|
|
623
|
+
`你的工具调用在界面上只是一行打不开的卡片,你不说,用户没法把"回合结束"和"任务完成"分开。`);
|
|
624
|
+
}
|
|
625
|
+
if (repeatWait && stillRunning.length) {
|
|
626
|
+
header.push(`⏳ 连续第 ${streak} 次等待,而且上一次也没等到结算 —— 这段等待里你什么都没做,它不是并行,是同步 task 的等价物。` +
|
|
627
|
+
`别再等:① 先做 lead 工作(合并骨架、路由下一个独立任务、你自己的验收项),做完再 tm_join;` +
|
|
628
|
+
`② 或就此收尾,把未收的派发逐条列成 open handoff 交给用户;` +
|
|
629
|
+
`③ 或 { cancel: true } 收掉已经不需要的那些。` +
|
|
630
|
+
`(宿主若开了 OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true,内置 task {background:true} 的卡片可以直接点开看实时进度。)`);
|
|
631
|
+
}
|
|
632
|
+
// Goal tripwire, at the exact moment a lead tends to wrap up: every
|
|
633
|
+
// child settled says nothing about the USER'S goal, and the host's own
|
|
634
|
+
// todo list does. Read-only (GET /session/{id}/todo has no write
|
|
635
|
+
// body), so this can remind but never rewrite.
|
|
636
|
+
if (settled && typeof api?.todo === "function" && parent) {
|
|
637
|
+
try {
|
|
638
|
+
const un = unwrapClientResult(await api.todo({ path: { id: parent }, ...(directory ? { query: { directory } } : {}) }));
|
|
639
|
+
const open = un.ok ? openHostTodos(un.data) : [];
|
|
640
|
+
if (open.length) {
|
|
641
|
+
header.push(`⚠ 目标未达成:宿主 todolist 还有 ${open.length} 项未完成 —— ${open.slice(0, 6).map((t) => `「${t.content}」(${t.status})`).join("、")}` +
|
|
642
|
+
(open.length > 6 ? ` …+${open.length - 6}` : "") +
|
|
643
|
+
`\n按目标指令:要么继续做掉,要么向用户写明哪一条被什么卡住;不要把这轮当成收尾。` +
|
|
644
|
+
`(若这些项其实已经做完——例如你刚把派发结果收齐——先用 todowrite 更新状态,再收尾。)`);
|
|
645
|
+
log({ step_id: "join", event: "goal_open", count: open.length });
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
/* the todo endpoint is an extra, never a reason to fail a join */
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// #80/#86/#87: THE LEASE TRIPWIRE — one computation (leaseLine above),
|
|
653
|
+
// attached to every answer this tool gives, so a settled round and an
|
|
654
|
+
// empty one cannot disagree about whether a window is still open.
|
|
655
|
+
if (settled) {
|
|
656
|
+
const lease = leaseLine();
|
|
657
|
+
if (lease)
|
|
658
|
+
header.push(lease);
|
|
659
|
+
}
|
|
409
660
|
const wantText = !(args.includeText === false || args.includeText === "false");
|
|
410
661
|
const collectible = mine.filter((r) => r.state !== "running");
|
|
411
662
|
const blocks = [];
|
|
@@ -413,7 +664,9 @@ export function buildDispatchTools(deps) {
|
|
|
413
664
|
for (const r of collectible) {
|
|
414
665
|
const reply = await fetchReply(r.sessionID, directory);
|
|
415
666
|
const text = reply.text;
|
|
416
|
-
blocks.push(`--- ${r.agent} "${r.label}" (${r.sessionID}) ---\n${text.trim() ||
|
|
667
|
+
blocks.push(`--- ${r.agent} "${r.label}" (${r.sessionID}) ---\n${text.trim() ||
|
|
668
|
+
`(该子会话没有可读的助手回复${r.error ? `;宿主错误:${r.error}` : reply.error ? `;宿主错误:${reply.error}` : ""})` +
|
|
669
|
+
`——子会话不是文件,tm_read 读不到它:在宿主的会话面板里看这条子会话,或改用内置 task 工具重做这一步。`}`);
|
|
417
670
|
}
|
|
418
671
|
}
|
|
419
672
|
const body = [...header, ...(blocks.length ? ["", blocks.join("\n")] : [])].join("\n");
|
|
@@ -425,7 +678,7 @@ export function buildDispatchTools(deps) {
|
|
|
425
678
|
}
|
|
426
679
|
catch (err) {
|
|
427
680
|
const e = err;
|
|
428
|
-
return toToolResult(tmError(tool, "execute", `tm_join 失败:${
|
|
681
|
+
return toToolResult(tmError(tool, "execute", `tm_join 失败:${describeHostError(e?.message ?? err, 160)}`));
|
|
429
682
|
}
|
|
430
683
|
},
|
|
431
684
|
};
|
|
@@ -451,7 +704,9 @@ export function buildDispatchTools(deps) {
|
|
|
451
704
|
if (type === "session.error") {
|
|
452
705
|
rec.state = "error";
|
|
453
706
|
rec.finishedAt = now();
|
|
454
|
-
|
|
707
|
+
// describeHostError, NOT String(err): the host nests its errors and the
|
|
708
|
+
// bare stringify produced "[object Object]" for a real launch failure
|
|
709
|
+
rec.error = describeHostError(props?.error ?? "session.error", 200);
|
|
455
710
|
log({ step_id: "events", event: "error", child: sid, agent: rec.agent, reason: rec.error });
|
|
456
711
|
return;
|
|
457
712
|
}
|
|
@@ -467,7 +722,6 @@ export function buildDispatchTools(deps) {
|
|
|
467
722
|
}
|
|
468
723
|
};
|
|
469
724
|
return {
|
|
470
|
-
tm_dispatch: dispatch,
|
|
471
725
|
tm_join: join,
|
|
472
726
|
observeEvent,
|
|
473
727
|
children: () => [...children.values()],
|