@realqiyan/agent-task 0.0.0-stage → 0.0.1-rc.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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -2
  3. package/bin/agent-task.mjs +66 -0
  4. package/config.example.json +7 -0
  5. package/deploy/agent-task.service.example +17 -0
  6. package/dist/acp.js +168 -0
  7. package/dist/admin.js +31 -0
  8. package/dist/config.js +113 -0
  9. package/dist/coordinator-provider.js +28 -0
  10. package/dist/coordinator-storage.js +43 -0
  11. package/dist/coordinator.js +117 -0
  12. package/dist/environment.js +23 -0
  13. package/dist/http-auth.js +9 -0
  14. package/dist/human-input.js +62 -0
  15. package/dist/lock.js +27 -0
  16. package/dist/main.js +131 -0
  17. package/dist/mcp.js +40 -0
  18. package/dist/model.js +28 -0
  19. package/dist/node-execution.js +103 -0
  20. package/dist/permissions.js +10 -0
  21. package/dist/private-files.js +81 -0
  22. package/dist/results.js +85 -0
  23. package/dist/settings-contract.js +16 -0
  24. package/dist/settings.js +163 -0
  25. package/dist/task-contract.js +37 -0
  26. package/dist/task-model.js +2 -0
  27. package/dist/task-storage.js +123 -0
  28. package/dist/tasks.js +509 -0
  29. package/dist/version.js +3 -0
  30. package/dist/web/api.js +26 -0
  31. package/dist/web/app.js +65 -0
  32. package/dist/web/dom.js +65 -0
  33. package/dist/web/index.html +33 -0
  34. package/dist/web/settings-view.js +121 -0
  35. package/dist/web/style.css +24 -0
  36. package/dist/web/tasks-view.js +83 -0
  37. package/dist/web.js +105 -0
  38. package/dist/worker.js +89 -0
  39. package/dist/workspace.js +16 -0
  40. package/docs/architecture-guidelines.md +52 -0
  41. package/docs/deployment.md +113 -0
  42. package/docs/design-proposal.md +55 -0
  43. package/docs/release.md +57 -0
  44. package/docs/technical-design.md +37 -0
  45. package/docs/validation.md +100 -0
  46. package/docs/web-console.md +46 -0
  47. package/package.json +68 -4
  48. package/scripts/install-lib.mjs +191 -0
  49. package/scripts/mcp-call.mjs +17 -0
  50. package/scripts/probe-agent.mjs +34 -0
  51. package/scripts/service-runner.mjs +14 -0
  52. package/scripts/setup-wizard.mjs +29 -0
  53. package/scripts/setup.mjs +126 -0
  54. package/scripts/uninstall.mjs +53 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 realqiyan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,46 @@
1
- # Temporary Holding Version
1
+ # Agent Task
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 独立的智能任务服务:**MCP 管理任务,pi durable 推进调用方定义的 DAG,ACP 执行端完成领域工作**。协调员负责澄清和交付验收,不拆解任务或修改计划。
4
+
5
+ 当前处于调研阶段,不宣称生产可用,不用于无人值守交易。已有验证结果及适用范围见[验证说明](docs/validation.md)。
6
+
7
+ ## 从哪里开始
8
+
9
+ | 目的 | 入口 |
10
+ | --- | --- |
11
+ | 安装、认证、后台运行、卸载 | [部署说明](docs/deployment.md) |
12
+ | 本机控制台、配置保存与手动重启 | [控制台说明](docs/web-console.md) |
13
+ | 理解产品目标和架构选择 | [总体方案](docs/design-proposal.md) |
14
+ | 查找实现与对应测试 | [代码导航](docs/technical-design.md) |
15
+ | 查询测试命令、真实验收及缺口 | [验证说明](docs/validation.md) |
16
+ | npm 候选验收、GitHub Actions 与受控发布 | [发布说明](docs/release.md) |
17
+ | 修改项目 | [开发指南](AGENTS.md)、[架构原则](docs/architecture-guidelines.md) |
18
+
19
+ 文档保留用途、约束和证据;字段、默认值、状态转换及内部流程以代码为准。
20
+
21
+ ## 使用入口
22
+
23
+ Linux/macOS,Node.js **22.19+**。安装包自带运行产物,无需克隆或编译。发布目标为 `0.0.1-rc.1`,使用 `latest`;实际发布与平台验收状态见[发布证据](docs/validation.md#npm-发布候选验证)。
24
+
25
+ ```bash
26
+ npm install -g @realqiyan/agent-task
27
+ agent-task setup
28
+ agent-task start
29
+ # 另一个终端进行带认证的健康检查:
30
+ agent-task status
31
+ ```
32
+
33
+ npm 包名为 `@realqiyan/agent-task`,安装后的命令仍为 `agent-task`。setup 提供交互配置;启动后可使用本机 Web 控制台。先停止前台实例,再用 `agent-task service install` 安装并启动用户后台服务。安装器不会替用户登录或批准执行端;详细操作见[部署说明](docs/deployment.md)。
34
+
35
+ MCP 客户端使用独立服务 token。源码开发辅助命令(凭据须安全注入环境,不写命令行):
36
+
37
+ ```bash
38
+ node scripts/mcp-call.mjs --list
39
+ node scripts/mcp-call.mjs service_status
40
+ ```
41
+
42
+ 任务由 `task_create` 提交固定 DAG,每个节点选择已注册执行端。具体契约见 [task-contract.ts](src/task-contract.ts),管理工具见 [mcp.ts](src/mcp.ts)。Prompt、人工回复和工作空间选择都不授予工具权限。
43
+
44
+ ## 许可证
45
+
46
+ [MIT](LICENSE),Copyright (c) 2026 realqiyan。依赖及执行端仍遵循各自许可证。
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+
4
+ const help = `Usage: agent-task <command>
5
+ setup [options] Interactive configuration (setup --help for flags)
6
+ start [--home PATH] Foreground service using the private installation
7
+ start --config PATH --environment PATH
8
+ Foreground service with explicit private files
9
+ status [--home PATH] Authenticated MCP health check; no token printed
10
+ service install [--home PATH]
11
+ Install/start systemd user service or LaunchAgent
12
+ service remove [--home PATH]
13
+ Stop/remove only the owned service
14
+ uninstall [--home PATH] Remove service and owned executor components;
15
+ preserve configuration, credentials and task data
16
+ --version | --help
17
+ Node.js 22.19+; Linux or macOS. Setup never grants executor permissions.`;
18
+ try {
19
+ const [major, minor] = process.versions.node.split(".").map(Number);
20
+ if (major < 22 || (major === 22 && minor < 19)) throw new Error("Node.js 22.19+ is required");
21
+ const [command, ...args] = process.argv.slice(2);
22
+ if (!command || command === "--help" || command === "help") {
23
+ console.log(help);
24
+ } else if (command === "--version") {
25
+ console.log(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
26
+ } else if (command === "setup" || command === "uninstall") {
27
+ process.argv = [process.execPath, command, ...args];
28
+ await import(`../scripts/${command}.mjs`);
29
+ } else if (command === "start" && args[0] === "--config") {
30
+ if (args.length !== 4 || args[2] !== "--environment") throw new Error("Use start --config PATH --environment PATH");
31
+ process.argv = [process.execPath, "agent-task", ...args];
32
+ await import("../dist/main.js");
33
+ } else {
34
+ const lib = await import("../scripts/install-lib.mjs");
35
+ const action = command === "service" ? args.shift() : undefined;
36
+ if (!["start", "status", "service"].includes(command) || (command === "service" && !["install", "remove"].includes(action))) throw new Error(help);
37
+ const options = lib.argumentsFor(args);
38
+ if (Object.keys(options).some(key => key !== "home")) throw new Error("Only --home is accepted here");
39
+ const root = lib.installRoot(options);
40
+ if (action === "install") {
41
+ process.argv = [process.execPath, "setup", "--home", root, "--service"];
42
+ await import("../scripts/setup.mjs");
43
+ } else if (action === "remove") {
44
+ lib.readManifest(root);
45
+ const unlock = lib.operationLock(root);
46
+ try {
47
+ const manifest = lib.readManifest(root);
48
+ lib.deactivate(manifest);
49
+ manifest.serviceInstalled = false;
50
+ lib.saveManifest(root, manifest);
51
+ console.log("Owned service removed; configuration, credentials, components and data preserved.");
52
+ } finally { unlock(); }
53
+ } else if (command === "start") {
54
+ process.argv = [process.execPath, "service-runner", root];
55
+ await import("../scripts/service-runner.mjs");
56
+ } else {
57
+ const manifest = lib.readManifest(root);
58
+ if (manifest.phase !== "ready") throw new Error("Installation is not ready");
59
+ await lib.checkHealth(lib.json(root + "/config.json"), lib.runtimeEnv(manifest));
60
+ console.log("Authenticated MCP health check passed.");
61
+ }
62
+ }
63
+ } catch (error) {
64
+ console.error(error.message);
65
+ process.exitCode = 1;
66
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "dataDir": "/absolute/private/path/agent-task",
3
+ "port": 8765,
4
+ "tokenEnv": "AGENT_TASK_TOKEN",
5
+ "coordinator": { "provider": "openai", "model": "YOUR_AVAILABLE_MODEL_ID" },
6
+ "executors": []
7
+ }
@@ -0,0 +1,17 @@
1
+ [Unit]
2
+ Description=Agent Task local service
3
+ StartLimitIntervalSec=60
4
+ StartLimitBurst=3
5
+
6
+ [Service]
7
+ Type=simple
8
+ WorkingDirectory=/absolute/path/to/agent-task
9
+ ExecStart=/absolute/path/to/node /absolute/path/to/agent-task/dist/main.js --config %h/.config/agent-task/config.json --environment %h/.config/agent-task/environment.json
10
+ Restart=on-failure
11
+ RestartSec=5
12
+ TimeoutStopSec=15
13
+ KillMode=control-group
14
+ UMask=0077
15
+
16
+ [Install]
17
+ WantedBy=default.target
package/dist/acp.js ADDED
@@ -0,0 +1,168 @@
1
+ import { spawn } from "node:child_process";
2
+ import { Readable, Writable } from "node:stream";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import * as acp from "@agentclientprotocol/sdk";
5
+ import { executorEnv } from "./config.js";
6
+ import { readInputFile } from "./results.js";
7
+ import { permissionDecision } from "./permissions.js";
8
+ import { version } from "./version.js";
9
+ /** Project only public progress. Queue every piece before yielding so concurrent notifications cannot reorder output. */
10
+ export function progressEvents(update) {
11
+ if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") {
12
+ const result = [];
13
+ for (let i = 0; i < update.content.text.length; i += 2000) {
14
+ const text = update.content.text.slice(i, i + 2000);
15
+ result.push({ kind: "output", data: { text }, text });
16
+ }
17
+ return result;
18
+ }
19
+ if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") {
20
+ return [{ kind: "tool", data: { id: update.toolCallId, name: update.name ?? null,
21
+ status: update.status ?? null, kind: update.kind ?? null } }];
22
+ }
23
+ if (update.sessionUpdate === "plan") {
24
+ return [{ kind: "plan", data: update.entries.slice(0, 30).map(entry => ({
25
+ content: entry.content.slice(0, 300), status: entry.status, priority: entry.priority,
26
+ })) }];
27
+ }
28
+ return [];
29
+ }
30
+ function signalGroup(child, signal) {
31
+ if (child.pid) {
32
+ try {
33
+ process.kill(-child.pid, signal);
34
+ }
35
+ catch (error) {
36
+ if (error.code !== "ESRCH")
37
+ throw error;
38
+ }
39
+ }
40
+ }
41
+ /** Kill the whole group even if its leader already exited; Linux systemd additionally owns a cgroup; macOS does not. */
42
+ export async function terminateGroup(child) {
43
+ signalGroup(child, "SIGTERM");
44
+ await delay(300);
45
+ signalGroup(child, "SIGKILL");
46
+ child.stdin?.destroy();
47
+ child.stdout?.destroy();
48
+ child.stderr?.destroy();
49
+ }
50
+ export async function openAcp(executor, signal, emit, inputFiles = []) {
51
+ signal.throwIfAborted();
52
+ const child = spawn(executor.command, executor.args, {
53
+ cwd: executor.cwd, env: executorEnv(executor.envKeys), detached: true, stdio: ["pipe", "pipe", "pipe"],
54
+ });
55
+ const controller = new AbortController();
56
+ const cancel = AbortSignal.any([signal, controller.signal]);
57
+ let sessionId, connection;
58
+ let killTimer;
59
+ let wireBytes = 0;
60
+ const closed = new Promise(resolve => child.once("close", () => resolve()));
61
+ child.once("error", error => controller.abort(error));
62
+ child.once("exit", (code, sig) => controller.abort(new Error("Agent exited: " + (sig ?? code))));
63
+ child.stdout.on("data", (chunk) => {
64
+ wireBytes += chunk.length;
65
+ if (wireBytes > 8_388_608)
66
+ controller.abort(new Error("ACP wire output limit exceeded"));
67
+ });
68
+ // Drain diagnostics without storing raw stderr, which can contain credentials.
69
+ child.stderr.on("data", (chunk) => {
70
+ wireBytes += chunk.length;
71
+ if (wireBytes > 8_388_608)
72
+ controller.abort(new Error("Agent diagnostic output limit exceeded"));
73
+ });
74
+ const abort = () => {
75
+ if (sessionId && connection)
76
+ void connection.agent.notify(acp.methods.agent.session.cancel, { sessionId }).catch(() => { });
77
+ killTimer ??= setTimeout(() => {
78
+ connection?.close(cancel.reason);
79
+ void terminateGroup(child).catch(() => { });
80
+ }, 500);
81
+ };
82
+ cancel.addEventListener("abort", abort, { once: true });
83
+ let reporting = Promise.resolve();
84
+ const report = (...args) => {
85
+ const next = reporting.then(() => emit(...args));
86
+ reporting = next.catch(error => { controller.abort(error); });
87
+ return next;
88
+ };
89
+ let closing;
90
+ const close = () => closing ??= (async () => {
91
+ cancel.removeEventListener("abort", abort);
92
+ if (killTimer)
93
+ clearTimeout(killTimer);
94
+ connection?.close();
95
+ await terminateGroup(child);
96
+ await closed;
97
+ })();
98
+ let busy = false;
99
+ const app = acp.client({ name: "agent-task" })
100
+ .onRequest(acp.methods.client.fs.readTextFile, async (ctx) => {
101
+ try {
102
+ cancel.throwIfAborted();
103
+ if (ctx.params.sessionId !== sessionId)
104
+ throw new Error("Wrong input-file session");
105
+ return { content: readInputFile(executor, inputFiles, ctx.params.path, ctx.params.line ?? 1, ctx.params.limit ?? undefined) };
106
+ }
107
+ catch (error) {
108
+ controller.abort(error);
109
+ throw error;
110
+ }
111
+ })
112
+ .onRequest(acp.methods.client.session.requestPermission, async (ctx) => {
113
+ const request = ctx.params;
114
+ const response = permissionDecision(request, executor.permissionAllowlist, cancel.aborted || request.sessionId !== sessionId);
115
+ await report("permission", { tool: request.toolCall.name ?? null, outcome: response.outcome });
116
+ return response;
117
+ })
118
+ .onNotification(acp.methods.client.session.update, async (ctx) => {
119
+ if (ctx.params.sessionId !== sessionId || cancel.aborted)
120
+ return;
121
+ await Promise.all(progressEvents(ctx.params.update).map(event => report(event.kind, event.data, event.text)));
122
+ // Do not persist internal thought streams, raw tool inputs/outputs, or arbitrary extensions.
123
+ });
124
+ try {
125
+ connection = await app.connect(acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout), { maxMessageBytes: 1_048_576 }));
126
+ if (cancel.aborted)
127
+ abort();
128
+ const init = await connection.agent.request(acp.methods.agent.initialize, {
129
+ protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: inputFiles.length ? { fs: { readTextFile: true, writeTextFile: false } } : {},
130
+ clientInfo: { name: "agent-task", version },
131
+ });
132
+ cancel.throwIfAborted();
133
+ if (init.protocolVersion !== acp.PROTOCOL_VERSION)
134
+ throw new Error("Incompatible ACP protocol version");
135
+ await report("agent", { name: init.agentInfo?.name ?? null, version: init.agentInfo?.version ?? null });
136
+ const session = await connection.agent.request(acp.methods.agent.session.new, { cwd: executor.cwd, mcpServers: [] });
137
+ sessionId = session.sessionId;
138
+ await report("session", sessionId);
139
+ cancel.throwIfAborted();
140
+ return {
141
+ close, closed,
142
+ async prompt(text) {
143
+ cancel.throwIfAborted();
144
+ if (closing || busy)
145
+ throw new Error("ACP session closed or busy");
146
+ busy = true;
147
+ try {
148
+ const result = await connection.agent.request(acp.methods.agent.session.prompt, {
149
+ sessionId: sessionId, prompt: [{ type: "text", text }],
150
+ });
151
+ await reporting;
152
+ cancel.throwIfAborted();
153
+ return result.stopReason;
154
+ }
155
+ finally {
156
+ busy = false;
157
+ }
158
+ },
159
+ };
160
+ }
161
+ catch (error) {
162
+ await close();
163
+ if (cancel.aborted)
164
+ throw cancel.reason;
165
+ throw error;
166
+ }
167
+ }
168
+ //# sourceMappingURL=acp.js.map
package/dist/admin.js ADDED
@@ -0,0 +1,31 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { loadConfig, validateConfig } from "./config.js";
4
+ import { replacePrivateText } from "./private-files.js";
5
+ import { executorSchema } from "./model.js";
6
+ import { acquireLock } from "./lock.js";
7
+ process.umask(0o077);
8
+ const [action, configPath, profilePath] = process.argv.slice(2);
9
+ if (!configPath || !["register", "list"].includes(action ?? "") || (action === "register" && !profilePath)) {
10
+ throw new Error("Usage: node dist/admin.js list CONFIG | register CONFIG PROFILE.json (service stopped)");
11
+ }
12
+ const path = resolve(configPath), config = loadConfig(path);
13
+ const release = await acquireLock(config.dataDir);
14
+ try {
15
+ if (action === "list") {
16
+ console.log(JSON.stringify(config.executors.map(({ id, command, args, cwd, envKeys, permissionAllowlist, unattendedApproved, resultReadRoots }) => ({ id, transport: "stdio", command, args, cwd, envKeys, permissionAllowlist, unattendedApproved, resultReadRoots })), null, 2));
17
+ }
18
+ else {
19
+ const profile = executorSchema.parse(JSON.parse(readFileSync(resolve(profilePath), "utf8")));
20
+ if (config.executors.some(item => item.id === profile.id))
21
+ throw new Error("Executor ID already registered; edit reviewed config while stopped to replace");
22
+ config.executors.push(profile);
23
+ // Validate the complete candidate before publication, using the same rules as startup and the console.
24
+ replacePrivateText(path, JSON.stringify(validateConfig(config), null, 2) + "\n");
25
+ console.log(JSON.stringify({ registered: profile.id, transport: "stdio" }));
26
+ }
27
+ }
28
+ finally {
29
+ await release();
30
+ }
31
+ //# sourceMappingURL=admin.js.map
package/dist/config.js ADDED
@@ -0,0 +1,113 @@
1
+ import { readFileSync, realpathSync, statSync, accessSync, constants } from "node:fs";
2
+ import { basename, delimiter, isAbsolute, join } from "node:path";
3
+ import { z } from "zod";
4
+ import { executorSchema } from "./model.js";
5
+ export const ADMIN_TOKEN_ENV = "AGENT_TASK_ADMIN_TOKEN";
6
+ const envKey = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/);
7
+ const baseEnvKeys = ["PATH", "LANG", "LC_ALL", "TZ"];
8
+ const modelId = z.string().min(1).max(200);
9
+ const coordinatorSchema = z.discriminatedUnion("provider", [
10
+ z.object({ provider: z.enum(["openai", "anthropic", "deepseek"]), model: modelId }).strict(),
11
+ z.object({
12
+ provider: z.literal("openai-compatible"), model: modelId,
13
+ api: z.enum(["openai-completions", "openai-responses"]).default("openai-completions"),
14
+ baseUrl: z.string().url().refine(value => {
15
+ const url = new URL(value);
16
+ return ["https:", "http:"].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash;
17
+ }, "baseUrl must be HTTP(S) without credentials, query or fragment"),
18
+ apiKeyEnv: envKey.refine(key => !baseEnvKeys.includes(key), "Credential must not use a base environment key"),
19
+ contextWindow: z.number().int().positive(),
20
+ maxTokens: z.number().int().positive(),
21
+ reasoning: z.boolean().default(false),
22
+ }).strict(),
23
+ ]).refine(value => value.provider !== "openai-compatible" || value.maxTokens <= value.contextWindow, "maxTokens must not exceed contextWindow");
24
+ export const configSchema = z.object({
25
+ dataDir: z.string().min(1),
26
+ port: z.number().int().min(0).max(65535).default(8765),
27
+ tokenEnv: z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/).default("AGENT_TASK_TOKEN"),
28
+ coordinator: coordinatorSchema.optional(),
29
+ executors: z.array(executorSchema).max(100).default([]),
30
+ }).strict();
31
+ export function coordinatorCredentialEnv(config) {
32
+ switch (config.provider) {
33
+ case "openai": return "OPENAI_API_KEY";
34
+ case "anthropic": return "ANTHROPIC_API_KEY";
35
+ case "deepseek": return "DEEPSEEK_API_KEY";
36
+ case "openai-compatible": return config.apiKeyEnv;
37
+ }
38
+ }
39
+ export function loadConfig(path, environment = process.env) {
40
+ let value;
41
+ try {
42
+ value = JSON.parse(readFileSync(path, "utf8"));
43
+ }
44
+ catch {
45
+ throw new Error("Cannot read configuration JSON");
46
+ }
47
+ return validateConfig(value, environment);
48
+ }
49
+ export function validateConfig(value, environment = process.env) {
50
+ const config = configSchema.parse(value);
51
+ if (!isAbsolute(config.dataDir))
52
+ throw new Error("dataDir must be absolute");
53
+ const coordinatorKey = config.coordinator ? coordinatorCredentialEnv(config.coordinator) : undefined;
54
+ if ([...baseEnvKeys, ADMIN_TOKEN_ENV, "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "DEEPSEEK_API_KEY", coordinatorKey].includes(config.tokenEnv))
55
+ throw new Error("tokenEnv must be separate from base environment, administrator and model credentials");
56
+ if (coordinatorKey === ADMIN_TOKEN_ENV)
57
+ throw new Error("Administrator token must not be used as a model credential");
58
+ const ids = new Set();
59
+ for (const executor of config.executors) {
60
+ if (ids.has(executor.id))
61
+ throw new Error("Duplicate executor ID");
62
+ ids.add(executor.id);
63
+ if (!isAbsolute(executor.command) || !isAbsolute(executor.cwd))
64
+ throw new Error("Executor command and cwd must be absolute");
65
+ if (basename(executor.command) === "pi") {
66
+ throw new Error(`Executor ${executor.id}: pi CLI is not an ACP server (including --mode rpc). Install pi-acp with npm install -g pi-acp@0.0.34 and configure its absolute executable path.`);
67
+ }
68
+ const piCommandOverride = executor.envKeys.includes("PI_ACP_PI_COMMAND") && environment.PI_ACP_PI_COMMAND;
69
+ if (basename(executor.command) === "pi-acp" && !piCommandOverride && !(environment.PATH ?? "").split(delimiter).some(directory => {
70
+ if (!isAbsolute(directory))
71
+ return false;
72
+ try {
73
+ const file = join(directory, "pi");
74
+ accessSync(file, constants.X_OK);
75
+ return statSync(file).isFile();
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ })) {
81
+ throw new Error(`Executor ${executor.id}: pi-acp requires pi on the executor PATH. Install/configure pi before starting the service.`);
82
+ }
83
+ try {
84
+ executor.command = realpathSync(executor.command);
85
+ accessSync(executor.command, constants.X_OK);
86
+ }
87
+ catch {
88
+ throw new Error(`Executor ${executor.id}: ACP executable is missing or not executable. Check the administrator-configured absolute command path. For Pi, install npm install -g pi-acp@0.0.34 and use command -v pi-acp to locate it; pi must also be on the executor PATH.`);
89
+ }
90
+ executor.cwd = realpathSync(executor.cwd);
91
+ if (!statSync(executor.command).isFile() || !statSync(executor.cwd).isDirectory())
92
+ throw new Error("Invalid executor command or cwd");
93
+ for (const root of executor.resultReadRoots) {
94
+ if (!isAbsolute(root) || realpathSync(root) !== root || !statSync(root).isDirectory())
95
+ throw new Error("Result read roots must be canonical absolute directories");
96
+ }
97
+ if (executor.envKeys.includes(config.tokenEnv) || executor.envKeys.includes(ADMIN_TOKEN_ENV))
98
+ throw new Error("Service or administrator token must not be passed to executors");
99
+ if (coordinatorKey && executor.envKeys.includes(coordinatorKey))
100
+ throw new Error("Coordinator credential must not be passed to executors; use separate executor credentials");
101
+ }
102
+ return config;
103
+ }
104
+ /** No shell or wholesale inheritance; secrets are references to administrator-selected environment keys. */
105
+ export function executorEnv(keys, source = process.env) {
106
+ const env = {};
107
+ for (const key of new Set(["PATH", "LANG", "LC_ALL", "TZ", ...keys])) {
108
+ if (key !== ADMIN_TOKEN_ENV && source[key] !== undefined)
109
+ env[key] = source[key];
110
+ }
111
+ return env;
112
+ }
113
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,28 @@
1
+ import { createProvider, envApiKeyAuth } from "@earendil-works/pi-ai";
2
+ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
3
+ import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
4
+ import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
5
+ import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";
6
+ import { deepseekProvider } from "@earendil-works/pi-ai/providers/deepseek";
7
+ /** Administrator-owned model metadata; wire conversion and authentication stay in pi-ai. */
8
+ export function createCoordinatorProvider(config) {
9
+ switch (config.provider) {
10
+ case "openai": return openaiProvider();
11
+ case "anthropic": return anthropicProvider();
12
+ case "deepseek": return deepseekProvider();
13
+ case "openai-compatible": return createProvider({
14
+ id: config.provider,
15
+ baseUrl: config.baseUrl,
16
+ auth: { apiKey: envApiKeyAuth("Coordinator API key", [config.apiKeyEnv]) },
17
+ api: config.api === "openai-responses" ? openAIResponsesApi() : openAICompletionsApi(),
18
+ models: [{
19
+ id: config.model, name: config.model, provider: config.provider,
20
+ api: config.api, baseUrl: config.baseUrl, input: ["text"], reasoning: config.reasoning,
21
+ contextWindow: config.contextWindow, maxTokens: config.maxTokens,
22
+ // Unknown pricing, not a claim that the endpoint is free.
23
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
24
+ }],
25
+ });
26
+ }
27
+ }
28
+ //# sourceMappingURL=coordinator-provider.js.map
@@ -0,0 +1,43 @@
1
+ import { existsSync, mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { SqliteStorage } from "@earendil-works/pi-durable/storage/sqlite";
5
+ import { openNodeSqliteDatabase } from "@earendil-works/pi-durable/storage/sqlite/node";
6
+ /** The caller holds the application directory lock. New files initialize; existing files must already match the pinned SDK. */
7
+ export async function openCoordinatorStorage(directory, runId) {
8
+ if (!/^[a-f0-9-]{36}$/.test(runId))
9
+ throw new Error("Invalid run identity");
10
+ const root = join(directory, "coordinators");
11
+ mkdirSync(root, { recursive: true, mode: 0o700 });
12
+ const path = join(root, runId + ".sqlite");
13
+ if (existsSync(path)) {
14
+ const db = new DatabaseSync(path, { readOnly: true });
15
+ try {
16
+ const version = db.prepare("SELECT version FROM durable_schema WHERE singleton=1").get()?.["version"];
17
+ if (version !== 1)
18
+ throw new Error("Unsupported durable schema; explicit offline upgrade required");
19
+ // SDK index columns such as kind are JSON-encoded; read the decoded record fields together.
20
+ const tasks = db.prepare("SELECT json_extract(record,'$.kind') AS kind, json_extract(record,'$.version') AS version FROM tasks WHERE status != 'terminal'").all();
21
+ for (const task of tasks) {
22
+ const expected = task["kind"] === "pi.tool" ? 2 : ["pi.generation", "pi.compaction"].includes(String(task["kind"])) ? 1 : undefined;
23
+ if (task["version"] !== expected || expected === undefined)
24
+ throw new Error("Unsupported durable task version");
25
+ }
26
+ if (db.prepare("SELECT 1 FROM document_revisions WHERE version != 1 LIMIT 1").get())
27
+ throw new Error("Unsupported durable document version");
28
+ }
29
+ finally {
30
+ db.close();
31
+ }
32
+ }
33
+ const database = await openNodeSqliteDatabase(path);
34
+ try {
35
+ await database.exec("PRAGMA synchronous=FULL");
36
+ return await SqliteStorage.open(database);
37
+ }
38
+ catch (error) {
39
+ await database.close().catch(() => { });
40
+ throw error;
41
+ }
42
+ }
43
+ //# sourceMappingURL=coordinator-storage.js.map