personal-bot 0.0.0-stage → 0.1.1

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 +115 -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/compute.md +97 -0
  19. package/resources/references/task-conductor.md +31 -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,33 @@
1
+ # 关键假设验证
2
+
3
+ 用于探索性研究的选题、实验安排和受阻后的方向判断,尽早取得能改变决策的证据。
4
+
5
+ ## 找出决定方向的假设
6
+
7
+ 从目标和已有证据识别:哪些条件必须成立,结论才有意义;哪项不成立会改变方案;哪些检验依赖其他条件先成立。
8
+
9
+ 区分研究假设与方法前提。例如,策略是否具有样本外效果是研究假设;数据是否覆盖所需时期、能否按实际可知时间构造输入,是方法前提。
10
+
11
+ 对影响决策的假设,梳理现有支持与反对证据、尚不确定的条件、失败后果、可用检验及其成本。
12
+
13
+ ## 选择最有用的下一次检验
14
+
15
+ 优先选择不同结果会改变后续决策、能较早取得证据且成本适当的检验。必要时先核实数据与方法前提,再投入更大规模的实验。
16
+
17
+ 最小检验可以使用已有数据、较小样本或较简单的方法,但要说明它能够排除什么、仍不能证明什么。
18
+
19
+ 执行前明确与本次判断有关的内容:
20
+
21
+ - 要区分的假设或替代解释,以及可观测结果。
22
+ - 所需数据与资源、方法、比较基准和主要限制。
23
+ - 哪些结果支持继续,哪些要求修改或停止,哪些仍无法区分假设。
24
+
25
+ 判据来自目标、领域依据或用户约束。看到结果后修改判据时,记录修改原因,并区分探索所得的解释与按原判据得到的结果。
26
+
27
+ ## 根据结果调整
28
+
29
+ 先判断检验是否有效执行。数据缺失、程序失败或方法不适用不等于研究假设被否定;代理指标改善也不自动证明最终目标达成。
30
+
31
+ 有效结果削弱关键假设时,比较继续排查、修改方法、缩小问题或停止该方向的价值。结果仍不确定时,根据下一项证据区分解释的能力决定是否继续。
32
+
33
+ 保留支撑方向判断的假设、判据、实际观测和产物引用。复用结果时保留检验条件与适用范围。
@@ -0,0 +1,13 @@
1
+ # 工作区资料入口
2
+
3
+ ## 目录树
4
+
5
+ ```text
6
+ ./
7
+ ├── areas/ # 长期目标、约束、建议、决策与复盘
8
+ ├── projects/ # 任务结论、必要数据与复现代码
9
+ ├── knowledge/ # 可复用知识、方法与参考资料
10
+ ├── providers/ # 能力提供方导航与手册
11
+ ├── scratch/ # 临时文件、运行环境与计算产物
12
+ └── agents/ # coordinator.md 与 general-purpose.md 的职责补充
13
+ ```
@@ -0,0 +1,13 @@
1
+ # 维护脚本
2
+
3
+ ## Python 环境准备
4
+
5
+ `setup-python.py` 创建当前用户的共享 venv,按 requirements.lock 安装依赖。Personal Bot 加载后自动调用;也可独立执行,或通过 /pbot python setup 重试。
6
+
7
+ 脚本不接受解释器路径,不修改工作区资料或历史记录。路径、激活和失败处理见 [Python 说明](../python/README.md)。
8
+
9
+ ## 构建清理
10
+
11
+ `clean-dist.mjs` 由 npm run build 调用,仅清理仓库内生成的 dist/,供 TypeScript 重新编译测试导入。
12
+
13
+ 本包不提供工作区升级命令或历史转换脚本。插件安装和更新由 Pi 原生包管理或用户维护的源码目录负责。
@@ -0,0 +1,144 @@
1
+ """Create or verify the current user's shared pbot virtual environment."""
2
+
3
+ import argparse
4
+ import fcntl
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import re
9
+ import stat
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ import venv
14
+
15
+ REQUIREMENTS = Path(__file__).resolve().parent.parent / "python" / "requirements.lock"
16
+ INSPECT = """
17
+ import importlib.metadata as m, json, sys
18
+ print(json.dumps({
19
+ "prefix": sys.prefix, "basePrefix": sys.base_prefix,
20
+ "version": list(sys.version_info[:3]),
21
+ "packages": {d.metadata["Name"]: d.version for d in m.distributions()}
22
+ }))
23
+ """
24
+
25
+
26
+ def directory():
27
+ if sys.platform == "darwin":
28
+ return Path.home() / "Library/Application Support/pbot/python/.venv"
29
+ data = os.environ.get("XDG_DATA_HOME", "")
30
+ root = Path(data) if os.path.isabs(data) else Path.home() / ".local/share"
31
+ return root / "pbot/python/.venv"
32
+
33
+
34
+ def normalize(name):
35
+ return re.sub(r"[-_.]+", "-", name).lower()
36
+
37
+
38
+ def locked_packages():
39
+ result = {}
40
+ for line in REQUIREMENTS.read_text().splitlines():
41
+ if not line.strip() or line.startswith("#"):
42
+ continue
43
+ match = re.fullmatch(r"([A-Za-z0-9_.-]+)==([A-Za-z0-9_.+!-]+)", line)
44
+ if not match:
45
+ raise ValueError("requirements.lock 必须逐行使用 name==version。")
46
+ name, version = match.groups()
47
+ key = normalize(name)
48
+ if key in result:
49
+ raise ValueError("requirements.lock 存在重复依赖。")
50
+ result[key] = version
51
+ if not result:
52
+ raise ValueError("requirements.lock 不能为空。")
53
+ return result
54
+
55
+
56
+ def inspect_environment(target):
57
+ if target.is_symlink():
58
+ raise ValueError("共享环境目录不能是符号链接。")
59
+ if not target.exists():
60
+ return None
61
+ config = target / "pyvenv.cfg"
62
+ if not target.is_dir() or not config.is_file() or config.is_symlink():
63
+ raise ValueError("共享环境位置已被非 venv 数据占用;不会覆盖或删除。")
64
+ if re.search(r"include-system-site-packages\s*=\s*true", config.read_text(), re.I):
65
+ raise ValueError("共享 venv 不能继承系统 site-packages。")
66
+ info = json.loads(subprocess.check_output([str(target / "bin/python"), "-I", "-B", "-c", INSPECT], text=True))
67
+ if Path(info["prefix"]).resolve() != target.resolve() or info["prefix"] == info["basePrefix"]:
68
+ raise ValueError("解释器不属于共享 venv;不会修改其他 Python 环境。")
69
+ if info["version"][:2] < [3, 11]:
70
+ raise ValueError("共享环境需要 Python 3.11+;不会自动替换其基础解释器。")
71
+ info["packages"] = {normalize(name): version for name, version in info["packages"].items()}
72
+ return info
73
+
74
+
75
+ def plan(target):
76
+ required = locked_packages()
77
+ current = inspect_environment(target)
78
+ installed = (current or {}).get("packages", {})
79
+ return {
80
+ "directory": str(target), "python": str(target / "bin/python"),
81
+ "current": current, "required": required,
82
+ "changes": [{"package": name, "from": installed.get(name), "to": version}
83
+ for name, version in required.items() if installed.get(name) != version],
84
+ }
85
+
86
+
87
+ def setup(target):
88
+ target.parent.mkdir(parents=True, exist_ok=True)
89
+ # Never unlink this file: concurrent installers must lock the same inode.
90
+ fd = os.open(str(target) + ".lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
91
+ try:
92
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
93
+ raise ValueError("共享环境锁必须是普通文件。")
94
+ # Concurrent startups wait, then inspect again rather than apply stale plans.
95
+ fcntl.flock(fd, fcntl.LOCK_EX)
96
+ actual = plan(target)
97
+ if actual["current"] is None:
98
+ venv.EnvBuilder(with_pip=True).create(target)
99
+ python = str(target / "bin/python")
100
+
101
+ def run(args):
102
+ subprocess.run([python, "-I", "-B", *args], check=True, pass_fds=(fd,),
103
+ stdout=sys.stderr, env={**os.environ, "PIP_CONFIG_FILE": os.devnull})
104
+
105
+ current = inspect_environment(target)
106
+ if "pip" not in current["packages"]:
107
+ run(["-m", "ensurepip"])
108
+ pip = ["-m", "pip", "--isolated", "--disable-pip-version-check"]
109
+ if actual["changes"]:
110
+ with tempfile.TemporaryDirectory(prefix="pbot-python-") as temp:
111
+ locked = Path(temp) / "requirements.txt"
112
+ locked.write_text("".join(name + "==" + version + "\n" for name, version in actual["required"].items()))
113
+ run([*pip, "download", "--only-binary=:all:", "--no-deps", "-r", str(locked), "--dest", temp])
114
+ run([*pip, "install", "--no-index", "--find-links", temp, "--no-deps", "-r", str(locked)])
115
+ run([*pip, "check"])
116
+ verified = inspect_environment(target)
117
+ if any(verified["packages"].get(name) != version for name, version in actual["required"].items()):
118
+ raise RuntimeError("安装后版本检查未通过。")
119
+ run(["-c", "import matplotlib; matplotlib.use('Agg'); "
120
+ "import numpy, pandas, talib, backtesting, scipy, statsmodels.api, "
121
+ "matplotlib.pyplot, sklearn, pyarrow, openpyxl, seaborn; "
122
+ "assert talib.SMA(numpy.arange(1., 6.), 3)[-1] == 4."])
123
+ return {"directory": str(target), "python": python, "packages": verified["packages"]}
124
+ finally:
125
+ os.close(fd)
126
+
127
+
128
+ def main():
129
+ parser = argparse.ArgumentParser(description=__doc__)
130
+ parser.add_argument("--check", action="store_true", help="只检查共享环境,不创建或安装")
131
+ args = parser.parse_args()
132
+ if sys.platform not in ("linux", "darwin") or sys.version_info < (3, 11):
133
+ raise RuntimeError("需要 Linux/macOS 和 Python 3.11+。")
134
+ target = directory()
135
+ print(json.dumps(plan(target) if args.check else setup(target), ensure_ascii=False))
136
+
137
+
138
+ if __name__ == "__main__":
139
+ try:
140
+ main()
141
+ except (Exception, KeyboardInterrupt) as error:
142
+ print(str(error), file=sys.stderr)
143
+ print("共享 Python 环境未就绪。请检查 Python 3.11+、venv/ensurepip、网络、安装权限和 wheel;修复后执行 /pbot python setup 或重启。不会删除现有环境、改用系统环境安装或编译 C 库。", file=sys.stderr)
144
+ sys.exit(1)
@@ -0,0 +1,25 @@
1
+ import { open, realpath, rename, unlink, link } from "node:fs/promises";
2
+ import { join, dirname, basename } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { requireThat } from "./validation.js";
5
+
6
+ export async function atomicFile(path: string, content: string, exclusive = false) {
7
+ const temp = path + "." + randomUUID();
8
+ const handle = await open(temp, "wx", 0o600);
9
+ try { await handle.writeFile(content); await handle.sync(); }
10
+ finally { await handle.close(); }
11
+ try {
12
+ if (exclusive) await link(temp, path);
13
+ else await rename(temp, path);
14
+ const directory = await open(dirname(path), "r");
15
+ try { await directory.sync(); } finally { await directory.close(); }
16
+ } finally { await unlink(temp).catch(error => { if (error.code !== "ENOENT") throw error; }); }
17
+ }
18
+ export async function mutationTarget(path: string): Promise<string> {
19
+ try { return await realpath(path); }
20
+ catch (error) {
21
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
22
+ const parent = dirname(path); requireThat(parent !== path, "无法定位目标路径。");
23
+ return join(await mutationTarget(parent), basename(path));
24
+ }
25
+ }
@@ -0,0 +1,14 @@
1
+ export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
2
+ export function object(value: unknown): value is Record<string, unknown> {
3
+ return value !== null && typeof value === "object" && !Array.isArray(value);
4
+ }
5
+ export function canonical(value: unknown): string {
6
+ if (value === null || typeof value === "boolean" || typeof value === "string") return JSON.stringify(value);
7
+ if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
8
+ if (Array.isArray(value)) return "[" + value.map(canonical).join(",") + "]";
9
+ if (object(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)) {
10
+ return "{" + Object.keys(value).sort().map(key => JSON.stringify(key) + ":" + canonical(value[key])).join(",") + "}";
11
+ }
12
+ throw new Error("需要有限、可序列化的 JSON 值。");
13
+ }
14
+ export function json(value: unknown): Json { return JSON.parse(canonical(value)) as Json; }
@@ -0,0 +1,3 @@
1
+ import { fileURLToPath } from "node:url";
2
+
3
+ export const resourceRoot = fileURLToPath(new URL("../../resources/", import.meta.url));
@@ -0,0 +1,14 @@
1
+ import { Ajv } from "ajv";
2
+ import addFormats from "ajv-formats";
3
+ import type { ValidateFunction } from "ajv";
4
+ import { object } from "./json.js";
5
+
6
+ export function schema(value: unknown): ValidateFunction {
7
+ if (!object(value)) throw new Error("需要对象形式的 JSON Schema。");
8
+ const ajv = new Ajv({ strict: false, allErrors: false, validateFormats: true, coerceTypes: false, useDefaults: false, removeAdditional: false });
9
+ addFormats.default(ajv);
10
+ return ajv.compile(value);
11
+ }
12
+ export function requireThat(condition: unknown, message: string): asserts condition {
13
+ if (!condition) throw new Error(message);
14
+ }
@@ -0,0 +1,15 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { object } from "./json.js";
3
+ import { requireThat } from "./validation.js";
4
+
5
+ /** Before 1.0, the minor release defines compatibility; afterwards, the major does. */
6
+ export function releaseLine(version: string): string {
7
+ const parts = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.exec(version);
8
+ requireThat(parts, "pbot 发布版本必须使用 MAJOR.MINOR.PATCH。");
9
+ return parts[1] === "0" ? `0.${parts[2]}` : parts[1]!;
10
+ }
11
+
12
+ const manifest: unknown = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
13
+ requireThat(object(manifest) && typeof manifest.version === "string", "pbot 包缺少发布版本。");
14
+ export const packageVersion = manifest.version;
15
+ releaseLine(packageVersion);
@@ -0,0 +1,65 @@
1
+ import type { ExtensionAPI, ExtensionContext, CustomMessageEntryDraft, SessionBoundaryDraft } from "@earendil-works/pi-coding-agent";
2
+ import type { Lifecycle } from "./lifecycle.js";
3
+ import type { Confirmations } from "../ui/confirmation.js";
4
+ import { agentOwner, deliveredCycles } from "../integrations/pi/agent-context.js";
5
+ import { agentBoard } from "../ui/agent-display.js";
6
+
7
+ export function registerAgentEvents(pi: ExtensionAPI, lifecycle: Lifecycle, confirmations: Confirmations) {
8
+ let ctx: ExtensionContext | undefined, timer: ReturnType<typeof setInterval> | undefined;
9
+ let paused = false;
10
+ const submitting = new Map<string, number>();
11
+ const notification = (): CustomMessageEntryDraft | undefined => {
12
+ const instance = lifecycle.active;
13
+ if (!ctx || !instance || paused || ctx.signal?.aborted) return;
14
+ const delivered = deliveredCycles(ctx), now = Date.now();
15
+ 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));
17
+ if (!pending.length) return;
18
+ const batch = pending.slice(0, 8), keys = batch.map(v => v.agentId + ":" + v.cycle.cycleId);
19
+ for (const key of keys) submitting.set(key, now);
20
+ 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 } };
26
+ };
27
+ const refresh = () => {
28
+ const instance = lifecycle.active;
29
+ if (!ctx || !instance) return;
30
+ try {
31
+ instance.agentOwner = agentOwner(ctx);
32
+ instance.agents.refresh(instance.agentOwner);
33
+ const values = instance.agents.list(instance.agentOwner);
34
+ 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
+ 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)); }
37
+ };
38
+ const boundary = (outcome: string, entries: SessionBoundaryDraft[], context: ExtensionContext) => {
39
+ ctx = context;
40
+ if (outcome === "aborted" || ctx.signal?.aborted) { paused = true; confirmations.pause(); return; }
41
+ const message = notification();
42
+ if (message) return { entries: [...entries, message], continue: true };
43
+ return;
44
+ };
45
+ pi.on("session_start", (_event, context) => {
46
+ ctx = context; paused = false; confirmations.resume(); submitting.clear();
47
+ if (timer) clearInterval(timer);
48
+ if (lifecycle.active) { lifecycle.active.agentOwner = agentOwner(ctx); timer = setInterval(refresh, 1000); timer.unref(); }
49
+ refresh();
50
+ });
51
+ pi.on("input", (_event, context) => { ctx = context; paused = false; confirmations.resume(); });
52
+ pi.on("before_agent_start", (_event, context) => { ctx = context; });
53
+ pi.on("session_tree", (_event, context) => { ctx = context; submitting.clear(); refresh(); });
54
+ pi.on("turn_end", (event, context) => boundary(event.outcome, event.entries, context));
55
+ pi.on("agent_before_settle", (event, context) => boundary(event.outcome, event.entries, context));
56
+ pi.on("agent_settled", (event, context) => {
57
+ ctx = context; if (event.aborted) { paused = true; confirmations.pause(); }
58
+ });
59
+ pi.on("tool_result", event => {
60
+ if (event.toolName !== "pbot_agent") return;
61
+ const keys = (event.details as { agentReadKeys?: string[] } | undefined)?.agentReadKeys;
62
+ if (keys?.length) pi.appendEntry("pbot.agent-read", { keys, toolCallId: event.parentToolCallId ?? event.toolCallId });
63
+ });
64
+ pi.on("session_shutdown", () => { if (timer) clearInterval(timer); timer = undefined; submitting.clear(); ctx = undefined; });
65
+ }
@@ -0,0 +1,49 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
+ import { Text, type TUI } from "@earendil-works/pi-tui";
4
+ import type { WorkspaceInstance } from "./lifecycle.js";
5
+ import { agentOwner } from "../integrations/pi/agent-context.js";
6
+ import { tmuxName } from "../integrations/pi/pi-agents.js";
7
+ import { agentSummary } from "../ui/agent-display.js";
8
+ import type { Confirmations } from "../ui/confirmation.js";
9
+
10
+ export async function showAgents(_pi: ExtensionAPI, instance: WorkspaceInstance, ctx: ExtensionCommandContext, confirmations: Confirmations) {
11
+ instance.check();
12
+ const owner = agentOwner(ctx), values = instance.agents.list(owner);
13
+ if (ctx.mode !== "tui") { ctx.ui.notify(JSON.stringify(values), "info"); return; }
14
+ if (!values.length) { ctx.ui.notify("没有子会话;用 pbot_agent start 创建。", "info"); return; }
15
+ if (confirmations.presenting) { ctx.ui.notify("请先处理主会话批准。", "info"); return; }
16
+ const options = values.map(v => agentSummary(v) + (v.owned ? "" : " · 其他分支"));
17
+ const selected = await ctx.ui.select("pbot 子会话", options);
18
+ const value = values[options.indexOf(selected ?? "")]; if (!value) return;
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 === "恢复并继续") {
24
+ 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
+ }
29
+ return;
30
+ }
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; }
37
+ let tui: TUI | undefined;
38
+ await ctx.ui.custom<void>((terminal, _theme, _keys, done) => { tui = terminal; queueMicrotask(() => done()); return new Text("", 0, 0); });
39
+ if (!tui) return;
40
+ const terminal = tui;
41
+ terminal.stop();
42
+ try {
43
+ 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" });
45
+ child.on("error", () => resolve(null)); child.on("close", resolve);
46
+ });
47
+ if (code !== 0) ctx.ui.notify("无法进入子会话。", "error");
48
+ } finally { terminal.start(); terminal.requestRender(true); }
49
+ }
@@ -0,0 +1,43 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { packageVersion } from "../common/version.js";
3
+ import { prepareWorkspace } from "../services/workspace/workspace-preparation.js";
4
+ import type { Confirmations } from "../ui/confirmation.js";
5
+ import type { Lifecycle } from "./lifecycle.js";
6
+ import { showAgents } from "./agents-command.js";
7
+
8
+ export function registerCommand(pi: ExtensionAPI, lifecycle: Lifecycle, confirmations: Confirmations) {
9
+ pi.registerCommand("pbot", {
10
+ description: "工作区:init、status、agents、python setup",
11
+ getArgumentCompletions: prefix => {
12
+ if (/\s/.test(prefix)) return null;
13
+ return ["init", "status", "agents", "python"]
14
+ .filter(command => command.startsWith(prefix))
15
+ .map(command => ({ value: command, label: command }));
16
+ },
17
+ handler: async (args, ctx) => {
18
+ const [, command, argument = ""] = args.trim().match(/^(\S+)(?:\s+([\s\S]*))?$/) ?? [];
19
+ confirmations.resume();
20
+ if (command === "init" && !argument) {
21
+ await ctx.waitForIdle();
22
+ const instance = lifecycle.get();
23
+ await prepareWorkspace(instance.root);
24
+ ctx.ui.notify("资料目录已准备;已有 AGENTS.md 保持不变。", "info");
25
+ return;
26
+ }
27
+ if (command === "status" && !argument) {
28
+ const instance = lifecycle.get();
29
+ ctx.ui.notify(JSON.stringify({ workspaceRoot: instance.root, release: packageVersion, python: instance.pythonEnvironment.status }, null, 2), "info"); return;
30
+ }
31
+ if (command === "agents") { await showAgents(pi, lifecycle.get(), ctx, confirmations); return; }
32
+ if (command === "python" && argument === "setup") {
33
+ const instance = lifecycle.get(), cancel = instance.signal(ctx.signal);
34
+ await ctx.waitForIdle();
35
+ ctx.ui.notify("正在准备共享 Python 环境;安装可能需要数分钟。", "info");
36
+ const result = await instance.preparePython(cancel);
37
+ ctx.ui.notify(JSON.stringify(result, null, 2), "info");
38
+ return;
39
+ }
40
+ ctx.ui.notify("用法:/pbot init | status | agents | python setup", "info");
41
+ },
42
+ });
43
+ }
@@ -0,0 +1,62 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { fileURLToPath } from "node:url";
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import { resourceRoot } from "../common/paths.js";
7
+ import { agentText } from "../integrations/pi/agent-resources.js";
8
+ import type { Confirmations } from "../ui/confirmation.js";
9
+ import type { Lifecycle } from "./lifecycle.js";
10
+
11
+ export function registerEvents(pi: ExtensionAPI, lifecycle: Lifecycle, confirmations: Confirmations) {
12
+ pi.on("session_start", async (_event, ctx) => {
13
+ try {
14
+ await lifecycle.open(ctx.cwd);
15
+ ctx.ui.notify("正在准备共享 Python 环境;首次安装可能需要数分钟。", "info");
16
+ try { await lifecycle.get().preparePython(); }
17
+ catch (error) {
18
+ const message = error instanceof Error ? error.message : String(error);
19
+ if (ctx.hasUI) ctx.ui.notify(message, "warning"); else console.error(message);
20
+ }
21
+ } catch (error) {
22
+ if (ctx.hasUI) ctx.ui.notify(error instanceof Error ? error.message : "pbot 启动失败。", "error");
23
+ else throw error;
24
+ }
25
+ });
26
+ pi.on("resources_discover", () => lifecycle.active ? { skillPaths: [join(resourceRoot, "skills")] } : {});
27
+ pi.on("before_agent_start", async (event, ctx) => {
28
+ const instance = lifecycle.active;
29
+ if (!instance) return;
30
+ instance.cancelWork();
31
+ const role = await agentText("coordinator", instance.root);
32
+ const workspace = await readFile(join(resourceRoot, "prompts/workspace.md"), "utf8");
33
+ instance.check();
34
+ event.systemPromptOptions.sections.pbot_workspace = workspace;
35
+ event.systemPromptOptions.sections.pbot_environment = JSON.stringify({
36
+ workspaceRoot: instance.root, cwd: ctx.cwd, resourceRoot, python: instance.pythonEnvironment.status,
37
+ });
38
+ event.systemPromptOptions.sections.pbot = role;
39
+ });
40
+ pi.on("input", () => { lifecycle.active?.cancelWork(); });
41
+ pi.on("agent_end", () => { lifecycle.active?.cancelWork(); });
42
+ pi.on("session_before_switch", () => { lifecycle.active?.cancelWork(); });
43
+ pi.on("session_before_fork", () => { lifecycle.active?.cancelWork(); });
44
+ pi.on("session_before_tree", () => { lifecycle.active?.cancelWork(); });
45
+ pi.on("session_shutdown", event => lifecycle.close(event.reason === "reload"));
46
+ pi.on("tool_call", async (event, ctx) => {
47
+ const instance = lifecycle.active;
48
+ if (["write", "edit"].includes(event.toolName) && "path" in event.input && typeof event.input.path === "string") {
49
+ const path = event.input.path.replace(/^@/, "").replace(/[\u00a0\u2000-\u200a\u202f\u205f\u3000]/g, " ");
50
+ const expanded = /^file:\/\//.test(path) ? fileURLToPath(path)
51
+ : path === "~" ? homedir() : path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
52
+ const target = resolve(ctx.cwd, expanded);
53
+ if (!instance) return;
54
+ const cancel = instance.signal(ctx.signal);
55
+ try {
56
+ await instance.authorizeFile(target, event.input,
57
+ value => confirmations.ask(ctx, "确认修改工作区外文件", value, cancel), cancel);
58
+ } catch (error) { return { block: true, reason: error instanceof Error ? error.message : String(error) }; }
59
+ }
60
+ return;
61
+ });
62
+ }
@@ -0,0 +1,77 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { activatePython } from "../integrations/python-activation.js";
3
+ import { requireThat } from "../common/validation.js";
4
+ import { authorizeMutation } from "../services/workspace/workspace-files.js";
5
+ import type { AgentOwner } from "../model/agent.js";
6
+ import type { PythonEnvironmentService } from "../services/workspace/python-environment-service.js";
7
+ import type { AgentService } from "../services/agents/agent-service.js";
8
+ import type { CallAccess } from "../tools/access.js";
9
+
10
+ export class WorkspaceInstance implements CallAccess {
11
+ readonly #controller = new AbortController();
12
+ #work = new AbortController();
13
+ #closing: Promise<void> | undefined;
14
+ #restorePython: (() => void) | undefined;
15
+ constructor(readonly root: string, readonly pythonEnvironment: PythonEnvironmentService, readonly agents: AgentService) {}
16
+ async preparePython(signal?: AbortSignal) {
17
+ this.check();
18
+ this.#restorePython?.();
19
+ this.#restorePython = undefined;
20
+ const result = await this.pythonEnvironment.setup(signal);
21
+ this.check();
22
+ this.#restorePython ??= activatePython(result.directory);
23
+ return result;
24
+ }
25
+ check() {
26
+ requireThat(!this.#controller.signal.aborted, "pbot 工作区实例已关闭。");
27
+ }
28
+ signal(signal?: AbortSignal) {
29
+ this.check();
30
+ return AbortSignal.any([this.#controller.signal, this.#work.signal, ...(signal ? [signal] : [])]);
31
+ }
32
+ async authorizeFile(path: string, change: unknown, confirm: (value: unknown) => Promise<boolean>, signal: AbortSignal) {
33
+ this.check();
34
+ await authorizeMutation(this.root, path, change, confirm, signal);
35
+ this.check();
36
+ }
37
+ agentOwner: AgentOwner | undefined;
38
+ cancelWork() {
39
+ this.#work.abort();
40
+ this.#work = new AbortController();
41
+ }
42
+ close(preserveAgents = false): Promise<void> {
43
+ if (this.#closing) return this.#closing;
44
+ this.#controller.abort();
45
+ this.#work.abort();
46
+ this.#closing = (async () => {
47
+ try {
48
+ 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);
50
+ if (errors.length) throw new AggregateError(errors, "工作区关闭失败。");
51
+ } finally { this.#restorePython?.(); this.#restorePython = undefined; }
52
+ })();
53
+ return this.#closing;
54
+ }
55
+ }
56
+ export class Lifecycle {
57
+ #active: WorkspaceInstance | undefined;
58
+ constructor(private readonly create: (root: string) => WorkspaceInstance,
59
+ private readonly register: (instance: WorkspaceInstance) => void, private readonly hide: () => void) {}
60
+ get active() { return this.#active; }
61
+ get() {
62
+ requireThat(this.#active, "pbot 工作区实例不可用。");
63
+ this.#active.check(); return this.#active;
64
+ }
65
+ async open(root: string) {
66
+ await this.close();
67
+ const instance = this.create(await realpath(root));
68
+ this.#active = instance;
69
+ try { this.register(instance); }
70
+ catch (error) { await this.close(); throw error; }
71
+ }
72
+ async close(preserveAgents = false) {
73
+ const old = this.#active;
74
+ this.#active = undefined;
75
+ try { await old?.close(preserveAgents); } finally { this.hide(); }
76
+ }
77
+ }