personal-bot 0.0.0-stage → 0.1.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.
Files changed (60) hide show
  1. package/CONTEXT.md +90 -0
  2. package/README.md +113 -2
  3. package/docs/README.md +17 -0
  4. package/docs/agent-collaboration.md +73 -0
  5. package/docs/architecture-guidelines.md +37 -0
  6. package/docs/capability-design.md +43 -0
  7. package/docs/file-storage.md +40 -0
  8. package/docs/release.md +96 -0
  9. package/docs/technical-design.md +80 -0
  10. package/docs/validation.md +40 -0
  11. package/package.json +66 -4
  12. package/python/README.md +69 -0
  13. package/python/requirements.lock +38 -0
  14. package/resources/agents/coordinator.md +31 -0
  15. package/resources/agents/general-purpose.md +13 -0
  16. package/resources/prompts/tool-guidelines.md +3 -0
  17. package/resources/prompts/workspace.md +39 -0
  18. package/resources/references/agent-task.md +31 -0
  19. package/resources/references/compute.md +97 -0
  20. package/resources/skills/analysis-validation/SKILL.md +48 -0
  21. package/resources/skills/project-maintenance/SKILL.md +48 -0
  22. package/resources/skills/provider-connections/SKILL.md +32 -0
  23. package/resources/skills/research-evidence/SKILL.md +33 -0
  24. package/resources/skills/research-evidence/references/hypothesis-checks.md +33 -0
  25. package/resources/templates/workspace-agents.md +13 -0
  26. package/scripts/README.md +13 -0
  27. package/scripts/setup-python.py +144 -0
  28. package/src/common/files.ts +25 -0
  29. package/src/common/json.ts +14 -0
  30. package/src/common/paths.ts +3 -0
  31. package/src/common/validation.ts +14 -0
  32. package/src/common/version.ts +15 -0
  33. package/src/extension/agent-events.ts +65 -0
  34. package/src/extension/agents-command.ts +49 -0
  35. package/src/extension/command.ts +43 -0
  36. package/src/extension/events.ts +62 -0
  37. package/src/extension/lifecycle.ts +77 -0
  38. package/src/extension/subagent-entry.ts +181 -0
  39. package/src/integrations/pi/agent-context.ts +18 -0
  40. package/src/integrations/pi/agent-resources.ts +15 -0
  41. package/src/integrations/pi/agent-tools.ts +35 -0
  42. package/src/integrations/pi/pi-agents.ts +50 -0
  43. package/src/integrations/process.ts +40 -0
  44. package/src/integrations/python-activation.ts +23 -0
  45. package/src/model/agent.ts +27 -0
  46. package/src/pbot.ts +22 -0
  47. package/src/services/agents/agent-service.ts +153 -0
  48. package/src/services/workspace/python-environment-service.ts +44 -0
  49. package/src/services/workspace/workspace-files.ts +17 -0
  50. package/src/services/workspace/workspace-preparation.ts +17 -0
  51. package/src/storage/agent-files.ts +84 -0
  52. package/src/storage/atomic-json.ts +74 -0
  53. package/src/storage/file-access.ts +19 -0
  54. package/src/tools/access.ts +7 -0
  55. package/src/tools/agents/contract.ts +54 -0
  56. package/src/tools/agents/tool.ts +60 -0
  57. package/src/tools/register.ts +27 -0
  58. package/src/tools/result.ts +7 -0
  59. package/src/ui/agent-display.ts +11 -0
  60. package/src/ui/confirmation.ts +162 -0
@@ -0,0 +1,17 @@
1
+ import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { requireThat } from "../../common/validation.js";
4
+ import { resourceRoot } from "../../common/paths.js";
5
+
6
+ export const directories = ["areas", "projects", "knowledge", "providers", "scratch", "agents"];
7
+ export async function prepareWorkspace(root: string) {
8
+ for (const part of directories) {
9
+ const path = join(root, part);
10
+ await mkdir(path, { mode: 0o700 }).catch(error => { if (error.code !== "EEXIST") throw error; });
11
+ const entry = await lstat(path);
12
+ requireThat(entry.isDirectory() && !entry.isSymbolicLink(), "工作区目录不能使用符号链接:" + path);
13
+ }
14
+ const agents = await readFile(join(resourceRoot, "templates/workspace-agents.md"), "utf8");
15
+ await writeFile(join(root, "AGENTS.md"), agents, { flag: "wx" })
16
+ .catch(error => { if (error.code !== "EEXIST") throw error; });
17
+ }
@@ -0,0 +1,84 @@
1
+ import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { object } from "../common/json.js";
5
+ import { requireThat } from "../common/validation.js";
6
+ import { readJsonFile, writeJsonFile } from "./atomic-json.js";
7
+ import { fileId, privateDirectory } from "./file-access.js";
8
+ import type { AgentDescriptor, AgentLaunch, AgentMessage, AgentStatus, AgentCycle } from "../model/agent.js";
9
+
10
+ const text = (v: unknown): v is string => typeof v === "string" && v.length > 0;
11
+ const number = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
12
+ export function descriptor(v: unknown): AgentDescriptor {
13
+ requireThat(object(v) && text(v.agentId) && text(v.release) && text(v.workspace) && text(v.cwd) && text(v.parentSessionId) &&
14
+ (v.parentLeaf === null || text(v.parentLeaf)) && number(v.createdAt) && object(v.options), "子会话身份无效。");
15
+ fileId(v.agentId as string); return v as unknown as AgentDescriptor;
16
+ }
17
+ export function launch(v: unknown): AgentLaunch {
18
+ requireThat(object(v) && text(v.generation) && text(v.name) && Number.isSafeInteger(v.sequence) && typeof v.stop === "boolean" && number(v.startedAt), "子会话启动状态无效。");
19
+ return v as unknown as AgentLaunch;
20
+ }
21
+ export function message(v: unknown): AgentMessage {
22
+ requireThat(object(v) && text(v.requestId) && text(v.generation) && Number.isSafeInteger(v.sequence) && typeof v.message === "string" &&
23
+ ["steer", "followUp"].includes(String(v.delivery)) && Array.isArray(v.files) && v.files.every(text), "子会话消息无效。");
24
+ return v as unknown as AgentMessage;
25
+ }
26
+ export function cycle(v: unknown): AgentCycle {
27
+ requireThat(object(v) && text(v.cycleId) && text(v.generation) && number(v.startedAt) &&
28
+ ["running", "completed", "failed", "cancelled", "interrupted"].includes(String(v.state)) &&
29
+ (v.leaf === null || text(v.leaf)) && Array.isArray(v.requestIds) && v.requestIds.every(text) &&
30
+ ["parent", "interactive", "mixed"].includes(String(v.source)) && (v.finishedAt === undefined || number(v.finishedAt)), "子会话周期无效。");
31
+ return v as unknown as AgentCycle;
32
+ }
33
+ export function status(v: unknown): AgentStatus {
34
+ requireThat(object(v) && text(v.generation) && ["starting", "busy", "idle", "stopping", "stopped", "error"].includes(String(v.state)) &&
35
+ typeof v.activity === "string" && number(v.updatedAt), "子会话状态无效。");
36
+ if (v.cycle) cycle(v.cycle); return v as unknown as AgentStatus;
37
+ }
38
+ export class AgentFiles {
39
+ constructor(readonly root: string) {}
40
+ directory(id: string) { return join(this.root, fileId(id)); }
41
+ read<T>(id: string, path: string, parse: (v: unknown) => T): T { return readJsonFile(this.directory(id), path, parse); }
42
+ optional<T>(id: string, path: string, parse: (v: unknown) => T): T | undefined {
43
+ try { return this.read(id, path, parse); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
44
+ }
45
+ write<T>(id: string, path: string, value: T, parse: (v: unknown) => T, create = false) {
46
+ requireThat(writeJsonFile(this.directory(id), path, value, parse, create), "子会话文件已存在。");
47
+ }
48
+ create(value: AgentDescriptor, run: AgentLaunch) {
49
+ mkdirSync(this.root, { recursive: true, mode: 0o700 });
50
+ mkdirSync(this.directory(value.agentId), { mode: 0o700 });
51
+ for (const dir of ["inbox", "completions"]) privateDirectory(this.directory(value.agentId), dir);
52
+ this.write(value.agentId, "descriptor.json", value, descriptor, true);
53
+ this.write(value.agentId, "launch.json", run, launch, true);
54
+ }
55
+ list() {
56
+ if (!existsSync(this.root)) return [];
57
+ return readdirSync(this.root, { withFileTypes: true }).filter(e => e.isDirectory() && !e.name.startsWith(".")).flatMap(e => {
58
+ const d = this.optional(e.name, "descriptor.json", descriptor); return d ? [d] : [];
59
+ });
60
+ }
61
+ messages(id: string) {
62
+ return readdirSync(join(this.directory(id), "inbox")).filter(n => n.endsWith(".json"))
63
+ .map(n => this.read(id, "inbox/" + n, message)).sort((a, b) => a.sequence - b.sequence);
64
+ }
65
+ completions(id: string) {
66
+ return readdirSync(join(this.directory(id), "completions")).filter(n => n.endsWith(".json"))
67
+ .map(n => this.read(id, "completions/" + n, cycle)).sort((a, b) => (a.finishedAt ?? 0) - (b.finishedAt ?? 0));
68
+ }
69
+ session(id: string) { return join(realpathSync(this.directory(id)), "session.jsonl"); }
70
+ /** Short controller edits are serialized; an abandoned lock fails closed. */
71
+ control<T>(work: () => T): T {
72
+ mkdirSync(this.root, { recursive: true, mode: 0o700 });
73
+ const path = join(this.root, ".control");
74
+ try { mkdirSync(path, { mode: 0o700 }); }
75
+ catch { throw new Error("子会话控制正在进行;稍后重试。崩溃遗留的 .control 需停止相关 Pi 后手动移除。"); }
76
+ try { return work(); } finally { rmSync(path, { recursive: true }); }
77
+ }
78
+ enqueue(id: string, text: string, delivery: AgentMessage["delivery"], files: string[] = []) {
79
+ const run = this.read(id, "launch.json", launch); run.sequence++;
80
+ this.write(id, "launch.json", run, launch);
81
+ const value: AgentMessage = { requestId: randomUUID(), generation: run.generation, sequence: run.sequence, message: text, delivery, files };
82
+ this.write(id, "inbox/" + value.requestId + ".json", value, message, true); return value;
83
+ }
84
+ }
@@ -0,0 +1,74 @@
1
+ import { constants, closeSync, fstatSync, fsyncSync, linkSync, lstatSync, openSync, readFileSync, realpathSync,
2
+ renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { canonical } from "../common/json.js";
6
+ import { requireThat } from "../common/validation.js";
7
+
8
+ /** The root must already exist. Never follow aliases inside internal storage. */
9
+ export function internalPath(directory: string, relative: string): string {
10
+ const parts = relative.split("/");
11
+ requireThat(parts.every(part => part && part !== "." && part !== ".." && !/[\\\x00-\x1f]/.test(part)), "内部状态路径无效。");
12
+ const info = lstatSync(directory);
13
+ requireThat(info.isDirectory() && !info.isSymbolicLink(), "内部状态必须使用普通目录。");
14
+ let path = realpathSync(directory);
15
+ for (const [index, part] of parts.entries()) {
16
+ path = join(path, part);
17
+ try {
18
+ const info = lstatSync(path);
19
+ requireThat(!info.isSymbolicLink() && (index === parts.length - 1 ? info.isFile() : info.isDirectory()), "内部状态不能使用符号链接或特殊文件。");
20
+ } catch (error) {
21
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT" || index !== parts.length - 1) throw error;
22
+ }
23
+ }
24
+ return path;
25
+ }
26
+
27
+ export function syncDirectory(directory: string): void {
28
+ const fd = openSync(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
29
+ try { fsyncSync(fd); } finally { closeSync(fd); }
30
+ }
31
+
32
+ /** Validators return plain data and must reject incomplete contracts. */
33
+ export function readJsonFile<T>(directory: string, relative: string, validate: (value: unknown) => T): T {
34
+ const path = internalPath(directory, relative);
35
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
36
+ try {
37
+ requireThat(fstatSync(fd).isFile(), "内部状态必须是普通文件。");
38
+ let value: unknown;
39
+ try { value = JSON.parse(readFileSync(fd, "utf8")); }
40
+ catch (error) { throw new Error("内部 JSON 文件不可读取:" + relative, { cause: error }); }
41
+ return validate(value);
42
+ } finally { closeSync(fd); }
43
+ }
44
+
45
+ /**
46
+ * A failure after publication does not mean that the old value remains.
47
+ * Callers must stop writes/sends on I/O failure, even if a subsequent read succeeds.
48
+ * createOnly publishes by link, so an existing destination is never overwritten.
49
+ */
50
+ export function writeJsonFile<T>(directory: string, relative: string, value: unknown,
51
+ validate: (value: unknown) => T, createOnly = false): boolean {
52
+ const validated = validate(value);
53
+ // Check before touching disk: JSON.stringify alone silently drops unsupported values.
54
+ const expected = canonical(validated);
55
+ const content = JSON.stringify(validated, null, 2) + "\n";
56
+ requireThat(canonical(JSON.parse(content)) === expected, "JSON 序列化不能改变状态值。");
57
+ const path = internalPath(directory, relative), parent = dirname(path);
58
+ const temporary = join(parent, `.tmp-${randomUUID()}`);
59
+ const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
60
+ try {
61
+ try { writeFileSync(fd, content, "utf8"); fsyncSync(fd); }
62
+ finally { closeSync(fd); }
63
+ requireThat(internalPath(directory, relative) === path, "内部状态路径已变化。");
64
+ if (createOnly) {
65
+ try { linkSync(temporary, path); }
66
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; throw error; }
67
+ } else renameSync(temporary, path);
68
+ syncDirectory(parent);
69
+ return true;
70
+ } finally {
71
+ try { unlinkSync(temporary); }
72
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
73
+ }
74
+ }
@@ -0,0 +1,19 @@
1
+ import { lstatSync, mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { requireThat } from "../common/validation.js";
4
+ import { syncDirectory } from "./atomic-json.js";
5
+
6
+ export function fileId(id: string) {
7
+ requireThat(/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(id), "内部记录 ID 无效。"); return id;
8
+ }
9
+ export function privateDirectory(root: string, relative: string) {
10
+ const parts = relative.split("/");
11
+ let parent = root;
12
+ for (const part of parts) {
13
+ fileId(part);
14
+ const path = join(parent, part);
15
+ try { mkdirSync(path, { mode: 0o700 }); syncDirectory(parent); }
16
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
17
+ requireThat(lstatSync(path).isDirectory() && !lstatSync(path).isSymbolicLink(), "内部状态目录无效。"); parent = path;
18
+ }
19
+ }
@@ -0,0 +1,7 @@
1
+
2
+ /** Each tool is bound to one workspace instance, not a mutable global lookup. */
3
+ export interface CallAccess {
4
+ check(): void;
5
+ signal(signal?: AbortSignal): AbortSignal;
6
+ authorizeFile(path: string, change: unknown, confirm: (value: unknown) => Promise<boolean>, signal: AbortSignal): Promise<void>;
7
+ }
@@ -0,0 +1,54 @@
1
+ import { Type } from "typebox";
2
+ const id = Type.String({ minLength: 1, maxLength: 128 });
3
+ const message = Type.String({ minLength: 1, maxLength: 24000, description: "本次目标、交付要求、资料依据与约束。" });
4
+ const name = Type.String({ minLength: 1, maxLength: 64, pattern: "^[^\\r\\n\\x00-\\x1f]+$" });
5
+ const options = {
6
+ name: Type.Optional(name), message: Type.Optional(message),
7
+ files: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { maxItems: 32, description: "随初始消息读取的文件;相对路径基于原工作区根目录。" })),
8
+ prompts: Type.Optional(Type.Array(message, { maxItems: 32, description: "与 message 按顺序合并为同一初始消息,不是多个执行轮次。" })),
9
+ provider: Type.Optional(Type.String({ ...id, description: "Pi 模型提供方,默认继承主会话。" })),
10
+ model: Type.Optional(Type.String({ ...id, description: "子进程可用的 Pi 模型 ID,默认继承主会话。" })),
11
+ thinking: Type.Optional(Type.Union([Type.Literal("off"), Type.Literal("minimal"), Type.Literal("low"), Type.Literal("medium"), Type.Literal("high"), Type.Literal("xhigh"), Type.Literal("max")], { description: "默认继承主会话思考级别。" })),
12
+ cwd: Type.Optional(Type.String({ minLength: 1, description: "执行目录,相对原工作区根目录;省略时使用工作区根目录。" })),
13
+ tools: Type.Optional(Type.Array(id, { minItems: 1, description: "按 Pi 原生规则选择工具,省略时沿用原生默认工具;MCP 工具遵循原生暴露设置。不提供递归委派;这不是宿主隔离。" })),
14
+ noExtensions: Type.Optional(Type.Boolean({ description: "默认 true,不发现其他扩展。" })),
15
+ noSkills: Type.Optional(Type.Boolean({ description: "关闭技能发现,默认 false。" })),
16
+ noPromptTemplates: Type.Optional(Type.Boolean({ description: "关闭提示模板发现,默认 false。" })),
17
+ noContextFiles: Type.Optional(Type.Boolean({ description: "关闭 Pi 原生上下文文件加载,默认 false。" })),
18
+ };
19
+ const page = {
20
+ cycleId: Type.Optional(Type.String({ ...id, description: "read 的历史工作周期;省略时读取最近周期。" })),
21
+ offset: Type.Optional(Type.Integer({ minimum: 0, description: "报告字符偏移,默认 0。" })),
22
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 16000, description: "最多返回字符数,默认 16000。" })),
23
+ };
24
+ const delivery = Type.Optional(Type.Union([Type.Literal("steer"), Type.Literal("followUp")], { description: "send 默认 steer 补充当前工作;followUp 排到当前工作之后。" }));
25
+ export const agentParametersSchema = Type.Object({
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。",
28
+ }),
29
+ agentId: Type.Optional(id), ...options, ...page, delivery,
30
+ }, { additionalProperties: false });
31
+ export const agentActionSchema = Type.Union([
32
+ Type.Object({ action: Type.Literal("start"), ...options }, { additionalProperties: false }),
33
+ Type.Object({ action: Type.Literal("send"), agentId: id, message, delivery }, { additionalProperties: false }),
34
+ Type.Object({ action: Type.Literal("rename"), agentId: id, name }, { additionalProperties: false }),
35
+ Type.Object({ action: Type.Literal("resume"), agentId: id, message }, { additionalProperties: false }),
36
+ Type.Object({ action: Type.Literal("list") }, { additionalProperties: false }),
37
+ ...["stop", "status"].map(action => Type.Object({ action: Type.Literal(action), agentId: id }, { additionalProperties: false })),
38
+ Type.Object({ action: Type.Literal("read"), agentId: id, ...page }, { additionalProperties: false }),
39
+ ]);
40
+
41
+ const cycle = Type.Object({ cycleId: Type.String(), startedAt: Type.Number(), finishedAt: Type.Optional(Type.Number()),
42
+ state: Type.Union([Type.Literal("running"), Type.Literal("completed"), Type.Literal("failed"), Type.Literal("cancelled"), Type.Literal("interrupted")], { description: "本周期的工作结果;completed 仍须核验实际交付。" }),
43
+ requestIds: Type.Array(Type.String()),
44
+ source: Type.Union([Type.Literal("parent"), Type.Literal("interactive"), Type.Literal("mixed")]), error: Type.Optional(Type.String()),
45
+ }, { additionalProperties: false });
46
+ export const agentViewSchema = Type.Object({ agentId: Type.String(), name: Type.String(), cwd: Type.String(), createdAt: Type.Number(),
47
+ owned: Type.Boolean({ description: "是否属于当前父会话分支;false 时须回到所属分支操作。" }),
48
+ state: Type.Union([Type.Literal("starting"), Type.Literal("busy"), Type.Literal("idle"), Type.Literal("stopping"), Type.Literal("stopped"), Type.Literal("error")], { description: "进程状态;idle 不代表上轮工作成功,结果见 cycle.state。" }),
49
+ activity: Type.String(), cycle: Type.Optional(cycle), error: Type.Optional(Type.String()),
50
+ requestId: Type.Optional(Type.String()), messageStatus: Type.Optional(Type.Literal("queued", { description: "消息已入队,不代表模型已阅读或完成。" })),
51
+ cycles: Type.Optional(Type.Array(Type.Object({ cycleId: Type.String(), state: Type.String() }))),
52
+ report: Type.Optional(Type.String()), totalLength: Type.Optional(Type.Integer()), nextOffset: Type.Optional(Type.Union([Type.Integer(), Type.Null()])),
53
+ }, { additionalProperties: false });
54
+ export const agentResultSchema = Type.Union([agentViewSchema, Type.Object({ agents: Type.Array(agentViewSchema) }, { additionalProperties: false })]);
@@ -0,0 +1,60 @@
1
+ import type { Static } from "typebox";
2
+ import { defineTool } from "@earendil-works/pi-coding-agent";
3
+ import type { AgentService } from "../../services/agents/agent-service.js";
4
+ import { agentOwner } from "../../integrations/pi/agent-context.js";
5
+ import type { AgentOptions, AgentView } from "../../model/agent.js";
6
+ import type { CallAccess } from "../access.js";
7
+ import { requireThat, schema } from "../../common/validation.js";
8
+ import { result } from "../result.js";
9
+ import { agentActionSchema, agentParametersSchema, agentResultSchema, agentViewSchema } from "./contract.js";
10
+
11
+ type AgentOutput = Static<typeof agentViewSchema>;
12
+ function agentOutput(view: AgentView & Partial<AgentOutput>): AgentOutput {
13
+ const c = view.cycle;
14
+ return {
15
+ agentId: view.agentId, name: view.name, cwd: view.cwd, createdAt: view.createdAt,
16
+ owned: view.owned, state: view.state, activity: view.activity,
17
+ ...(c ? { cycle: { cycleId: c.cycleId, startedAt: c.startedAt, state: c.state, requestIds: c.requestIds, source: c.source,
18
+ ...(c.finishedAt === undefined ? {} : { finishedAt: c.finishedAt }), ...(c.error === undefined ? {} : { error: c.error }) } } : {}),
19
+ ...(view.error === undefined ? {} : { error: view.error }),
20
+ ...(view.requestId === undefined ? {} : { requestId: view.requestId }),
21
+ ...(view.messageStatus === undefined ? {} : { messageStatus: view.messageStatus }),
22
+ ...(view.cycles === undefined ? {} : { cycles: view.cycles }),
23
+ ...(view.report === undefined ? {} : { report: view.report }),
24
+ ...(view.totalLength === undefined ? {} : { totalLength: view.totalLength }),
25
+ ...(view.nextOffset === undefined ? {} : { nextOffset: view.nextOffset }),
26
+ };
27
+ }
28
+
29
+ export function agentTool(service: AgentService, access: CallAccess) {
30
+ const valid = schema(agentActionSchema);
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 个存活子会话,空闲也计入。",
33
+ parameters: agentParametersSchema, outputSchema: agentResultSchema,
34
+ async execute(_id, args, signal, _update, ctx) {
35
+ access.check(); signal?.throwIfAborted();
36
+ requireThat(valid(args), "子会话参数不符合所选 action;请核对必要字段。");
37
+ const input = args as Static<typeof agentParametersSchema>, owner = agentOwner(ctx);
38
+ let value: AgentView & Partial<AgentOutput>;
39
+ switch (input.action) {
40
+ case "start": {
41
+ requireThat(input.message || input.prompts?.length || input.files?.length, "start 需要 message、prompts 或 files。");
42
+ const { action: _action, ...supplied } = input;
43
+ value = service.start({ provider: ctx.model?.provider, model: ctx.model?.id, thinking: ctx.thinkingLevel, ...supplied } as AgentOptions, owner); break;
44
+ }
45
+ case "list": return result({ agents: service.list(owner).map(agentOutput) });
46
+ case "send": value = service.send(input.agentId!, input.message!, input.delivery ?? "steer", owner); break;
47
+ case "rename": value = service.rename(input.agentId!, input.name!, owner); break;
48
+ case "resume": value = service.resume(input.agentId!, input.message!, owner); break;
49
+ case "stop": value = service.stop(input.agentId!, owner); break;
50
+ case "status": value = service.read(input.agentId!, owner, undefined, 0, 1); break;
51
+ case "read": {
52
+ const page = service.read(input.agentId!, owner, input.cycleId, input.offset, input.limit);
53
+ const data = agentOutput(page), output = result(data);
54
+ return { ...output, details: { ...data, ...(page.cycle && page.cycle.state !== "running" ? { agentReadKeys: [page.agentId + ":" + page.cycle.cycleId] } : {}) } };
55
+ }
56
+ }
57
+ return result(agentOutput(value));
58
+ },
59
+ });
60
+ }
@@ -0,0 +1,27 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
4
+ import { resourceRoot } from "../common/paths.js";
5
+ import type { AgentService } from "../services/agents/agent-service.js";
6
+ import type { CallAccess } from "./access.js";
7
+ import { agentTool } from "./agents/tool.js";
8
+
9
+ export function mainTools(agents: AgentService, access: CallAccess): ToolDefinition[] {
10
+ return [agentTool(agents, access)];
11
+ }
12
+ export class ToolRegistry {
13
+ readonly #registered = new Map<string, ToolDefinition>();
14
+ constructor(private readonly pi: ExtensionAPI) {}
15
+ register(tools: ToolDefinition[]) {
16
+ const promptGuidelines = readFileSync(join(resourceRoot, "prompts/tool-guidelines.md"), "utf8")
17
+ .trim().split(/\r?\n\s*\r?\n/);
18
+ for (const definition of tools) {
19
+ const tool = { ...definition, namespace: { name: "pbot", description: "Personal Bot 独立子会话工具。" },
20
+ promptGuidelines };
21
+ this.#registered.set(tool.name, tool); this.pi.registerTool(tool);
22
+ }
23
+ }
24
+ hide() {
25
+ for (const tool of this.#registered.values()) this.pi.registerTool({ ...tool, exposure: "hidden" });
26
+ }
27
+ }
@@ -0,0 +1,7 @@
1
+ import { json } from "../common/json.js";
2
+
3
+ export function result(value: unknown, isError = false, text?: string) {
4
+ const data = json(value);
5
+ return { content: [{ type: "text" as const, text: text ?? JSON.stringify(data) }],
6
+ details: data, structuredContent: data, ...(isError ? { isError: true } : {}) };
7
+ }
@@ -0,0 +1,11 @@
1
+ import { truncateToWidth, type Component } from "@earendil-works/pi-tui";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import type { AgentView } from "../model/agent.js";
4
+ export const agentSummary = (v: AgentView) => `${v.name} · ${v.agentId.slice(0, 8)} · ${v.state} · ${v.activity.replace(/\s+/g, " ")}`;
5
+ export function agentBoard(values: AgentView[], theme: Theme): Component {
6
+ return { invalidate() {}, render(width) {
7
+ return values.filter(v => ["starting", "busy", "stopping"].includes(v.state)).map(v => truncateToWidth(
8
+ theme.fg("accent", "● " + v.name) + theme.fg("dim", ` · ${v.state} · ${Math.max(0, Math.floor((Date.now() - (v.cycle?.startedAt ?? v.createdAt)) / 1000))}s · `) +
9
+ v.activity.replace(/\s+/g, " "), Math.max(1, width)));
10
+ } };
11
+ }
@@ -0,0 +1,162 @@
1
+ import type { ExtensionContext, KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { ScrollView, SelectList, Text, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import type { Component } from "@earendil-works/pi-tui";
4
+ import { requireThat } from "../common/validation.js";
5
+
6
+ /** One confirmation owns its viewport, selection, and abort listener. */
7
+ export class ConfirmationDialog implements Component {
8
+ private readonly details: Text;
9
+ private readonly scroll: ScrollView;
10
+ private readonly choices: SelectList;
11
+ private finished = false;
12
+ private usable = false;
13
+ private showingRaw = false;
14
+ private readonly abort = () => this.finish(false);
15
+
16
+ constructor(
17
+ private readonly title: string,
18
+ private readonly text: string,
19
+ private readonly theme: Theme,
20
+ private readonly keys: KeybindingsManager,
21
+ private readonly rows: () => number,
22
+ private readonly requestRender: () => void,
23
+ private readonly done: (allowed: boolean) => void,
24
+ private readonly signal?: AbortSignal,
25
+ private readonly summary?: string,
26
+ ) {
27
+ this.details = new Text(summary ?? text, 0, 0);
28
+ this.scroll = new ScrollView(this.details, { overscroll: "contain" });
29
+ this.choices = new SelectList([{ value: "allow", label: "确认" }, { value: "cancel", label: "取消" }], 2, {
30
+ selectedPrefix: text => theme.fg("accent", text),
31
+ selectedText: text => theme.fg("accent", text),
32
+ description: text => theme.fg("muted", text),
33
+ scrollInfo: text => theme.fg("dim", text),
34
+ noMatch: text => theme.fg("dim", text),
35
+ });
36
+ this.choices.setSelectedIndex(0);
37
+ this.choices.onSelect = item => this.finish(item.value === "allow");
38
+ this.choices.onCancel = () => this.finish(false);
39
+ signal?.addEventListener("abort", this.abort, { once: true });
40
+ if (signal?.aborted) this.finish(false);
41
+ }
42
+
43
+ private finish(allowed: boolean) {
44
+ if (this.finished) return;
45
+ this.finished = true;
46
+ this.dispose();
47
+ this.done(allowed && this.usable && !this.signal?.aborted);
48
+ }
49
+
50
+ dispose() { this.signal?.removeEventListener("abort", this.abort); }
51
+
52
+ invalidate() {
53
+ this.details.invalidate();
54
+ this.choices.invalidate();
55
+ }
56
+
57
+ render(width: number): string[] {
58
+ const height = Math.max(1, Math.floor(this.rows() * 0.7));
59
+ this.usable = width >= 24 && height >= 10;
60
+ if (!this.usable) {
61
+ return new Text("窗口过小;请放大终端。Esc 取消。", 0, 0).render(Math.max(1, width)).slice(0, height);
62
+ }
63
+ const innerWidth = width - 4;
64
+ const content = this.scroll.render(innerWidth);
65
+ const viewport = Math.min(content.length, height - (this.summary === undefined ? 8 : 9));
66
+ this.scroll.updateLayout(content.length, viewport, this.requestRender);
67
+ const start = this.scroll.scrollTop;
68
+ const visible = content.slice(start, start + viewport);
69
+ const lines = [
70
+ this.theme.fg("accent", truncateToWidth(this.title, innerWidth)),
71
+ ...visible,
72
+ this.theme.fg("dim", truncateToWidth(`${start + 1}–${Math.min(start + viewport, content.length)} / ${content.length}`, innerWidth)),
73
+ ...this.choices.render(innerWidth),
74
+ this.theme.fg("dim", truncateToWidth("↑↓滚动详情 PgUp/PgDn翻页", innerWidth)),
75
+ this.theme.fg("dim", truncateToWidth("Tab切换选项 Enter选 Esc取消", innerWidth)),
76
+ ...(this.summary === undefined ? [] : [this.theme.fg("dim", truncateToWidth(this.showingRaw ? "D 查看摘要" : "D 查看完整配置", innerWidth))]),
77
+ ];
78
+ const border = (text: string) => this.theme.fg("accent", text);
79
+ return [
80
+ border(`╭${"─".repeat(width - 2)}╮`),
81
+ ...lines.map(line => `${border("│")} ${truncateToWidth(line, innerWidth, "", true)} ${border("│")}`),
82
+ border(`╰${"─".repeat(width - 2)}╯`),
83
+ ];
84
+ }
85
+
86
+ handleInput(data: string) {
87
+ if (this.finished) return;
88
+ if (this.keys.matches(data, "tui.select.cancel") || matchesKey(data, "ctrl+c")) {
89
+ this.finish(false); return;
90
+ }
91
+ if (!this.usable) return;
92
+ if (matchesKey(data, "d") && this.summary !== undefined) {
93
+ this.showingRaw = !this.showingRaw;
94
+ this.details.setText(this.showingRaw ? this.text : this.summary);
95
+ this.scroll.scrollToStart();
96
+ this.choices.setSelectedIndex(0);
97
+ } else if (this.keys.matches(data, "tui.select.up")) this.scroll.scrollBy(-1);
98
+ else if (this.keys.matches(data, "tui.select.down")) this.scroll.scrollBy(1);
99
+ else if (matchesKey(data, "pageUp")) this.scroll.scrollBy(-this.scroll.viewportHeight);
100
+ else if (matchesKey(data, "pageDown")) this.scroll.scrollBy(this.scroll.viewportHeight);
101
+ else if (matchesKey(data, "home")) this.scroll.scrollToStart();
102
+ else if (matchesKey(data, "end")) this.scroll.scrollToEnd();
103
+ else if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) {
104
+ this.choices.setSelectedIndex(this.choices.getSelectedItem()?.value === "cancel" ? 0 : 1);
105
+ } else if (this.keys.matches(data, "tui.select.confirm")) this.finish(this.choices.getSelectedItem()?.value === "allow");
106
+ this.requestRender();
107
+ }
108
+ }
109
+
110
+ export async function ask(ctx: ExtensionContext, title: string, value: unknown, signal?: AbortSignal, summary?: string): Promise<boolean> {
111
+ requireThat(ctx.hasUI, "此操作需要用户确认通道,非交互模式不能授权。");
112
+ signal?.throwIfAborted();
113
+ const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
114
+ // RPC clients own their layout. Keep the complete payload separate from the title.
115
+ const allowed = ctx.mode === "tui"
116
+ ? await ctx.ui.custom<boolean>((tui, theme, keys, done) => new ConfirmationDialog(
117
+ title, text, theme, keys, () => tui.terminal.rows, () => tui.requestRender(), done, signal, summary,
118
+ ), { overlay: true, overlayOptions: { width: "80%", anchor: "center" } })
119
+ : await ctx.ui.confirm(title, summary === undefined ? text : `${summary}\n\n完整配置:\n${text}`, signal ? { signal } : {});
120
+ signal?.throwIfAborted();
121
+ return allowed === true;
122
+ }
123
+
124
+ export class Confirmations {
125
+ #queue: Promise<unknown> = Promise.resolve();
126
+ #epoch = new AbortController();
127
+ #paused = false;
128
+ #presenting = false;
129
+ get presenting() { return this.#presenting; }
130
+ readonly #resumes = new Set<() => void>();
131
+ pause() { this.#paused = true; this.#epoch.abort(); }
132
+ resume() {
133
+ if (!this.#paused) return;
134
+ this.#paused = false; this.#epoch = new AbortController();
135
+ for (const resume of this.#resumes) resume();
136
+ }
137
+ async ready(signal?: AbortSignal) {
138
+ signal?.throwIfAborted();
139
+ if (!this.#paused) return;
140
+ await new Promise<void>((resolve, reject) => {
141
+ const cleanup = () => { this.#resumes.delete(resume); signal?.removeEventListener("abort", abort); };
142
+ const resume = () => { cleanup(); resolve(); };
143
+ const abort = () => { cleanup(); reject(signal?.reason); };
144
+ this.#resumes.add(resume); signal?.addEventListener("abort", abort, { once: true });
145
+ });
146
+ signal?.throwIfAborted();
147
+ }
148
+ async ask(ctx: ExtensionContext, title: string, value: unknown, signal?: AbortSignal, summary?: string,
149
+ recheck?: () => Promise<void>): Promise<boolean> {
150
+ requireThat(ctx.hasUI, "此操作需要用户确认通道,非交互模式不能授权。");
151
+ await this.ready(signal);
152
+ const cancel = AbortSignal.any([this.#epoch.signal, ...(signal ? [signal] : [])]);
153
+ const pending = this.#queue.then(async () => {
154
+ cancel.throwIfAborted(); await recheck?.(); cancel.throwIfAborted();
155
+ this.#presenting = true;
156
+ try { return await ask(ctx, title, value, cancel, summary); }
157
+ finally { this.#presenting = false; }
158
+ });
159
+ this.#queue = pending.catch(() => undefined);
160
+ return pending;
161
+ }
162
+ }