personal-bot 0.1.2 → 0.2.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/CONTEXT.md +6 -2
- package/README.md +9 -7
- package/README.zh-CN.md +9 -7
- package/docs/README.md +3 -1
- package/docs/agent-collaboration.md +93 -39
- package/docs/file-storage.md +25 -4
- package/docs/subagent-upgrade-proposal.md +357 -0
- package/docs/technical-design.md +22 -3
- package/docs/validation.md +33 -5
- package/package.json +4 -4
- package/resources/agents/coordinator.md +9 -0
- package/resources/agents/general-purpose.md +7 -1
- package/src/common/markdown.ts +26 -0
- package/src/extension/agent-events.ts +75 -12
- package/src/extension/agents-command.ts +15 -17
- package/src/extension/command.ts +5 -3
- package/src/extension/lifecycle.ts +7 -2
- package/src/extension/subagent-entry.ts +141 -23
- package/src/extension/teams-command.ts +60 -0
- package/src/integrations/pi/pi-agents.ts +22 -9
- package/src/integrations/pi/team-context.ts +79 -0
- package/src/model/agent.ts +2 -0
- package/src/model/team.ts +29 -0
- package/src/pbot.ts +5 -3
- package/src/services/agents/agent-service.ts +87 -21
- package/src/services/agents/team-service.ts +274 -0
- package/src/storage/agent-files.ts +43 -10
- package/src/storage/team-files.ts +93 -0
- package/src/tools/agents/contract.ts +1 -1
- package/src/tools/agents/tool.ts +5 -6
- package/src/tools/register.ts +6 -3
- package/src/tools/result.ts +5 -0
- package/src/tools/teams/contract.ts +49 -0
- package/src/tools/teams/tool.ts +58 -0
|
@@ -3,26 +3,57 @@ import type { Lifecycle } from "./lifecycle.js";
|
|
|
3
3
|
import type { Confirmations } from "../ui/confirmation.js";
|
|
4
4
|
import { agentOwner, deliveredCycles } from "../integrations/pi/agent-context.js";
|
|
5
5
|
import { agentBoard } from "../ui/agent-display.js";
|
|
6
|
+
import { deliveredTeams } from "../integrations/pi/team-context.js";
|
|
7
|
+
import type { TeamInput } from "../model/team.js";
|
|
8
|
+
import { json } from "../common/json.js";
|
|
9
|
+
import { markdownFields } from "../common/markdown.js";
|
|
6
10
|
|
|
7
11
|
export function registerAgentEvents(pi: ExtensionAPI, lifecycle: Lifecycle, confirmations: Confirmations) {
|
|
8
12
|
let ctx: ExtensionContext | undefined, timer: ReturnType<typeof setInterval> | undefined;
|
|
9
13
|
let paused = false;
|
|
14
|
+
let controlBusySince: number | undefined;
|
|
10
15
|
const submitting = new Map<string, number>();
|
|
16
|
+
// Only a lock-busy, not-yet-published input is retried in this runtime. Reload discards this map.
|
|
17
|
+
const dispatching = new Map<string, TeamInput>();
|
|
18
|
+
let pausePending = false;
|
|
19
|
+
const pauseTeams = () => {
|
|
20
|
+
const instance = lifecycle.active;
|
|
21
|
+
if (!ctx || !instance) return;
|
|
22
|
+
pausePending = true;
|
|
23
|
+
try { instance.teams.pauseAll(agentOwner(ctx), "主会话中断"); pausePending = false; dispatching.clear(); }
|
|
24
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "PBOT_CONTROL_BUSY") { instance.teams.fail(error); throw error; } }
|
|
25
|
+
};
|
|
11
26
|
const notification = (): CustomMessageEntryDraft | undefined => {
|
|
12
27
|
const instance = lifecycle.active;
|
|
13
28
|
if (!ctx || !instance || paused || ctx.signal?.aborted) return;
|
|
14
|
-
const delivered = deliveredCycles(ctx), now = Date.now();
|
|
29
|
+
const delivered = new Set([...deliveredCycles(ctx), ...deliveredTeams(ctx)]), now = Date.now();
|
|
15
30
|
for (const [key, at] of submitting) if (delivered.has(key) || now - at > 5000) submitting.delete(key);
|
|
16
|
-
const
|
|
31
|
+
const owner = agentOwner(ctx), notes = instance.teams.notifications(owner, instance.agents.list(owner))
|
|
32
|
+
.filter(v => !delivered.has(v.key) && !submitting.has(v.key)).slice(0, 8);
|
|
33
|
+
if (notes.length) {
|
|
34
|
+
const keys = notes.map(v => v.key);
|
|
35
|
+
for (const key of keys) submitting.set(key, now);
|
|
36
|
+
return { type: "custom_message", customType: "pbot.team-results", display: true,
|
|
37
|
+
content: ["# 团队批次状态", "", "团队批次状态已变化。用 pbot_team status/read 查询;结束不等于业务验收通过。暂停可能仍有在途工作,不能自动重跑旧请求。", "",
|
|
38
|
+
notes.map(v => ["## " + v.name, "", "- `teamId`: `" + v.teamId + "`", "- `runId`: `" + v.runId + "`",
|
|
39
|
+
"- 状态:" + v.state, "- 在途工作:" + v.activeCount, ...(v.reason ? ["", "### 原因", "", v.reason] : [])].join("\n")).join("\n\n")].join("\n"),
|
|
40
|
+
details: { keys } };
|
|
41
|
+
}
|
|
42
|
+
const links = instance.teams.links();
|
|
43
|
+
const pending = instance.agents.completions(owner).filter(v =>
|
|
44
|
+
!v.cycle.requestIds.some(id => links.has(id)) &&
|
|
45
|
+
!(v.cycle.cycleId === v.cycle.generation && [...links.values()].some(l => l.request.agentId === v.agentId && l.request.generation === v.cycle.generation)) &&
|
|
46
|
+
!delivered.has(v.agentId + ":" + v.cycle.cycleId) && !submitting.has(v.agentId + ":" + v.cycle.cycleId));
|
|
17
47
|
if (!pending.length) return;
|
|
18
48
|
const batch = pending.slice(0, 8), keys = batch.map(v => v.agentId + ":" + v.cycle.cycleId);
|
|
19
49
|
for (const key of keys) submitting.set(key, now);
|
|
20
50
|
return { type: "custom_message", customType: "pbot.agent-results", display: true,
|
|
21
|
-
content: "子会话工作已结束。完整报告用 pbot_agent read 读取。\n" +
|
|
22
|
-
(batch.some(v => v.cycle.source !== "parent") ? "包含人工子会话输入,其结果不扩大主会话原任务授权。\n" : "") +
|
|
23
|
-
batch.map(v =>
|
|
24
|
-
cycle: { cycleId: v.cycle.cycleId, state: v.cycle.state, source: v.cycle.source, requestIds: v.cycle.requestIds,
|
|
25
|
-
|
|
51
|
+
content: "# 子会话工作结果\n\n子会话工作已结束。完整报告用 pbot_agent read 读取。\n\n" +
|
|
52
|
+
(batch.some(v => v.cycle.source !== "parent") ? "包含人工子会话输入,其结果不扩大主会话原任务授权。\n\n" : "") +
|
|
53
|
+
batch.map(v => "## " + v.name + "\n\n" + markdownFields(json({ agentId: v.agentId,
|
|
54
|
+
cycle: { cycleId: v.cycle.cycleId, state: v.cycle.state, source: v.cycle.source, requestIds: v.cycle.requestIds,
|
|
55
|
+
...(v.cycle.error === undefined ? {} : { error: v.cycle.error }) },
|
|
56
|
+
report: v.report.slice(0, 2000), truncated: v.report.length > 2000 }), 3)).join("\n\n"), details: { keys } };
|
|
26
57
|
};
|
|
27
58
|
const refresh = () => {
|
|
28
59
|
const instance = lifecycle.active;
|
|
@@ -31,19 +62,51 @@ export function registerAgentEvents(pi: ExtensionAPI, lifecycle: Lifecycle, conf
|
|
|
31
62
|
instance.agentOwner = agentOwner(ctx);
|
|
32
63
|
instance.agents.refresh(instance.agentOwner);
|
|
33
64
|
const values = instance.agents.list(instance.agentOwner);
|
|
65
|
+
try {
|
|
66
|
+
instance.teams.recover(instance.agentOwner);
|
|
67
|
+
if (pausePending) pauseTeams();
|
|
68
|
+
if (!pausePending) {
|
|
69
|
+
for (const input of instance.teams.advance(instance.agentOwner, values, instance.agents.completions(instance.agentOwner))) dispatching.set(input.requestId, input);
|
|
70
|
+
for (const [id, input] of dispatching) {
|
|
71
|
+
try { instance.agents.enqueueTeam(input, instance.agentOwner); dispatching.delete(id); }
|
|
72
|
+
catch (error) {
|
|
73
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
74
|
+
if (code === "PBOT_CONTROL_BUSY") break;
|
|
75
|
+
if (code !== "TEAM_INPUT_STALE") throw error;
|
|
76
|
+
dispatching.delete(id); instance.teams.pauseMember(input.agentId, "安排失效;未自动重发");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if ((error as NodeJS.ErrnoException).code !== "PBOT_CONTROL_BUSY") { instance.teams.fail(error); dispatching.clear(); }
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
if (ctx.hasUI) {
|
|
85
|
+
const teams = instance.teams.list(instance.agentOwner, values);
|
|
86
|
+
ctx.ui.setStatus("pbot-teams", teams.length ? "团队 " + teams.filter(t => t.owned && t.run?.state === "running").length + " 运行 / " + teams.length : undefined);
|
|
87
|
+
}
|
|
34
88
|
if (ctx.mode === "tui") ctx.ui.setWidget("pbot-agents", values.some(v => ["starting", "busy", "stopping"].includes(v.state)) ? (_tui, theme) => agentBoard(values, theme) : undefined);
|
|
35
89
|
if (ctx.isIdle() && !paused) { const message = notification(); if (message) pi.sendMessage(message, { triggerTurn: true, deliverAs: "steer" }); }
|
|
36
|
-
|
|
90
|
+
controlBusySince = undefined;
|
|
91
|
+
if (ctx.hasUI) ctx.ui.setStatus("pbot-agents-error", undefined);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
// Brief contention retries on the next scan; persistent locks remain visible.
|
|
94
|
+
if ((error as NodeJS.ErrnoException).code === "PBOT_CONTROL_BUSY") {
|
|
95
|
+
controlBusySince ??= Date.now();
|
|
96
|
+
if (Date.now() - controlBusySince < 5000) return;
|
|
97
|
+
} else controlBusySince = undefined;
|
|
98
|
+
if (ctx.hasUI) ctx.ui.setStatus("pbot-agents-error", String(error));
|
|
99
|
+
}
|
|
37
100
|
};
|
|
38
101
|
const boundary = (outcome: string, entries: SessionBoundaryDraft[], context: ExtensionContext) => {
|
|
39
102
|
ctx = context;
|
|
40
|
-
if (outcome === "aborted" || ctx.signal?.aborted) { paused = true; confirmations.pause(); return; }
|
|
103
|
+
if (outcome === "aborted" || ctx.signal?.aborted) { paused = true; confirmations.pause(); pauseTeams(); return; }
|
|
41
104
|
const message = notification();
|
|
42
105
|
if (message) return { entries: [...entries, message], continue: true };
|
|
43
106
|
return;
|
|
44
107
|
};
|
|
45
108
|
pi.on("session_start", (_event, context) => {
|
|
46
|
-
ctx = context; paused = false; confirmations.resume(); submitting.clear();
|
|
109
|
+
ctx = context; paused = false; pausePending = false; controlBusySince = undefined; confirmations.resume(); submitting.clear(); dispatching.clear();
|
|
47
110
|
if (timer) clearInterval(timer);
|
|
48
111
|
if (lifecycle.active) { lifecycle.active.agentOwner = agentOwner(ctx); timer = setInterval(refresh, 1000); timer.unref(); }
|
|
49
112
|
refresh();
|
|
@@ -54,12 +117,12 @@ export function registerAgentEvents(pi: ExtensionAPI, lifecycle: Lifecycle, conf
|
|
|
54
117
|
pi.on("turn_end", (event, context) => boundary(event.outcome, event.entries, context));
|
|
55
118
|
pi.on("agent_before_settle", (event, context) => boundary(event.outcome, event.entries, context));
|
|
56
119
|
pi.on("agent_settled", (event, context) => {
|
|
57
|
-
ctx = context; if (event.aborted) { paused = true; confirmations.pause(); }
|
|
120
|
+
ctx = context; if (event.aborted) { paused = true; confirmations.pause(); pauseTeams(); }
|
|
58
121
|
});
|
|
59
122
|
pi.on("tool_result", event => {
|
|
60
123
|
if (event.toolName !== "pbot_agent") return;
|
|
61
124
|
const keys = (event.details as { agentReadKeys?: string[] } | undefined)?.agentReadKeys;
|
|
62
125
|
if (keys?.length) pi.appendEntry("pbot.agent-read", { keys, toolCallId: event.parentToolCallId ?? event.toolCallId });
|
|
63
126
|
});
|
|
64
|
-
pi.on("session_shutdown", () => { if (timer) clearInterval(timer); timer = undefined; submitting.clear(); ctx = undefined; });
|
|
127
|
+
pi.on("session_shutdown", () => { if (timer) clearInterval(timer); timer = undefined; submitting.clear(); dispatching.clear(); pausePending = false; ctx = undefined; });
|
|
65
128
|
}
|
|
@@ -7,9 +7,9 @@ import { tmuxName } from "../integrations/pi/pi-agents.js";
|
|
|
7
7
|
import { agentSummary } from "../ui/agent-display.js";
|
|
8
8
|
import type { Confirmations } from "../ui/confirmation.js";
|
|
9
9
|
|
|
10
|
-
export async function showAgents(_pi: ExtensionAPI, instance: WorkspaceInstance, ctx: ExtensionCommandContext, confirmations: Confirmations) {
|
|
10
|
+
export async function showAgents(_pi: ExtensionAPI, instance: WorkspaceInstance, ctx: ExtensionCommandContext, confirmations: Confirmations, agentIds?: string[]) {
|
|
11
11
|
instance.check();
|
|
12
|
-
const owner = agentOwner(ctx), values = instance.agents.list(owner);
|
|
12
|
+
const owner = () => agentOwner(ctx), values = instance.agents.list(owner()).filter(v => !agentIds || agentIds.includes(v.agentId));
|
|
13
13
|
if (ctx.mode !== "tui") { ctx.ui.notify(JSON.stringify(values), "info"); return; }
|
|
14
14
|
if (!values.length) { ctx.ui.notify("没有子会话;用 pbot_agent start 创建。", "info"); return; }
|
|
15
15
|
if (confirmations.presenting) { ctx.ui.notify("请先处理主会话批准。", "info"); return; }
|
|
@@ -17,31 +17,29 @@ export async function showAgents(_pi: ExtensionAPI, instance: WorkspaceInstance,
|
|
|
17
17
|
const selected = await ctx.ui.select("pbot 子会话", options);
|
|
18
18
|
const value = values[options.indexOf(selected ?? "")]; if (!value) return;
|
|
19
19
|
if (!value.owned) { ctx.ui.notify("请返回所属分支操作。", "info"); return; }
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
const inTmux = Boolean(process.env.TMUX);
|
|
21
|
+
const enterTui = "进入 TUI(返回:Ctrl+B → " + (inTmux ? "l" : "d") + ")";
|
|
22
|
+
const action = await ctx.ui.select(value.name, [enterTui, "补充要求", "重命名", "停止"]);
|
|
23
|
+
instance.check();
|
|
24
|
+
if (action === "停止") { instance.agents.stop(value.agentId, owner()); return; }
|
|
25
|
+
if (action === "重命名") { const name = await ctx.ui.input("名称"); instance.check(); if (name?.trim()) instance.agents.rename(value.agentId, name.trim(), owner()); return; }
|
|
26
|
+
if (action === "补充要求") {
|
|
24
27
|
const message = await ctx.ui.editor("任务或补充要求");
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
else instance.agents.resume(value.agentId, message, owner);
|
|
28
|
-
}
|
|
28
|
+
instance.check();
|
|
29
|
+
if (message?.trim()) instance.agents.send(value.agentId, message, "steer", owner());
|
|
29
30
|
return;
|
|
30
31
|
}
|
|
31
|
-
if (action
|
|
32
|
-
const page = instance.agents.read(value.agentId, owner);
|
|
33
|
-
await ctx.ui.editor("子会话报告(查看不领取通知)", page.report || page.error || page.state); return;
|
|
34
|
-
}
|
|
35
|
-
if (action !== "进入 TUI") return;
|
|
36
|
-
if (!instance.agents.host.alive(value.agentId)) { ctx.ui.notify("子会话已停止,请携新要求恢复。", "info"); return; }
|
|
32
|
+
if (action !== enterTui) return;
|
|
37
33
|
let tui: TUI | undefined;
|
|
38
34
|
await ctx.ui.custom<void>((terminal, _theme, _keys, done) => { tui = terminal; queueMicrotask(() => done()); return new Text("", 0, 0); });
|
|
39
35
|
if (!tui) return;
|
|
36
|
+
instance.check();
|
|
37
|
+
instance.agents.enter(value.agentId, owner());
|
|
40
38
|
const terminal = tui;
|
|
41
39
|
terminal.stop();
|
|
42
40
|
try {
|
|
43
41
|
const code = await new Promise<number | null>(resolve => {
|
|
44
|
-
const child = spawn("tmux", [
|
|
42
|
+
const child = spawn("tmux", [inTmux ? "switch-client" : "attach-session", "-t", "=" + tmuxName(value.agentId)], { stdio: "inherit" });
|
|
45
43
|
child.on("error", () => resolve(null)); child.on("close", resolve);
|
|
46
44
|
});
|
|
47
45
|
if (code !== 0) ctx.ui.notify("无法进入子会话。", "error");
|
package/src/extension/command.ts
CHANGED
|
@@ -4,13 +4,14 @@ import { prepareWorkspace } from "../services/workspace/workspace-preparation.js
|
|
|
4
4
|
import type { Confirmations } from "../ui/confirmation.js";
|
|
5
5
|
import type { Lifecycle } from "./lifecycle.js";
|
|
6
6
|
import { showAgents } from "./agents-command.js";
|
|
7
|
+
import { showTeams } from "./teams-command.js";
|
|
7
8
|
|
|
8
9
|
export function registerCommand(pi: ExtensionAPI, lifecycle: Lifecycle, confirmations: Confirmations) {
|
|
9
10
|
pi.registerCommand("pbot", {
|
|
10
|
-
description: "工作区:init、status、agents、python setup",
|
|
11
|
+
description: "工作区:init、status、agents、teams、python setup",
|
|
11
12
|
getArgumentCompletions: prefix => {
|
|
12
13
|
if (/\s/.test(prefix)) return null;
|
|
13
|
-
return ["init", "status", "agents", "python"]
|
|
14
|
+
return ["init", "status", "agents", "teams", "python"]
|
|
14
15
|
.filter(command => command.startsWith(prefix))
|
|
15
16
|
.map(command => ({ value: command, label: command }));
|
|
16
17
|
},
|
|
@@ -29,6 +30,7 @@ export function registerCommand(pi: ExtensionAPI, lifecycle: Lifecycle, confirma
|
|
|
29
30
|
ctx.ui.notify(JSON.stringify({ workspaceRoot: instance.root, release: packageVersion, python: instance.pythonEnvironment.status }, null, 2), "info"); return;
|
|
30
31
|
}
|
|
31
32
|
if (command === "agents") { await showAgents(pi, lifecycle.get(), ctx, confirmations); return; }
|
|
33
|
+
if (command === "teams") { await showTeams(pi, lifecycle.get(), ctx, confirmations); return; }
|
|
32
34
|
if (command === "python" && argument === "setup") {
|
|
33
35
|
const instance = lifecycle.get(), cancel = instance.signal(ctx.signal);
|
|
34
36
|
await ctx.waitForIdle();
|
|
@@ -37,7 +39,7 @@ export function registerCommand(pi: ExtensionAPI, lifecycle: Lifecycle, confirma
|
|
|
37
39
|
ctx.ui.notify(JSON.stringify(result, null, 2), "info");
|
|
38
40
|
return;
|
|
39
41
|
}
|
|
40
|
-
ctx.ui.notify("用法:/pbot init | status | agents | python setup", "info");
|
|
42
|
+
ctx.ui.notify("用法:/pbot init | status | agents | teams | python setup", "info");
|
|
41
43
|
},
|
|
42
44
|
});
|
|
43
45
|
}
|
|
@@ -5,6 +5,7 @@ import { authorizeMutation } from "../services/workspace/workspace-files.js";
|
|
|
5
5
|
import type { AgentOwner } from "../model/agent.js";
|
|
6
6
|
import type { PythonEnvironmentService } from "../services/workspace/python-environment-service.js";
|
|
7
7
|
import type { AgentService } from "../services/agents/agent-service.js";
|
|
8
|
+
import type { TeamService } from "../services/agents/team-service.js";
|
|
8
9
|
import type { CallAccess } from "../tools/access.js";
|
|
9
10
|
|
|
10
11
|
export class WorkspaceInstance implements CallAccess {
|
|
@@ -12,7 +13,7 @@ export class WorkspaceInstance implements CallAccess {
|
|
|
12
13
|
#work = new AbortController();
|
|
13
14
|
#closing: Promise<void> | undefined;
|
|
14
15
|
#restorePython: (() => void) | undefined;
|
|
15
|
-
constructor(readonly root: string, readonly pythonEnvironment: PythonEnvironmentService, readonly agents: AgentService) {}
|
|
16
|
+
constructor(readonly root: string, readonly pythonEnvironment: PythonEnvironmentService, readonly agents: AgentService, readonly teams: TeamService) {}
|
|
16
17
|
async preparePython(signal?: AbortSignal) {
|
|
17
18
|
this.check();
|
|
18
19
|
this.#restorePython?.();
|
|
@@ -45,8 +46,12 @@ export class WorkspaceInstance implements CallAccess {
|
|
|
45
46
|
this.#work.abort();
|
|
46
47
|
this.#closing = (async () => {
|
|
47
48
|
try {
|
|
49
|
+
const errors: unknown[] = [];
|
|
50
|
+
try { if (this.agentOwner) this.teams.pauseAll(this.agentOwner, "主会话关闭或重新载入"); }
|
|
51
|
+
catch (error) { errors.push(error); }
|
|
52
|
+
this.teams.dispose();
|
|
48
53
|
const results = await Promise.allSettled([this.agents.close(this.agentOwner, preserveAgents), this.pythonEnvironment.close()]);
|
|
49
|
-
|
|
54
|
+
errors.push(...results.filter(result => result.status === "rejected").map(result => result.reason));
|
|
50
55
|
if (errors.length) throw new AggregateError(errors, "工作区关闭失败。");
|
|
51
56
|
} finally { this.#restorePython?.(); this.#restorePython = undefined; }
|
|
52
57
|
})();
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { readFile, stat } from "node:fs/promises";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
-
import { realpathSync } from "node:fs";
|
|
3
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { createReadTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import type { TextContent, ImageContent } from "@earendil-works/pi-ai";
|
|
7
7
|
import { AgentFiles, descriptor, launch, status, cycle } from "../storage/agent-files.js";
|
|
8
|
+
import { TeamFiles } from "../storage/team-files.js";
|
|
9
|
+
import { TeamService } from "../services/agents/team-service.js";
|
|
10
|
+
import { teamMessageTool } from "../tools/teams/tool.js";
|
|
11
|
+
import { teamBrief, teamContext } from "../integrations/pi/team-context.js";
|
|
12
|
+
import { readAgentSession } from "../integrations/pi/pi-agents.js";
|
|
13
|
+
import type { TeamLink } from "../model/team.js";
|
|
8
14
|
import { PythonEnvironmentService } from "../services/workspace/python-environment-service.js";
|
|
9
15
|
import { activatePython } from "../integrations/python-activation.js";
|
|
10
16
|
import { authorizeMutation } from "../services/workspace/workspace-files.js";
|
|
@@ -24,7 +30,10 @@ export default function subagent(pi: ExtensionAPI) {
|
|
|
24
30
|
const d = files.read(id, "descriptor.json", descriptor);
|
|
25
31
|
requireThat(d.agentId === id && releaseLine(d.release) === releaseLine(packageVersion), "子会话身份或版本无效。");
|
|
26
32
|
requireThat(files.read(id, "launch.json", launch).generation === generation, "子会话启动代次已失效。");
|
|
33
|
+
const teams = new TeamService(d.workspace, files, new TeamFiles(files));
|
|
27
34
|
const lifetime = new AbortController();
|
|
35
|
+
let activeTeam: TeamLink | undefined, pendingPause: string | undefined;
|
|
36
|
+
let ready = false, disposed = false;
|
|
28
37
|
let ctx: ExtensionContext | undefined, timer: ReturnType<typeof setInterval> | undefined;
|
|
29
38
|
let python: PythonEnvironmentService | undefined, restorePython: (() => void) | undefined;
|
|
30
39
|
let processing = false, closing = false, current: AgentCycle | undefined, startLeaf: string | null = null;
|
|
@@ -41,11 +50,21 @@ export default function subagent(pi: ExtensionAPI) {
|
|
|
41
50
|
files.write(id, "status.json", snapshot, status);
|
|
42
51
|
};
|
|
43
52
|
const ensure = () => {
|
|
44
|
-
lifetime.signal.throwIfAborted();
|
|
53
|
+
lifetime.signal.throwIfAborted(); files.check();
|
|
45
54
|
const run = files.read(id, "launch.json", launch);
|
|
46
55
|
requireThat(run.generation === generation && !run.stop, "子会话已停止或启动代次变化。");
|
|
47
56
|
requireThat(python, "子会话尚未就绪。");
|
|
48
57
|
};
|
|
58
|
+
const bindSession = () => {
|
|
59
|
+
const file = ctx?.sessionManager.getSessionFile();
|
|
60
|
+
requireThat(file, "子会话必须使用持久原生会话。");
|
|
61
|
+
files.bindSession(id, file);
|
|
62
|
+
};
|
|
63
|
+
const pauseTeam = (reason: string) => {
|
|
64
|
+
pendingPause = reason;
|
|
65
|
+
try { teams.pauseMember(id, reason); pendingPause = undefined; }
|
|
66
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "PBOT_CONTROL_BUSY") throw error; }
|
|
67
|
+
};
|
|
49
68
|
const finish = (aborted: boolean) => {
|
|
50
69
|
if (!ctx || !current) return;
|
|
51
70
|
const branch = ctx.sessionManager.getBranch();
|
|
@@ -58,15 +77,17 @@ export default function subagent(pi: ExtensionAPI) {
|
|
|
58
77
|
...(!assistant || assistant.stopReason !== "stop" ? { error: assistant?.errorMessage ?? "没有有效最终回答。" } : {}) };
|
|
59
78
|
files.write(id, "completions/" + current.cycleId + ".json", current, cycle, true);
|
|
60
79
|
save({ state: "idle", activity: current.state });
|
|
61
|
-
current = undefined;
|
|
80
|
+
current = undefined; activeTeam = undefined;
|
|
62
81
|
};
|
|
63
82
|
const shutdown = async () => {
|
|
64
83
|
if (closing) return;
|
|
65
84
|
closing = true;
|
|
66
|
-
save({ state: "stopping", activity: "停止" });
|
|
67
85
|
lifetime.abort(); ctx?.abort();
|
|
68
|
-
|
|
69
|
-
|
|
86
|
+
try {
|
|
87
|
+
teams.files.setReady(id, generation, false); ready = false;
|
|
88
|
+
pauseTeam("成员停止");
|
|
89
|
+
save({ state: "stopping", activity: "停止" });
|
|
90
|
+
} finally { try { await python?.close(); } finally { ctx?.shutdown(); } }
|
|
70
91
|
};
|
|
71
92
|
const pump = async () => {
|
|
72
93
|
if (processing || !ctx || closing) return;
|
|
@@ -75,8 +96,28 @@ export default function subagent(pi: ExtensionAPI) {
|
|
|
75
96
|
const run = files.read(id, "launch.json", launch);
|
|
76
97
|
if (run.generation !== generation || run.stop) { await shutdown(); return; }
|
|
77
98
|
if (pi.getSessionName() !== run.name) pi.setSessionName(run.name);
|
|
99
|
+
if (pendingPause) { pauseTeam(pendingPause); if (pendingPause) return; }
|
|
100
|
+
const capable = pi.getActiveTools().includes("pbot_team_message");
|
|
101
|
+
if (capable !== ready) {
|
|
102
|
+
teams.files.setReady(id, generation, capable); ready = capable;
|
|
103
|
+
if (!ready) pauseTeam("成员团队工具已停用");
|
|
104
|
+
}
|
|
105
|
+
if (activeTeam) {
|
|
106
|
+
if (Date.now() >= activeTeam.run.deadlineAt) {
|
|
107
|
+
pauseTeam("已到期限");
|
|
108
|
+
if (!interactive && current?.source !== "mixed") {
|
|
109
|
+
ctx.abort();
|
|
110
|
+
if (!current && ctx.isIdle()) await shutdown();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const links = teams.links();
|
|
78
116
|
for (const request of files.messages(id)) {
|
|
79
117
|
if (request.generation !== generation || accepted.has(request.requestId)) continue;
|
|
118
|
+
const link = links.get(request.requestId);
|
|
119
|
+
if (link && (link.team.closed || link.run.state !== "running" || link.run.deadlineAt <= Date.now())) continue;
|
|
120
|
+
if (link && (!ctx.isIdle() || ctx.hasPendingMessages() || current || interactive || queuedRequests.length)) break;
|
|
80
121
|
const content: (TextContent | ImageContent)[] = [];
|
|
81
122
|
for (const path of request.files) {
|
|
82
123
|
const output = await createReadTool(d.cwd).execute("file-" + request.requestId, { path }, lifetime.signal);
|
|
@@ -85,19 +126,33 @@ export default function subagent(pi: ExtensionAPI) {
|
|
|
85
126
|
content.push({ type: "text", text: request.message || "请处理所附文件。" });
|
|
86
127
|
requireThat(ctx.model, "子会话没有可用模型。");
|
|
87
128
|
requireThat(ctx.modelRegistry.hasConfiguredAuth(ctx.model) || await ctx.modelRegistry.getProviderAuth(ctx.model.provider), "子会话模型未认证,请配置原生 Pi 认证后显式恢复。");
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
129
|
+
ensure();
|
|
130
|
+
const accept = () => {
|
|
131
|
+
bindSession();
|
|
132
|
+
accepted.add(request.requestId);
|
|
133
|
+
pi.appendEntry("pbot.agent-input", { requestId: request.requestId, generation });
|
|
134
|
+
if (current) { current.requestIds.push(request.requestId); if (current.source === "interactive") current.source = "mixed"; }
|
|
135
|
+
else queuedRequests.push(request.requestId);
|
|
136
|
+
save({ state: "busy", activity: "消息已接收" });
|
|
137
|
+
};
|
|
138
|
+
if (link) {
|
|
139
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages() || current || interactive || queuedRequests.length) break;
|
|
140
|
+
activeTeam = teams.claim(id, generation, request.requestId, accept);
|
|
141
|
+
if (!activeTeam) continue;
|
|
142
|
+
} else accept();
|
|
93
143
|
pi.sendUserMessage(content, ctx.isIdle() ? {} : { deliverAs: request.delivery });
|
|
94
144
|
break;
|
|
95
145
|
}
|
|
96
146
|
} catch (error) {
|
|
147
|
+
if ((error as NodeJS.ErrnoException).code === "PBOT_CONTROL_BUSY") return;
|
|
97
148
|
save({ state: "error", activity: "控制失败", error: String(error) });
|
|
98
149
|
await shutdown();
|
|
99
150
|
} finally { processing = false; }
|
|
100
151
|
};
|
|
152
|
+
pi.registerTool({ ...teamMessageTool(teams, { check: ensure }, () => ({
|
|
153
|
+
kind: "member", agentId: id, generation,
|
|
154
|
+
...(activeTeam && current?.source === "parent" ? { requestId: activeTeam.request.requestId } : {}),
|
|
155
|
+
})), namespace: { name: "pbot", description: "团队公共通信,不提供管理或递归委派。" } });
|
|
101
156
|
pi.on("resources_discover", async () => {
|
|
102
157
|
if (d.options.noSkills) return {};
|
|
103
158
|
const skillPaths = [join(resourceRoot, "skills")];
|
|
@@ -112,7 +167,15 @@ export default function subagent(pi: ExtensionAPI) {
|
|
|
112
167
|
pi.on("session_start", async (_event, context) => {
|
|
113
168
|
ctx = context;
|
|
114
169
|
try {
|
|
115
|
-
|
|
170
|
+
const run = files.read(id, "launch.json", launch);
|
|
171
|
+
requireThat(run.generation === generation && !run.stop, "子会话启动代次已失效或已停止。");
|
|
172
|
+
teams.files.setReady(id, generation, false);
|
|
173
|
+
pauseTeam("子会话载入:旧批次不自动恢复");
|
|
174
|
+
const file = ctx.sessionManager.getSessionFile(), previous = files.session(id);
|
|
175
|
+
requireThat(realpathSync(ctx.cwd) === d.cwd && ctx.sessionManager.getSessionId() === id && file &&
|
|
176
|
+
(previous === null || previous === file), "子会话身份、工作目录或文件不匹配。");
|
|
177
|
+
// Native setup-only sessions have a path but no file. Do not manufacture a conversation to persist it.
|
|
178
|
+
if (previous !== null || existsSync(file)) { readAgentSession(d, file, true); bindSession(); }
|
|
116
179
|
for (const entry of ctx.sessionManager.getEntries()) if (entry.type === "custom" && entry.customType === "pbot.agent-input") {
|
|
117
180
|
const receipt = entry.data as { requestId?: string }; if (receipt.requestId) accepted.add(receipt.requestId);
|
|
118
181
|
}
|
|
@@ -120,6 +183,15 @@ export default function subagent(pi: ExtensionAPI) {
|
|
|
120
183
|
const interrupted: AgentCycle = { ...saved.cycle, state: "interrupted", finishedAt: Date.now() };
|
|
121
184
|
if (!files.completions(id).some(c => c.cycleId === interrupted.cycleId)) files.write(id, "completions/" + interrupted.cycleId + ".json", interrupted, cycle, true);
|
|
122
185
|
}
|
|
186
|
+
// A receipt without a cycle is uncertain, not permission to replay after reload.
|
|
187
|
+
for (const link of teams.links().values()) {
|
|
188
|
+
const q = link.request;
|
|
189
|
+
if (q.agentId !== id || q.generation !== generation || !accepted.has(q.requestId) ||
|
|
190
|
+
files.completions(id).some(c => c.generation === generation && c.requestIds.includes(q.requestId))) continue;
|
|
191
|
+
const interrupted: AgentCycle = { cycleId: q.requestId, generation, startedAt: files.read(id, "launch.json", launch).startedAt,
|
|
192
|
+
finishedAt: Date.now(), state: "interrupted", leaf: null, requestIds: [q.requestId], source: "parent", error: "领取后执行结果不明;未重跑。" };
|
|
193
|
+
files.write(id, "completions/" + interrupted.cycleId + ".json", interrupted, cycle, true);
|
|
194
|
+
}
|
|
123
195
|
const active = pi.getActiveTools();
|
|
124
196
|
for (const tool of agentFileTools(d.cwd, lifetime.signal, async (path, change, cancel) => {
|
|
125
197
|
ensure();
|
|
@@ -132,27 +204,64 @@ export default function subagent(pi: ExtensionAPI) {
|
|
|
132
204
|
try { const result = await python.setup(lifetime.signal); restorePython = activatePython(result.directory); }
|
|
133
205
|
catch (error) { ctx.ui.notify(String(error), "warning"); }
|
|
134
206
|
save({ state: "idle", activity: "空闲" });
|
|
135
|
-
|
|
136
|
-
|
|
207
|
+
ready = !pendingPause && pi.getActiveTools().includes("pbot_team_message");
|
|
208
|
+
teams.files.setReady(id, generation, ready);
|
|
209
|
+
const tick = () => void pump().catch(error => {
|
|
210
|
+
closing = true; lifetime.abort(); ctx?.abort();
|
|
211
|
+
console.error("子会话控制失败:", error); ctx?.shutdown();
|
|
212
|
+
});
|
|
213
|
+
timer = setInterval(tick, 250); timer.unref(); tick();
|
|
214
|
+
} catch (error) {
|
|
215
|
+
try { save({ state: "error", activity: "启动失败", error: String(error) }); }
|
|
216
|
+
finally { await shutdown(); }
|
|
217
|
+
}
|
|
137
218
|
});
|
|
138
|
-
pi.on("before_agent_start", async event => {
|
|
139
|
-
ensure();
|
|
219
|
+
pi.on("before_agent_start", async (event, context) => {
|
|
220
|
+
ctx = context; ensure(); bindSession();
|
|
221
|
+
delete event.systemPromptOptions.sections.pbot_team;
|
|
140
222
|
requireThat(python, "子会话计算环境状态不可用。");
|
|
141
223
|
event.systemPromptOptions.sections.pbot_environment = JSON.stringify({
|
|
142
224
|
workspaceRoot: d.workspace, cwd: d.cwd, resourceRoot, python: python.status,
|
|
143
225
|
});
|
|
144
226
|
event.systemPromptOptions.sections.pbot_workspace = await readFile(join(resourceRoot, "prompts/workspace.md"), "utf8");
|
|
145
227
|
event.systemPromptOptions.sections.pbot_child = await agentText("general-purpose", d.workspace);
|
|
228
|
+
if (activeTeam && !interactive && current?.source !== "mixed") {
|
|
229
|
+
const link = teams.links().get(activeTeam.request.requestId);
|
|
230
|
+
requireThat(link, "团队关联不可读取。");
|
|
231
|
+
event.systemPromptOptions.sections.pbot_team = teamBrief(link);
|
|
232
|
+
const message = teamContext(link, context, link.request.throughSeq);
|
|
233
|
+
if (message) return { message };
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
});
|
|
237
|
+
pi.on("turn_end", (event, context) => {
|
|
238
|
+
if (!activeTeam || current?.source !== "parent" || event.outcome === "aborted") return;
|
|
239
|
+
const link = teams.links().get(activeTeam.request.requestId);
|
|
240
|
+
if (!link) return;
|
|
241
|
+
const message = teamContext(link, context);
|
|
242
|
+
if (message) return { entries: [...event.entries, message] };
|
|
243
|
+
return;
|
|
146
244
|
});
|
|
147
|
-
pi.on("input", event => {
|
|
245
|
+
pi.on("input", async event => {
|
|
246
|
+
try { ensure(); bindSession(); }
|
|
247
|
+
catch (error) {
|
|
248
|
+
try { save({ state: "error", activity: "会话引用失败", error: String(error) }); }
|
|
249
|
+
catch { /* A failed store must not allow the native input fallback. */ }
|
|
250
|
+
try { await shutdown(); } catch { ctx?.shutdown(); }
|
|
251
|
+
return { action: "handled" };
|
|
252
|
+
}
|
|
148
253
|
if (event.source !== "extension") {
|
|
149
254
|
if (current) current.source = current.source === "parent" ? "mixed" : current.source;
|
|
150
255
|
else interactive = true;
|
|
256
|
+
save({ state: "busy", activity: "人工输入" });
|
|
257
|
+
pauseTeam("成员由人工接管");
|
|
151
258
|
}
|
|
152
259
|
return;
|
|
153
260
|
});
|
|
154
261
|
pi.on("agent_start", (_event, context) => {
|
|
155
|
-
ctx = context;
|
|
262
|
+
ctx = context;
|
|
263
|
+
if (current) { save({ state: "busy", activity: "继续思考" }); return; }
|
|
264
|
+
startLeaf = ctx.sessionManager.getLeafId();
|
|
156
265
|
current = { cycleId: randomUUID(), generation, startedAt: Date.now(), state: "running", leaf: null,
|
|
157
266
|
requestIds: queuedRequests, source: interactive ? queuedRequests.length ? "mixed" : "interactive" : "parent" };
|
|
158
267
|
queuedRequests = []; interactive = false;
|
|
@@ -161,7 +270,10 @@ export default function subagent(pi: ExtensionAPI) {
|
|
|
161
270
|
pi.on("tool_call", event => {
|
|
162
271
|
try {
|
|
163
272
|
ensure();
|
|
164
|
-
requireThat(event.toolName !== "pbot_agent", "子会话禁止管理和委派。");
|
|
273
|
+
requireThat(event.toolName !== "pbot_agent" && event.toolName !== "pbot_team", "子会话禁止管理和委派。");
|
|
274
|
+
if (activeTeam && current?.source === "parent" && Date.now() >= activeTeam.run.deadlineAt) {
|
|
275
|
+
ctx?.abort(); throw new Error("团队请求已到期限。");
|
|
276
|
+
}
|
|
165
277
|
save({ state: "busy", activity: event.toolName });
|
|
166
278
|
} catch (error) { return { block: true, reason: String(error) }; }
|
|
167
279
|
return;
|
|
@@ -172,10 +284,16 @@ export default function subagent(pi: ExtensionAPI) {
|
|
|
172
284
|
pi.on("session_before_fork", () => ({ cancel: true }));
|
|
173
285
|
pi.on("session_before_tree", (_event, context) => ({ cancel: !context.isIdle() }));
|
|
174
286
|
pi.on("session_shutdown", async event => {
|
|
287
|
+
if (disposed) return;
|
|
288
|
+
disposed = true;
|
|
175
289
|
if (timer) clearInterval(timer); timer = undefined; closing = true; lifetime.abort();
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
290
|
+
try {
|
|
291
|
+
teams.files.setReady(id, generation, false); ready = false;
|
|
292
|
+
pauseTeam("子会话关闭或重新载入");
|
|
293
|
+
if (current) finish(true);
|
|
294
|
+
if (event.reason !== "reload" && snapshot.state !== "error") save({ state: "stopped", activity: "已停止" });
|
|
295
|
+
} finally {
|
|
296
|
+
try { await python?.close(); } finally { restorePython?.(); teams.dispose(); ctx = undefined; }
|
|
297
|
+
}
|
|
180
298
|
});
|
|
181
299
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { agentOwner } from "../integrations/pi/agent-context.js";
|
|
4
|
+
import type { WorkspaceInstance } from "./lifecycle.js";
|
|
5
|
+
import type { Confirmations } from "../ui/confirmation.js";
|
|
6
|
+
import { showAgents } from "./agents-command.js";
|
|
7
|
+
|
|
8
|
+
export async function showTeams(pi: ExtensionAPI, instance: WorkspaceInstance, ctx: ExtensionCommandContext, confirmations: Confirmations) {
|
|
9
|
+
instance.check();
|
|
10
|
+
const owner = () => agentOwner(ctx), views = () => instance.agents.list(owner()), service = instance.teams;
|
|
11
|
+
service.recover(owner());
|
|
12
|
+
const values = service.list(owner(), views());
|
|
13
|
+
if (ctx.mode !== "tui") { ctx.ui.notify(JSON.stringify({ teams: values }), "info"); return; }
|
|
14
|
+
if (confirmations.presenting) { ctx.ui.notify("请先处理主会话批准。", "info"); return; }
|
|
15
|
+
if (!values.length) { ctx.ui.notify("没有团队;先用 pbot_agent start 创建成员,再用 pbot_team create 组队。", "info"); return; }
|
|
16
|
+
const options = values.map(v => v.name + (v.owned ? " · " + (v.closed ? "closed" : v.run?.state ?? "open") + " · 在途 " + v.activeCount : " · 其他分支"));
|
|
17
|
+
const selected = await ctx.ui.select("pbot 团队", options), value = values[options.indexOf(selected ?? "")];
|
|
18
|
+
if (!value) return;
|
|
19
|
+
if (!value.owned) { ctx.ui.notify("请返回所属分支操作。", "info"); return; }
|
|
20
|
+
const action = await ctx.ui.select(value.name, ["状态", "公共记录", "发布信息", "新批次", "暂停", "关闭", "成员 TUI/报告"]);
|
|
21
|
+
instance.check();
|
|
22
|
+
if (action === "状态") { ctx.ui.notify(JSON.stringify(service.status(value.teamId, owner(), views()), null, 2), "info"); return; }
|
|
23
|
+
if (action === "成员 TUI/报告") { await showAgents(pi, instance, ctx, confirmations, value.members.map(m => m.agentId)); return; }
|
|
24
|
+
if (action === "暂停") { service.pause(value.teamId, owner()); return; }
|
|
25
|
+
if (action === "关闭") { service.close(value.teamId, owner(), views()); return; }
|
|
26
|
+
if (action === "发布信息") {
|
|
27
|
+
const text = await ctx.ui.editor("公开给全体成员(不会唤醒模型)");
|
|
28
|
+
instance.check();
|
|
29
|
+
if (text?.trim()) service.post(value.teamId, { kind: "user", owner: owner() }, randomUUID(), text);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (action === "新批次") {
|
|
33
|
+
const mode = await ctx.ui.select("协作策略", ["roundRobin", "parallel"]);
|
|
34
|
+
if (mode !== "roundRobin" && mode !== "parallel") return;
|
|
35
|
+
const instruction = await ctx.ui.editor("本批次要求;不要把新批次当作未知外部操作的重试");
|
|
36
|
+
if (!instruction?.trim()) return;
|
|
37
|
+
const rounds = await ctx.ui.input("轮数 1–10;期限默认 15 分钟", "2");
|
|
38
|
+
if (rounds === undefined) return;
|
|
39
|
+
instance.check();
|
|
40
|
+
service.run(value.teamId, owner(), { instruction, mode, rounds: rounds.trim() ? Number(rounds) : 2 }, views());
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (action !== "公共记录") return;
|
|
44
|
+
let page: { afterSeq?: number; seq?: number; offset?: number } = {};
|
|
45
|
+
for (;;) {
|
|
46
|
+
instance.check();
|
|
47
|
+
const data = service.read(value.teamId, { kind: "user", owner: owner() }, page);
|
|
48
|
+
await ctx.ui.editor("公共记录(查看不领取模型通知)", data.messages.map(m =>
|
|
49
|
+
"#" + m.seq + " " + m.author.name + (m.replyTo ? " → #" + m.replyTo : "") + "\n" + m.text +
|
|
50
|
+
(m.nextOffset === null ? "" : "\n[本条未完,字符 " + m.nextOffset + "/" + m.totalLength + "]")).join("\n\n") || "尚无消息");
|
|
51
|
+
const last = data.messages.at(-1);
|
|
52
|
+
if (last?.nextOffset !== null && last?.nextOffset !== undefined) {
|
|
53
|
+
if (await ctx.ui.select("继续阅读", ["本条后续", "返回"]) !== "本条后续") return;
|
|
54
|
+
page = { seq: last.seq, offset: last.nextOffset };
|
|
55
|
+
} else if (last && last.seq < data.lastSeq) {
|
|
56
|
+
if (await ctx.ui.select("继续阅读", ["后续消息", "返回"]) !== "后续消息") return;
|
|
57
|
+
page = { afterSeq: last.seq };
|
|
58
|
+
} else return;
|
|
59
|
+
}
|
|
60
|
+
}
|