personal-bot 0.1.1 → 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.
@@ -0,0 +1,26 @@
1
+ import type { Json } from "./json.js";
2
+
3
+ /** Preserve original text, including embedded Markdown fences. */
4
+ export function markdownBlock(text: string): string {
5
+ let fenceLength = 3;
6
+ for (const match of text.matchAll(/`+/g)) fenceLength = Math.max(fenceLength, match[0].length + 1);
7
+ const fence = "`".repeat(fenceLength);
8
+ return fence + "text\n" + text + "\n" + fence;
9
+ }
10
+
11
+ /** Render structured result fields without changing their machine-readable values. */
12
+ export function markdownFields(value: Json, level = 2): string {
13
+ if (value === null || typeof value !== "object") return typeof value === "string" ? markdownBlock(value) : String(value);
14
+ if (Array.isArray(value)) return value.length ? value.map((item, index) =>
15
+ "#".repeat(level) + " " + (index + 1) + "\n\n" + markdownFields(item, level + 1)).join("\n\n") : "(空)";
16
+ const fields: string[] = [], sections: string[] = [];
17
+ for (const [key, item] of Object.entries(value)) {
18
+ if ((item !== null && typeof item === "object") || (typeof item === "string" && (item.includes("\n") || key === "report" || key === "text"))) {
19
+ sections.push("#".repeat(level) + " " + key + "\n\n" + markdownFields(item, level + 1));
20
+ } else {
21
+ const text = String(item).replace(/[\\`*_{}\[\]<>#!|]/g, "\\$&");
22
+ fields.push("- " + key + ": " + text);
23
+ }
24
+ }
25
+ return [fields.join("\n"), ...sections].filter(Boolean).join("\n\n");
26
+ }
@@ -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 pending = instance.agents.completions(agentOwner(ctx)).filter(v => !delivered.has(v.agentId + ":" + v.cycle.cycleId) && !submitting.has(v.agentId + ":" + v.cycle.cycleId));
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 => JSON.stringify({ agentId: v.agentId, name: v.name,
24
- cycle: { cycleId: v.cycle.cycleId, state: v.cycle.state, source: v.cycle.source, requestIds: v.cycle.requestIds, error: v.cycle.error },
25
- report: v.report.slice(0, 2000), truncated: v.report.length > 2000 })).join("\n"), details: { keys } };
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
- } catch (error) { if (ctx.hasUI) ctx.ui.setStatus("pbot-agents-error", String(error)); }
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 action = await ctx.ui.select(value.name, ["进入 TUI", "阅读报告", "补充要求", "重命名", "停止", "恢复并继续"]);
21
- if (action === "停止") { instance.agents.stop(value.agentId, owner); return; }
22
- if (action === "重命名") { const name = await ctx.ui.input("名称"); if (name?.trim()) instance.agents.rename(value.agentId, name.trim(), owner); return; }
23
- if (action === "补充要求" || action === "恢复并继续") {
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
- if (message?.trim()) {
26
- if (action === "补充要求") instance.agents.send(value.agentId, message, "steer", owner);
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", [process.env.TMUX ? "switch-client" : "attach-session", "-t", "=" + tmuxName(value.agentId)], { stdio: "inherit" });
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");
@@ -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
- const errors = results.filter(result => result.status === "rejected").map(result => result.reason);
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
- await python?.close();
69
- ctx?.shutdown();
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
- accepted.add(request.requestId);
89
- pi.appendEntry("pbot.agent-input", { requestId: request.requestId, generation });
90
- if (current) { current.requestIds.push(request.requestId); if (current.source === "interactive") current.source = "mixed"; }
91
- else queuedRequests.push(request.requestId);
92
- save({ state: "busy", activity: "消息已接收" });
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
- requireThat(realpathSync(ctx.cwd) === d.cwd && ctx.sessionManager.getSessionFile() === files.session(id), "子会话工作目录或文件不匹配。");
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
- timer = setInterval(() => void pump(), 250); timer.unref(); void pump();
136
- } catch (error) { save({ state: "error", activity: "启动失败", error: String(error) }); ctx.shutdown(); }
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; startLeaf = ctx.sessionManager.getLeafId();
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
- if (current) finish(true);
177
- await python?.close(); restorePython?.();
178
- if (event.reason !== "reload" && snapshot.state !== "error") save({ state: "stopped", activity: "已停止" });
179
- ctx = undefined;
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
+ }