@te-river/opencode-team-mode 1.6.0 → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -15
- package/README.zh-CN.md +67 -18
- package/dist/host/note.d.ts +7 -0
- package/dist/host/note.d.ts.map +1 -0
- package/dist/host/note.js +31 -0
- package/dist/host/note.js.map +1 -0
- package/dist/host/v1.d.ts +14 -0
- package/dist/host/v1.d.ts.map +1 -0
- package/dist/host/v1.js +340 -0
- package/dist/host/v1.js.map +1 -0
- package/dist/host/v2-browser-gate.d.ts +45 -0
- package/dist/host/v2-browser-gate.d.ts.map +1 -0
- package/dist/host/v2-browser-gate.js +319 -0
- package/dist/host/v2-browser-gate.js.map +1 -0
- package/dist/host/v2-capabilities.d.ts +78 -0
- package/dist/host/v2-capabilities.d.ts.map +1 -0
- package/dist/host/v2-capabilities.js +173 -0
- package/dist/host/v2-capabilities.js.map +1 -0
- package/dist/host/v2-client.d.ts +53 -0
- package/dist/host/v2-client.d.ts.map +1 -0
- package/dist/host/v2-client.js +193 -0
- package/dist/host/v2-client.js.map +1 -0
- package/dist/host/v2-events.d.ts +56 -0
- package/dist/host/v2-events.d.ts.map +1 -0
- package/dist/host/v2-events.js +158 -0
- package/dist/host/v2-events.js.map +1 -0
- package/dist/host/v2-guard.d.ts +101 -0
- package/dist/host/v2-guard.d.ts.map +1 -0
- package/dist/host/v2-guard.js +199 -0
- package/dist/host/v2-guard.js.map +1 -0
- package/dist/host/v2-offload.d.ts +148 -0
- package/dist/host/v2-offload.d.ts.map +1 -0
- package/dist/host/v2-offload.js +288 -0
- package/dist/host/v2-offload.js.map +1 -0
- package/dist/host/v2-permissions.d.ts +123 -0
- package/dist/host/v2-permissions.d.ts.map +1 -0
- package/dist/host/v2-permissions.js +190 -0
- package/dist/host/v2-permissions.js.map +1 -0
- package/dist/host/v2-probe.d.ts +85 -0
- package/dist/host/v2-probe.d.ts.map +1 -0
- package/dist/host/v2-probe.js +268 -0
- package/dist/host/v2-probe.js.map +1 -0
- package/dist/host/v2-scope.d.ts +51 -0
- package/dist/host/v2-scope.d.ts.map +1 -0
- package/dist/host/v2-scope.js +58 -0
- package/dist/host/v2-scope.js.map +1 -0
- package/dist/host/v2-session-client.d.ts +105 -0
- package/dist/host/v2-session-client.d.ts.map +1 -0
- package/dist/host/v2-session-client.js +390 -0
- package/dist/host/v2-session-client.js.map +1 -0
- package/dist/host/v2-session.d.ts +85 -0
- package/dist/host/v2-session.d.ts.map +1 -0
- package/dist/host/v2-session.js +171 -0
- package/dist/host/v2-session.js.map +1 -0
- package/dist/host/v2-subagent.d.ts +157 -0
- package/dist/host/v2-subagent.d.ts.map +1 -0
- package/dist/host/v2-subagent.js +286 -0
- package/dist/host/v2-subagent.js.map +1 -0
- package/dist/host/v2-tool.d.ts +67 -0
- package/dist/host/v2-tool.d.ts.map +1 -0
- package/dist/host/v2-tool.js +209 -0
- package/dist/host/v2-tool.js.map +1 -0
- package/dist/host/v2-transcript.d.ts +73 -0
- package/dist/host/v2-transcript.d.ts.map +1 -0
- package/dist/host/v2-transcript.js +151 -0
- package/dist/host/v2-transcript.js.map +1 -0
- package/dist/host/v2-types.d.ts +229 -0
- package/dist/host/v2-types.d.ts.map +1 -0
- package/dist/host/v2-types.js +17 -0
- package/dist/host/v2-types.js.map +1 -0
- package/dist/host/v2.d.ts +42 -0
- package/dist/host/v2.d.ts.map +1 -0
- package/dist/host/v2.js +853 -0
- package/dist/host/v2.js.map +1 -0
- package/dist/index.d.ts +23 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -366
- package/dist/index.js.map +1 -1
- package/dist/prompts/lead.d.ts +1 -1
- package/dist/prompts/lead.d.ts.map +1 -1
- package/dist/prompts/lead.js +456 -426
- package/dist/prompts/lead.js.map +1 -1
- package/dist/prompts/shared.d.ts +2 -2
- package/dist/prompts/shared.d.ts.map +1 -1
- package/dist/prompts/shared.js +38 -11
- package/dist/prompts/shared.js.map +1 -1
- package/dist/prompts/specialists.d.ts +2 -2
- package/dist/prompts/specialists.d.ts.map +1 -1
- package/dist/prompts/specialists.js +7 -6
- package/dist/prompts/specialists.js.map +1 -1
- package/dist/task-offload.d.ts +16 -0
- package/dist/task-offload.d.ts.map +1 -1
- package/dist/task-offload.js +51 -0
- package/dist/task-offload.js.map +1 -1
- package/dist/tm/browser.d.ts.map +1 -1
- package/dist/tm/browser.js +41 -10
- package/dist/tm/browser.js.map +1 -1
- package/dist/tm/dispatch.d.ts +86 -1
- package/dist/tm/dispatch.d.ts.map +1 -1
- package/dist/tm/dispatch.js +360 -39
- package/dist/tm/dispatch.js.map +1 -1
- package/dist/tm/dupe-guard.d.ts +16 -3
- package/dist/tm/dupe-guard.d.ts.map +1 -1
- package/dist/tm/dupe-guard.js +27 -7
- package/dist/tm/dupe-guard.js.map +1 -1
- package/dist/tm/index.d.ts +40 -1
- package/dist/tm/index.d.ts.map +1 -1
- package/dist/tm/index.js +40 -11
- package/dist/tm/index.js.map +1 -1
- package/dist/tm/ledger.d.ts +114 -0
- package/dist/tm/ledger.d.ts.map +1 -0
- package/dist/tm/ledger.js +308 -0
- package/dist/tm/ledger.js.map +1 -0
- package/dist/tm/perm-ask.d.ts +1 -3
- package/dist/tm/perm-ask.d.ts.map +1 -1
- package/dist/tm/perm-ask.js +10 -1
- package/dist/tm/perm-ask.js.map +1 -1
- package/dist/tm/preview.d.ts +55 -0
- package/dist/tm/preview.d.ts.map +1 -1
- package/dist/tm/preview.js +225 -0
- package/dist/tm/preview.js.map +1 -1
- package/dist/tm/search.d.ts.map +1 -1
- package/dist/tm/search.js +40 -10
- package/dist/tm/search.js.map +1 -1
- package/dist/tm/serp-loop.d.ts +45 -0
- package/dist/tm/serp-loop.d.ts.map +1 -0
- package/dist/tm/serp-loop.js +80 -0
- package/dist/tm/serp-loop.js.map +1 -0
- package/dist/tm/stats.d.ts +29 -0
- package/dist/tm/stats.d.ts.map +1 -1
- package/dist/tm/stats.js +278 -4
- package/dist/tm/stats.js.map +1 -1
- package/dist/tm/webfetch.d.ts +4 -0
- package/dist/tm/webfetch.d.ts.map +1 -1
- package/dist/tm/webfetch.js +75 -8
- package/dist/tm/webfetch.js.map +1 -1
- package/docs/installation-v2.md +445 -0
- package/docs/installation.md +12 -0
- package/docs/research/agent-data-exchange.md +117 -0
- package/docs/research/browser-pane.md +89 -0
- package/docs/research/host-http-api.md +132 -0
- package/docs/research/host-subagent-injection.md +187 -0
- package/docs/research/plugin-loader-contract.md +261 -0
- package/docs/research/v2-builtin-tools.md +447 -0
- package/docs/v2-port-notes.md +413 -0
- package/index.js +7 -0
- package/package.json +64 -61
- package/scripts/gen-v2-config.mjs +304 -0
- package/scripts/lib/config-surgery.cjs +239 -0
- package/scripts/lib/fake-ctx.mjs +169 -0
package/dist/tm/dispatch.js
CHANGED
|
@@ -33,9 +33,29 @@ import { tmError, toToolResult } from "./result.js";
|
|
|
33
33
|
import { unwrapClientResult } from "./client-unwrap.js";
|
|
34
34
|
import { detectContentType } from "./preview.js";
|
|
35
35
|
import { shorten } from "./config.js";
|
|
36
|
+
import { ledgerGoalLine, openItems } from "./ledger.js";
|
|
36
37
|
/** The five dispatchable specialists — "team" is deliberately NOT in the
|
|
37
38
|
* list: a lead spawning a lead is the nesting T3 closed. */
|
|
38
39
|
export const DISPATCH_TARGETS = ["architect", "implementer", "reviewer", "tester", "researcher"];
|
|
40
|
+
/** A child seen being dispatched by the host's own tool, turned into a registry row.
|
|
41
|
+
* Null when the ids are unusable: a child whose id equals the caller's would make
|
|
42
|
+
* tm_join wait on the session it is running in, and an anonymous child cannot be
|
|
43
|
+
* settled by an event we match on session id. */
|
|
44
|
+
export function hostChildRecord(child, now = () => Date.now()) {
|
|
45
|
+
const sessionID = String(child?.sessionID ?? "").trim();
|
|
46
|
+
const parentSessionID = String(child?.parentSessionID ?? "").trim();
|
|
47
|
+
if (!sessionID || !parentSessionID || sessionID === parentSessionID)
|
|
48
|
+
return null;
|
|
49
|
+
return {
|
|
50
|
+
sessionID,
|
|
51
|
+
agent: String(child.agent ?? "").trim().toLowerCase() || "subagent",
|
|
52
|
+
label: String(child.label ?? "").trim().slice(0, 40) || "宿主子代理",
|
|
53
|
+
parentSessionID,
|
|
54
|
+
startedAt: now(),
|
|
55
|
+
state: "running",
|
|
56
|
+
via: "host-injection",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
39
59
|
export function openHostTodos(todos) {
|
|
40
60
|
if (!Array.isArray(todos))
|
|
41
61
|
return [];
|
|
@@ -169,9 +189,17 @@ export function parseDispatchTitle(title, targets) {
|
|
|
169
189
|
* bare Part[] — the LAST assistant message is the answer, everything earlier
|
|
170
190
|
* is that agent's own working transcript.
|
|
171
191
|
*
|
|
192
|
+
* This function deliberately does NOT learn about the host's message shapes:
|
|
193
|
+
* `ctx.session.context` answers with `{id, role, metadata, content:[…]}` on
|
|
194
|
+
* 2.0.20 and with flat `{id, time, text, type}` on 2.0.18, and BOTH are
|
|
195
|
+
* normalised at that seam (`normaliseContextMessages`, src/host/v2-session-client.ts)
|
|
196
|
+
* before they reach here. Recognising `content` here as well would mean two
|
|
197
|
+
* places guess at a shape nobody measured, which is exactly how A1 shipped: the
|
|
198
|
+
* seam answered, the body was dropped, and the sentence blamed the host.
|
|
199
|
+
*
|
|
172
200
|
* `completedAt` is what lets a rebuilt registry tell "still running" from
|
|
173
201
|
* "finished while we were not listening": AssistantMessage.time.completed is
|
|
174
|
-
* only set once the message is done.
|
|
202
|
+
* only set once the message is done, and it is copied, never invented.
|
|
175
203
|
*/
|
|
176
204
|
export function lastAssistantMessage(messages) {
|
|
177
205
|
const list = Array.isArray(messages) ? messages : [];
|
|
@@ -259,7 +287,14 @@ export function describeHostError(err, max = 220) {
|
|
|
259
287
|
export function renderChildLine(c, elapsedMs) {
|
|
260
288
|
const secs = Math.round(elapsedMs / 1000);
|
|
261
289
|
const tag = c.state === "running" ? `运行中 ${secs}s` : c.state === "error" ? `失败:${shorten(c.error ?? "", 80)}` : `已完成 ${secs}s`;
|
|
262
|
-
|
|
290
|
+
// #33: a row the host reported IDLE for a stop attempt is not a delivery — the lead
|
|
291
|
+
// needs to see that the child was never running, not a bare 已完成.
|
|
292
|
+
const stop = c.state === "idle" && c.cancelNote ? ` ·${c.cancelNote}` : "";
|
|
293
|
+
return `${c.sessionID} · ${c.agent} · "${c.label}" · ${tag}${stop}${c.adopted ? " ·(本进程重启后由宿主会话树接管)" : ""}${c.via === "host-injection" && c.settleSource === "parent-idle"
|
|
294
|
+
? " ·(宿主子代理:随父会话空闲推定已结算,宿主没给过这个子会话的 idle 事件)"
|
|
295
|
+
: c.via === "host-injection"
|
|
296
|
+
? " ·(宿主 subagent 派发)"
|
|
297
|
+
: ""}`;
|
|
263
298
|
}
|
|
264
299
|
/** Verdicts tm_join can actually observe (issue #7's "leader keeps control"). */
|
|
265
300
|
export function summarizeStates(children) {
|
|
@@ -311,6 +346,61 @@ export function parseIdList(raw) {
|
|
|
311
346
|
}
|
|
312
347
|
return s.split(",").map((x) => x.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
313
348
|
}
|
|
349
|
+
/** Read the stop verdict out of the v2 adapter's `{data:{outcome,message}}`. Anything
|
|
350
|
+
* unrecognisable is `unknown`, never `stopped` — a shape we cannot read is not evidence
|
|
351
|
+
* a child died. */
|
|
352
|
+
export function cancelOutcomeOf(res) {
|
|
353
|
+
const un = unwrapClientResult(res);
|
|
354
|
+
if (!un.ok)
|
|
355
|
+
return { outcome: "threw", note: un.message };
|
|
356
|
+
const data = un.data;
|
|
357
|
+
const o = String(data?.outcome ?? "");
|
|
358
|
+
const known = ["stopped", "idle", "unknown", "no-seam", "threw"];
|
|
359
|
+
if (known.includes(o))
|
|
360
|
+
return { outcome: o, note: typeof data?.message === "string" ? data.message : undefined };
|
|
361
|
+
return { outcome: "unknown", note: o ? `宿主返回了没认出的结果(${shorten(o, 24)})` : "宿主没有返回 interrupted 字段" };
|
|
362
|
+
}
|
|
363
|
+
/** The line the lead reads, per outcome. Terse and falsifiable: each names what was
|
|
364
|
+
* OBSERVED, and the escape hatch for the cases where nothing was observed. */
|
|
365
|
+
export function cancelVerdictLine(outcome, note) {
|
|
366
|
+
const why = note ? `:${shorten(note, 80)}` : "";
|
|
367
|
+
switch (outcome) {
|
|
368
|
+
case "stopped":
|
|
369
|
+
return "已由宿主中断(interrupted=true)";
|
|
370
|
+
case "idle":
|
|
371
|
+
return "宿主回 idle no-op——它当时没有活动执行,所以我没有停掉任何东西";
|
|
372
|
+
case "unknown":
|
|
373
|
+
return `中断调用成功,但宿主没给出 interrupted 布尔${why}——是否真的停了,我不知道`;
|
|
374
|
+
case "no-seam":
|
|
375
|
+
return "这个宿主没给中断子会话的缝(session.interrupt / abort 都没有),未取消";
|
|
376
|
+
case "threw":
|
|
377
|
+
return `中断被宿主拒绝${why}`;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/** Short name for one outcome, used in the count line. */
|
|
381
|
+
export function cancelOutcomeLabel(o) {
|
|
382
|
+
return o === "stopped"
|
|
383
|
+
? "已由宿主中断"
|
|
384
|
+
: o === "idle"
|
|
385
|
+
? "空闲未中断"
|
|
386
|
+
: o === "unknown"
|
|
387
|
+
? "未确认"
|
|
388
|
+
: o === "no-seam"
|
|
389
|
+
? "无中断缝"
|
|
390
|
+
: "被宿主拒绝";
|
|
391
|
+
}
|
|
392
|
+
/** The summary counts, spelled the way the lead has to quote them. Fixed order so a
|
|
393
|
+
* rerun prints the same line, and an absent outcome prints nothing rather than "0". */
|
|
394
|
+
export function cancelOutcomeParts(tally) {
|
|
395
|
+
const order = ["stopped", "idle", "unknown", "threw", "no-seam"];
|
|
396
|
+
const out = [];
|
|
397
|
+
for (const o of order) {
|
|
398
|
+
const n = tally[o];
|
|
399
|
+
if (n)
|
|
400
|
+
out.push(`${n} ${cancelOutcomeLabel(o)}`);
|
|
401
|
+
}
|
|
402
|
+
return out;
|
|
403
|
+
}
|
|
314
404
|
export function buildDispatchTools(deps) {
|
|
315
405
|
const leadAgent = deps.leadAgent ?? "team";
|
|
316
406
|
const targets = deps.targets ?? DISPATCH_TARGETS;
|
|
@@ -366,14 +456,29 @@ export function buildDispatchTools(deps) {
|
|
|
366
456
|
}
|
|
367
457
|
function fetchReply(sid, directory) {
|
|
368
458
|
return (async () => {
|
|
369
|
-
if (typeof api?.messages !== "function")
|
|
370
|
-
return { text: "" };
|
|
459
|
+
if (typeof api?.messages !== "function") {
|
|
460
|
+
return { text: "", error: "这个宿主客户端没有给出读取子会话正文的缝(v2 上它是 ctx.session.context 的兼容层)" };
|
|
461
|
+
}
|
|
371
462
|
try {
|
|
372
|
-
const
|
|
373
|
-
|
|
463
|
+
const raw = await api.messages({ path: { id: sid }, ...(directory ? { query: { directory } } : {}) });
|
|
464
|
+
const un = unwrapClientResult(raw);
|
|
465
|
+
const got = un.ok ? lastAssistantMessage(un.data) : { text: "", error: un.message };
|
|
466
|
+
// The seam that ANSWERED, named by the seam itself. On v1 that is the host's
|
|
467
|
+
// `session.messages`; on v2 the same call is served by `ctx.session.context`, and
|
|
468
|
+
// printing "session.messages" there would credit an endpoint this host does not
|
|
469
|
+
// have — the class of claim this product exists to refuse.
|
|
470
|
+
const via = String(raw?.via ?? "session.messages");
|
|
471
|
+
// And the SHAPE the seam saw, reported by the seam (names and counts only). This is
|
|
472
|
+
// what makes a `child_body_missing` row falsifiable instead of a shrug: "answered,
|
|
473
|
+
// and the last item carried no text part" is a different fact from "no seam".
|
|
474
|
+
const reported = raw?.shape;
|
|
475
|
+
const shape = typeof reported === "string" && reported ? reported : undefined;
|
|
476
|
+
return got.text
|
|
477
|
+
? { ...got, via, ...(shape ? { shape } : {}) }
|
|
478
|
+
: { text: "", error: got.error, via, ...(shape ? { shape } : {}) };
|
|
374
479
|
}
|
|
375
|
-
catch {
|
|
376
|
-
return { text: "" };
|
|
480
|
+
catch (err) {
|
|
481
|
+
return { text: "", error: describeHostError(err?.message ?? err, 140) };
|
|
377
482
|
}
|
|
378
483
|
})();
|
|
379
484
|
}
|
|
@@ -392,17 +497,28 @@ export function buildDispatchTools(deps) {
|
|
|
392
497
|
* path that may take a host child, and the parentage check it must pass.
|
|
393
498
|
*/
|
|
394
499
|
async function adoptFromHost(parent, directory, only) {
|
|
395
|
-
|
|
396
|
-
|
|
500
|
+
// Three outcomes used to collapse into `0`: the host surface is absent (v2's
|
|
501
|
+
// client shim has no `session` domain), the call failed, and "we asked and
|
|
502
|
+
// there was nothing". The empty-round answer reads "宿主会话树里也没有可认领的
|
|
503
|
+
// 子会话", which is a claim about case three — asserted in cases one and two,
|
|
504
|
+
// where nobody looked. A lead that believes it will stop waiting for a report
|
|
505
|
+
// that exists, which is the silent loss this function exists to prevent.
|
|
506
|
+
// The reason names the MISSING SEAM, not a host generation: the same absence
|
|
507
|
+
// occurs on a v1-shaped client that lacks `session.messages`, and a sentence
|
|
508
|
+
// blaming "v2" there would be false in the opposite direction.
|
|
509
|
+
if (typeof api?.children !== "function")
|
|
510
|
+
return { adopted: 0, looked: false, why: "这个宿主客户端没有给出 session.children(可辨认的 session API 缺失),我没有看过会话树" };
|
|
511
|
+
if (!parent)
|
|
512
|
+
return { adopted: 0, looked: false, why: "调用方会话 id 缺失,无从查询子会话" };
|
|
397
513
|
let un;
|
|
398
514
|
try {
|
|
399
515
|
un = unwrapClientResult(await api.children({ path: { id: parent }, ...(directory ? { query: { directory } } : {}) }));
|
|
400
516
|
}
|
|
401
|
-
catch {
|
|
402
|
-
return 0;
|
|
517
|
+
catch (err) {
|
|
518
|
+
return { adopted: 0, looked: false, why: `查询会话树失败:${describeHostError(err)}` };
|
|
403
519
|
}
|
|
404
520
|
if (!un.ok || !Array.isArray(un.data))
|
|
405
|
-
return 0;
|
|
521
|
+
return { adopted: 0, looked: false, why: "宿主返回了无法解析的会话树结果" };
|
|
406
522
|
let adopted = 0;
|
|
407
523
|
for (const raw of un.data) {
|
|
408
524
|
const sid = String(raw?.id ?? "").trim();
|
|
@@ -429,7 +545,7 @@ export function buildDispatchTools(deps) {
|
|
|
429
545
|
adopted++;
|
|
430
546
|
log({ step_id: "join", event: "adopt", child: sid, agent: parsed.agent, label: rec.label });
|
|
431
547
|
}
|
|
432
|
-
return adopted;
|
|
548
|
+
return { adopted, looked: true, why: adopted ? "" : "会话树里没有被认领标题的子会话" };
|
|
433
549
|
}
|
|
434
550
|
/** Claim ONE explicitly named child of the calling session — the host's own
|
|
435
551
|
* `task` child included, which `adoptFromHost` must never pick up on its
|
|
@@ -482,11 +598,11 @@ export function buildDispatchTools(deps) {
|
|
|
482
598
|
}
|
|
483
599
|
}
|
|
484
600
|
const join = {
|
|
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.
|
|
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.
|
|
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.
|
|
601
|
+
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.
|
|
602
|
+
- 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.
|
|
603
|
+
- { 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 }).
|
|
604
|
+
- { 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.
|
|
605
|
+
- 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
606
|
- 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.`,
|
|
491
607
|
args: {
|
|
492
608
|
ids: { descriptor: "ids: string[] (optional — only these child sessions)" },
|
|
@@ -513,10 +629,19 @@ export function buildDispatchTools(deps) {
|
|
|
513
629
|
const idFilter = idList && idList.length ? new Set(idList) : null;
|
|
514
630
|
const owned = () => [...children.values()].filter((r) => (!parent || r.parentSessionID === parent) && (!idFilter || idFilter.has(r.sessionID)));
|
|
515
631
|
let mine = owned();
|
|
632
|
+
// Whether we got to LOOK at the host's tree, and what it said — the empty
|
|
633
|
+
// answer must not claim "there is nothing to adopt" on a host we cannot
|
|
634
|
+
// query, so the fact travels with the attempt rather than being inferred.
|
|
635
|
+
let adoption = {
|
|
636
|
+
adopted: 0,
|
|
637
|
+
looked: false,
|
|
638
|
+
why: "本进程已经认全了要收的子代理,没有去查会话树",
|
|
639
|
+
};
|
|
516
640
|
// This process has never seen some (or all) of what the lead is asking
|
|
517
641
|
// for — the host's session tree, not our memory, is the authority.
|
|
518
642
|
if (mine.length < (idFilter ? idFilter.size : 1)) {
|
|
519
|
-
|
|
643
|
+
adoption = await adoptFromHost(parent, directory, idFilter);
|
|
644
|
+
if (adoption.adopted)
|
|
520
645
|
mine = owned();
|
|
521
646
|
// Plan B: a NAMED id may be the host's own `task` child (no ·tm
|
|
522
647
|
// marker, so the tree walk above never claims it). Collecting it is
|
|
@@ -563,7 +688,28 @@ export function buildDispatchTools(deps) {
|
|
|
563
688
|
};
|
|
564
689
|
if (!mine.length) {
|
|
565
690
|
const lease = leaseLine();
|
|
566
|
-
|
|
691
|
+
// The parenthetical is now chosen by whether we actually looked, so a
|
|
692
|
+
// host we cannot query no longer reports as "confirmed: nothing there".
|
|
693
|
+
const saw = adoption.looked;
|
|
694
|
+
const inner = idFilter
|
|
695
|
+
? (() => {
|
|
696
|
+
// Naming the shapes the v2 session bridge tried is the whole point:
|
|
697
|
+
// without it, a claim that failed because `ctx.session.get` answered
|
|
698
|
+
// in a shape we do not know prints the same sentence as a tree we
|
|
699
|
+
// did query, and the lead goes back to waiting for a report that
|
|
700
|
+
// exists.
|
|
701
|
+
const sr = deps.sessionReaderReport?.();
|
|
702
|
+
const bridge = sr && sr.attempted > 0
|
|
703
|
+
? `;我试过的 ctx.session 形状:${(sr.failedShapes.length ? sr.failedShapes.join("→") : sr.usedShape ?? "?")}${sr.lastError ? `(宿主最后一次的错误:${shorten(sr.lastError, 90)})` : "(宿主没有报错,只是没给出 parentID)"}`
|
|
704
|
+
: "";
|
|
705
|
+
return saw
|
|
706
|
+
? `(ids 未匹配到本会话的子代理,宿主会话树里也没有可认领的子会话)${bridge}`
|
|
707
|
+
: `(ids 未匹配到;而且我没能查看宿主会话树:${adoption.why})${bridge}`;
|
|
708
|
+
})()
|
|
709
|
+
: saw
|
|
710
|
+
? ""
|
|
711
|
+
: `(注意:${adoption.why},所以"确实没有子代理"这个结论我给不出)`;
|
|
712
|
+
return toToolResult(`没有待收集的派发${inner}。` +
|
|
567
713
|
// The old line asserted the dispatch had failed. Measured live: it
|
|
568
714
|
// had not — a completed host `task` with a full reply was sitting in
|
|
569
715
|
// the transcript, and the honest reason is that a synchronous host
|
|
@@ -599,25 +745,62 @@ export function buildDispatchTools(deps) {
|
|
|
599
745
|
still_running: stillRunning.length,
|
|
600
746
|
repeat: repeatWait,
|
|
601
747
|
});
|
|
748
|
+
// #33 — the STOP path. Runs BEFORE the header is built, because the per-child
|
|
749
|
+
// verdict belongs in the child's own row, and the summary line below must be able
|
|
750
|
+
// to say which of the outcomes it is counting.
|
|
751
|
+
let cancelTally = null;
|
|
602
752
|
if (args.cancel === true || args.cancel === "true") {
|
|
753
|
+
cancelTally = {};
|
|
603
754
|
for (const r of stillRunning) {
|
|
755
|
+
let outcome;
|
|
756
|
+
let note;
|
|
604
757
|
if (typeof api?.abort === "function") {
|
|
758
|
+
// v1's path, byte-exact and FIRST: the personality is frozen and a shipped
|
|
759
|
+
// test pins "aborted on request".
|
|
605
760
|
const un = unwrapClientResult(await api.abort({ path: { id: r.sessionID } }));
|
|
761
|
+
outcome = un.ok ? "stopped" : "threw";
|
|
606
762
|
r.error = un.ok ? "aborted on request" : `abort failed: ${shorten(un.message ?? "", 60)}`;
|
|
763
|
+
r.state = "error";
|
|
764
|
+
}
|
|
765
|
+
else if (typeof api?.interrupt === "function") {
|
|
766
|
+
const got = cancelOutcomeOf(await api.interrupt({ path: { id: r.sessionID } }));
|
|
767
|
+
outcome = got.outcome;
|
|
768
|
+
note = got.note;
|
|
769
|
+
// `idle` is the host saying there was no active execution: the registry row
|
|
770
|
+
// was stale, not a failed stop. Anything else closes as undelivered.
|
|
771
|
+
r.state = outcome === "idle" ? "idle" : "error";
|
|
772
|
+
r.cancelNote = cancelVerdictLine(outcome, note);
|
|
773
|
+
if (r.state === "error")
|
|
774
|
+
r.error = r.cancelNote;
|
|
607
775
|
}
|
|
608
776
|
else {
|
|
777
|
+
outcome = "no-seam";
|
|
609
778
|
r.error = "宿主无 abort 接口,未取消";
|
|
779
|
+
r.state = "error";
|
|
610
780
|
}
|
|
611
|
-
r.state = "error";
|
|
612
781
|
r.finishedAt = now();
|
|
613
|
-
|
|
782
|
+
cancelTally[outcome] = (cancelTally[outcome] ?? 0) + 1;
|
|
783
|
+
log({ step_id: "join", event: "cancel", child: r.sessionID, agent: r.agent, outcome, ok: outcome === "stopped" });
|
|
614
784
|
}
|
|
785
|
+
log({
|
|
786
|
+
step_id: "join",
|
|
787
|
+
event: "cancel_summary",
|
|
788
|
+
count: stillRunning.length,
|
|
789
|
+
outcomes: cancelOutcomeParts(cancelTally).join(" / "),
|
|
790
|
+
});
|
|
615
791
|
}
|
|
616
792
|
const sums = summarizeStates(mine);
|
|
617
793
|
const header = [
|
|
618
794
|
`派发汇总:${sums.idle} 完成 / ${sums.running} 运行中 / ${sums.error} 失败${settled ? "(全部已结算)" : `(未等到全部结算 · 本次已等 ${Math.round((now() - waitStart) / 1000)}s)`}`,
|
|
619
795
|
...mine.map((r) => renderChildLine(r, (r.finishedAt ?? now()) - r.startedAt)),
|
|
620
796
|
];
|
|
797
|
+
if (cancelTally) {
|
|
798
|
+
const parts = cancelOutcomeParts(cancelTally);
|
|
799
|
+
header.push(`取消尝试:${parts.join(" · ") || "没有运行中的子代理可停"} —— ` +
|
|
800
|
+
`只有「已由宿主中断」是宿主确认过的停止;「空闲未中断」说明它当时没在活动执行里` +
|
|
801
|
+
`(登记行过时了,不是失败);「未确认」说明中断调用成功但宿主没回 interrupted 布尔。` +
|
|
802
|
+
`这三种结论不要混成一句"已取消"转述给用户。`);
|
|
803
|
+
}
|
|
621
804
|
if (stillRunning.length && !settled) {
|
|
622
805
|
header.push(`⏱ 还有 ${stillRunning.length} 个子代理在跑 —— 先告诉用户你在等谁、在等什么、这一轮不是交付,再决定是等还是去干活。` +
|
|
623
806
|
`你的工具调用在界面上只是一行打不开的卡片,你不说,用户没法把"回合结束"和"任务完成"分开。`);
|
|
@@ -633,20 +816,75 @@ export function buildDispatchTools(deps) {
|
|
|
633
816
|
// child settled says nothing about the USER'S goal, and the host's own
|
|
634
817
|
// todo list does. Read-only (GET /session/{id}/todo has no write
|
|
635
818
|
// body), so this can remind but never rewrite.
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
819
|
+
//
|
|
820
|
+
// The check has THREE outcomes and all three must be sayable. Silence when
|
|
821
|
+
// the seam is missing (v2's client shim has no `todo`) read as "the goal
|
|
822
|
+
// check ran and passed", which is the same overstatement this tool was
|
|
823
|
+
// already fixed for on the adoption path — a settled round is exactly when
|
|
824
|
+
// an unchecked box matters most.
|
|
825
|
+
if (settled) {
|
|
826
|
+
const canCheck = typeof api?.todo === "function" && Boolean(parent);
|
|
827
|
+
let checked = false;
|
|
828
|
+
let ledgerEmpty = false;
|
|
829
|
+
if (canCheck) {
|
|
830
|
+
try {
|
|
831
|
+
const un = unwrapClientResult(await api.todo({ path: { id: parent }, ...(directory ? { query: { directory } } : {}) }));
|
|
832
|
+
checked = un.ok;
|
|
833
|
+
const open = un.ok ? openHostTodos(un.data) : [];
|
|
834
|
+
if (open.length) {
|
|
835
|
+
header.push(`⚠ 目标未达成:宿主 todolist 还有 ${open.length} 项未完成 —— ${open.slice(0, 6).map((t) => `「${t.content}」(${t.status})`).join("、")}` +
|
|
836
|
+
(open.length > 6 ? ` …+${open.length - 6}` : "") +
|
|
837
|
+
`\n按目标指令:要么继续做掉,要么向用户写明哪一条被什么卡住;不要把这轮当成收尾。` +
|
|
838
|
+
// v1 has the built-in `todowrite`; a host without it must not be
|
|
839
|
+
// told to call a tool it cannot call (the tm_ptc_run / `task`
|
|
840
|
+
// lesson a third time). Without the write seam the only honest
|
|
841
|
+
// move is to say which items are done in the reply.
|
|
842
|
+
(typeof api?.todo === "function"
|
|
843
|
+
? `(若这些项其实已经做完——例如你刚把派发结果收齐——先用 todowrite 更新状态,再收尾。)`
|
|
844
|
+
: `(这个宿主没给写清单的入口:若这些项其实已完成,就在回复里逐条写明哪条做完了、依据是什么。)`));
|
|
845
|
+
log({ step_id: "join", event: "goal_open", count: open.length });
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
catch {
|
|
849
|
+
checked = false;
|
|
850
|
+
/* the todo endpoint is an extra, never a reason to fail a join */
|
|
646
851
|
}
|
|
647
852
|
}
|
|
648
|
-
|
|
649
|
-
|
|
853
|
+
if (!checked && deps.ledgerStore?.available?.() && parent) {
|
|
854
|
+
// v2 has no `session.todo` to read, so the list that CAN be checked is
|
|
855
|
+
// the one this plugin keeps (`tm_ledger`, in ctx.storage). A non-empty
|
|
856
|
+
// open set is a fact worth refusing a wrap-up over; an EMPTY one is not
|
|
857
|
+
// evidence the goal was met — it usually means nothing was ever
|
|
858
|
+
// recorded, which is its own honest "cannot check".
|
|
859
|
+
try {
|
|
860
|
+
const led = await deps.ledgerStore.load(parent);
|
|
861
|
+
const rest = led ? openItems(led) : [];
|
|
862
|
+
if (rest.length) {
|
|
863
|
+
const line = ledgerGoalLine(led);
|
|
864
|
+
if (line)
|
|
865
|
+
header.push(line);
|
|
866
|
+
log({ step_id: "join", event: "goal_open", count: rest.length, source: "ledger" });
|
|
867
|
+
checked = true;
|
|
868
|
+
}
|
|
869
|
+
else if (led) {
|
|
870
|
+
ledgerEmpty = true;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
catch {
|
|
874
|
+
/* the ledger is an extra too — a store that will not read never fails a join */
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
if (!checked) {
|
|
878
|
+
header.push(canCheck
|
|
879
|
+
? "(目标核对没做成:宿主 todo 端点返回异常,这一轮我没有看到你的清单状态。)"
|
|
880
|
+
: ledgerEmpty
|
|
881
|
+
? "(目标核对没做成:这个宿主没给 session.todo 端点,而 tm_ledger 里一条都没有——没有可核对的目标状态。`所有子代理已结算` 不等于 `目标已达成`:要么先 tm_ledger { action:\"add\" } 把要求记下来,要么在回复里自己逐条核对再收尾。)"
|
|
882
|
+
: "(目标核对没做成:这个宿主没给 session.todo 端点,我也读不到本插件的 tm_ledger——无法读取清单状态。`所有子代理已结算` 不等于 `目标已达成`,这一轮请自己核对再收尾。)");
|
|
883
|
+
log({
|
|
884
|
+
step_id: "join",
|
|
885
|
+
event: "goal_unchecked",
|
|
886
|
+
reason: canCheck ? "endpoint_failed" : ledgerEmpty ? "ledger_empty" : "no_seam",
|
|
887
|
+
});
|
|
650
888
|
}
|
|
651
889
|
}
|
|
652
890
|
// #80/#86/#87: THE LEASE TRIPWIRE — one computation (leaseLine above),
|
|
@@ -664,9 +902,28 @@ export function buildDispatchTools(deps) {
|
|
|
664
902
|
for (const r of collectible) {
|
|
665
903
|
const reply = await fetchReply(r.sessionID, directory);
|
|
666
904
|
const text = reply.text;
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
905
|
+
const src = text && reply.via ? ` ·正文来源=${reply.via}` : "";
|
|
906
|
+
// Which seam answered, per child — an id, a seam name and the SHAPE it saw, never
|
|
907
|
+
// a body (R6 binds a diagnostic too). This is the line that makes "正文来源=…"
|
|
908
|
+
// checkable later, and the only place the three outcomes stay distinguishable:
|
|
909
|
+
// a real body, a seam that answered with nothing readable (`shape=items=2 …
|
|
910
|
+
// content=1 kinds=[tool] text=0`), and a seam that never answered (`no-context-method`).
|
|
911
|
+
log({
|
|
912
|
+
step_id: "join",
|
|
913
|
+
event: text ? "child_body" : "child_body_missing",
|
|
914
|
+
child: r.sessionID,
|
|
915
|
+
via: reply.via ?? "none",
|
|
916
|
+
// Only when the SEAM reported one: v1's SDK client gives no shape, and the v1
|
|
917
|
+
// personality is frozen — its rows stay byte-exact rather than gaining a field
|
|
918
|
+
// nobody asked for. The v2 bridge reports a shape on every path.
|
|
919
|
+
...(text || !reply.shape ? {} : { shape: reply.shape }),
|
|
920
|
+
});
|
|
921
|
+
blocks.push(`--- ${r.agent} "${r.label}" (${r.sessionID})${src} ---\n${text.trim() ||
|
|
922
|
+
(r.via === "host-injection"
|
|
923
|
+
? `(这个子会话由宿主的 subagent 工具派发,而这一次我没有读到它的正文${reply.error ? `:${reply.error}` : ":这个宿主没给可读正文的缝"}。完成时宿主仍会把 \`<subagent sessionID=\"${r.sessionID}\" …>\` 注入本会话,我已经把它登记在案、状态如上。` +
|
|
924
|
+
`要全文就在那条注入到达后读它,或让子代理把交付写进黑板;不要为了拿正文反复 join。)`
|
|
925
|
+
: `(该子会话没有可读的助手回复${r.error ? `;宿主错误:${r.error}` : reply.error ? `;宿主错误:${reply.error}` : ""})` +
|
|
926
|
+
`——子会话不是文件,tm_read 读不到它:在宿主的会话面板里看这条子会话,或改用内置 task 工具重做这一步。`)}`);
|
|
670
927
|
}
|
|
671
928
|
}
|
|
672
929
|
const body = [...header, ...(blocks.length ? ["", blocks.join("\n")] : [])].join("\n");
|
|
@@ -691,12 +948,31 @@ export function buildDispatchTools(deps) {
|
|
|
691
948
|
if (!sid)
|
|
692
949
|
return;
|
|
693
950
|
const rec = children.get(sid);
|
|
694
|
-
if (!rec)
|
|
951
|
+
if (!rec) {
|
|
952
|
+
// The parent's own idle is the only completion signal v2 gives us for a host
|
|
953
|
+
// child: the background reply is injected into the parent before the parent's turn
|
|
954
|
+
// ends (measured: child injected …824730, parent idle …829038), and the plugin has
|
|
955
|
+
// no endpoint to read a child session's state. So settle the still-running host
|
|
956
|
+
// children of THIS session — and say plainly that it is a presumption.
|
|
957
|
+
if (type === "session.idle") {
|
|
958
|
+
for (const child of children.values()) {
|
|
959
|
+
if (child.via !== "host-injection" || child.state !== "running")
|
|
960
|
+
continue;
|
|
961
|
+
if (child.parentSessionID !== sid)
|
|
962
|
+
continue;
|
|
963
|
+
child.state = "idle";
|
|
964
|
+
child.finishedAt = now();
|
|
965
|
+
child.settleSource = "parent-idle";
|
|
966
|
+
log({ step_id: "events", event: "idle_presumed", child: child.sessionID, agent: child.agent, ms: child.finishedAt - child.startedAt });
|
|
967
|
+
}
|
|
968
|
+
}
|
|
695
969
|
return;
|
|
970
|
+
}
|
|
696
971
|
if (type === "session.idle") {
|
|
697
972
|
if (rec.state === "running") {
|
|
698
973
|
rec.state = "idle";
|
|
699
974
|
rec.finishedAt = now();
|
|
975
|
+
rec.settleSource = "event";
|
|
700
976
|
log({ step_id: "events", event: "idle", child: sid, agent: rec.agent, ms: (rec.finishedAt ?? 0) - rec.startedAt });
|
|
701
977
|
}
|
|
702
978
|
return;
|
|
@@ -721,9 +997,54 @@ export function buildDispatchTools(deps) {
|
|
|
721
997
|
}
|
|
722
998
|
}
|
|
723
999
|
};
|
|
1000
|
+
/** Open a row for a child the HOST dispatched (v2's `subagent` tool) that this
|
|
1001
|
+
* registry never created. Returns false when the ids are unusable or the child is
|
|
1002
|
+
* already known — a plugin reload replays hooks, and a second row for one session
|
|
1003
|
+
* would make tm_join count the same work twice. */
|
|
1004
|
+
function register(child) {
|
|
1005
|
+
const rec = hostChildRecord(child, now);
|
|
1006
|
+
if (!rec || children.has(rec.sessionID))
|
|
1007
|
+
return false;
|
|
1008
|
+
children.set(rec.sessionID, rec);
|
|
1009
|
+
deps.onChildSession?.(rec.sessionID, rec.agent);
|
|
1010
|
+
// The label is model-authored free text: it belongs in the lead's table, never in
|
|
1011
|
+
// the audit trail (R6 binds a diagnostic as much as a guard).
|
|
1012
|
+
log({ step_id: "join", event: "host_subagent", child: rec.sessionID, agent: rec.agent, parent: rec.parentSessionID });
|
|
1013
|
+
return true;
|
|
1014
|
+
}
|
|
1015
|
+
/** Settle a child from the host's OWN completion envelope (see
|
|
1016
|
+
* `applyV2CompletionWatch`). Returns false when this process does not know the id, so
|
|
1017
|
+
* an unrelated session's injection never touches our registry. This is the third
|
|
1018
|
+
* settle source and the only one that works mid-turn on v2: the child's own
|
|
1019
|
+
* `session.idle` is not forwarded to a plugin subscriber (measured 2026-09-26), and a
|
|
1020
|
+
* parent-idle presumption cannot fire while the parent is still working — which left a
|
|
1021
|
+
* child whose report had already been injected showing 运行中. */
|
|
1022
|
+
function settle(sessionID, state) {
|
|
1023
|
+
const rec = children.get(String(sessionID ?? "").trim());
|
|
1024
|
+
if (!rec || rec.state !== "running")
|
|
1025
|
+
return false;
|
|
1026
|
+
rec.finishedAt = now();
|
|
1027
|
+
if (state === "error" || state === "failed" || state === "aborted") {
|
|
1028
|
+
rec.state = "error";
|
|
1029
|
+
rec.error = `宿主报告子会话 ${state}(其正文随注入消息送达)`;
|
|
1030
|
+
}
|
|
1031
|
+
else {
|
|
1032
|
+
rec.state = "idle";
|
|
1033
|
+
}
|
|
1034
|
+
rec.settleSource = "injection";
|
|
1035
|
+
log({ step_id: "events", event: "idle_injection", child: rec.sessionID, agent: rec.agent, ms: rec.finishedAt - rec.startedAt });
|
|
1036
|
+
return true;
|
|
1037
|
+
}
|
|
724
1038
|
return {
|
|
725
1039
|
tm_join: join,
|
|
726
1040
|
observeEvent,
|
|
1041
|
+
register,
|
|
1042
|
+
settle,
|
|
1043
|
+
/** Anything still running? The completion scan is O(messages) per request, so it
|
|
1044
|
+
* asks this first and skips the read when the answer is no. */
|
|
1045
|
+
hasOpen: () => { for (const c of children.values())
|
|
1046
|
+
if (c.state === "running")
|
|
1047
|
+
return true; return false; },
|
|
727
1048
|
children: () => [...children.values()],
|
|
728
1049
|
};
|
|
729
1050
|
}
|