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.
@@ -0,0 +1,274 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { requireThat } from "../../common/validation.js";
3
+ import { packageVersion, releaseLine } from "../../common/version.js";
4
+ import { AgentFiles, descriptor, launch, status, message } from "../../storage/agent-files.js";
5
+ import { TeamFiles } from "../../storage/team-files.js";
6
+ import type { AgentCompletion, AgentOwner } from "../../model/agent.js";
7
+ import type { TeamActor, TeamAgent, TeamInput, TeamLink, TeamMessage, TeamRecord, TeamRunOptions } from "../../model/team.js";
8
+
9
+ /** Team decisions only. The parent dispatches returned inputs through AgentService. */
10
+ export class TeamService {
11
+ #failure: unknown;
12
+ #disposed = false;
13
+ readonly #recovered = new Set<string>();
14
+ constructor(readonly root: string, readonly agents: AgentFiles, readonly files: TeamFiles) {}
15
+ check() { requireThat(!this.#disposed, "团队服务已关闭。"); this.files.check(); if (this.#failure) throw this.#failure; }
16
+ fail(error: unknown) { this.#failure = error; }
17
+ dispose() { this.#disposed = true; }
18
+ private owned(team: TeamRecord, owner: AgentOwner) {
19
+ return team.workspace === this.root && team.parentSessionId === owner.sessionId && (team.parentLeaf === null || owner.branch.includes(team.parentLeaf));
20
+ }
21
+ private records(sessionId?: string) {
22
+ this.check();
23
+ return this.files.list().filter(t => t.workspace === this.root && releaseLine(t.release) === releaseLine(packageVersion) &&
24
+ (!sessionId || t.parentSessionId === sessionId));
25
+ }
26
+ private get(id: string, actor: TeamActor) {
27
+ this.check(); const team = this.files.read(id);
28
+ requireThat(team.workspace === this.root && releaseLine(team.release) === releaseLine(packageVersion), "团队工作区或版本不匹配。");
29
+ if (actor.kind === "member") {
30
+ const d = this.agents.read(actor.agentId, "descriptor.json", descriptor), run = this.agents.read(actor.agentId, "launch.json", launch);
31
+ requireThat(d.workspace === this.root && d.parentSessionId === team.parentSessionId && team.members.some(m => m.agentId === actor.agentId) &&
32
+ run.generation === actor.generation && !run.stop, "团队成员身份已失效。");
33
+ } else requireThat(this.owned(team, actor.owner), "团队不属于当前会话分支。");
34
+ return team;
35
+ }
36
+ private member(id: string, owner: AgentOwner, views: TeamAgent[]) {
37
+ const v = views.find(v => v.agentId === id), d = this.agents.read(id, "descriptor.json", descriptor);
38
+ requireThat(v && v.owned && d.workspace === this.root && d.parentSessionId === owner.sessionId &&
39
+ (d.parentLeaf === null || owner.branch.includes(d.parentLeaf)), "成员不属于当前分支:" + id);
40
+ requireThat(releaseLine(d.release) === releaseLine(packageVersion), "成员版本不兼容:" + id);
41
+ return v;
42
+ }
43
+ private requests(team: TeamRecord, views: TeamAgent[]) {
44
+ const states = new Map(team.members.map(m => [m.agentId, { view: views.find(v => v.agentId === m.agentId),
45
+ saved: this.agents.optional(m.agentId, "status.json", status),
46
+ cycles: new Map(this.agents.completions(m.agentId).flatMap(c => c.requestIds.map(id => [c.generation + ":" + id, c] as const))) }]));
47
+ return team.runs.flatMap(run => run.issued.map(q => {
48
+ const s = states.get(q.agentId)!, ended = s.cycles.get(q.generation + ":" + q.requestId);
49
+ const accepted = s.saved?.generation === q.generation && !!s.saved.accepted?.includes(q.requestId);
50
+ const active = !ended && accepted && !!s.view?.alive && s.view.generation === q.generation;
51
+ let outcome: string | undefined = ended?.state;
52
+ if (ended && ended.source !== "parent") outcome = ended.source;
53
+ else if (outcome === "completed" && !team.messages.some(m => m.source?.requestId === q.requestId)) outcome = "unpublished";
54
+ return { requestId: q.requestId, agentId: q.agentId, runId: run.runId, round: q.round, active,
55
+ state: outcome ?? (active ? "accepted" : accepted ? "uncertain" : run.state === "running" ? "queued" : "cancelled"),
56
+ ...(ended ? { cycleId: ended.cycleId } : {}) };
57
+ }));
58
+ }
59
+ private active(team: TeamRecord, views: TeamAgent[]) { return this.requests(team, views).filter(q => q.active); }
60
+ private available(id: string, owner: AgentOwner, views: TeamAgent[], links: Map<string, TeamLink>) {
61
+ const v = this.member(id, owner, views), run = this.agents.read(id, "launch.json", launch);
62
+ const saved = this.agents.optional(id, "status.json", status), ready = this.files.ready(id);
63
+ const available = (condition: unknown, message: string): void => {
64
+ if (!condition) throw Object.assign(new Error(message), { code: "TEAM_NOT_READY" });
65
+ };
66
+ available(v.alive && v.state === "idle" && !run.stop && v.generation === run.generation && saved?.generation === run.generation && saved.state === "idle", "成员尚未空闲:" + id);
67
+ available(ready?.ready && ready.generation === run.generation, "成员需加载新版子入口:" + id);
68
+ available(!this.agents.messages(id).some(q => q.generation === run.generation && !saved?.accepted?.includes(q.requestId) && !links.has(q.requestId)), "成员有待处理私人输入:" + id);
69
+ return v;
70
+ }
71
+ create(owner: AgentOwner, input: Pick<TeamRecord, "name" | "brief" | "members">, views: TeamAgent[]) {
72
+ this.check();
73
+ return this.agents.control(() => {
74
+ const records = this.records();
75
+ for (const m of input.members) {
76
+ this.member(m.agentId, owner, views);
77
+ requireThat(!records.some(t => !t.closed && t.members.some(v => v.agentId === m.agentId)), "成员已属于未关闭团队:" + m.agentId);
78
+ }
79
+ const team: TeamRecord = { ...input, members: structuredClone(input.members), teamId: randomUUID(), release: packageVersion,
80
+ workspace: this.root, parentSessionId: owner.sessionId, parentLeaf: owner.leaf, closed: false, runs: [], messages: [] };
81
+ this.files.save(team, true); return team;
82
+ });
83
+ }
84
+ run(id: string, owner: AgentOwner, options: TeamRunOptions, views: TeamAgent[]) {
85
+ this.check();
86
+ return this.agents.control(() => {
87
+ const team = this.get(id, { kind: "coordinator", owner }), links = this.files.links(this.records());
88
+ requireThat(!team.closed && !team.runs.some(r => r.state === "running") && !this.active(team, views).length, "团队仍在工作或已关闭。");
89
+ const members = team.members.map(m => this.available(m.agentId, owner, views, links));
90
+ requireThat(options.mode !== "roundRobin" || options.maxConcurrent === undefined || options.maxConcurrent === 1, "轮流讨论并发须为 1。");
91
+ const rounds = options.rounds ?? 2, maxConcurrent = options.mode === "roundRobin" ? 1 : options.maxConcurrent ?? Math.min(2, members.length);
92
+ requireThat(Number.isInteger(rounds) && rounds >= 1 && rounds <= 10 && Number.isInteger(maxConcurrent) && maxConcurrent >= 1 && maxConcurrent <= members.length, "轮次或并发无效。");
93
+ const seconds = options.timeoutSeconds ?? 900;
94
+ requireThat(Number.isInteger(seconds) && seconds >= 1 && seconds <= 86400, "期限须为 1–86400 秒。");
95
+ requireThat(options.instruction.trim().length > 0, "新批次需要新的明确要求。");
96
+ const run = { runId: randomUUID(), instruction: options.instruction, mode: options.mode, rounds, maxConcurrent,
97
+ deadlineAt: Date.now() + seconds * 1000, generations: Object.fromEntries(members.map(v => [v.agentId, v.generation])),
98
+ state: "running" as const, issued: [] };
99
+ team.runs.push(run); this.files.save(team); return run;
100
+ });
101
+ }
102
+ pause(id: string, owner: AgentOwner, reason = "主会话暂停") {
103
+ this.check(); this.agents.control(() => { const team = this.get(id, { kind: "coordinator", owner }); this.pauseRecord(team, reason); });
104
+ }
105
+ private pauseRecord(team: TeamRecord, reason: string) {
106
+ let changed = false;
107
+ for (const run of team.runs) if (run.state === "running") { run.state = "paused"; run.reason = reason; changed = true; }
108
+ if (changed) this.files.save(team);
109
+ }
110
+ pauseAll(owner: AgentOwner, reason: string) {
111
+ this.check(); this.agents.control(() => { for (const team of this.records(owner.sessionId)) this.pauseRecord(team, reason); });
112
+ }
113
+ recover(owner: AgentOwner) {
114
+ if (this.#recovered.has(owner.sessionId)) return;
115
+ this.pauseAll(owner, "主会话恢复:未自动续排旧批次"); this.#recovered.add(owner.sessionId);
116
+ }
117
+ pauseMember(agentId: string, reason: string) {
118
+ this.check(); this.agents.control(() => { for (const team of this.records()) if (team.members.some(m => m.agentId === agentId)) this.pauseRecord(team, reason); });
119
+ }
120
+ close(id: string, owner: AgentOwner, views: TeamAgent[]) {
121
+ this.check(); this.agents.control(() => {
122
+ const team = this.get(id, { kind: "coordinator", owner });
123
+ requireThat(!team.runs.some(r => r.state === "running") && !this.active(team, views).length, "先暂停团队并等待在途工作结束。");
124
+ if (!team.closed) { team.closed = true; this.files.save(team); }
125
+ });
126
+ }
127
+ post(id: string, actor: TeamActor, operationId: string, text: string, replyTo?: number) {
128
+ this.check(); requireThat(text.trim().length > 0 && text.length <= 24000 && operationId.length > 0, "消息须为 1–24000 字符。");
129
+ operationId = actor.kind === "member" ? "post:" + actor.agentId + ":" + actor.generation + ":" + operationId : "post:" + actor.kind + ":" + actor.owner.sessionId + ":" + operationId;
130
+ return this.agents.control(() => {
131
+ const team = this.get(id, actor);
132
+ const prior = team.messages.find(m => m.operationId === operationId);
133
+ if (prior) {
134
+ requireThat(prior.text === text && prior.replyTo === replyTo && prior.author.kind === actor.kind &&
135
+ (actor.kind !== "member" || prior.author.agentId === actor.agentId), "发布操作 ID 冲突。"); return prior;
136
+ }
137
+ requireThat(!team.closed, "团队已关闭。");
138
+ requireThat(replyTo === undefined || team.messages.some(m => m.seq === replyTo), "回复目标不存在。");
139
+ let runId: string | undefined;
140
+ if (actor.kind === "member") {
141
+ const run = team.runs.find(r => r.issued.some(q => q.requestId === actor.requestId && q.agentId === actor.agentId && q.generation === actor.generation));
142
+ const saved = this.agents.optional(actor.agentId, "status.json", status);
143
+ requireThat(run && saved?.generation === actor.generation && saved.cycle?.state === "running" && saved.cycle.source === "parent" &&
144
+ !!actor.requestId && saved.cycle.requestIds.includes(actor.requestId), "只能在自己的团队工作中发言。"); runId = run.runId;
145
+ }
146
+ const value: TeamMessage = { seq: team.messages.length + 1, operationId, text,
147
+ author: actor.kind === "member" ? { kind: "member", agentId: actor.agentId, name: this.agents.read(actor.agentId, "launch.json", launch).name }
148
+ : { kind: actor.kind, name: actor.kind === "user" ? "用户" : "主会话" },
149
+ ...(replyTo === undefined ? {} : { replyTo }), ...(runId ? { runId } : {}) };
150
+ team.messages.push(value); this.files.save(team); return value;
151
+ });
152
+ }
153
+ read(id: string, actor: TeamActor, page: { afterSeq?: number; seq?: number; offset?: number; limit?: number } = {}) {
154
+ const team = this.get(id, actor), limit = page.limit ?? 16000, after = page.afterSeq ?? 0, offset = page.offset ?? 0;
155
+ requireThat(Number.isInteger(limit) && limit >= 1 && limit <= 16000 && Number.isInteger(after) && after >= 0 && after <= team.messages.length &&
156
+ Number.isInteger(offset) && offset >= 0 && (page.seq !== undefined || offset === 0), "分页参数无效。");
157
+ const values = page.seq === undefined ? team.messages.filter(m => m.seq > after) : team.messages.filter(m => m.seq === page.seq);
158
+ requireThat(page.seq === undefined || values.length === 1, "消息不存在。");
159
+ let left = limit;
160
+ const messages = [];
161
+ for (const m of values) {
162
+ const at = page.seq === undefined ? 0 : offset;
163
+ requireThat(at <= m.text.length, "消息分页位置超出范围。");
164
+ const text = m.text.slice(at, at + left); left -= Math.max(1, text.length);
165
+ messages.push({ ...m, text, offset: at, totalLength: m.text.length, nextOffset: at + text.length < m.text.length ? at + text.length : null });
166
+ if (left <= 0) break;
167
+ }
168
+ const last = messages.at(-1)?.seq ?? after;
169
+ return { teamId: id, messages, lastSeq: team.messages.length, nextAfterSeq: page.seq === undefined && last < team.messages.length ? last : null };
170
+ }
171
+ status(id: string, owner: AgentOwner, views: TeamAgent[]) {
172
+ return this.summary(this.get(id, { kind: "coordinator", owner }), owner, views);
173
+ }
174
+ list(owner: AgentOwner, views: TeamAgent[]) { return this.records(owner.sessionId).map(t => this.summary(t, owner, views)); }
175
+ private summary(team: TeamRecord, owner: AgentOwner, views: TeamAgent[]) {
176
+ const identity = { teamId: team.teamId, name: team.name, closed: team.closed };
177
+ if (!this.owned(team, owner)) return { ...identity, owned: false as const };
178
+ const run = team.runs.at(-1), requests = this.requests(team, views), activeCount = requests.filter(q => q.active).length;
179
+ return { ...identity, brief: team.brief, owned: true as const,
180
+ members: team.members.map(m => { const v = views.find(v => v.agentId === m.agentId); return { ...m, name: v?.name ?? m.agentId, state: v?.state ?? "stopped" }; }),
181
+ lastSeq: team.messages.length, activeCount, unresolved: requests.filter(q => q.state !== "completed"),
182
+ ...(run ? { run: { runId: run.runId, instruction: run.instruction, mode: run.mode, state: run.state, rounds: run.rounds,
183
+ maxConcurrent: run.maxConcurrent, deadlineAt: run.deadlineAt, scheduled: run.issued.length,
184
+ published: team.messages.filter(m => m.runId === run.runId && m.source).length, ...(run.reason ? { reason: run.reason } : {}) } } : {}) };
185
+ }
186
+ links() { return this.files.links(this.records()); }
187
+ /** The callback persists the existing child receipt while the shared control lock is held. */
188
+ claim(agentId: string, generation: string, requestId: string, commit: () => void): TeamLink | undefined {
189
+ this.check();
190
+ return this.agents.control(() => {
191
+ const link = this.links().get(requestId), run = this.agents.read(agentId, "launch.json", launch), ready = this.files.ready(agentId);
192
+ const saved = this.agents.optional(agentId, "status.json", status), input = this.agents.optional(agentId, "inbox/" + requestId + ".json", message);
193
+ if (!link || link.request.agentId !== agentId || link.request.generation !== generation || run.generation !== generation || run.stop ||
194
+ !ready?.ready || ready.generation !== generation || saved?.generation !== generation || saved.state !== "idle" ||
195
+ saved.accepted?.includes(requestId) || input?.generation !== generation || link.team.closed || link.run.state !== "running" || link.run.deadlineAt <= Date.now()) return;
196
+ for (const member of link.team.members) {
197
+ const memberRun = this.agents.read(member.agentId, "launch.json", launch), memberReady = this.files.ready(member.agentId);
198
+ if (memberRun.stop || memberRun.generation !== link.run.generations[member.agentId] || !memberReady?.ready || memberReady.generation !== memberRun.generation) return;
199
+ }
200
+ commit(); return link;
201
+ });
202
+ }
203
+ advance(owner: AgentOwner, views: TeamAgent[], completions: AgentCompletion[]): TeamInput[] {
204
+ this.check();
205
+ return this.agents.control(() => {
206
+ const inputs: TeamInput[] = [], records = this.records(owner.sessionId), links = this.files.links(records);
207
+ for (const team of records) {
208
+ let changed = false;
209
+ for (const run of team.runs) {
210
+ for (const q of run.issued) {
211
+ const done = completions.find(v => v.agentId === q.agentId && v.cycle.generation === q.generation && v.cycle.requestIds.includes(q.requestId));
212
+ if (!done) continue;
213
+ if (done.cycle.state !== "completed" || done.cycle.source !== "parent" || done.cycle.requestIds.length !== 1 || !done.report.trim()) {
214
+ if (run.state === "running") { run.state = "paused"; run.reason = "成员失败或人工接管:" + q.agentId; changed = true; }
215
+ } else if (!team.messages.some(m => m.source?.requestId === q.requestId)) {
216
+ team.messages.push({ seq: team.messages.length + 1, operationId: "result:" + q.requestId,
217
+ author: { kind: "member", agentId: q.agentId, name: done.name }, text: done.report, runId: run.runId,
218
+ source: { requestId: q.requestId, cycleId: done.cycle.cycleId } }); changed = true;
219
+ }
220
+ }
221
+ if (run.state !== "running") continue;
222
+ const unavailable = team.members.find(m => {
223
+ const v = views.find(v => v.agentId === m.agentId), ready = this.files.ready(m.agentId);
224
+ return !v?.alive || !v.owned || v.generation !== run.generations[m.agentId] || !ready?.ready || ready.generation !== v.generation ||
225
+ ["stopping", "stopped", "error"].includes(v.state) || (v.cycle?.state === "running" && v.cycle.source !== "parent");
226
+ });
227
+ if (!this.owned(team, owner) || unavailable || Date.now() >= run.deadlineAt) {
228
+ run.state = "paused"; run.reason = !this.owned(team, owner) ? "当前分支无团队归属" : unavailable ? "成员不可用:" + unavailable.agentId : "已到期限";
229
+ changed = true; continue;
230
+ }
231
+ const finished = new Set(team.messages.filter(m => m.runId === run.runId && m.source).map(m => m.source!.requestId));
232
+ const outstanding = run.issued.filter(q => !finished.has(q.requestId));
233
+ const round = Math.max(1, ...run.issued.map(q => q.round));
234
+ const thisRound = run.issued.filter(q => q.round === round);
235
+ const nextRound = thisRound.length === team.members.length && thisRound.every(q => finished.has(q.requestId)) ? round + 1 : round;
236
+ if (nextRound > run.rounds) { run.state = "finished"; changed = true; continue; }
237
+ let slots = run.maxConcurrent - outstanding.length;
238
+ for (const m of team.members) {
239
+ if (slots <= 0) break;
240
+ if (run.issued.some(q => q.round === nextRound && q.agentId === m.agentId)) continue;
241
+ // Waiting here does not create a second work queue. Unexpected private work pauses the batch.
242
+ const view = views.find(v => v.agentId === m.agentId);
243
+ if (view?.state === "busy" && view.cycle?.requestIds.some(id => run.issued.some(q => q.requestId === id))) break;
244
+ try { this.available(m.agentId, owner, views, links); }
245
+ catch (error) {
246
+ if ((error as NodeJS.ErrnoException).code !== "TEAM_NOT_READY") throw error;
247
+ run.state = "paused"; run.reason = String(error); changed = true; break;
248
+ }
249
+ const q = { requestId: randomUUID(), agentId: m.agentId, generation: run.generations[m.agentId]!, round: nextRound, throughSeq: team.messages.length };
250
+ run.issued.push(q); slots--; changed = true;
251
+ inputs.push({ ...q, teamId: team.teamId, runId: run.runId,
252
+ message: ["# 团队发言任务", "", "执行本次团队发言安排。最终答复将公开给全体成员;这不扩大原任务授权。", "",
253
+ "- `teamId`: `" + team.teamId + "`", "- `runId`: `" + run.runId + "`", "- 轮次:" + q.round,
254
+ "", "## 本批次要求", "", run.instruction].join("\n") });
255
+ }
256
+ }
257
+ if (changed) this.files.save(team);
258
+ }
259
+ return inputs.filter(input => records.find(t => t.teamId === input.teamId)?.runs.find(r => r.runId === input.runId)?.state === "running");
260
+ });
261
+ }
262
+ notifications(owner: AgentOwner, views: TeamAgent[]) {
263
+ return this.records(owner.sessionId).filter(t => this.owned(t, owner) && t.members.every(m => views.some(v => v.agentId === m.agentId && v.owned)))
264
+ .flatMap(team => {
265
+ const requests = this.requests(team, views);
266
+ return team.runs.filter(r => r.state !== "running").map(run => {
267
+ const activeCount = requests.filter(q => q.runId === run.runId && q.active).length;
268
+ return { key: team.teamId + ":" + run.runId + ":" + run.state + (activeCount ? ":draining" : ":settled"),
269
+ teamId: team.teamId, name: team.name, runId: run.runId, state: run.state, activeCount,
270
+ ...(run.reason ? { reason: run.reason } : {}) };
271
+ });
272
+ });
273
+ }
274
+ }
@@ -1,11 +1,11 @@
1
- import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
- import { object } from "../common/json.js";
4
+ import { canonical, object } from "../common/json.js";
5
5
  import { requireThat } from "../common/validation.js";
6
6
  import { readJsonFile, writeJsonFile } from "./atomic-json.js";
7
7
  import { fileId, privateDirectory } from "./file-access.js";
8
- import type { AgentDescriptor, AgentLaunch, AgentMessage, AgentStatus, AgentCycle } from "../model/agent.js";
8
+ import type { AgentDescriptor, AgentLaunch, AgentMessage, AgentStatus, AgentCycle, AgentSessionRef } from "../model/agent.js";
9
9
 
10
10
  const text = (v: unknown): v is string => typeof v === "string" && v.length > 0;
11
11
  const number = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
@@ -14,6 +14,10 @@ export function descriptor(v: unknown): AgentDescriptor {
14
14
  (v.parentLeaf === null || text(v.parentLeaf)) && number(v.createdAt) && object(v.options), "子会话身份无效。");
15
15
  fileId(v.agentId as string); return v as unknown as AgentDescriptor;
16
16
  }
17
+ export function sessionRef(v: unknown): AgentSessionRef {
18
+ requireThat(object(v) && (v.file === null || (text(v.file) && isAbsolute(v.file) && !v.file.includes("\0"))), "子会话引用无效。");
19
+ return v as unknown as AgentSessionRef;
20
+ }
17
21
  export function launch(v: unknown): AgentLaunch {
18
22
  requireThat(object(v) && text(v.generation) && text(v.name) && Number.isSafeInteger(v.sequence) && typeof v.stop === "boolean" && number(v.startedAt), "子会话启动状态无效。");
19
23
  return v as unknown as AgentLaunch;
@@ -36,21 +40,30 @@ export function status(v: unknown): AgentStatus {
36
40
  if (v.cycle) cycle(v.cycle); return v as unknown as AgentStatus;
37
41
  }
38
42
  export class AgentFiles {
43
+ #failure: unknown;
39
44
  constructor(readonly root: string) {}
45
+ check() { if (this.#failure) throw this.#failure; }
46
+ fail(error: unknown) { this.#failure = error; }
40
47
  directory(id: string) { return join(this.root, fileId(id)); }
41
48
  read<T>(id: string, path: string, parse: (v: unknown) => T): T { return readJsonFile(this.directory(id), path, parse); }
42
49
  optional<T>(id: string, path: string, parse: (v: unknown) => T): T | undefined {
43
50
  try { return this.read(id, path, parse); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
44
51
  }
45
52
  write<T>(id: string, path: string, value: T, parse: (v: unknown) => T, create = false) {
46
- requireThat(writeJsonFile(this.directory(id), path, value, parse, create), "子会话文件已存在。");
53
+ this.check(); parse(value);
54
+ let published: boolean;
55
+ try { published = writeJsonFile(this.directory(id), path, value, parse, create); }
56
+ catch (error) { this.fail(error); throw error; }
57
+ requireThat(published, "子会话文件已存在。");
47
58
  }
48
59
  create(value: AgentDescriptor, run: AgentLaunch) {
60
+ this.check();
49
61
  mkdirSync(this.root, { recursive: true, mode: 0o700 });
50
62
  mkdirSync(this.directory(value.agentId), { mode: 0o700 });
51
63
  for (const dir of ["inbox", "completions"]) privateDirectory(this.directory(value.agentId), dir);
52
64
  this.write(value.agentId, "descriptor.json", value, descriptor, true);
53
65
  this.write(value.agentId, "launch.json", run, launch, true);
66
+ this.write(value.agentId, "session-ref.json", { file: null }, sessionRef, true);
54
67
  }
55
68
  list() {
56
69
  if (!existsSync(this.root)) return [];
@@ -66,19 +79,39 @@ export class AgentFiles {
66
79
  return readdirSync(join(this.directory(id), "completions")).filter(n => n.endsWith(".json"))
67
80
  .map(n => this.read(id, "completions/" + n, cycle)).sort((a, b) => (a.finishedAt ?? 0) - (b.finishedAt ?? 0));
68
81
  }
69
- session(id: string) { return join(realpathSync(this.directory(id)), "session.jsonl"); }
82
+ session(id: string): string | null {
83
+ const ref = this.optional(id, "session-ref.json", sessionRef);
84
+ requireThat(ref, "缺少原生会话引用;不导入旧记录,请重新 start。");
85
+ return ref.file;
86
+ }
87
+ /** The child fixes the native path before accepting input; never reset it to null. */
88
+ bindSession(id: string, file: string) {
89
+ const previous = this.session(id);
90
+ requireThat(previous === null || previous === file, "子会话文件引用不匹配。");
91
+ if (previous === null) this.write(id, "session-ref.json", { file }, sessionRef);
92
+ }
70
93
  /** Short controller edits are serialized; an abandoned lock fails closed. */
71
94
  control<T>(work: () => T): T {
95
+ this.check();
72
96
  mkdirSync(this.root, { recursive: true, mode: 0o700 });
73
97
  const path = join(this.root, ".control");
74
98
  try { mkdirSync(path, { mode: 0o700 }); }
75
- catch { throw new Error("子会话控制正在进行;稍后重试。崩溃遗留的 .control 需停止相关 Pi 后手动移除。"); }
99
+ catch (error) {
100
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
101
+ throw Object.assign(new Error("子会话控制正在进行;稍后重试。崩溃遗留的 .control 需停止相关 Pi 后手动移除。"), { code: "PBOT_CONTROL_BUSY" });
102
+ }
76
103
  try { return work(); } finally { rmSync(path, { recursive: true }); }
77
104
  }
78
- enqueue(id: string, text: string, delivery: AgentMessage["delivery"], files: string[] = []) {
79
- const run = this.read(id, "launch.json", launch); run.sequence++;
105
+ enqueue(id: string, text: string, delivery: AgentMessage["delivery"], files: string[] = [], requestId: string = randomUUID()) {
106
+ fileId(requestId);
107
+ const run = this.read(id, "launch.json", launch), previous = this.optional(id, "inbox/" + requestId + ".json", message);
108
+ if (previous) {
109
+ requireThat(previous.generation === run.generation && previous.message === text && previous.delivery === delivery && canonical(previous.files) === canonical(files), "请求 ID 已被不同输入使用。");
110
+ return previous;
111
+ }
112
+ run.sequence++;
80
113
  this.write(id, "launch.json", run, launch);
81
- const value: AgentMessage = { requestId: randomUUID(), generation: run.generation, sequence: run.sequence, message: text, delivery, files };
114
+ const value: AgentMessage = { requestId, generation: run.generation, sequence: run.sequence, message: text, delivery, files };
82
115
  this.write(id, "inbox/" + value.requestId + ".json", value, message, true); return value;
83
116
  }
84
117
  }
@@ -0,0 +1,93 @@
1
+ import { existsSync, lstatSync, mkdirSync, readdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { object } from "../common/json.js";
4
+ import { requireThat } from "../common/validation.js";
5
+ import type { TeamRecord, TeamReady, TeamLink } from "../model/team.js";
6
+ import { AgentFiles } from "./agent-files.js";
7
+ import { readJsonFile, writeJsonFile } from "./atomic-json.js";
8
+ import { fileId, privateDirectory } from "./file-access.js";
9
+
10
+ const text = (v: unknown): v is string => typeof v === "string" && v.length > 0;
11
+ const integer = (v: unknown, min = 0): v is number => Number.isSafeInteger(v) && (v as number) >= min;
12
+ export function teamReady(v: unknown): TeamReady {
13
+ requireThat(object(v) && text(v.generation) && typeof v.ready === "boolean", "团队就绪标记无效。");
14
+ return v as unknown as TeamReady;
15
+ }
16
+ export function teamRecord(v: unknown): TeamRecord {
17
+ requireThat(object(v) && text(v.teamId) && text(v.release) && text(v.workspace) && text(v.parentSessionId) &&
18
+ (v.parentLeaf === null || text(v.parentLeaf)) && text(v.name) && text(v.brief) && typeof v.closed === "boolean" &&
19
+ Array.isArray(v.members) && v.members.length > 0 && v.members.length <= 50 && Array.isArray(v.runs) && Array.isArray(v.messages), "团队记录无效。");
20
+ fileId(v.teamId);
21
+ const members = new Set<string>(), runs = new Set<string>(), requests = new Map<string, { agentId: string; runId: string }>(), operations = new Set<string>();
22
+ for (const m of v.members) {
23
+ requireThat(object(m) && text(m.agentId) && text(m.role) && !members.has(m.agentId), "团队成员无效或重复。");
24
+ fileId(m.agentId); members.add(m.agentId);
25
+ }
26
+ let running = 0;
27
+ for (const r of v.runs) {
28
+ requireThat(object(r) && text(r.runId) && !runs.has(r.runId) && text(r.instruction) &&
29
+ ["roundRobin", "parallel"].includes(String(r.mode)) && integer(r.rounds, 1) && r.rounds <= 10 &&
30
+ integer(r.maxConcurrent, 1) && r.maxConcurrent <= members.size && (r.mode !== "roundRobin" || r.maxConcurrent === 1) &&
31
+ integer(r.deadlineAt, 1) && ["running", "paused", "finished"].includes(String(r.state)) &&
32
+ (r.reason === undefined || text(r.reason)) && object(r.generations) && Array.isArray(r.issued), "团队批次无效。");
33
+ fileId(r.runId); runs.add(r.runId);
34
+ requireThat(Object.keys(r.generations).length === members.size && [...members].every(id => text((r.generations as Record<string, unknown>)[id])), "团队代次无效。");
35
+ if (r.state === "running") running++;
36
+ const turns = new Set<string>();
37
+ for (const q of r.issued) {
38
+ requireThat(object(q) && text(q.requestId) && !requests.has(q.requestId) && text(q.agentId) && members.has(q.agentId) &&
39
+ q.generation === r.generations[q.agentId] && integer(q.round, 1) && q.round <= r.rounds &&
40
+ integer(q.throughSeq) && q.throughSeq <= v.messages.length && !turns.has(q.agentId + ":" + q.round), "团队请求关联无效。");
41
+ fileId(q.requestId); requests.set(q.requestId, { agentId: q.agentId, runId: r.runId }); turns.add(q.agentId + ":" + q.round);
42
+ }
43
+ }
44
+ requireThat(running <= 1 && (!v.closed || running === 0), "团队运行状态冲突。");
45
+ for (const [i, m] of v.messages.entries()) {
46
+ requireThat(object(m) && m.seq === i + 1 && text(m.operationId) && !operations.has(m.operationId) && typeof m.text === "string" &&
47
+ object(m.author) && ["user", "coordinator", "member"].includes(String(m.author.kind)) && text(m.author.name) &&
48
+ (m.author.kind === "member" ? text(m.author.agentId) && members.has(m.author.agentId) : m.author.agentId === undefined) &&
49
+ (m.replyTo === undefined || (integer(m.replyTo, 1) && m.replyTo <= i)) &&
50
+ (m.runId === undefined || (text(m.runId) && runs.has(m.runId))), "团队消息无效。");
51
+ if (m.source !== undefined) requireThat(object(m.source) && text(m.source.requestId) && text(m.source.cycleId) &&
52
+ requests.get(m.source.requestId)?.agentId === m.author.agentId && requests.get(m.source.requestId)?.runId === m.runId, "团队报告关联无效。");
53
+ operations.add(m.operationId);
54
+ }
55
+ return v as unknown as TeamRecord;
56
+ }
57
+ export class TeamFiles {
58
+ readonly root: string;
59
+ constructor(readonly agents: AgentFiles) { this.root = join(dirname(agents.root), "teams"); }
60
+ check() { this.agents.check(); }
61
+ read(id: string) {
62
+ const value = readJsonFile(this.root, fileId(id) + "/team.json", teamRecord);
63
+ requireThat(value.teamId === id, "团队路径与身份不匹配。"); return value;
64
+ }
65
+ list() {
66
+ if (!existsSync(this.root)) return [];
67
+ return readdirSync(this.root, { withFileTypes: true }).filter(e => !e.name.startsWith(".")).map(e => this.read(e.name));
68
+ }
69
+ save(value: TeamRecord, create = false) {
70
+ this.check(); teamRecord(value);
71
+ let published: boolean;
72
+ try {
73
+ mkdirSync(this.root, { recursive: true, mode: 0o700 });
74
+ const directory = lstatSync(this.root);
75
+ requireThat(directory.isDirectory() && !directory.isSymbolicLink(), "团队状态必须使用普通目录。");
76
+ if (create) privateDirectory(this.root, fileId(value.teamId));
77
+ published = writeJsonFile(this.root, fileId(value.teamId) + "/team.json", value, teamRecord, create);
78
+ } catch (error) { this.agents.fail(error); throw error; }
79
+ requireThat(published, "团队已存在。");
80
+ }
81
+ ready(id: string) { return this.agents.optional(id, "team-ready.json", teamReady); }
82
+ setReady(id: string, generation: string, ready: boolean) {
83
+ this.agents.write(id, "team-ready.json", { generation, ready }, teamReady);
84
+ }
85
+ links(records = this.list()): Map<string, TeamLink> {
86
+ const result = new Map<string, TeamLink>();
87
+ for (const team of records) for (const run of team.runs) for (const request of run.issued) {
88
+ requireThat(!result.has(request.requestId), "团队请求 ID 重复。");
89
+ result.set(request.requestId, { team, run, request });
90
+ }
91
+ return result;
92
+ }
93
+ }
@@ -24,7 +24,7 @@ const page = {
24
24
  const delivery = Type.Optional(Type.Union([Type.Literal("steer"), Type.Literal("followUp")], { description: "send 默认 steer 补充当前工作;followUp 排到当前工作之后。" }));
25
25
  export const agentParametersSchema = Type.Object({
26
26
  action: Type.Union([Type.Literal("start"), Type.Literal("send"), Type.Literal("rename"), Type.Literal("status"), Type.Literal("list"), Type.Literal("read"), Type.Literal("stop"), Type.Literal("resume")], {
27
- description: "start 至少提供 message/prompts/files 之一,其余启动选项仅用于 start。list 不带其他字段;其余操作须 agentId。send/resume 须 message,rename 须 name;分页仅用于 read。",
27
+ description: "start 可省略 message/prompts/files,只创建待命进程;启动选项仅用于 start。list 不带其他字段;其余操作须 agentId。send/resume 须 message,rename 须 name;分页仅用于 read。",
28
28
  }),
29
29
  agentId: Type.Optional(id), ...options, ...page, delivery,
30
30
  }, { additionalProperties: false });
@@ -5,7 +5,7 @@ import { agentOwner } from "../../integrations/pi/agent-context.js";
5
5
  import type { AgentOptions, AgentView } from "../../model/agent.js";
6
6
  import type { CallAccess } from "../access.js";
7
7
  import { requireThat, schema } from "../../common/validation.js";
8
- import { result } from "../result.js";
8
+ import { markdownResult } from "../result.js";
9
9
  import { agentActionSchema, agentParametersSchema, agentResultSchema, agentViewSchema } from "./contract.js";
10
10
 
11
11
  type AgentOutput = Static<typeof agentViewSchema>;
@@ -29,7 +29,7 @@ function agentOutput(view: AgentView & Partial<AgentOutput>): AgentOutput {
29
29
  export function agentTool(service: AgentService, access: CallAccess) {
30
30
  const valid = schema(agentActionSchema);
31
31
  return defineTool({ name: "pbot_agent", label: "子会话",
32
- description: "独立 Pi 子会话,不继承父聊天历史。start 立即返回,完成自动通知,无需轮询。send 继续对话,rename 改名,status/list 查看状态,read 非阻塞读取报告。state 是进程状态,cycle.state 是工作结果,queued 仅表示入队。stop 停止并保留历史,resume 携新 message 恢复,不重放旧工作。独立复核须 start 新上下文。子会话使用原生 CLI/MCP,按委派范围工作,不递归委派。每个父会话最多 50 个存活子会话,空闲也计入。",
32
+ description: "独立 Pi 子会话,不继承父聊天历史。start 立即返回;未提供 message/prompts/files 时只启动待命进程,不调用模型。完成自动通知,无需轮询。团队占用期间不能私人 send。send 继续对话,rename 改名,status/list 查看状态,read 非阻塞读取报告。state 是进程状态,cycle.state 是工作结果,queued 仅表示入队。stop 停止并保留历史,resume 携新 message 恢复,不重放旧工作。独立复核须 start 新上下文。子会话使用原生 CLI/MCP,按委派范围工作,不递归委派。每个父会话最多 50 个存活子会话,空闲也计入。",
33
33
  parameters: agentParametersSchema, outputSchema: agentResultSchema,
34
34
  async execute(_id, args, signal, _update, ctx) {
35
35
  access.check(); signal?.throwIfAborted();
@@ -38,11 +38,10 @@ export function agentTool(service: AgentService, access: CallAccess) {
38
38
  let value: AgentView & Partial<AgentOutput>;
39
39
  switch (input.action) {
40
40
  case "start": {
41
- requireThat(input.message || input.prompts?.length || input.files?.length, "start 需要 message、prompts 或 files。");
42
41
  const { action: _action, ...supplied } = input;
43
42
  value = service.start({ provider: ctx.model?.provider, model: ctx.model?.id, thinking: ctx.thinkingLevel, ...supplied } as AgentOptions, owner); break;
44
43
  }
45
- case "list": return result({ agents: service.list(owner).map(agentOutput) });
44
+ case "list": return markdownResult({ agents: service.list(owner).map(agentOutput) }, "子会话列表");
46
45
  case "send": value = service.send(input.agentId!, input.message!, input.delivery ?? "steer", owner); break;
47
46
  case "rename": value = service.rename(input.agentId!, input.name!, owner); break;
48
47
  case "resume": value = service.resume(input.agentId!, input.message!, owner); break;
@@ -50,11 +49,11 @@ export function agentTool(service: AgentService, access: CallAccess) {
50
49
  case "status": value = service.read(input.agentId!, owner, undefined, 0, 1); break;
51
50
  case "read": {
52
51
  const page = service.read(input.agentId!, owner, input.cycleId, input.offset, input.limit);
53
- const data = agentOutput(page), output = result(data);
52
+ const data = agentOutput(page), output = markdownResult(data, "子会话报告");
54
53
  return { ...output, details: { ...data, ...(page.cycle && page.cycle.state !== "running" ? { agentReadKeys: [page.agentId + ":" + page.cycle.cycleId] } : {}) } };
55
54
  }
56
55
  }
57
- return result(agentOutput(value));
56
+ return markdownResult(agentOutput(value), "子会话状态");
58
57
  },
59
58
  });
60
59
  }
@@ -5,9 +5,12 @@ import { resourceRoot } from "../common/paths.js";
5
5
  import type { AgentService } from "../services/agents/agent-service.js";
6
6
  import type { CallAccess } from "./access.js";
7
7
  import { agentTool } from "./agents/tool.js";
8
+ import type { TeamService } from "../services/agents/team-service.js";
9
+ import { teamTool, teamMessageTool } from "./teams/tool.js";
10
+ import { agentOwner } from "../integrations/pi/agent-context.js";
8
11
 
9
- export function mainTools(agents: AgentService, access: CallAccess): ToolDefinition[] {
10
- return [agentTool(agents, access)];
12
+ export function mainTools(agents: AgentService, teams: TeamService, access: CallAccess): ToolDefinition[] {
13
+ return [agentTool(agents, access), teamTool(teams, agents, access), teamMessageTool(teams, access, ctx => ({ kind: "coordinator", owner: agentOwner(ctx) }))];
11
14
  }
12
15
  export class ToolRegistry {
13
16
  readonly #registered = new Map<string, ToolDefinition>();
@@ -16,7 +19,7 @@ export class ToolRegistry {
16
19
  const promptGuidelines = readFileSync(join(resourceRoot, "prompts/tool-guidelines.md"), "utf8")
17
20
  .trim().split(/\r?\n\s*\r?\n/);
18
21
  for (const definition of tools) {
19
- const tool = { ...definition, namespace: { name: "pbot", description: "Personal Bot 独立子会话工具。" },
22
+ const tool = { ...definition, namespace: { name: "pbot", description: "Personal Bot 独立子会话与团队协作工具。" },
20
23
  promptGuidelines };
21
24
  this.#registered.set(tool.name, tool); this.pi.registerTool(tool);
22
25
  }
@@ -1,4 +1,9 @@
1
1
  import { json } from "../common/json.js";
2
+ import { markdownFields } from "../common/markdown.js";
3
+
4
+ export function markdownResult(value: unknown, title: string) {
5
+ return result(value, false, "# " + title + "\n\n" + markdownFields(json(value)));
6
+ }
2
7
 
3
8
  export function result(value: unknown, isError = false, text?: string) {
4
9
  const data = json(value);
@@ -0,0 +1,49 @@
1
+ import { Type } from "typebox";
2
+ import type { TSchema } from "typebox";
3
+
4
+ const id = Type.String({ minLength: 1, maxLength: 128 });
5
+ const text = Type.String({ minLength: 1, maxLength: 24000 });
6
+ const name = Type.String({ minLength: 1, maxLength: 64, pattern: "^[^\\r\\n\\x00-\\x1f]+$" });
7
+ const mode = Type.Union([Type.Literal("roundRobin"), Type.Literal("parallel")]);
8
+ const members = Type.Array(Type.Object({ agentId: id, role: Type.String({ minLength: 1, maxLength: 4000 }) }, { additionalProperties: false }), { minItems: 1, maxItems: 50 });
9
+ const run = { instruction: text, mode, rounds: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })),
10
+ maxConcurrent: Type.Optional(Type.Integer({ minimum: 1, maximum: 50 })), timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 86400 })) };
11
+ const page = { afterSeq: Type.Optional(Type.Integer({ minimum: 0 })), seq: Type.Optional(Type.Integer({ minimum: 1 })),
12
+ offset: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 16000 })) };
13
+ const object = <T extends Record<string, TSchema>>(fields: T) => Type.Object(fields, { additionalProperties: false });
14
+ export const teamParametersSchema = object({
15
+ action: Type.Union(["create", "list", "status", "read", "run", "pause", "close"].map(v => Type.Literal(v))),
16
+ teamId: Type.Optional(id), name: Type.Optional(name), brief: Type.Optional(text), members: Type.Optional(members),
17
+ instruction: Type.Optional(text), mode: Type.Optional(mode), rounds: run.rounds, maxConcurrent: run.maxConcurrent, timeoutSeconds: run.timeoutSeconds, ...page,
18
+ });
19
+ export const teamActionSchema = Type.Union([
20
+ object({ action: Type.Literal("create"), name, brief: text, members }),
21
+ object({ action: Type.Literal("list") }),
22
+ ...["status", "pause", "close"].map(v => object({ action: Type.Literal(v), teamId: id })),
23
+ object({ action: Type.Literal("run"), teamId: id, ...run }),
24
+ object({ action: Type.Literal("read"), teamId: id, ...page }),
25
+ ]);
26
+ export const teamMessageParametersSchema = object({
27
+ action: Type.Union([Type.Literal("read"), Type.Literal("post")]), teamId: id, text: Type.Optional(text),
28
+ replyTo: Type.Optional(Type.Integer({ minimum: 1 })), ...page,
29
+ });
30
+ export const teamMessageActionSchema = Type.Union([
31
+ object({ action: Type.Literal("read"), teamId: id, ...page }),
32
+ object({ action: Type.Literal("post"), teamId: id, text, replyTo: Type.Optional(Type.Integer({ minimum: 1 })) }),
33
+ ]);
34
+ const nullableInteger = Type.Union([Type.Integer(), Type.Null()]);
35
+ const message = object({ seq: Type.Integer(), operationId: Type.String(),
36
+ author: object({ kind: Type.Union([Type.Literal("user"), Type.Literal("coordinator"), Type.Literal("member")]), agentId: Type.Optional(id), name: Type.String() }),
37
+ text: Type.String(), replyTo: Type.Optional(Type.Integer()), runId: Type.Optional(id),
38
+ source: Type.Optional(object({ requestId: id, cycleId: id })),
39
+ offset: Type.Integer(), totalLength: Type.Integer(), nextOffset: nullableInteger });
40
+ export const teamPageSchema = object({ teamId: id, messages: Type.Array(message), lastSeq: Type.Integer(), nextAfterSeq: nullableInteger });
41
+ const ownedView = object({ teamId: id, name: Type.String(), brief: Type.String(), closed: Type.Boolean(), owned: Type.Literal(true),
42
+ members: Type.Array(object({ agentId: id, role: Type.String(), name: Type.String(), state: Type.String() })),
43
+ lastSeq: Type.Integer(), activeCount: Type.Integer(),
44
+ unresolved: Type.Array(object({ requestId: id, agentId: id, runId: id, round: Type.Integer(), active: Type.Boolean(), state: Type.String(), cycleId: Type.Optional(id) })),
45
+ run: Type.Optional(object({ runId: id, instruction: Type.String(), mode, state: Type.Union([Type.Literal("running"), Type.Literal("paused"), Type.Literal("finished")]),
46
+ rounds: Type.Integer(), maxConcurrent: Type.Integer(), deadlineAt: Type.Number(), scheduled: Type.Integer(), published: Type.Integer(), reason: Type.Optional(Type.String()) })) });
47
+ const view = Type.Union([ownedView, object({ teamId: id, name: Type.String(), closed: Type.Boolean(), owned: Type.Literal(false) })]);
48
+ export const teamResultSchema = Type.Union([view, object({ teams: Type.Array(view) }), teamPageSchema]);
49
+ export const teamMessageResultSchema = Type.Union([teamPageSchema, object({ teamId: id, seq: Type.Integer(), status: Type.Literal("stored") })]);