@realqiyan/agent-task 0.0.1-rc.3 → 0.1.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CONTEXT.md +17 -0
  2. package/README.md +28 -6
  3. package/bin/agent-task.mjs +18 -6
  4. package/config.example.json +1 -1
  5. package/dist/application/launcher.js +30 -16
  6. package/dist/application/main.js +5 -2
  7. package/dist/application/mcp-entry.js +71 -0
  8. package/dist/application/task-runtime.js +3 -3
  9. package/dist/configuration/contract.js +2 -2
  10. package/dist/configuration/files.js +2 -3
  11. package/dist/configuration/management.js +1 -1
  12. package/dist/configuration/policy.js +3 -8
  13. package/dist/configuration/settings.js +10 -33
  14. package/dist/executors/ports.js +2 -0
  15. package/dist/executors/registry.js +117 -15
  16. package/dist/executors/validation.js +4 -5
  17. package/dist/infrastructure/lock.js +25 -13
  18. package/dist/infrastructure/workspace.js +4 -3
  19. package/dist/integrations/acp/connection.js +5 -5
  20. package/dist/integrations/acp/discovery.js +271 -0
  21. package/dist/integrations/acp/runtime.js +38 -23
  22. package/dist/integrations/acp/worker.js +5 -3
  23. package/dist/integrations/local-service/control.js +36 -11
  24. package/dist/protocol/cli/commands.js +41 -46
  25. package/dist/protocol/http/server.js +8 -0
  26. package/dist/protocol/http/web.js +7 -1
  27. package/dist/protocol/mcp-contract.js +194 -0
  28. package/dist/protocol/mcp.js +99 -32
  29. package/dist/protocol/settings-contract.js +4 -7
  30. package/dist/protocol/stdio.js +72 -0
  31. package/dist/protocol/task-contract.js +45 -18
  32. package/dist/shared/errors.js +15 -0
  33. package/dist/shared/limits.js +3 -0
  34. package/dist/tasks/definitions.js +19 -14
  35. package/dist/tasks/graph.js +5 -4
  36. package/dist/tasks/human-input.js +10 -9
  37. package/dist/tasks/runs.js +31 -30
  38. package/dist/tasks/schedule.js +2 -1
  39. package/dist/tasks/storage/records.js +6 -6
  40. package/dist/tasks/storage/store.js +8 -7
  41. package/dist/tasks/supervisor.js +10 -7
  42. package/dist/tasks/validation.js +3 -0
  43. package/dist/web/settings-view.js +43 -37
  44. package/docs/adr/0001-task-request-scope.md +3 -0
  45. package/docs/adr/0002-mcp-workspace-source.md +3 -0
  46. package/docs/adr/0003-adapter-installation.md +3 -0
  47. package/docs/architecture-guidelines.md +2 -2
  48. package/docs/cli.md +12 -4
  49. package/docs/deployment.md +52 -36
  50. package/docs/design-proposal.md +3 -0
  51. package/docs/release.md +27 -8
  52. package/docs/technical-design.md +5 -3
  53. package/docs/validation.md +59 -9
  54. package/docs/web-console.md +7 -4
  55. package/package.json +3 -1
  56. package/scripts/install-lib.mjs +5 -23
  57. package/scripts/probe-agent.mjs +10 -9
  58. package/scripts/service-runner.mjs +8 -3
  59. package/scripts/setup-wizard.mjs +38 -11
  60. package/scripts/setup.mjs +69 -95
@@ -18,20 +18,23 @@
18
18
 
19
19
  - 任务列表、固定 DAG、运行历史、节点进度、已接受交付、错误和问题只读。触发计划结束不等于运行成功,原始输出不等于交付。
20
20
  - 任务写操作仅复用 `task_update`,暂停、恢复或取消未来触发。不停止当前运行,不补跑错过的触发,不回答问题或修改 DAG。已有不确定外部结果的任务拒绝恢复未来触发;当前不提供人工核对后强制恢复能力。
21
- - 数据目录、监听端口、MCP token 环境变量名、执行端注册和协调模型可编辑,下次启动生效。修改 token 变量名保留令牌值,不在页面回显。
22
- - 执行端只选择 pi、codex、claude 或 opencode,保留 ID、默认工作空间和无人值守审核,并可填写备注。启动方式由系统决定;原生认证和权限继续用官方工具管理。没有命令、参数、环境变量清单、结果目录或 ACP 工具白名单配置。
21
+ - 协调模型是常规配置入口。数据目录、监听端口、MCP token 环境变量名和执行端启停位于高级设置,下次启动生效。修改 token 变量名保留令牌值,不在页面回显。
22
+ - 执行端自动检测本机 pi、codex、claude 和 opencode。页面显示本机路径、准备结果和错误,提供重新检测与显式适配器升级;准备过程中自动更新状态。就绪不代表原生认证或工具权限已经验收。安装与更新边界见[部署说明](deployment.md)。
23
+ - 无须手工注册执行端、设置默认工作目录或另行批准。原生认证和权限继续用官方工具管理;任务工作目录通过 MCP 上下文或创建参数确定。
23
24
 
24
25
  页面与接口入口见 [web.ts](../src/protocol/http/web.ts)、[页面模块](../src/web/),任务契约见 [task-contract.ts](../src/protocol/task-contract.ts)。提供受管理员认证保护的重启入口;没有通用 MCP 代理、文件浏览器或下载接口。固定结果目录不授予整个 dataDir 的读取能力。
25
26
 
26
27
  ## 保存与重启
27
28
 
28
29
  1. 密钥只显示是否设置,原值不回显。不提交即保留;替换和删除必须明确操作。先保存新密钥,再保存引用它的配置。新凭据命名和受保护变量见 [settings.ts](../src/configuration/settings.ts)。
29
- 2. 配置先校验并预览影响,再确认保存。校验不调用模型或启动 ACP,不能证明认证、原生权限或实际执行成功。修改执行端类型、ID 或工作空间后,配置服务要求重新明确批准无人值守执行;仅修改备注不改变授权。
30
+ 2. 配置先校验并预览影响,再确认保存。校验不调用模型或启动 ACP,不能证明认证、原生权限或实际执行成功。禁用执行端时显示受影响任务,不自动改派。
30
31
  3. 配置与密钥分别原子替换,不是跨文件事务。更换模型端点须使用新的凭据引用,避免中途重启将新密钥交给旧端点。旧凭据不自动清理。
31
32
  4. 保存不热更新。检查活动运行及待回答运行后,点击“重启并检查可用性”或执行 `agent-task restart`。目录切换、原生环境来源及故障处理见[部署说明](deployment.md)。
32
33
  5. 只有新进程通过认证健康检查才显示成功。页面清除令牌并给出新地址,重新连接后核对运行配置。请求未确认时先用 CLI status 核查,不删除历史、自动回滚或重放执行来修复。
33
34
 
34
- 待回答运行也可能持有 ACP 会话,重启不保证无损续接。执行端类型、工作空间或批准状态与任务快照不一致时,旧任务的派发会被拒绝;页面显示受影响任务,但不改写快照、DAG 或执行端指派。需要新任务时由 MCP 调用方处理。
35
+ 待回答运行也可能持有 ACP 会话,重启不保证无损续接。执行端未就绪、被禁用或本机路径与任务快照不一致时,旧任务的派发会被拒绝;页面不改写快照、DAG 或执行端指派。需要新任务时由 MCP 调用方处理。
36
+
37
+ 重新检测和适配器升级直接作用于当前执行端目录,无需重启;它们不保存启动配置。普通刷新复用已经准备的适配器,只有显式升级才请求更新。升级期间暂停向该执行端派发新工作,完成后可在状态表核对结果。OpenCode 使用原生 ACP,通过原生 CLI 的安装渠道更新。
35
38
 
36
39
  Web 与 CLI 的旧编辑都会被拒绝,不做自动合并。离线编辑配置须先停服。校验与保存职责见 [settings.ts](../src/configuration/settings.ts)、[配置影响预览](../src/configuration/management.ts)、[settings-contract.ts](../src/protocol/settings-contract.ts)、[配置文件适配](../src/configuration/files.ts)。
37
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@realqiyan/agent-task",
3
- "version": "0.0.1-rc.3",
3
+ "version": "0.1.0-rc.2",
4
4
  "license": "MIT",
5
5
  "author": "realqiyan",
6
6
  "repository": {
@@ -24,6 +24,8 @@
24
24
  "scripts/probe-agent.mjs",
25
25
  "scripts/mcp-call.mjs",
26
26
  "docs/*.md",
27
+ "docs/adr/*.md",
28
+ "CONTEXT.md",
27
29
  "config.example.json",
28
30
  "LICENSE"
29
31
  ],
@@ -1,25 +1,18 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import { DatabaseSync } from "../dist/infrastructure/sqlite.js";
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync, lstatSync, realpathSync, readdirSync, unlinkSync, openSync, closeSync, fsyncSync, renameSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, lstatSync, realpathSync, unlinkSync, openSync, closeSync, fsyncSync, renameSync } from "node:fs";
4
3
  import { dirname, join, resolve, isAbsolute, basename } from "node:path";
5
4
  import { homedir } from "node:os";
6
5
  import { fileURLToPath } from "node:url";
7
6
 
8
7
  export const packageRoot = realpathSync(fileURLToPath(new URL("..", import.meta.url)));
9
8
 
10
- export const packages = {
11
- pi: ["@earendil-works/pi-coding-agent@1.3.0", "pi-acp@0.0.34"],
12
- codex: ["@agentclientprotocol/codex-acp@2.2.2"],
13
- claude: ["@zed-industries/claude-agent-acp@0.23.1"],
14
- opencode: ["opencode-ai@1.19.3"],
15
- };
16
9
  export function argumentsFor(argv) {
17
- const flags = new Set(["check", "probe", "resume", "help"]), options = {};
10
+ const flags = new Set(["check", "resume", "help", "yes", "generate-tokens"]), options = {};
18
11
  for (let i = 0; i < argv.length; i++) {
19
12
  const key = argv[i].replace(/^--/, "");
20
13
  if (!argv[i].startsWith("--") || key in options) throw new Error("Invalid or repeated option");
21
14
  if (flags.has(key)) options[key] = true;
22
- else if (["home", "executors", "cwd", "provider", "model", "port"].includes(key) && argv[i + 1] && !argv[i + 1].startsWith("--")) options[key] = argv[++i];
15
+ else if (["home", "data-dir", "provider", "model", "port", "token-env", "credentials-file", "base-url", "api", "api-key-env", "context-window", "max-tokens"].includes(key) && argv[i + 1] && !argv[i + 1].startsWith("--")) options[key] = argv[++i];
23
16
  else throw new Error("Unknown option or missing value: " + key);
24
17
  }
25
18
  return options;
@@ -48,12 +41,6 @@ export function createFile(path, text) {
48
41
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
49
42
  writeFileSync(path, text, { flag: "wx", mode: 0o600 });
50
43
  }
51
- export function run(command, args, options = {}) {
52
- const env = Object.fromEntries(["HOME", "PATH", "LANG", "LC_ALL", "TZ", "TMPDIR"].filter(key => process.env[key] !== undefined).map(key => [key, process.env[key]]));
53
- const result = spawnSync(command, args, { encoding: "utf8", timeout: 300000, stdio: "pipe", env, ...options });
54
- if (result.error || result.status !== 0) throw new Error("Command failed (diagnostics withheld; may contain credentials): " + command + " " + args[0]);
55
- return result.stdout?.trim();
56
- }
57
44
  export function operationLock(root) {
58
45
  const db = new DatabaseSync(join(root, "setup-lock.sqlite"));
59
46
  try { db.exec("PRAGMA busy_timeout=0; BEGIN EXCLUSIVE"); }
@@ -67,7 +54,7 @@ export function readManifest(root) {
67
54
  return value;
68
55
  }
69
56
  export function saveManifest(root, manifest) {
70
- // Preserve the last committed inventory even if a disk-full write fails.
57
+ // Preserve the last committed initialization record if a write fails.
71
58
  const temporary = join(root, `installation-${process.pid}.tmp`);
72
59
  let fd;
73
60
  try {
@@ -84,10 +71,5 @@ export function saveManifest(root, manifest) {
84
71
  }
85
72
  export function runtimeBaseEnv(manifest) {
86
73
  // Capture the OS user environment; the runtime isolates private service values before worker creation.
87
- return { ...process.env, HOME: manifest.home, PATH: [join(manifest.root, "components/node_modules/.bin"), dirname(manifest.node), process.env.PATH ?? "", "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"].join(":"), LANG: process.env.LANG ?? "en_US.UTF-8" };
88
- }
89
- export function inventory(root, relative) {
90
- const path = join(root, relative), stat = lstatSync(path);
91
- if (!stat.isDirectory() || stat.isSymbolicLink()) return [relative];
92
- return readdirSync(path).flatMap(name => inventory(root, join(relative, name))).concat(relative);
74
+ return { ...process.env, HOME: manifest.home, PATH: [process.env.PATH ?? "", dirname(manifest.node), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"].join(":"), LANG: process.env.LANG ?? "en_US.UTF-8" };
93
75
  }
@@ -2,22 +2,23 @@
2
2
  import { spawn } from "node:child_process";
3
3
  import { Readable, Writable } from "node:stream";
4
4
  import * as acp from "@agentclientprotocol/sdk";
5
- import { loadConfig } from "../dist/configuration/files.js";
5
+ import { loadConfig, startupPaths } from "../dist/configuration/files.js";
6
6
  import { executorEnvironment } from "../dist/executors/environment.js";
7
- import { executorCommand } from "../dist/integrations/acp/runtime.js";
7
+ import { executorLaunch } from "../dist/integrations/acp/runtime.js";
8
+ import { LocalExecutors } from "../dist/integrations/acp/discovery.js";
8
9
  import { readEnvironment } from "../dist/configuration/files.js";
9
10
  import { join } from "node:path";
10
11
  import { terminateGroup } from "../dist/integrations/acp/connection.js";
11
12
  import { version } from "../dist/infrastructure/version.js";
12
- const [file, id] = process.argv.slice(2);
13
- if (!file || !id) { console.error("Usage: node scripts/probe-agent.mjs <config.json> <executorId>"); process.exit(2); }
14
- const config = loadConfig(file);
15
- const executor = config.executors.find(value => value.id === id);
16
- if (!executor) throw new Error("Executor not found");
13
+ const [home, id] = process.argv.slice(2);
14
+ if (!home || !id) { console.error("Usage: node scripts/probe-agent.mjs <installation-home> <pi|codex|claude|opencode>"); process.exit(2); }
15
+ const config = loadConfig(startupPaths(home).configPath);
17
16
  const env = executorEnvironment(process.env, readEnvironment(join(config.dataDir, "environment.json")), config);
18
- const launch = executorCommand(executor.type, env);
17
+ const executor = new LocalExecutors(join(home, "adapters"), env).discover().executors.find(value => value.id === id);
18
+ if (!executor || config.disabledExecutors.includes(executor.type)) throw new Error("Executor unavailable; connect MCP to prepare its adapter first");
19
+ const launch = executorLaunch(executor, env);
19
20
  const child = spawn(launch.command, launch.args, {
20
- cwd: executor.cwd, env, detached: true, stdio: ["pipe", "pipe", "pipe"],
21
+ cwd: process.cwd(), env: launch.env, detached: true, stdio: ["pipe", "pipe", "pipe"],
21
22
  });
22
23
  child.stderr.resume();
23
24
  let connection;
@@ -4,6 +4,11 @@ import { launch } from "../dist/application/launcher.js";
4
4
  process.umask(0o077);
5
5
  const root = process.argv[2];
6
6
  if (!root || process.argv.length !== 3) throw new Error("Expected installation home");
7
- const manifest = readManifest(root);
8
- if (manifest.phase !== "ready") throw new Error("Installation is not ready");
9
- await launch(root, runtimeBaseEnv(manifest));
7
+ try {
8
+ const manifest = readManifest(root);
9
+ if (manifest.phase !== "ready") throw new Error("Installation is not ready; run agent-task setup --resume");
10
+ await launch(root, runtimeBaseEnv(manifest));
11
+ } catch (error) {
12
+ console.error(error.message);
13
+ process.exitCode = 1;
14
+ }
@@ -1,24 +1,51 @@
1
1
  import { createInterface } from "node:readline/promises";
2
+ import { Writable } from "node:stream";
3
+ import { join } from "node:path";
2
4
 
5
+ async function secret(question) {
6
+ let muted = false;
7
+ const output = new Writable({ write(chunk, _encoding, callback) { if (!muted) process.stderr.write(chunk); callback(); } });
8
+ const input = createInterface({ input: process.stdin, output, terminal: true });
9
+ try {
10
+ process.stderr.write(question + ": "); muted = true;
11
+ return (await input.question("")).trim();
12
+ } finally { muted = false; process.stderr.write("\n"); input.close(); output.end(); }
13
+ }
14
+
15
+ /** Terminal collection only; setup validates and publishes the settings. */
3
16
  export async function setupWizard(options, root) {
4
- if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("Interactive setup requires a terminal; use --executors none|pi,codex,claude,opencode and --cwd ABSOLUTE_DIR for noninteractive setup");
17
+ if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("Setup requires a terminal or explicit --home --data-dir --port --generate-tokens/--credentials-file and --yes");
5
18
  const input = createInterface({ input: process.stdin, output: process.stdout });
6
19
  const abort = new AbortController();
7
20
  input.on("SIGINT", () => abort.abort());
8
- const ask = async (question, fallback) => (await input.question(`${question}${fallback ? " [" + fallback + "]" : ""}: `, { signal: abort.signal })).trim() || fallback;
21
+ const ask = async (question, fallback = "") => (await input.question(`${question}${fallback ? " [" + fallback + "]" : ""}: `, { signal: abort.signal })).trim() || fallback;
22
+ let needsKey = false;
9
23
  try {
10
- console.log(`Private installation: ${root}`);
11
- options.executors ??= await ask("Executors to install (none or comma-separated pi,codex,claude,opencode)", "none");
12
- options.cwd ??= await ask("Existing absolute workspace (outside installation and npm package)", process.cwd());
13
- if (!options.provider) {
14
- const provider = await ask("Coordinator provider (none/openai/anthropic/deepseek; configurable later in Web)", "none");
15
- if (provider !== "none") options.provider = provider;
24
+ options.home = await ask("Private installation directory", options.home ?? root);
25
+ options["data-dir"] = await ask("Private data directory", options["data-dir"] ?? join(options.home, "data"));
26
+ options.port = await ask("Loopback HTTP port (0 assigns an available port)", options.port ?? "8765");
27
+ options["token-env"] = await ask("MCP token variable name", options["token-env"] ?? "AGENT_TASK_TOKEN");
28
+ const tokens = await ask("Service/admin tokens (generate/file)", options["credentials-file"] ? "file" : "generate");
29
+ if (tokens === "generate") options["generate-tokens"] = true;
30
+ else if (tokens === "file") options["credentials-file"] = await ask("Private credentials JSON file", options["credentials-file"]);
31
+ else throw new Error("Choose generate or file for tokens");
32
+ options.provider = await ask("Coordinator provider (none/openai/anthropic/deepseek/openai-compatible)", options.provider ?? "none");
33
+ if (options.provider !== "none") {
34
+ options.model = await ask("Coordinator model ID", options.model);
35
+ if (options.provider === "openai-compatible") {
36
+ options["base-url"] = await ask("Model endpoint URL", options["base-url"]);
37
+ options.api = await ask("API (openai-completions/openai-responses)", options.api ?? "openai-completions");
38
+ options["api-key-env"] = await ask("Coordinator credential name", options["api-key-env"] ?? "COORDINATOR_API_KEY");
39
+ options["context-window"] = await ask("Model context window", options["context-window"]);
40
+ options["max-tokens"] = await ask("Maximum output tokens", options["max-tokens"]);
41
+ }
42
+ needsKey = !options["credentials-file"];
16
43
  }
17
- if (options.provider) options.model ??= await ask("Coordinator model ID", "");
18
- options.port ??= await ask("Loopback HTTP port", "8765");
19
- if (await ask("Write private configuration and install selected components? (yes/no)", "no") !== "yes") throw new Error("Setup cancelled; no configuration written");
44
+ if (await ask("Confirm directories, port, token configuration and coordinator? (yes/no)", "no") !== "yes") throw new Error("Setup cancelled; no configuration written");
45
+ options.yes = true;
20
46
  } catch (error) {
21
47
  if (abort.signal.aborted) throw new Error("Setup cancelled; no configuration written");
22
48
  throw error;
23
49
  } finally { input.close(); }
50
+ if (needsKey) options.coordinatorKey = await secret("Coordinator API key (hidden)");
24
51
  }
package/scripts/setup.mjs CHANGED
@@ -1,117 +1,91 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, mkdirSync, realpathSync, lstatSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { pathToFileURL } from "node:url";
3
+ import { join, isAbsolute } from "node:path";
5
4
  import { homedir } from "node:os";
6
5
  import { randomBytes } from "node:crypto";
7
- import { argumentsFor, installRoot, packages, createFile, json, run, operationLock, readManifest, saveManifest,
8
- runtimeBaseEnv, inventory, packageRoot } from "./install-lib.mjs";
6
+ import { argumentsFor, installRoot, createFile, json, operationLock, readManifest, saveManifest, packageRoot } from "./install-lib.mjs";
9
7
  import { setupWizard } from "./setup-wizard.mjs";
10
- import { startupPaths } from "../dist/configuration/files.js";
11
- import { readEnvironment } from "../dist/configuration/files.js";
12
- import { executorEnvironment } from "../dist/executors/environment.js";
13
- import { executorCommand } from "../dist/integrations/acp/runtime.js";
8
+ import { startupPaths, loadConfig, readEnvironment } from "../dist/configuration/files.js";
9
+ import { configSchema } from "../dist/configuration/contract.js";
10
+ import { validateConfig, coordinatorCredentialEnv } from "../dist/configuration/policy.js";
11
+ import { validateServiceEnvironment, parseEnvironment } from "../dist/configuration/environment.js";
12
+ import { checkDataDirectory } from "../dist/infrastructure/private-files.js";
14
13
 
15
14
  process.umask(0o077);
16
15
  let unlock;
17
16
  try {
18
17
  const options = argumentsFor(process.argv.slice(2));
19
18
  if (options.help) {
20
- console.log("Usage: agent-task setup [--home ABSOLUTE_PRIVATE_DIR]\nInteractive on a terminal for a new installation. Noninteractive: --executors pi,codex,claude,opencode|none --cwd ABSOLUTE_DIR [--provider openai|anthropic|deepseek --model ID] [--port 8765]\nExisting installation: --check [--probe] | --resume. No credentials accepted on the command line. Existing configuration is never replaced.");
19
+ console.log("Usage: agent-task setup [--home ABSOLUTE_PRIVATE_DIR]\nNew installation: confirm directories, port, tokens and coordinator in the terminal.\nNoninteractive: --home DIR --data-dir DIR --port NUMBER --generate-tokens|--credentials-file PRIVATE_JSON --yes [--provider openai|anthropic|deepseek|openai-compatible --model ID]\nCompatible provider: --base-url URL --api openai-completions|openai-responses --api-key-env NAME --context-window NUMBER --max-tokens NUMBER\nExisting installation: --check | --resume. Secrets are read from a private JSON file or hidden terminal input, never command arguments. No executor installation during setup.");
21
20
  process.exit(0);
22
21
  }
23
- if (options.probe && !options.check) throw new Error("--probe requires --check");
24
- const root = installRoot(options), manifestPath = join(root, "installation.json");
25
- if (!existsSync(manifestPath) && !options.check && !options.resume && (!options.executors || !options.cwd)) await setupWizard(options, root);
26
- if (!existsSync(root)) mkdirSync(root, { recursive: true, mode: 0o700 });
27
- if (realpathSync(root) !== root || lstatSync(root).uid !== process.getuid() || (lstatSync(root).mode & 0o077)) throw new Error("Installation home must be canonical, owned and private");
28
- unlock = operationLock(root);
29
- let manifest;
30
- if (existsSync(manifestPath)) {
22
+ let root = installRoot(options);
23
+ if (!existsSync(join(root, "installation.json")) && !options.check && !options.resume && !options.yes) {
24
+ await setupWizard(options, root); root = installRoot(options);
25
+ }
26
+ let manifest, initialEnvironment;
27
+ if (existsSync(join(root, "installation.json"))) {
31
28
  manifest = readManifest(root);
32
- if (["executors", "cwd", "provider", "model", "port"].some(key => options[key] !== undefined)) throw new Error("Installation already exists; initialization options cannot replace existing configuration");
33
- if (manifest.phase !== "ready" && !options.resume) throw new Error("Incomplete components; use --resume explicitly (configuration will be preserved)");
29
+ if (Object.keys(options).some(key => !["home", "check", "resume"].includes(key))) throw new Error("Installation exists; use configuration commands instead of initialization flags");
30
+ if (manifest.phase !== "ready" && !options.resume) throw new Error("Incomplete initialization; inspect private files and use --resume explicitly");
34
31
  } else {
35
- if (options.check || options.probe || options.resume) throw new Error("No installation to check/resume");
36
- if (!options.executors || !options.cwd) throw new Error("Select --executors pi,codex,claude,opencode|none and --cwd ABSOLUTE_DIR");
37
- const selected = options.executors === "none" ? [] : options.executors.split(",");
38
- if (selected.some(id => !Object.hasOwn(packages, id)) || new Set(selected).size !== selected.length) throw new Error("Invalid executor selection");
39
- if (!options.cwd.startsWith("/") || !lstatSync(realpathSync(options.cwd)).isDirectory()) throw new Error("cwd must be an existing absolute directory");
40
- const cwd = realpathSync(options.cwd);
41
- if ([root, packageRoot].some(path => cwd === path || cwd.startsWith(path + "/"))) throw new Error("Workspace must be outside the installation home and npm package");
42
- if (Boolean(options.provider) !== Boolean(options.model) || (options.provider && !["openai", "anthropic", "deepseek"].includes(options.provider))) throw new Error("Specify a supported --provider together with --model");
43
- const port = Number(options.port ?? 8765);
44
- if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid port");
45
- // Reject unowned artifacts. This script never adopts or clears an existing installation.
46
- for (const name of ["components", "launch.json", "data"]) {
47
- if (existsSync(join(root, name))) throw new Error("Existing unowned installation artifact: " + name);
48
- }
49
- manifest = { project: "agent-task", root, home: homedir(), platform: process.platform, node: process.execPath, app: packageRoot,
50
- selected, cwd, port, coordinator: options.provider ? { provider: options.provider, model: options.model } : undefined,
51
- phase: "installing", files: [] };
52
- createFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
53
- }
54
- const app = manifest.app, components = join(root, "components");
55
- if (!existsSync(join(app, "dist/application/main.js"))) throw new Error("Prebuilt runtime is missing; reinstall the npm package (source contributors: npm run build)");
56
- if (manifest.phase !== "ready") {
57
- for (const name of ["components"]) {
58
- if (!existsSync(join(root, name))) continue;
59
- const known = new Set(manifest.files);
60
- if (inventory(root, name).some(file => !known.has(file))) throw new Error("Uninventoried component files exist; refusing to overwrite them during resume");
32
+ if (options.check || options.resume) throw new Error("No installation to check/resume");
33
+ if (!options.yes || !options.home || !options["data-dir"] || options.port === undefined ||
34
+ (!options["generate-tokens"] && !options["credentials-file"])) throw new Error("Confirm initialization with --home, --data-dir, --port, --generate-tokens/--credentials-file and --yes");
35
+ const dataDir = installRoot({ home: options["data-dir"] });
36
+ if (dataDir === root || dataDir === packageRoot || dataDir.startsWith(packageRoot + "/")) throw new Error("Data directory must be separate from the installation root and npm package");
37
+ const coordinator = !options.provider || options.provider === "none" ? undefined : {
38
+ provider: options.provider, model: options.model,
39
+ ...(options.provider === "openai-compatible" ? { baseUrl: options["base-url"], api: options.api,
40
+ apiKeyEnv: options["api-key-env"], contextWindow: Number(options["context-window"]), maxTokens: Number(options["max-tokens"]), reasoning: false } : {}),
41
+ };
42
+ const config = validateConfig(configSchema.parse({ dataDir, port: Number(options.port), tokenEnv: options["token-env"] ?? "AGENT_TASK_TOKEN", coordinator, disabledExecutors: [] }));
43
+ initialEnvironment = options["credentials-file"] ? parseEnvironment(JSON.stringify(json(options["credentials-file"]))) : {};
44
+ if (options["generate-tokens"]) {
45
+ if (initialEnvironment[config.tokenEnv] || initialEnvironment.AGENT_TASK_ADMIN_TOKEN) throw new Error("Choose either supplied service/admin tokens or generated tokens");
46
+ initialEnvironment[config.tokenEnv] = randomBytes(32).toString("hex");
47
+ initialEnvironment.AGENT_TASK_ADMIN_TOKEN = randomBytes(32).toString("hex");
61
48
  }
62
- try {
63
- mkdirSync(components, { recursive: true, mode: 0o700 });
64
- if (!existsSync(join(components, "package.json"))) createFile(join(components, "package.json"), JSON.stringify({ private: true }));
65
- const dependencies = manifest.selected.flatMap(id => packages[id]);
66
- if (dependencies.length) {
67
- console.log("Installing selected ACP executors...");
68
- const installArgs = existsSync(join(components, "package-lock.json"))
69
- ? ["ci", "--ignore-scripts", "--no-audit", "--no-fund"]
70
- : ["install", "--save-exact", "--ignore-scripts", "--no-audit", "--no-fund", ...dependencies];
71
- run("npm", installArgs, { cwd: components });
72
- if (manifest.selected.includes("opencode")) {
73
- // This pinned package requires copying its platform binary. No other lifecycle scripts run.
74
- run(manifest.node, [join(components, "node_modules/opencode-ai/postinstall.mjs")], { cwd: components });
75
- }
76
- }
77
- mkdirSync(join(root, "data/results"), { recursive: true, mode: 0o700 });
78
- const dataDir = join(root, "data");
79
- if (!existsSync(join(root, "launch.json"))) createFile(join(root, "launch.json"), JSON.stringify({ dataDir }) + "\n");
80
- if (!existsSync(join(dataDir, "environment.json"))) createFile(join(dataDir, "environment.json"), JSON.stringify({ AGENT_TASK_TOKEN: randomBytes(32).toString("hex"), AGENT_TASK_ADMIN_TOKEN: randomBytes(32).toString("hex") }, null, 2) + "\n");
81
- if (!existsSync(join(dataDir, "config.json"))) createFile(join(dataDir, "config.json"), JSON.stringify({ dataDir, port: manifest.port,
82
- tokenEnv: "AGENT_TASK_TOKEN", coordinator: manifest.coordinator,
83
- executors: manifest.selected.map(id => ({ id, type: id, note: "", cwd: manifest.cwd, unattendedApproved: false })) }, null, 2) + "\n");
84
- manifest.phase = "ready";
85
- delete manifest.port; delete manifest.cwd; delete manifest.coordinator;
86
- } finally {
87
- manifest.files = ["components"].filter(name => existsSync(join(root, name))).flatMap(name => inventory(root, name));
88
- saveManifest(root, manifest);
49
+ if (coordinator && options.coordinatorKey) initialEnvironment[coordinatorCredentialEnv(config.coordinator)] = options.coordinatorKey;
50
+ validateServiceEnvironment(config, initialEnvironment);
51
+ if (!initialEnvironment.AGENT_TASK_ADMIN_TOKEN) throw new Error("An independent administrator token is required for initialization");
52
+ if (coordinator && !initialEnvironment[coordinatorCredentialEnv(config.coordinator)]) throw new Error("Coordinator credential is missing; supply it through the private credentials file");
53
+ for (const path of [join(root, "launch.json"), join(dataDir, "config.json"), join(dataDir, "environment.json")]) {
54
+ if (existsSync(path)) throw new Error("Existing unowned initialization file; refusing to overwrite: " + path);
89
55
  }
56
+ checkDataDirectory(root); checkDataDirectory(dataDir);
57
+ manifest = { project: "agent-task", root, home: homedir(), platform: process.platform, node: process.execPath,
58
+ app: packageRoot, phase: "initializing", dataDir, config };
90
59
  }
91
- const paths = startupPaths(root), env = runtimeBaseEnv(manifest);
92
- // Use the same native launch environment; private values are read separately, never passed to probes.
93
- run(manifest.node, ["--input-type=module", "-e", `import {loadConfig} from ${JSON.stringify(pathToFileURL(join(app, "dist/configuration/files.js")).href)}; loadConfig(process.argv[1]);`, paths.configPath], { env });
94
- const config = json(paths.configPath);
95
- for (const id of manifest.selected) {
96
- const cliEnv = executorEnvironment(env, readEnvironment(paths.environmentPath), config);
97
- executorCommand(id, cliEnv); // No native process is launched without an explicit --probe.
98
- }
99
- const report = { platform: process.platform, dependencies: "ready", configuration: "valid",
100
- coordinator: config.coordinator ? "configured; authentication not verified" : "not configured; task admission disabled",
101
- executors: config.executors.map(executor => ({ id: executor.id, authorized: executor.unattendedApproved,
102
- handshake: "not checked", execution: "not verified by setup" })) };
103
- if (options.probe) {
104
- for (const entry of report.executors) {
105
- try { run(manifest.node, [join(app, "scripts/probe-agent.mjs"), paths.configPath, entry.id], { env, timeout: 20000 }); entry.handshake = "passed (initialize only)"; }
106
- catch { entry.handshake = "failed; inspect native dependencies/configuration separately"; process.exitCode = 1; }
60
+ mkdirSync(root, { recursive: true, mode: 0o700 });
61
+ if (realpathSync(root) !== root || lstatSync(root).uid !== process.getuid() || (lstatSync(root).mode & 0o077)) throw new Error("Installation home must be canonical, owned and private");
62
+ unlock = operationLock(root);
63
+ if (!existsSync(join(root, "installation.json"))) createFile(join(root, "installation.json"), JSON.stringify(manifest, null, 2) + "\n");
64
+ if (!existsSync(join(packageRoot, "dist/application/main.js"))) throw new Error("Prebuilt runtime missing; reinstall the package");
65
+ if (manifest.phase !== "ready") {
66
+ if (!isAbsolute(manifest.dataDir) || !manifest.config) throw new Error("Incomplete installation record; no repair performed");
67
+ const dataDir = manifest.dataDir;
68
+ mkdirSync(dataDir, { recursive: true, mode: 0o700 }); checkDataDirectory(dataDir);
69
+ const environmentPath = join(dataDir, "environment.json"), configPath = join(dataDir, "config.json");
70
+ if (!existsSync(environmentPath)) {
71
+ if (!initialEnvironment) throw new Error("Initialization has no saved credentials; inspect the private directory before resuming");
72
+ createFile(environmentPath, JSON.stringify(initialEnvironment, null, 2) + "\n");
107
73
  }
74
+ if (!existsSync(configPath)) createFile(configPath, JSON.stringify(manifest.config, null, 2) + "\n");
75
+ const config = loadConfig(configPath), environment = readEnvironment(environmentPath);
76
+ if (JSON.stringify(config) !== JSON.stringify(manifest.config)) throw new Error("Saved initialization configuration differs; no repair performed");
77
+ validateServiceEnvironment(config, environment);
78
+ if (!existsSync(join(root, "launch.json"))) createFile(join(root, "launch.json"), JSON.stringify({ dataDir }) + "\n");
79
+ if (startupPaths(root).dataDir !== dataDir) throw new Error("Launch locator differs from initialization");
80
+ manifest.phase = "ready"; delete manifest.config; delete manifest.dataDir;
81
+ saveManifest(root, manifest);
108
82
  }
109
- console.log(JSON.stringify(report, null, 2));
110
- if (!options.check) {
111
- console.log(`Config: ${paths.configPath}\nCredentials: ${paths.environmentPath}\nConsole: http://127.0.0.1:${config.port}/admin/\nSign in with AGENT_TASK_ADMIN_TOKEN from the credentials file.`);
112
- if (config.executors.length) console.log("Prepare native executor authentication and review permissions before approving unattended execution.");
113
- console.log(`Start: agent-task start --home '${root.replaceAll("'", "'\"'\"'")}'`);
114
- }
83
+ const paths = startupPaths(root), config = loadConfig(paths.configPath);
84
+ validateServiceEnvironment(config, readEnvironment(paths.environmentPath));
85
+ console.log(JSON.stringify({ configuration: "valid", coordinator: config.coordinator ? "configured; authentication not verified" : "not configured",
86
+ configPath: paths.configPath, credentialsPath: paths.environmentPath, executorDiscovery: "on MCP connection" }, null, 2));
87
+ if (!options.check) console.log("Add agent-task with args [mcp] to your MCP client (include --home for a custom installation). Token values are not printed. Use agent-task start for foreground management.");
115
88
  } catch (error) {
116
- console.error(error.message); process.exitCode = 1;
89
+ console.error(error.name === "ZodError" ? "Invalid initialization configuration; check provider, model, directories and port" : error.message);
90
+ process.exitCode = 1;
117
91
  } finally { unlock?.(); }