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.
- package/CONTEXT.md +90 -0
- package/README.md +113 -2
- package/docs/README.md +17 -0
- package/docs/agent-collaboration.md +73 -0
- package/docs/architecture-guidelines.md +37 -0
- package/docs/capability-design.md +43 -0
- package/docs/file-storage.md +40 -0
- package/docs/release.md +96 -0
- package/docs/technical-design.md +80 -0
- package/docs/validation.md +40 -0
- package/package.json +66 -4
- package/python/README.md +69 -0
- package/python/requirements.lock +38 -0
- package/resources/agents/coordinator.md +31 -0
- package/resources/agents/general-purpose.md +13 -0
- package/resources/prompts/tool-guidelines.md +3 -0
- package/resources/prompts/workspace.md +39 -0
- package/resources/references/agent-task.md +31 -0
- package/resources/references/compute.md +97 -0
- package/resources/skills/analysis-validation/SKILL.md +48 -0
- package/resources/skills/project-maintenance/SKILL.md +48 -0
- package/resources/skills/provider-connections/SKILL.md +32 -0
- package/resources/skills/research-evidence/SKILL.md +33 -0
- package/resources/skills/research-evidence/references/hypothesis-checks.md +33 -0
- package/resources/templates/workspace-agents.md +13 -0
- package/scripts/README.md +13 -0
- package/scripts/setup-python.py +144 -0
- package/src/common/files.ts +25 -0
- package/src/common/json.ts +14 -0
- package/src/common/paths.ts +3 -0
- package/src/common/validation.ts +14 -0
- package/src/common/version.ts +15 -0
- package/src/extension/agent-events.ts +65 -0
- package/src/extension/agents-command.ts +49 -0
- package/src/extension/command.ts +43 -0
- package/src/extension/events.ts +62 -0
- package/src/extension/lifecycle.ts +77 -0
- package/src/extension/subagent-entry.ts +181 -0
- package/src/integrations/pi/agent-context.ts +18 -0
- package/src/integrations/pi/agent-resources.ts +15 -0
- package/src/integrations/pi/agent-tools.ts +35 -0
- package/src/integrations/pi/pi-agents.ts +50 -0
- package/src/integrations/process.ts +40 -0
- package/src/integrations/python-activation.ts +23 -0
- package/src/model/agent.ts +27 -0
- package/src/pbot.ts +22 -0
- package/src/services/agents/agent-service.ts +153 -0
- package/src/services/workspace/python-environment-service.ts +44 -0
- package/src/services/workspace/workspace-files.ts +17 -0
- package/src/services/workspace/workspace-preparation.ts +17 -0
- package/src/storage/agent-files.ts +84 -0
- package/src/storage/atomic-json.ts +74 -0
- package/src/storage/file-access.ts +19 -0
- package/src/tools/access.ts +7 -0
- package/src/tools/agents/contract.ts +54 -0
- package/src/tools/agents/tool.ts +60 -0
- package/src/tools/register.ts +27 -0
- package/src/tools/result.ts +7 -0
- package/src/ui/agent-display.ts +11 -0
- package/src/ui/confirmation.ts +162 -0
|
@@ -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,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
|
+
}
|