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,181 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { createReadTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { TextContent, ImageContent } from "@earendil-works/pi-ai";
|
|
7
|
+
import { AgentFiles, descriptor, launch, status, cycle } from "../storage/agent-files.js";
|
|
8
|
+
import { PythonEnvironmentService } from "../services/workspace/python-environment-service.js";
|
|
9
|
+
import { activatePython } from "../integrations/python-activation.js";
|
|
10
|
+
import { authorizeMutation } from "../services/workspace/workspace-files.js";
|
|
11
|
+
import { agentText } from "../integrations/pi/agent-resources.js";
|
|
12
|
+
import { agentFileTools } from "../integrations/pi/agent-tools.js";
|
|
13
|
+
import { resourceRoot } from "../common/paths.js";
|
|
14
|
+
import { requireThat } from "../common/validation.js";
|
|
15
|
+
import { packageVersion, releaseLine } from "../common/version.js";
|
|
16
|
+
import type { AgentCycle, AgentStatus } from "../model/agent.js";
|
|
17
|
+
|
|
18
|
+
/** Explicit child composition; never installs main commands or approval capabilities. */
|
|
19
|
+
export default function subagent(pi: ExtensionAPI) {
|
|
20
|
+
const id = process.env.PBOT_SUBAGENT_RUN_ID, directory = process.env.PBOT_SUBAGENT_DIR, generation = process.env.PBOT_SUBAGENT_GENERATION;
|
|
21
|
+
requireThat(id && directory && generation, "缺少子会话启动身份。");
|
|
22
|
+
const files = new AgentFiles(dirname(realpathSync(directory)));
|
|
23
|
+
requireThat(files.directory(id) === realpathSync(directory), "子会话路径不匹配。");
|
|
24
|
+
const d = files.read(id, "descriptor.json", descriptor);
|
|
25
|
+
requireThat(d.agentId === id && releaseLine(d.release) === releaseLine(packageVersion), "子会话身份或版本无效。");
|
|
26
|
+
requireThat(files.read(id, "launch.json", launch).generation === generation, "子会话启动代次已失效。");
|
|
27
|
+
const lifetime = new AbortController();
|
|
28
|
+
let ctx: ExtensionContext | undefined, timer: ReturnType<typeof setInterval> | undefined;
|
|
29
|
+
let python: PythonEnvironmentService | undefined, restorePython: (() => void) | undefined;
|
|
30
|
+
let processing = false, closing = false, current: AgentCycle | undefined, startLeaf: string | null = null;
|
|
31
|
+
let queuedRequests: string[] = [], interactive = false;
|
|
32
|
+
const accepted = new Set<string>();
|
|
33
|
+
const saved = files.optional(id, "status.json", status);
|
|
34
|
+
let snapshot: AgentStatus = { generation, state: "starting", activity: "启动", updatedAt: Date.now() };
|
|
35
|
+
if (saved?.generation === generation) for (const requestId of saved.accepted ?? []) accepted.add(requestId);
|
|
36
|
+
let lastActivity = 0;
|
|
37
|
+
const save = (patch: Partial<AgentStatus>) => {
|
|
38
|
+
snapshot = { ...snapshot, ...patch, updatedAt: Date.now() };
|
|
39
|
+
if (current) snapshot.cycle = structuredClone(current);
|
|
40
|
+
snapshot.accepted = [...accepted];
|
|
41
|
+
files.write(id, "status.json", snapshot, status);
|
|
42
|
+
};
|
|
43
|
+
const ensure = () => {
|
|
44
|
+
lifetime.signal.throwIfAborted();
|
|
45
|
+
const run = files.read(id, "launch.json", launch);
|
|
46
|
+
requireThat(run.generation === generation && !run.stop, "子会话已停止或启动代次变化。");
|
|
47
|
+
requireThat(python, "子会话尚未就绪。");
|
|
48
|
+
};
|
|
49
|
+
const finish = (aborted: boolean) => {
|
|
50
|
+
if (!ctx || !current) return;
|
|
51
|
+
const branch = ctx.sessionManager.getBranch();
|
|
52
|
+
const at = startLeaf === null ? -1 : branch.findIndex(e => e.id === startLeaf);
|
|
53
|
+
const last = branch.slice(at + 1).filter(e => e.type === "message" && e.message.role === "assistant").at(-1);
|
|
54
|
+
const assistant = last?.type === "message" && last.message.role === "assistant" ? last.message : undefined;
|
|
55
|
+
const text = assistant?.content.filter(p => p.type === "text").map(p => p.text).join("\n") ?? "";
|
|
56
|
+
current = { ...current, finishedAt: Date.now(), leaf: assistant ? ctx.sessionManager.getLeafId() : null,
|
|
57
|
+
state: aborted ? "cancelled" : assistant?.stopReason === "stop" && text.trim() ? "completed" : "failed",
|
|
58
|
+
...(!assistant || assistant.stopReason !== "stop" ? { error: assistant?.errorMessage ?? "没有有效最终回答。" } : {}) };
|
|
59
|
+
files.write(id, "completions/" + current.cycleId + ".json", current, cycle, true);
|
|
60
|
+
save({ state: "idle", activity: current.state });
|
|
61
|
+
current = undefined;
|
|
62
|
+
};
|
|
63
|
+
const shutdown = async () => {
|
|
64
|
+
if (closing) return;
|
|
65
|
+
closing = true;
|
|
66
|
+
save({ state: "stopping", activity: "停止" });
|
|
67
|
+
lifetime.abort(); ctx?.abort();
|
|
68
|
+
await python?.close();
|
|
69
|
+
ctx?.shutdown();
|
|
70
|
+
};
|
|
71
|
+
const pump = async () => {
|
|
72
|
+
if (processing || !ctx || closing) return;
|
|
73
|
+
processing = true;
|
|
74
|
+
try {
|
|
75
|
+
const run = files.read(id, "launch.json", launch);
|
|
76
|
+
if (run.generation !== generation || run.stop) { await shutdown(); return; }
|
|
77
|
+
if (pi.getSessionName() !== run.name) pi.setSessionName(run.name);
|
|
78
|
+
for (const request of files.messages(id)) {
|
|
79
|
+
if (request.generation !== generation || accepted.has(request.requestId)) continue;
|
|
80
|
+
const content: (TextContent | ImageContent)[] = [];
|
|
81
|
+
for (const path of request.files) {
|
|
82
|
+
const output = await createReadTool(d.cwd).execute("file-" + request.requestId, { path }, lifetime.signal);
|
|
83
|
+
content.push(...output.content.filter((p): p is TextContent | ImageContent => p.type === "text" || p.type === "image"));
|
|
84
|
+
}
|
|
85
|
+
content.push({ type: "text", text: request.message || "请处理所附文件。" });
|
|
86
|
+
requireThat(ctx.model, "子会话没有可用模型。");
|
|
87
|
+
requireThat(ctx.modelRegistry.hasConfiguredAuth(ctx.model) || await ctx.modelRegistry.getProviderAuth(ctx.model.provider), "子会话模型未认证,请配置原生 Pi 认证后显式恢复。");
|
|
88
|
+
accepted.add(request.requestId);
|
|
89
|
+
pi.appendEntry("pbot.agent-input", { requestId: request.requestId, generation });
|
|
90
|
+
if (current) { current.requestIds.push(request.requestId); if (current.source === "interactive") current.source = "mixed"; }
|
|
91
|
+
else queuedRequests.push(request.requestId);
|
|
92
|
+
save({ state: "busy", activity: "消息已接收" });
|
|
93
|
+
pi.sendUserMessage(content, ctx.isIdle() ? {} : { deliverAs: request.delivery });
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
save({ state: "error", activity: "控制失败", error: String(error) });
|
|
98
|
+
await shutdown();
|
|
99
|
+
} finally { processing = false; }
|
|
100
|
+
};
|
|
101
|
+
pi.on("resources_discover", async () => {
|
|
102
|
+
if (d.options.noSkills) return {};
|
|
103
|
+
const skillPaths = [join(resourceRoot, "skills")];
|
|
104
|
+
const workspaceSkills = join(d.workspace, ".agents/skills");
|
|
105
|
+
try {
|
|
106
|
+
if ((await stat(workspaceSkills)).isDirectory()) skillPaths.push(workspaceSkills);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
109
|
+
}
|
|
110
|
+
return { skillPaths };
|
|
111
|
+
});
|
|
112
|
+
pi.on("session_start", async (_event, context) => {
|
|
113
|
+
ctx = context;
|
|
114
|
+
try {
|
|
115
|
+
requireThat(realpathSync(ctx.cwd) === d.cwd && ctx.sessionManager.getSessionFile() === files.session(id), "子会话工作目录或文件不匹配。");
|
|
116
|
+
for (const entry of ctx.sessionManager.getEntries()) if (entry.type === "custom" && entry.customType === "pbot.agent-input") {
|
|
117
|
+
const receipt = entry.data as { requestId?: string }; if (receipt.requestId) accepted.add(receipt.requestId);
|
|
118
|
+
}
|
|
119
|
+
if (saved?.generation === generation && saved.cycle?.state === "running") {
|
|
120
|
+
const interrupted: AgentCycle = { ...saved.cycle, state: "interrupted", finishedAt: Date.now() };
|
|
121
|
+
if (!files.completions(id).some(c => c.cycleId === interrupted.cycleId)) files.write(id, "completions/" + interrupted.cycleId + ".json", interrupted, cycle, true);
|
|
122
|
+
}
|
|
123
|
+
const active = pi.getActiveTools();
|
|
124
|
+
for (const tool of agentFileTools(d.cwd, lifetime.signal, async (path, change, cancel) => {
|
|
125
|
+
ensure();
|
|
126
|
+
await authorizeMutation(d.workspace, path, change, async () => false, cancel);
|
|
127
|
+
ensure();
|
|
128
|
+
})) pi.registerTool(tool);
|
|
129
|
+
// Keep the native CLI loadout, including MCP exposure and explicit --tools choices.
|
|
130
|
+
pi.setActiveTools(active);
|
|
131
|
+
python = new PythonEnvironmentService(d.workspace);
|
|
132
|
+
try { const result = await python.setup(lifetime.signal); restorePython = activatePython(result.directory); }
|
|
133
|
+
catch (error) { ctx.ui.notify(String(error), "warning"); }
|
|
134
|
+
save({ state: "idle", activity: "空闲" });
|
|
135
|
+
timer = setInterval(() => void pump(), 250); timer.unref(); void pump();
|
|
136
|
+
} catch (error) { save({ state: "error", activity: "启动失败", error: String(error) }); ctx.shutdown(); }
|
|
137
|
+
});
|
|
138
|
+
pi.on("before_agent_start", async event => {
|
|
139
|
+
ensure();
|
|
140
|
+
requireThat(python, "子会话计算环境状态不可用。");
|
|
141
|
+
event.systemPromptOptions.sections.pbot_environment = JSON.stringify({
|
|
142
|
+
workspaceRoot: d.workspace, cwd: d.cwd, resourceRoot, python: python.status,
|
|
143
|
+
});
|
|
144
|
+
event.systemPromptOptions.sections.pbot_workspace = await readFile(join(resourceRoot, "prompts/workspace.md"), "utf8");
|
|
145
|
+
event.systemPromptOptions.sections.pbot_child = await agentText("general-purpose", d.workspace);
|
|
146
|
+
});
|
|
147
|
+
pi.on("input", event => {
|
|
148
|
+
if (event.source !== "extension") {
|
|
149
|
+
if (current) current.source = current.source === "parent" ? "mixed" : current.source;
|
|
150
|
+
else interactive = true;
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
});
|
|
154
|
+
pi.on("agent_start", (_event, context) => {
|
|
155
|
+
ctx = context; startLeaf = ctx.sessionManager.getLeafId();
|
|
156
|
+
current = { cycleId: randomUUID(), generation, startedAt: Date.now(), state: "running", leaf: null,
|
|
157
|
+
requestIds: queuedRequests, source: interactive ? queuedRequests.length ? "mixed" : "interactive" : "parent" };
|
|
158
|
+
queuedRequests = []; interactive = false;
|
|
159
|
+
save({ state: "busy", activity: "思考" });
|
|
160
|
+
});
|
|
161
|
+
pi.on("tool_call", event => {
|
|
162
|
+
try {
|
|
163
|
+
ensure();
|
|
164
|
+
requireThat(event.toolName !== "pbot_agent", "子会话禁止管理和委派。");
|
|
165
|
+
save({ state: "busy", activity: event.toolName });
|
|
166
|
+
} catch (error) { return { block: true, reason: String(error) }; }
|
|
167
|
+
return;
|
|
168
|
+
});
|
|
169
|
+
pi.on("message_update", () => { if (current && Date.now() - lastActivity > 1000) { lastActivity = Date.now(); save({ state: "busy", activity: "回答" }); } });
|
|
170
|
+
pi.on("agent_settled", (event, context) => { ctx = context; finish(event.aborted); });
|
|
171
|
+
pi.on("session_before_switch", () => ({ cancel: true }));
|
|
172
|
+
pi.on("session_before_fork", () => ({ cancel: true }));
|
|
173
|
+
pi.on("session_before_tree", (_event, context) => ({ cancel: !context.isIdle() }));
|
|
174
|
+
pi.on("session_shutdown", async event => {
|
|
175
|
+
if (timer) clearInterval(timer); timer = undefined; closing = true; lifetime.abort();
|
|
176
|
+
if (current) finish(true);
|
|
177
|
+
await python?.close(); restorePython?.();
|
|
178
|
+
if (event.reason !== "reload" && snapshot.state !== "error") save({ state: "stopped", activity: "已停止" });
|
|
179
|
+
ctx = undefined;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AgentOwner } from "../../model/agent.js";
|
|
3
|
+
export function agentOwner(ctx: ExtensionContext): AgentOwner {
|
|
4
|
+
return { sessionId: ctx.sessionManager.getSessionId(), leaf: ctx.sessionManager.getLeafId(), branch: ctx.sessionManager.getBranch().map(e => e.id) };
|
|
5
|
+
}
|
|
6
|
+
export function deliveredCycles(ctx: ExtensionContext): Set<string> {
|
|
7
|
+
const keys = new Set<string>();
|
|
8
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
9
|
+
const data = entry.type === "custom_message" && entry.customType === "pbot.agent-results" ? entry.details
|
|
10
|
+
: entry.type === "custom" && entry.customType === "pbot.agent-read" ? entry.data : undefined;
|
|
11
|
+
if (entry.type === "custom" && entry.customType === "pbot.agent-read") {
|
|
12
|
+
const receipt = entry.data as { toolCallId?: string };
|
|
13
|
+
if (!ctx.sessionManager.getBranch().some(e => e.type === "message" && e.message.role === "toolResult" && e.message.toolCallId === receipt.toolCallId)) continue;
|
|
14
|
+
}
|
|
15
|
+
if (data && typeof data === "object" && "keys" in data && Array.isArray(data.keys)) for (const key of data.keys) if (typeof key === "string") keys.add(key);
|
|
16
|
+
}
|
|
17
|
+
return keys;
|
|
18
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { resourceRoot } from "../../common/paths.js";
|
|
4
|
+
import { requireThat } from "../../common/validation.js";
|
|
5
|
+
|
|
6
|
+
export async function agentText(agent: "coordinator" | "general-purpose", root: string) {
|
|
7
|
+
requireThat(agent === "coordinator" || agent === "general-purpose", "未知职责。");
|
|
8
|
+
let text = await readFile(join(resourceRoot, "agents", agent + ".md"), "utf8");
|
|
9
|
+
try {
|
|
10
|
+
const local = await readFile(join(root, "agents", agent + ".md"), "utf8");
|
|
11
|
+
text += "\n\n工作区职责补充:\n" + local;
|
|
12
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
13
|
+
requireThat(text.trim(), "专家职责为空。");
|
|
14
|
+
return text;
|
|
15
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { access as fileAccess, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { defineTool, createWriteToolDefinition, createEditToolDefinition,
|
|
5
|
+
type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
/** Native tools still own parsing, mutation queues and results; authorize at the actual file write. */
|
|
8
|
+
export function agentFileTools(root: string, signal: AbortSignal,
|
|
9
|
+
authorize: (path: string, change: unknown, signal: AbortSignal) => Promise<void>): ToolDefinition[] {
|
|
10
|
+
const guardedWrite = (cancel: AbortSignal, parameters: unknown) => async (path: string, content: string) => {
|
|
11
|
+
await authorize(path, { parameters, content }, cancel);
|
|
12
|
+
await mkdir(dirname(path), { recursive: true });
|
|
13
|
+
cancel.throwIfAborted();
|
|
14
|
+
await writeFile(path, content, "utf8");
|
|
15
|
+
};
|
|
16
|
+
return [
|
|
17
|
+
defineTool({ ...createWriteToolDefinition(root),
|
|
18
|
+
execute(id, args, inner, update, child) {
|
|
19
|
+
const cancel = AbortSignal.any([signal, ...(inner ? [inner] : [])]);
|
|
20
|
+
return createWriteToolDefinition(root, { operations: {
|
|
21
|
+
// Directory creation belongs to guardedWrite, after authorization.
|
|
22
|
+
mkdir: async () => {}, writeFile: guardedWrite(cancel, args),
|
|
23
|
+
} }).execute(id, args, cancel, update, child);
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
defineTool({ ...createEditToolDefinition(root),
|
|
27
|
+
execute(id, args, inner, update, child) {
|
|
28
|
+
const cancel = AbortSignal.any([signal, ...(inner ? [inner] : [])]);
|
|
29
|
+
return createEditToolDefinition(root, { operations: {
|
|
30
|
+
readFile, access: path => fileAccess(path, constants.R_OK | constants.W_OK), writeFile: guardedWrite(cancel, args),
|
|
31
|
+
} }).execute(id, args, cancel, update, child);
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
];
|
|
35
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { getAgentDir, getPackageDir, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { requireThat } from "../../common/validation.js";
|
|
7
|
+
import type { AgentDescriptor } from "../../model/agent.js";
|
|
8
|
+
import type { AgentHost } from "../../services/agents/agent-service.js";
|
|
9
|
+
|
|
10
|
+
export const agentDirectory = () => join(getAgentDir(), "pbot/subagents");
|
|
11
|
+
export const tmuxName = (id: string) => "pbot-" + id;
|
|
12
|
+
export class PiAgents implements AgentHost {
|
|
13
|
+
available() {
|
|
14
|
+
requireThat(spawnSync("tmux", ["-V"], { encoding: "utf8" }).status === 0, "子会话需要 tmux。");
|
|
15
|
+
requireThat(existsSync(join(getPackageDir(), "dist/cli.js")), "无法定位 Pi CLI。");
|
|
16
|
+
}
|
|
17
|
+
alive(id: string) { return spawnSync("tmux", ["has-session", "-t", "=" + tmuxName(id)], { stdio: "ignore" }).status === 0; }
|
|
18
|
+
kill(id: string) { spawnSync("tmux", ["kill-session", "-t", "=" + tmuxName(id)], { stdio: "ignore" }); }
|
|
19
|
+
launch(d: AgentDescriptor, directory: string, generation: string) {
|
|
20
|
+
const o = d.options;
|
|
21
|
+
const entry = fileURLToPath(new URL("../../extension/subagent-entry.ts", import.meta.url));
|
|
22
|
+
const compiled = fileURLToPath(new URL("../../extension/subagent-entry.js", import.meta.url));
|
|
23
|
+
const args = ["--session", join(directory, "session.jsonl"), "--extension", existsSync(entry) ? entry : compiled];
|
|
24
|
+
if (o.noExtensions !== false) args.push("--no-extensions",
|
|
25
|
+
"--extension", "builtin:mcp", "--extension", "builtin:codemode", "--extension", "builtin:tool-search");
|
|
26
|
+
if (o.noSkills) args.push("--no-skills");
|
|
27
|
+
if (o.noPromptTemplates) args.push("--no-prompt-templates");
|
|
28
|
+
if (o.noContextFiles) args.push("--no-context-files");
|
|
29
|
+
// Resume restores native model choices. Explicit model options apply only to a new transcript.
|
|
30
|
+
if (!existsSync(join(directory, "session.jsonl"))) {
|
|
31
|
+
args.push("--provider", o.provider!, "--model", o.model!);
|
|
32
|
+
if (o.thinking) args.push("--thinking", o.thinking);
|
|
33
|
+
}
|
|
34
|
+
if (o.tools) args.push("--tools", o.tools.join(","));
|
|
35
|
+
const result = spawnSync("tmux", ["new-session", "-d", "-s", tmuxName(d.agentId), "-x", "120", "-y", "40", "-c", d.cwd, "--",
|
|
36
|
+
"env", `PBOT_SUBAGENT_RUN_ID=${d.agentId}`, `PBOT_SUBAGENT_DIR=${directory}`, `PBOT_SUBAGENT_GENERATION=${generation}`,
|
|
37
|
+
`PI_CODING_AGENT_DIR=${getAgentDir()}`,
|
|
38
|
+
...["PATH", "HOME", "XDG_DATA_HOME", "XDG_CONFIG_HOME"].flatMap(key => process.env[key] === undefined ? [] : [`${key}=${process.env[key]}`]),
|
|
39
|
+
process.execPath, join(getPackageDir(), "dist/cli.js"), ...args], { encoding: "utf8" });
|
|
40
|
+
requireThat(result.status === 0, result.stderr || "子会话启动失败。");
|
|
41
|
+
}
|
|
42
|
+
report(file: string, leaf: string | null) {
|
|
43
|
+
if (!leaf || !existsSync(file)) return "";
|
|
44
|
+
const manager = SessionManager.open(file);
|
|
45
|
+
requireThat(manager.getEntry(leaf), "报告叶节点不存在。");
|
|
46
|
+
const last = manager.getBranch(leaf).filter(e => e.type === "message" && e.message.role === "assistant").at(-1);
|
|
47
|
+
return last?.type === "message" && last.message.role === "assistant"
|
|
48
|
+
? last.message.content.filter(p => p.type === "text").map(p => p.text).join("\n") : "";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { Json } from "../common/json.js";
|
|
3
|
+
|
|
4
|
+
interface ProcessResult {
|
|
5
|
+
success: boolean;
|
|
6
|
+
data: Json;
|
|
7
|
+
raw: Json;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function processEnvironment(env: Record<string, string>) {
|
|
11
|
+
return { PATH: process.env.PATH ?? "/usr/bin:/bin", LANG: "C.UTF-8", ...env };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function runCommand(command: string, args: string[], input: string | undefined, cwd: string,
|
|
15
|
+
env: Record<string, string>, signal?: AbortSignal, options: { timeoutMs?: number } = {}): Promise<ProcessResult> {
|
|
16
|
+
signal?.throwIfAborted();
|
|
17
|
+
return new Promise(resolve => {
|
|
18
|
+
const child = spawn(command, args, { cwd, env: processEnvironment(env), shell: false,
|
|
19
|
+
detached: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
20
|
+
let bytes = 0, failed = false;
|
|
21
|
+
const out: Buffer[] = [], err: Buffer[] = [];
|
|
22
|
+
const kill = () => { if (child.pid) { try { process.kill(-child.pid, "SIGKILL"); } catch { /* Already stopped. */ } } };
|
|
23
|
+
const stop = () => { failed = true; kill(); };
|
|
24
|
+
const timer = setTimeout(stop, options.timeoutMs ?? 30_000);
|
|
25
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
26
|
+
const collect = (target: Buffer[]) => (chunk: Buffer) => {
|
|
27
|
+
bytes += chunk.length; if (bytes > 2 * 1024 * 1024) stop(); else target.push(chunk);
|
|
28
|
+
};
|
|
29
|
+
child.stdout.on("data", collect(out)); child.stderr.on("data", collect(err));
|
|
30
|
+
child.on("error", stop); child.stdin.on("error", stop);
|
|
31
|
+
child.once("close", code => {
|
|
32
|
+
clearTimeout(timer); signal?.removeEventListener("abort", stop); kill();
|
|
33
|
+
const text = Buffer.concat(out).toString("utf8");
|
|
34
|
+
let data: Json = { stdout: text, stderr: Buffer.concat(err).toString("utf8"), exitCode: code };
|
|
35
|
+
try { data = JSON.parse(text) as Json; } catch { /* Preserve text and exit code. */ }
|
|
36
|
+
resolve({ success: !failed && !signal?.aborted && code === 0, data, raw: { stdout: text, stderr: Buffer.concat(err).toString("utf8"), exitCode: code, incomplete: failed || !!signal?.aborted } });
|
|
37
|
+
});
|
|
38
|
+
if (signal?.aborted) stop(); child.stdin.end(input);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { delimiter, join } from "node:path";
|
|
2
|
+
|
|
3
|
+
/** Like venv activation, scoped to this Pi process; never edits shell configuration. */
|
|
4
|
+
export function activatePython(directory: string): () => void {
|
|
5
|
+
const values: Record<string, string | undefined> = {
|
|
6
|
+
PATH: join(directory, "bin") + delimiter + (process.env.PATH ?? "/usr/bin:/bin"),
|
|
7
|
+
VIRTUAL_ENV: directory,
|
|
8
|
+
PYTHONHOME: undefined,
|
|
9
|
+
PYTHONPATH: undefined,
|
|
10
|
+
PYTHONNOUSERSITE: "1",
|
|
11
|
+
};
|
|
12
|
+
const previous = new Map(Object.keys(values).map(key => [key, process.env[key]]));
|
|
13
|
+
const assign = (key: string, value: string | undefined) => {
|
|
14
|
+
if (value === undefined) delete process.env[key]; else process.env[key] = value;
|
|
15
|
+
};
|
|
16
|
+
for (const [key, value] of Object.entries(values)) assign(key, value);
|
|
17
|
+
let restored = false;
|
|
18
|
+
return () => {
|
|
19
|
+
if (restored) return;
|
|
20
|
+
restored = true;
|
|
21
|
+
for (const [key, value] of previous) if (process.env[key] === values[key]) assign(key, value);
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface AgentOptions {
|
|
2
|
+
name?: string; message?: string; files?: string[]; prompts?: string[];
|
|
3
|
+
provider?: string; model?: string; thinking?: string; cwd?: string; tools?: string[];
|
|
4
|
+
noExtensions?: boolean; noSkills?: boolean; noPromptTemplates?: boolean; noContextFiles?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export interface AgentOwner { sessionId: string; leaf: string | null; branch: string[] }
|
|
7
|
+
export interface AgentDescriptor {
|
|
8
|
+
agentId: string; release: string; workspace: string; cwd: string; parentSessionId: string;
|
|
9
|
+
parentLeaf: string | null; createdAt: number; options: AgentOptions;
|
|
10
|
+
}
|
|
11
|
+
export interface AgentLaunch { generation: string; name: string; sequence: number; stop: boolean; startedAt: number }
|
|
12
|
+
export interface AgentMessage { requestId: string; generation: string; sequence: number; message: string; delivery: "steer" | "followUp"; files: string[] }
|
|
13
|
+
export interface AgentCycle {
|
|
14
|
+
cycleId: string; generation: string; startedAt: number; finishedAt?: number;
|
|
15
|
+
state: "running" | "completed" | "failed" | "cancelled" | "interrupted";
|
|
16
|
+
leaf: string | null; requestIds: string[]; source: "parent" | "interactive" | "mixed";
|
|
17
|
+
error?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface AgentStatus {
|
|
20
|
+
generation: string; state: "starting" | "busy" | "idle" | "stopping" | "stopped" | "error";
|
|
21
|
+
activity: string; updatedAt: number; accepted?: string[]; cycle?: AgentCycle; error?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface AgentView extends AgentDescriptor {
|
|
24
|
+
name: string; generation: string; state: AgentStatus["state"]; activity: string;
|
|
25
|
+
cycle?: AgentCycle; error?: string; owned: boolean;
|
|
26
|
+
}
|
|
27
|
+
export interface AgentCompletion { agentId: string; name: string; cycle: AgentCycle; report: string }
|
package/src/pbot.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { PiAgents, agentDirectory } from "./integrations/pi/pi-agents.js";
|
|
3
|
+
import { PythonEnvironmentService } from "./services/workspace/python-environment-service.js";
|
|
4
|
+
import { AgentService } from "./services/agents/agent-service.js";
|
|
5
|
+
import { Confirmations } from "./ui/confirmation.js";
|
|
6
|
+
import { ToolRegistry, mainTools } from "./tools/register.js";
|
|
7
|
+
import { Lifecycle, WorkspaceInstance } from "./extension/lifecycle.js";
|
|
8
|
+
import { registerCommand } from "./extension/command.js";
|
|
9
|
+
import { registerEvents } from "./extension/events.js";
|
|
10
|
+
import { registerAgentEvents } from "./extension/agent-events.js";
|
|
11
|
+
|
|
12
|
+
export default function pbot(pi: ExtensionAPI) {
|
|
13
|
+
// The explicitly loaded child entry owns the child; normal discovery never grants main privileges.
|
|
14
|
+
if (process.env.PBOT_SUBAGENT_RUN_ID) return;
|
|
15
|
+
const confirmations = new Confirmations(), registry = new ToolRegistry(pi);
|
|
16
|
+
const lifecycle = new Lifecycle(root => new WorkspaceInstance(root, new PythonEnvironmentService(root),
|
|
17
|
+
new AgentService(root, agentDirectory(), new PiAgents())),
|
|
18
|
+
instance => registry.register(mainTools(instance.agents, instance)), () => registry.hide());
|
|
19
|
+
registerCommand(pi, lifecycle, confirmations);
|
|
20
|
+
registerEvents(pi, lifecycle, confirmations);
|
|
21
|
+
registerAgentEvents(pi, lifecycle, confirmations);
|
|
22
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { packageVersion, releaseLine } from "../../common/version.js";
|
|
5
|
+
import { requireThat } from "../../common/validation.js";
|
|
6
|
+
import { AgentFiles, descriptor, launch, status, cycle } from "../../storage/agent-files.js";
|
|
7
|
+
import type { AgentOwner, AgentOptions, AgentView, AgentDescriptor, AgentCycle, AgentCompletion } from "../../model/agent.js";
|
|
8
|
+
|
|
9
|
+
export interface AgentHost {
|
|
10
|
+
available(): void;
|
|
11
|
+
alive(id: string): boolean;
|
|
12
|
+
launch(value: AgentDescriptor, directory: string, generation: string): void;
|
|
13
|
+
kill(id: string): void;
|
|
14
|
+
report(file: string, leaf: string | null): string;
|
|
15
|
+
}
|
|
16
|
+
export class AgentService {
|
|
17
|
+
readonly files: AgentFiles;
|
|
18
|
+
#closed = false;
|
|
19
|
+
constructor(readonly root: string, directory: string, readonly host: AgentHost) { this.files = new AgentFiles(directory); }
|
|
20
|
+
private check() { requireThat(!this.#closed, "子会话服务已关闭。"); }
|
|
21
|
+
private owned(d: AgentDescriptor, owner: AgentOwner) {
|
|
22
|
+
return d.workspace === this.root && d.parentSessionId === owner.sessionId && (d.parentLeaf === null || owner.branch.includes(d.parentLeaf));
|
|
23
|
+
}
|
|
24
|
+
private get(id: string, owner: AgentOwner) {
|
|
25
|
+
this.check(); const d = this.files.read(id, "descriptor.json", descriptor);
|
|
26
|
+
requireThat(this.owned(d, owner), "子会话不属于当前会话分支。");
|
|
27
|
+
requireThat(releaseLine(d.release) === releaseLine(packageVersion), "子会话版本不兼容;请停止后使用匹配版本。"); return d;
|
|
28
|
+
}
|
|
29
|
+
list(owner: AgentOwner): AgentView[] {
|
|
30
|
+
this.check(); return this.files.list().filter(d => d.workspace === this.root && d.parentSessionId === owner.sessionId).map(d => this.view(d, owner));
|
|
31
|
+
}
|
|
32
|
+
private view(d: AgentDescriptor, owner: AgentOwner): AgentView {
|
|
33
|
+
const run = this.files.read(d.agentId, "launch.json", launch);
|
|
34
|
+
const saved = this.files.optional(d.agentId, "status.json", status);
|
|
35
|
+
const current = saved?.generation === run.generation ? saved : undefined;
|
|
36
|
+
const alive = this.host.alive(d.agentId);
|
|
37
|
+
return { ...d, name: run.name, generation: run.generation, owned: this.owned(d, owner),
|
|
38
|
+
state: alive ? run.stop ? "stopping" : current?.state ?? "starting" : current?.state === "error" ? "error" : "stopped",
|
|
39
|
+
activity: alive ? current?.activity ?? "启动" : current?.error ?? "已停止",
|
|
40
|
+
...(current?.cycle ? { cycle: current.cycle } : {}), ...(current?.error ? { error: current.error } : {}) };
|
|
41
|
+
}
|
|
42
|
+
start(options: AgentOptions, owner: AgentOwner) {
|
|
43
|
+
this.check(); this.host.available();
|
|
44
|
+
options = Object.fromEntries(Object.entries(options).filter(([, v]) => v !== undefined));
|
|
45
|
+
requireThat(options.provider && options.model, "请选择可在子进程使用的模型。");
|
|
46
|
+
return this.files.control(() => {
|
|
47
|
+
requireThat(this.list(owner).filter(v => this.host.alive(v.agentId)).length < 50, "最多 50 个存活子会话;请先停止一个空闲会话。");
|
|
48
|
+
const agentId = randomUUID();
|
|
49
|
+
const cwd = realpathSync(resolve(this.root, options.cwd ?? "."));
|
|
50
|
+
const files = (options.files ?? []).map(p => realpathSync(resolve(this.root, p)));
|
|
51
|
+
const d: AgentDescriptor = { agentId, release: packageVersion, workspace: this.root, cwd,
|
|
52
|
+
parentSessionId: owner.sessionId, parentLeaf: owner.leaf, createdAt: Date.now(), options: { ...options, files, cwd } };
|
|
53
|
+
const run = { generation: randomUUID(), name: options.name ?? agentId.slice(0, 8), sequence: 0, stop: false, startedAt: Date.now() };
|
|
54
|
+
this.files.create(d, run);
|
|
55
|
+
const request = this.files.enqueue(agentId, [options.message, ...(options.prompts ?? [])].filter(Boolean).join("\n\n"), "steer", files);
|
|
56
|
+
try { this.host.launch(d, this.files.directory(agentId), run.generation); }
|
|
57
|
+
catch (error) {
|
|
58
|
+
this.files.write(agentId, "status.json", { generation: run.generation, state: "error", activity: "启动失败", updatedAt: Date.now(), error: String(error) }, status);
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
return { ...this.view(d, owner), requestId: request.requestId, messageStatus: "queued" as const };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
send(id: string, text: string, delivery: "steer" | "followUp", owner: AgentOwner) {
|
|
65
|
+
const d = this.get(id, owner);
|
|
66
|
+
return this.files.control(() => {
|
|
67
|
+
requireThat(this.host.alive(id) && !this.files.read(id, "launch.json", launch).stop, "子会话已停止,请显式 resume。");
|
|
68
|
+
const request = this.files.enqueue(id, text, delivery);
|
|
69
|
+
return { ...this.view(d, owner), requestId: request.requestId, messageStatus: "queued" as const };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
rename(id: string, name: string, owner: AgentOwner) {
|
|
73
|
+
requireThat(name.trim().length > 0 && name.length <= 64 && !/[\x00-\x1f]/.test(name), "名称必须是 1 到 64 字符的单行文本。");
|
|
74
|
+
const d = this.get(id, owner);
|
|
75
|
+
this.files.control(() => { const run = this.files.read(id, "launch.json", launch); run.name = name; this.files.write(id, "launch.json", run, launch); });
|
|
76
|
+
return this.view(d, owner);
|
|
77
|
+
}
|
|
78
|
+
stop(id: string, owner: AgentOwner) {
|
|
79
|
+
const d = this.get(id, owner); this.requestStop(id); return this.view(d, owner);
|
|
80
|
+
}
|
|
81
|
+
private requestStop(id: string) {
|
|
82
|
+
this.files.control(() => { const run = this.files.read(id, "launch.json", launch); run.stop = true; this.files.write(id, "launch.json", run, launch); });
|
|
83
|
+
}
|
|
84
|
+
resume(id: string, text: string, owner: AgentOwner) {
|
|
85
|
+
const d = this.get(id, owner); this.host.available();
|
|
86
|
+
return this.files.control(() => {
|
|
87
|
+
requireThat(!this.host.alive(id), "子会话仍在运行。");
|
|
88
|
+
requireThat(this.list(owner).filter(v => this.host.alive(v.agentId)).length < 50, "最多 50 个存活子会话。");
|
|
89
|
+
const previous = this.files.read(id, "launch.json", launch);
|
|
90
|
+
// The old process is gone: seal its interruption before replacing its generation.
|
|
91
|
+
for (const ended of this.cycles(id, owner)) {
|
|
92
|
+
if (ended.generation === previous.generation && !this.files.completions(id).some(c => c.cycleId === ended.cycleId)) {
|
|
93
|
+
this.files.write(id, "completions/" + ended.cycleId + ".json", ended, cycle, true);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
this.#stopping.delete(id);
|
|
97
|
+
const run = { ...previous, generation: randomUUID(), sequence: 0, stop: false, startedAt: Date.now() };
|
|
98
|
+
this.files.write(id, "launch.json", run, launch);
|
|
99
|
+
const request = this.files.enqueue(id, text, "steer");
|
|
100
|
+
this.host.launch(d, this.files.directory(id), run.generation);
|
|
101
|
+
return { ...this.view(d, owner), requestId: request.requestId, messageStatus: "queued" as const };
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
cycles(id: string, owner: AgentOwner): AgentCycle[] {
|
|
105
|
+
const d = this.get(id, owner), view = this.view(d, owner), values = this.files.completions(id);
|
|
106
|
+
if (view.cycle && !values.some(c => c.cycleId === view.cycle!.cycleId)) {
|
|
107
|
+
values.push(view.state === "stopped" || view.state === "error" ? { ...view.cycle, state: "interrupted", finishedAt: view.cycle.finishedAt ?? Date.now() } : view.cycle);
|
|
108
|
+
}
|
|
109
|
+
if (!values.some(c => c.generation === view.generation) && ["stopped", "error"].includes(view.state)) {
|
|
110
|
+
const run = this.files.read(id, "launch.json", launch);
|
|
111
|
+
values.push({ cycleId: run.generation, generation: run.generation, startedAt: run.startedAt, finishedAt: run.startedAt,
|
|
112
|
+
state: "interrupted", leaf: null, requestIds: [], source: "parent", error: view.error ?? "子进程在交付前退出。" });
|
|
113
|
+
}
|
|
114
|
+
return values;
|
|
115
|
+
}
|
|
116
|
+
read(id: string, owner: AgentOwner, cycleId?: string, offset = 0, limit = 16000) {
|
|
117
|
+
const d = this.get(id, owner), cycles = this.cycles(id, owner);
|
|
118
|
+
const c = cycleId ? cycles.find(v => v.cycleId === cycleId) : cycles.at(-1);
|
|
119
|
+
requireThat(!cycleId || c, "周期不存在。");
|
|
120
|
+
const report = c ? this.host.report(this.files.session(id), c.leaf) : "";
|
|
121
|
+
requireThat(offset <= report.length, "分页位置超出报告。");
|
|
122
|
+
return { ...this.view(d, owner), ...(c ? { cycle: c } : {}), cycles: cycles.map(v => ({ cycleId: v.cycleId, state: v.state })),
|
|
123
|
+
report: report.slice(offset, offset + limit), totalLength: report.length, nextOffset: offset + limit < report.length ? offset + limit : null };
|
|
124
|
+
}
|
|
125
|
+
completions(owner: AgentOwner): AgentCompletion[] {
|
|
126
|
+
return this.list(owner).filter(v => v.owned).flatMap(v => this.cycles(v.agentId, owner).filter(c => c.state !== "running").map(c =>
|
|
127
|
+
({ agentId: v.agentId, name: v.name, cycle: c, report: this.host.report(this.files.session(v.agentId), c.leaf) })));
|
|
128
|
+
}
|
|
129
|
+
/** Called by the parent scanner; stop remains nonblocking to the model. */
|
|
130
|
+
refresh(owner: AgentOwner) {
|
|
131
|
+
for (const d of this.files.list().filter(d => d.workspace === this.root && d.parentSessionId === owner.sessionId)) {
|
|
132
|
+
const run = this.files.read(d.agentId, "launch.json", launch);
|
|
133
|
+
// A stopped request gets its own grace deadline without modifying child status.
|
|
134
|
+
if (!run.stop) this.#stopping.delete(d.agentId);
|
|
135
|
+
if (run.stop && this.host.alive(d.agentId)) {
|
|
136
|
+
const since = this.#stopping.get(d.agentId) ?? Date.now(); this.#stopping.set(d.agentId, since);
|
|
137
|
+
if (Date.now() - since > 5000) this.host.kill(d.agentId);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
readonly #stopping = new Map<string, number>();
|
|
142
|
+
async close(owner?: AgentOwner, preserve = false) {
|
|
143
|
+
if (this.#closed) return;
|
|
144
|
+
if (!preserve && owner) {
|
|
145
|
+
const ids = this.list(owner).filter(v => this.host.alive(v.agentId)).map(v => v.agentId);
|
|
146
|
+
for (const id of ids) this.requestStop(id);
|
|
147
|
+
const deadline = Date.now() + 5000;
|
|
148
|
+
while (ids.some(id => this.host.alive(id)) && Date.now() < deadline) await new Promise(r => setTimeout(r, 100));
|
|
149
|
+
for (const id of ids) if (this.host.alive(id)) this.host.kill(id);
|
|
150
|
+
}
|
|
151
|
+
this.#closed = true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { object } from "../../common/json.js";
|
|
3
|
+
import { runCommand } from "../../integrations/process.js";
|
|
4
|
+
import { requireThat } from "../../common/validation.js";
|
|
5
|
+
import { resourceRoot } from "../../common/paths.js";
|
|
6
|
+
|
|
7
|
+
export class PythonEnvironmentService {
|
|
8
|
+
readonly #shutdown = new AbortController();
|
|
9
|
+
#pending: Promise<{ directory: string; python: string }> | undefined;
|
|
10
|
+
#status: { state: "unprepared" | "preparing" | "ready" | "error"; directory?: string; error?: string } = { state: "unprepared" };
|
|
11
|
+
constructor(private readonly root: string) {}
|
|
12
|
+
get status() { return { ...this.#status }; }
|
|
13
|
+
|
|
14
|
+
setup(signal?: AbortSignal): Promise<{ directory: string; python: string }> {
|
|
15
|
+
const cancel = signal ? AbortSignal.any([this.#shutdown.signal, signal]) : this.#shutdown.signal;
|
|
16
|
+
if (cancel.aborted) return Promise.reject(cancel.reason);
|
|
17
|
+
if (this.#pending) return this.#pending;
|
|
18
|
+
this.#status = { state: "preparing" };
|
|
19
|
+
this.#pending = (async () => {
|
|
20
|
+
try {
|
|
21
|
+
const environment: Record<string, string> = {};
|
|
22
|
+
for (const [key, value] of Object.entries(process.env)) if (value !== undefined) environment[key] = value;
|
|
23
|
+
const result = await runCommand("/bin/sh", ["-c", 'exec "$(command -v python3 || command -v python)" "$@"',
|
|
24
|
+
"pbot-python", "-I", "-B", join(resourceRoot, "../scripts/setup-python.py")],
|
|
25
|
+
undefined, this.root, environment, cancel, { timeoutMs: 15 * 60_000 });
|
|
26
|
+
cancel.throwIfAborted();
|
|
27
|
+
requireThat(result.success && object(result.data) && typeof result.data.directory === "string" && typeof result.data.python === "string",
|
|
28
|
+
"共享 Python 环境未就绪;修复后执行 /pbot python setup。\n" + JSON.stringify(result.data).slice(-6000));
|
|
29
|
+
const ready = { directory: result.data.directory, python: result.data.python };
|
|
30
|
+
this.#status = { state: "ready", directory: ready.directory };
|
|
31
|
+
return ready;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
this.#status = { state: "error", error: error instanceof Error ? error.message : String(error) };
|
|
34
|
+
throw error;
|
|
35
|
+
} finally { this.#pending = undefined; }
|
|
36
|
+
})();
|
|
37
|
+
return this.#pending;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async close(): Promise<void> {
|
|
41
|
+
this.#shutdown.abort();
|
|
42
|
+
await this.#pending?.catch(() => undefined);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { relative, isAbsolute } from "node:path";
|
|
2
|
+
import { mutationTarget } from "../../common/files.js";
|
|
3
|
+
import { requireThat } from "../../common/validation.js";
|
|
4
|
+
|
|
5
|
+
export function mutationScope(root: string, target: string): "outside" | "inside" {
|
|
6
|
+
const rel = relative(root, target);
|
|
7
|
+
if (rel === ".." || rel.startsWith("../") || isAbsolute(rel)) return "outside";
|
|
8
|
+
return "inside";
|
|
9
|
+
}
|
|
10
|
+
export async function authorizeMutation(root: string, path: string, change: unknown,
|
|
11
|
+
confirm: (value: unknown) => Promise<boolean>, signal: AbortSignal) {
|
|
12
|
+
signal.throwIfAborted();
|
|
13
|
+
const target = await mutationTarget(path), scope = mutationScope(root, target);
|
|
14
|
+
if (scope === "outside") requireThat(await confirm({ path: target, change }), "用户未确认文件修改。");
|
|
15
|
+
signal.throwIfAborted();
|
|
16
|
+
requireThat(await mutationTarget(path) === target, "确认期间文件路径已变化。");
|
|
17
|
+
}
|