@realqiyan/agent-task 0.0.1-rc.1 → 0.0.1-rc.3

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 (91) hide show
  1. package/README.md +8 -5
  2. package/bin/agent-task.mjs +23 -41
  3. package/dist/application/launcher.js +215 -0
  4. package/dist/application/main.js +122 -0
  5. package/dist/application/startup.js +51 -0
  6. package/dist/application/task-runtime.js +42 -0
  7. package/dist/configuration/contract.js +28 -0
  8. package/dist/configuration/environment.js +32 -0
  9. package/dist/configuration/files.js +43 -0
  10. package/dist/configuration/management.js +6 -0
  11. package/dist/configuration/model.js +2 -0
  12. package/dist/configuration/policy.js +29 -0
  13. package/dist/configuration/ports.js +2 -0
  14. package/dist/{settings.js → configuration/settings.js} +97 -42
  15. package/dist/executors/environment.js +10 -0
  16. package/dist/executors/model.js +2 -0
  17. package/dist/{permissions.js → executors/permissions.js} +3 -4
  18. package/dist/executors/registry.js +29 -0
  19. package/dist/executors/validation.js +10 -0
  20. package/dist/{lock.js → infrastructure/lock.js} +17 -1
  21. package/dist/{private-files.js → infrastructure/private-files.js} +17 -9
  22. package/dist/infrastructure/sqlite.js +19 -0
  23. package/dist/infrastructure/version.js +3 -0
  24. package/dist/{acp.js → integrations/acp/connection.js} +14 -11
  25. package/dist/{node-execution.js → integrations/acp/execution.js} +3 -4
  26. package/dist/integrations/acp/runtime.js +28 -0
  27. package/dist/{worker.js → integrations/acp/worker.js} +5 -4
  28. package/dist/{coordinator.js → integrations/coordinator/coordinator.js} +16 -7
  29. package/dist/{coordinator-provider.js → integrations/coordinator/provider.js} +1 -1
  30. package/dist/{coordinator-storage.js → integrations/coordinator/storage.js} +2 -2
  31. package/dist/integrations/local-service/control.js +33 -0
  32. package/dist/protocol/cli/admin-client.js +45 -0
  33. package/dist/protocol/cli/commands.js +394 -0
  34. package/dist/{http-auth.js → protocol/http/auth.js} +1 -1
  35. package/dist/protocol/http/server.js +111 -0
  36. package/dist/{web.js → protocol/http/web.js} +31 -18
  37. package/dist/{mcp.js → protocol/mcp.js} +13 -13
  38. package/dist/protocol/services.js +2 -0
  39. package/dist/protocol/settings-contract.js +40 -0
  40. package/dist/protocol/task-contract.js +28 -0
  41. package/dist/results/files.js +70 -0
  42. package/dist/results/ports.js +2 -0
  43. package/dist/results/service.js +42 -0
  44. package/dist/shared/errors.js +13 -0
  45. package/dist/shared/limits.js +3 -0
  46. package/dist/shared/time.js +2 -0
  47. package/dist/shared/validation.js +8 -0
  48. package/dist/tasks/definitions.js +83 -0
  49. package/dist/tasks/graph.js +23 -0
  50. package/dist/{human-input.js → tasks/human-input.js} +8 -7
  51. package/dist/tasks/model.js +2 -0
  52. package/dist/tasks/ports.js +2 -0
  53. package/dist/tasks/runs.js +263 -0
  54. package/dist/tasks/schedule.js +36 -0
  55. package/dist/tasks/sessions.js +62 -0
  56. package/dist/tasks/storage/records.js +45 -0
  57. package/dist/{task-storage.js → tasks/storage/store.js} +23 -36
  58. package/dist/tasks/supervisor.js +227 -0
  59. package/dist/tasks/validation.js +24 -0
  60. package/dist/web/api.js +1 -1
  61. package/dist/web/app.js +7 -0
  62. package/dist/web/settings-view.js +40 -28
  63. package/docs/architecture-guidelines.md +8 -6
  64. package/docs/cli.md +49 -0
  65. package/docs/deployment.md +32 -70
  66. package/docs/design-proposal.md +3 -3
  67. package/docs/release.md +17 -18
  68. package/docs/technical-design.md +47 -26
  69. package/docs/validation.md +57 -17
  70. package/docs/web-console.md +15 -19
  71. package/package.json +8 -8
  72. package/scripts/install-lib.mjs +7 -105
  73. package/scripts/mcp-call.mjs +1 -1
  74. package/scripts/probe-agent.mjs +11 -5
  75. package/scripts/service-runner.mjs +2 -7
  76. package/scripts/setup-wizard.mjs +3 -8
  77. package/scripts/setup.mjs +31 -40
  78. package/deploy/agent-task.service.example +0 -17
  79. package/dist/admin.js +0 -31
  80. package/dist/config.js +0 -113
  81. package/dist/environment.js +0 -23
  82. package/dist/main.js +0 -131
  83. package/dist/model.js +0 -28
  84. package/dist/results.js +0 -85
  85. package/dist/settings-contract.js +0 -16
  86. package/dist/task-contract.js +0 -37
  87. package/dist/task-model.js +0 -2
  88. package/dist/tasks.js +0 -509
  89. package/dist/version.js +0 -3
  90. package/scripts/uninstall.mjs +0 -53
  91. /package/dist/{workspace.js → infrastructure/workspace.js} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@realqiyan/agent-task",
3
- "version": "0.0.1-rc.1",
3
+ "version": "0.0.1-rc.3",
4
4
  "license": "MIT",
5
5
  "author": "realqiyan",
6
6
  "repository": {
@@ -21,11 +21,9 @@
21
21
  "scripts/setup-wizard.mjs",
22
22
  "scripts/install-lib.mjs",
23
23
  "scripts/service-runner.mjs",
24
- "scripts/uninstall.mjs",
25
24
  "scripts/probe-agent.mjs",
26
25
  "scripts/mcp-call.mjs",
27
26
  "docs/*.md",
28
- "deploy/*.example",
29
27
  "config.example.json",
30
28
  "LICENSE"
31
29
  ],
@@ -34,24 +32,26 @@
34
32
  "registry": "https://registry.npmjs.org/",
35
33
  "tag": "latest"
36
34
  },
37
- "os": ["linux", "darwin"],
35
+ "os": [
36
+ "linux",
37
+ "darwin"
38
+ ],
38
39
  "type": "module",
39
40
  "description": "Durable intelligent task coordination over MCP with ACP executors",
40
41
  "engines": {
41
42
  "node": ">=22.19.0"
42
43
  },
43
44
  "scripts": {
44
- "check": "tsc --noEmit",
45
+ "check": "tsc --noEmit && node scripts/check-boundaries.mjs",
45
46
  "build": "rm -rf dist && tsc && node scripts/copy-web.mjs",
46
47
  "test": "npm run build && node --test --test-concurrency=1 tests/*.test.mjs",
47
48
  "test:e2e": "npm run build && node --test --test-concurrency=1 tests/e2e/deepseek-acp.test.mjs",
48
49
  "test:e2e:human": "npm run build && node scripts/run-pi-human-e2e.mjs",
49
- "start": "node dist/main.js",
50
+ "start": "node bin/agent-task.mjs start",
50
51
  "setup": "node bin/agent-task.mjs setup",
51
52
  "prepack": "npm run build",
52
53
  "pack:release": "node scripts/pack-release.mjs",
53
- "test:install": "node scripts/verify-install.mjs",
54
- "uninstall:service": "node scripts/uninstall.mjs"
54
+ "test:install": "node scripts/verify-install.mjs"
55
55
  },
56
56
  "dependencies": {
57
57
  "@agentclientprotocol/sdk": "2.0.0",
@@ -1,22 +1,20 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { DatabaseSync } from "node:sqlite";
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync, lstatSync, realpathSync, readdirSync, unlinkSync, rmdirSync, openSync, closeSync, fsyncSync, renameSync } from "node:fs";
2
+ import { DatabaseSync } from "../dist/infrastructure/sqlite.js";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, lstatSync, realpathSync, readdirSync, unlinkSync, openSync, closeSync, fsyncSync, renameSync } from "node:fs";
4
4
  import { dirname, join, resolve, isAbsolute, basename } from "node:path";
5
5
  import { homedir } from "node:os";
6
6
  import { fileURLToPath } from "node:url";
7
- import { setTimeout as delay } from "node:timers/promises";
8
7
 
9
8
  export const packageRoot = realpathSync(fileURLToPath(new URL("..", import.meta.url)));
10
9
 
11
- export const label = "com.agent-task.service";
12
- export const unit = "agent-task.service";
13
10
  export const packages = {
14
11
  pi: ["@earendil-works/pi-coding-agent@1.3.0", "pi-acp@0.0.34"],
15
12
  codex: ["@agentclientprotocol/codex-acp@2.2.2"],
13
+ claude: ["@zed-industries/claude-agent-acp@0.23.1"],
16
14
  opencode: ["opencode-ai@1.19.3"],
17
15
  };
18
16
  export function argumentsFor(argv) {
19
- const flags = new Set(["prepare-only", "service", "check", "probe", "resume", "help"]), options = {};
17
+ const flags = new Set(["check", "probe", "resume", "help"]), options = {};
20
18
  for (let i = 0; i < argv.length; i++) {
21
19
  const key = argv[i].replace(/^--/, "");
22
20
  if (!argv[i].startsWith("--") || key in options) throw new Error("Invalid or repeated option");
@@ -59,7 +57,7 @@ export function run(command, args, options = {}) {
59
57
  export function operationLock(root) {
60
58
  const db = new DatabaseSync(join(root, "setup-lock.sqlite"));
61
59
  try { db.exec("PRAGMA busy_timeout=0; BEGIN EXCLUSIVE"); }
62
- catch { db.close(); throw new Error("Another setup/uninstall operation owns this installation"); }
60
+ catch { db.close(); throw new Error("Another setup operation owns this installation"); }
63
61
  return () => db.close();
64
62
  }
65
63
  export function readManifest(root) {
@@ -85,107 +83,11 @@ export function saveManifest(root, manifest) {
85
83
  }
86
84
  }
87
85
  export function runtimeBaseEnv(manifest) {
88
- // Do not inherit shell/service-manager credentials, loader hooks or executor overrides.
89
- return { HOME: manifest.home, PATH: [join(manifest.root, "components/node_modules/.bin"), dirname(manifest.node), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"].join(":"), LANG: "en_US.UTF-8" };
90
- }
91
- export function runtimeEnv(manifest) {
92
- const values = json(join(manifest.root, "environment.json"));
93
- if (!values || typeof values !== "object" || Array.isArray(values) || Object.entries(values).some(([key, value]) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || ["__proto__", "constructor", "prototype"].includes(key) || typeof value !== "string" || value.includes("\0"))) throw new Error("Invalid environment.json");
94
- return { ...runtimeBaseEnv(manifest), ...values };
95
- }
96
- const xml = value => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
97
- const systemdQuote = value => '"' + value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%").replaceAll("$", "$$") + '"';
98
- export function serviceDefinition(manifest) {
99
- const runner = join(manifest.app, "scripts/service-runner.mjs");
100
- if (manifest.platform === "linux") return {
101
- path: join(manifest.home, ".config/systemd/user", unit),
102
- text: `[Unit]\nDescription=Agent Task (managed by setup)\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=${[manifest.node, runner, manifest.root].map(systemdQuote).join(" ")}\nRestart=on-failure\nRestartSec=5\nTimeoutStopSec=20\nKillMode=control-group\nUMask=0077\n\n[Install]\nWantedBy=default.target\n`,
103
- };
104
- return { path: join(manifest.home, "Library/LaunchAgents", label + ".plist"), text:
105
- `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n<key>Label</key><string>${label}</string>\n<key>ProgramArguments</key><array>${[manifest.node, runner, manifest.root].map(value => `<string>${xml(value)}</string>`).join("")}</array>\n<key>RunAtLoad</key><true/>\n<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>\n<key>ThrottleInterval</key><integer>5</integer>\n<key>ExitTimeOut</key><integer>20</integer>\n<key>AbandonProcessGroup</key><false/>\n<key>Umask</key><integer>63</integer>\n<key>StandardOutPath</key><string>${xml(join(manifest.root, "service.log"))}</string>\n<key>StandardErrorPath</key><string>${xml(join(manifest.root, "service-error.log"))}</string>\n</dict></plist>\n` };
106
- }
107
- export function serviceEnv() {
108
- const keys = ["HOME", "PATH", "LANG", "LC_ALL", "DBUS_SESSION_BUS_ADDRESS"];
109
- return { ...Object.fromEntries(keys.filter(key => process.env[key] !== undefined).map(key => [key, process.env[key]])),
110
- XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${process.getuid()}` };
111
- }
112
- export async function checkHealth(config, env, waitMs = 5000) {
113
- const token = env[config.tokenEnv ?? "AGENT_TASK_TOKEN"];
114
- if (!token || token.length < 32) throw new Error("A valid service token is required for the health check");
115
- const deadline = Date.now() + waitMs;
116
- do {
117
- try {
118
- const response = await fetch(`http://127.0.0.1:${config.port}/mcp`, {
119
- method: "POST", headers: { Authorization: "Bearer " + token, "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
120
- body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "service_status", arguments: {} } }),
121
- signal: AbortSignal.timeout(1000),
122
- });
123
- const body = await response.json();
124
- if (response.ok && body.result?.structuredContent?.ok && body.result.structuredContent.data?.running) return;
125
- } catch { /* Startup can take several seconds; never log the authenticated request. */ }
126
- await delay(200);
127
- } while (Date.now() < deadline);
128
- throw new Error("Authenticated MCP health check failed; inspect the service manager and private configuration");
129
- }
130
- export function verifyServiceFile(manifest) {
131
- const definition = serviceDefinition(manifest);
132
- if (existsSync(definition.path)) {
133
- privateFile(definition.path);
134
- if (readFileSync(definition.path, "utf8") !== definition.text) throw new Error("Service file differs; refusing to overwrite or remove it: " + definition.path);
135
- }
136
- return definition;
137
- }
138
- export function activate(manifest) {
139
- const definition = verifyServiceFile(manifest);
140
- if (!existsSync(definition.path)) createFile(definition.path, definition.text);
141
- if (manifest.platform === "linux") {
142
- run("systemctl", ["--user", "daemon-reload"], { env: serviceEnv() });
143
- run("systemctl", ["--user", "enable", "--now", unit], { env: serviceEnv() });
144
- run("systemctl", ["--user", "is-active", unit], { env: serviceEnv() });
145
- } else {
146
- const domain = `gui/${process.getuid()}`;
147
- const active = spawnSync("launchctl", ["print", domain + "/" + label], { stdio: "ignore" });
148
- if (active.status !== 0) run("launchctl", ["bootstrap", domain, definition.path]);
149
- run("launchctl", ["kickstart", domain + "/" + label]);
150
- run("launchctl", ["print", domain + "/" + label]);
151
- }
152
- }
153
- export function deactivate(manifest) {
154
- const definition = verifyServiceFile(manifest);
155
- if (!existsSync(definition.path)) {
156
- if (manifest.serviceInstalled) throw new Error("Managed service file is missing; verify it is unloaded before cleanup");
157
- return;
158
- }
159
- if (manifest.platform === "linux") {
160
- run("systemctl", ["--user", "disable", "--now", unit], { env: serviceEnv() });
161
- // Do not remove code while the service is still active or deactivating.
162
- const state = run("systemctl", ["--user", "show", "--property=ActiveState", "--value", unit], { env: serviceEnv() });
163
- if (!["inactive", "failed"].includes(state)) throw new Error("Service has not stopped");
164
- } else {
165
- const target = `gui/${process.getuid()}/${label}`;
166
- const active = spawnSync("launchctl", ["print", target], { stdio: "ignore" });
167
- if (active.status === 0) run("launchctl", ["bootout", target]);
168
- if (spawnSync("launchctl", ["print", target], { stdio: "ignore" }).status === 0) throw new Error("LaunchAgent is still loaded");
169
- }
170
- unlinkSync(definition.path);
171
- if (manifest.platform === "linux") run("systemctl", ["--user", "daemon-reload"], { env: serviceEnv() });
86
+ // 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" };
172
88
  }
173
89
  export function inventory(root, relative) {
174
90
  const path = join(root, relative), stat = lstatSync(path);
175
91
  if (!stat.isDirectory() || stat.isSymbolicLink()) return [relative];
176
92
  return readdirSync(path).flatMap(name => inventory(root, join(relative, name))).concat(relative);
177
93
  }
178
- export function removeInventory(root, files) {
179
- for (const relative of files) {
180
- if (!/^components(\/|$)/.test(relative) || relative.split("/").includes("..")) throw new Error("Invalid component inventory");
181
- const path = join(root, relative);
182
- // Never follow a directory replaced by a symlink outside the installation.
183
- try {
184
- if (realpathSync(dirname(path)) !== dirname(path)) throw new Error("Component parent path changed; cleanup stopped");
185
- const stat = lstatSync(path);
186
- if (stat.isDirectory() && !stat.isSymbolicLink()) {
187
- try { rmdirSync(path); } catch (error) { if (error.code !== "ENOTEMPTY") throw error; }
188
- } else unlinkSync(path);
189
- } catch (error) { if (error.code !== "ENOENT") throw error; }
190
- }
191
- }
@@ -1,6 +1,6 @@
1
1
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
2
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
- import { version } from "../dist/version.js";
3
+ import { version } from "../dist/infrastructure/version.js";
4
4
  const [tool, input = "{}"] = process.argv.slice(2);
5
5
  if (!tool || !process.env.AGENT_TASK_TOKEN) {
6
6
  console.error("Usage: AGENT_TASK_TOKEN=... node scripts/mcp-call.mjs <tool|--list> '[JSON arguments]'\nAGENT_TASK_URL defaults to http://127.0.0.1:8765/mcp");
@@ -2,16 +2,22 @@
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, executorEnv } from "../dist/config.js";
6
- import { terminateGroup } from "../dist/acp.js";
7
- import { version } from "../dist/version.js";
5
+ import { loadConfig } from "../dist/configuration/files.js";
6
+ import { executorEnvironment } from "../dist/executors/environment.js";
7
+ import { executorCommand } from "../dist/integrations/acp/runtime.js";
8
+ import { readEnvironment } from "../dist/configuration/files.js";
9
+ import { join } from "node:path";
10
+ import { terminateGroup } from "../dist/integrations/acp/connection.js";
11
+ import { version } from "../dist/infrastructure/version.js";
8
12
  const [file, id] = process.argv.slice(2);
9
13
  if (!file || !id) { console.error("Usage: node scripts/probe-agent.mjs <config.json> <executorId>"); process.exit(2); }
10
14
  const config = loadConfig(file);
11
15
  const executor = config.executors.find(value => value.id === id);
12
16
  if (!executor) throw new Error("Executor not found");
13
- const child = spawn(executor.command, executor.args, {
14
- cwd: executor.cwd, env: executorEnv(executor.envKeys), detached: true, stdio: ["pipe", "pipe", "pipe"],
17
+ const env = executorEnvironment(process.env, readEnvironment(join(config.dataDir, "environment.json")), config);
18
+ const launch = executorCommand(executor.type, env);
19
+ const child = spawn(launch.command, launch.args, {
20
+ cwd: executor.cwd, env, detached: true, stdio: ["pipe", "pipe", "pipe"],
15
21
  });
16
22
  child.stderr.resume();
17
23
  let connection;
@@ -1,14 +1,9 @@
1
- // Same process as the service: the service manager supervises the actual HTTP owner.
2
- import { join } from "node:path";
3
1
  import { readManifest, runtimeBaseEnv } from "./install-lib.mjs";
2
+ import { launch } from "../dist/application/launcher.js";
4
3
 
5
4
  process.umask(0o077);
6
5
  const root = process.argv[2];
7
6
  if (!root || process.argv.length !== 3) throw new Error("Expected installation home");
8
7
  const manifest = readManifest(root);
9
8
  if (manifest.phase !== "ready") throw new Error("Installation is not ready");
10
- const env = runtimeBaseEnv(manifest);
11
- for (const key of Object.keys(process.env)) delete process.env[key];
12
- Object.assign(process.env, env);
13
- process.argv = [process.execPath, join(manifest.app, "dist/main.js"), "--config", join(root, "config.json"), "--environment", join(root, "environment.json")];
14
- await import("../dist/main.js");
9
+ await launch(root, runtimeBaseEnv(manifest));
@@ -1,14 +1,14 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
 
3
3
  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,opencode and --cwd ABSOLUTE_DIR for noninteractive setup");
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");
5
5
  const input = createInterface({ input: process.stdin, output: process.stdout });
6
6
  const abort = new AbortController();
7
7
  input.on("SIGINT", () => abort.abort());
8
8
  const ask = async (question, fallback) => (await input.question(`${question}${fallback ? " [" + fallback + "]" : ""}: `, { signal: abort.signal })).trim() || fallback;
9
9
  try {
10
- console.log(`Private installation: ${root}\nNo credentials are requested here. Use the local Web console or a private editor after setup.\nExecutors are optional; installing one does not authorize it.`);
11
- options.executors ??= await ask("Executors to install (none or comma-separated pi,codex,opencode)", "none");
10
+ console.log(`Private installation: ${root}`);
11
+ options.executors ??= await ask("Executors to install (none or comma-separated pi,codex,claude,opencode)", "none");
12
12
  options.cwd ??= await ask("Existing absolute workspace (outside installation and npm package)", process.cwd());
13
13
  if (!options.provider) {
14
14
  const provider = await ask("Coordinator provider (none/openai/anthropic/deepseek; configurable later in Web)", "none");
@@ -16,11 +16,6 @@ export async function setupWizard(options, root) {
16
16
  }
17
17
  if (options.provider) options.model ??= await ask("Coordinator model ID", "");
18
18
  options.port ??= await ask("Loopback HTTP port", "8765");
19
- if (!options["prepare-only"] && !options.service) {
20
- const answer = await ask("Install and start the background service now? (yes/no)", "no");
21
- if (!["yes", "no"].includes(answer)) throw new Error("Expected yes or no; setup cancelled");
22
- if (answer === "yes") options.service = true;
23
- }
24
19
  if (await ask("Write private configuration and install selected components? (yes/no)", "no") !== "yes") throw new Error("Setup cancelled; no configuration written");
25
20
  } catch (error) {
26
21
  if (abort.signal.aborted) throw new Error("Setup cancelled; no configuration written");
package/scripts/setup.mjs CHANGED
@@ -5,18 +5,21 @@ import { pathToFileURL } from "node:url";
5
5
  import { homedir } from "node:os";
6
6
  import { randomBytes } from "node:crypto";
7
7
  import { argumentsFor, installRoot, packages, createFile, json, run, operationLock, readManifest, saveManifest,
8
- runtimeEnv, activate, inventory, verifyServiceFile, packageRoot, checkHealth } from "./install-lib.mjs";
8
+ runtimeBaseEnv, inventory, packageRoot } from "./install-lib.mjs";
9
9
  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";
10
14
 
11
15
  process.umask(0o077);
12
16
  let unlock;
13
17
  try {
14
18
  const options = argumentsFor(process.argv.slice(2));
15
19
  if (options.help) {
16
- console.log("Usage: agent-task setup [--home ABSOLUTE_PRIVATE_DIR]\nInteractive on a terminal for a new installation. Noninteractive: --executors pi,codex,opencode|none --cwd ABSOLUTE_DIR [--provider openai|anthropic|deepseek --model ID] [--port 8765]\nDefault: --prepare-only (no background service). --service explicitly installs/starts the background service.\nExisting installation: --check [--probe] | --resume. No credentials accepted on the command line. Existing configuration is never replaced.");
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.");
17
21
  process.exit(0);
18
22
  }
19
- if (options.service && (options["prepare-only"] || options.check)) throw new Error("--service cannot be combined with --prepare-only or --check");
20
23
  if (options.probe && !options.check) throw new Error("--probe requires --check");
21
24
  const root = installRoot(options), manifestPath = join(root, "installation.json");
22
25
  if (!existsSync(manifestPath) && !options.check && !options.resume && (!options.executors || !options.cwd)) await setupWizard(options, root);
@@ -27,10 +30,10 @@ try {
27
30
  if (existsSync(manifestPath)) {
28
31
  manifest = readManifest(root);
29
32
  if (["executors", "cwd", "provider", "model", "port"].some(key => options[key] !== undefined)) throw new Error("Installation already exists; initialization options cannot replace existing configuration");
30
- if (manifest.phase !== "ready" && !options.resume) throw new Error("Incomplete or uninstalled components; use --resume explicitly (configuration will be preserved)");
33
+ if (manifest.phase !== "ready" && !options.resume) throw new Error("Incomplete components; use --resume explicitly (configuration will be preserved)");
31
34
  } else {
32
35
  if (options.check || options.probe || options.resume) throw new Error("No installation to check/resume");
33
- if (!options.executors || !options.cwd) throw new Error("Select --executors pi,codex,opencode|none and --cwd ABSOLUTE_DIR");
36
+ if (!options.executors || !options.cwd) throw new Error("Select --executors pi,codex,claude,opencode|none and --cwd ABSOLUTE_DIR");
34
37
  const selected = options.executors === "none" ? [] : options.executors.split(",");
35
38
  if (selected.some(id => !Object.hasOwn(packages, id)) || new Set(selected).size !== selected.length) throw new Error("Invalid executor selection");
36
39
  if (!options.cwd.startsWith("/") || !lstatSync(realpathSync(options.cwd)).isDirectory()) throw new Error("cwd must be an existing absolute directory");
@@ -40,31 +43,28 @@ try {
40
43
  const port = Number(options.port ?? 8765);
41
44
  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid port");
42
45
  // Reject unowned artifacts. This script never adopts or clears an existing installation.
43
- for (const name of ["components", "config.json", "environment.json", "data"]) {
46
+ for (const name of ["components", "launch.json", "data"]) {
44
47
  if (existsSync(join(root, name))) throw new Error("Existing unowned installation artifact: " + name);
45
48
  }
46
49
  manifest = { project: "agent-task", root, home: homedir(), platform: process.platform, node: process.execPath, app: packageRoot,
47
50
  selected, cwd, port, coordinator: options.provider ? { provider: options.provider, model: options.model } : undefined,
48
- phase: "installing", serviceInstalled: false, files: [] };
51
+ phase: "installing", files: [] };
49
52
  createFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
50
53
  }
51
- verifyServiceFile(manifest);
52
54
  const app = manifest.app, components = join(root, "components");
53
- if (!existsSync(join(app, "dist/main.js"))) throw new Error("Prebuilt runtime is missing; reinstall the npm package (source contributors: npm run build)");
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)");
54
56
  if (manifest.phase !== "ready") {
55
- if (manifest.serviceInstalled) throw new Error("Stop/uninstall the existing service before rebuilding components");
56
57
  for (const name of ["components"]) {
57
58
  if (!existsSync(join(root, name))) continue;
58
59
  const known = new Set(manifest.files);
59
60
  if (inventory(root, name).some(file => !known.has(file))) throw new Error("Uninventoried component files exist; refusing to overwrite them during resume");
60
61
  }
61
62
  try {
62
- console.log("Using the prebuilt npm runtime. No source checkout or compilation required.");
63
63
  mkdirSync(components, { recursive: true, mode: 0o700 });
64
64
  if (!existsSync(join(components, "package.json"))) createFile(join(components, "package.json"), JSON.stringify({ private: true }));
65
65
  const dependencies = manifest.selected.flatMap(id => packages[id]);
66
66
  if (dependencies.length) {
67
- console.log("Installing selected ACP executors into private components (not global executables).");
67
+ console.log("Installing selected ACP executors...");
68
68
  const installArgs = existsSync(join(components, "package-lock.json"))
69
69
  ? ["ci", "--ignore-scripts", "--no-audit", "--no-fund"]
70
70
  : ["install", "--save-exact", "--ignore-scripts", "--no-audit", "--no-fund", ...dependencies];
@@ -75,52 +75,43 @@ try {
75
75
  }
76
76
  }
77
77
  mkdirSync(join(root, "data/results"), { recursive: true, mode: 0o700 });
78
- if (!existsSync(join(root, "environment.json"))) createFile(join(root, "environment.json"), JSON.stringify({ AGENT_TASK_TOKEN: randomBytes(32).toString("hex"), AGENT_TASK_ADMIN_TOKEN: randomBytes(32).toString("hex") }, null, 2) + "\n");
79
- if (!existsSync(join(root, "config.json"))) createFile(join(root, "config.json"), JSON.stringify({ dataDir: join(root, "data"), port: manifest.port,
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,
80
82
  tokenEnv: "AGENT_TASK_TOKEN", coordinator: manifest.coordinator,
81
- executors: manifest.selected.map(id => ({ id, command: join(components, "node_modules/.bin", id === "pi" ? "pi-acp" : id === "codex" ? "codex-acp" : "opencode"),
82
- args: id === "opencode" ? ["acp"] : [], cwd: manifest.cwd, envKeys: ["HOME"],
83
- permissionAllowlist: [], unattendedApproved: false, resultReadRoots: [] })) }, null, 2) + "\n");
83
+ executors: manifest.selected.map(id => ({ id, type: id, note: "", cwd: manifest.cwd, unattendedApproved: false })) }, null, 2) + "\n");
84
84
  manifest.phase = "ready";
85
+ delete manifest.port; delete manifest.cwd; delete manifest.coordinator;
85
86
  } finally {
86
87
  manifest.files = ["components"].filter(name => existsSync(join(root, name))).flatMap(name => inventory(root, name));
87
88
  saveManifest(root, manifest);
88
89
  }
89
90
  }
90
- const env = runtimeEnv(manifest);
91
- // Config validation and probes run in the SAME explicit environment as the background service.
92
- run(manifest.node, ["--input-type=module", "-e", `import {loadConfig} from ${JSON.stringify(pathToFileURL(join(app, "dist/config.js")).href)}; loadConfig(process.argv[1]);`, join(root, "config.json")], { env });
93
- const config = json(join(root, "config.json"));
94
- const { executorEnv } = await import(pathToFileURL(join(app, "dist/config.js")).href);
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
95
  for (const id of manifest.selected) {
96
- const binary = id === "pi" ? "pi" : id;
97
- const cliEnv = executorEnv(config.executors.find(executor => executor.id === id)?.envKeys ?? [], env);
98
- run(join(components, "node_modules/.bin", binary), ["--version"], { env: cliEnv, timeout: 20000 });
96
+ const cliEnv = executorEnvironment(env, readEnvironment(paths.environmentPath), config);
97
+ executorCommand(id, cliEnv); // No native process is launched without an explicit --probe.
99
98
  }
100
- const report = { platform: process.platform, dependencies: "ready", configuration: "valid", background: "not-checked",
99
+ const report = { platform: process.platform, dependencies: "ready", configuration: "valid",
101
100
  coordinator: config.coordinator ? "configured; authentication not verified" : "not configured; task admission disabled",
102
101
  executors: config.executors.map(executor => ({ id: executor.id, authorized: executor.unattendedApproved,
103
102
  handshake: "not checked", execution: "not verified by setup" })) };
104
103
  if (options.probe) {
105
104
  for (const entry of report.executors) {
106
- try { run(manifest.node, [join(app, "scripts/probe-agent.mjs"), join(root, "config.json"), entry.id], { env, timeout: 20000 }); entry.handshake = "passed (initialize only)"; }
105
+ try { run(manifest.node, [join(app, "scripts/probe-agent.mjs"), paths.configPath, entry.id], { env, timeout: 20000 }); entry.handshake = "passed (initialize only)"; }
107
106
  catch { entry.handshake = "failed; inspect native dependencies/configuration separately"; process.exitCode = 1; }
108
107
  }
109
108
  }
110
- if (options.service) {
111
- // Record intent before activating so a failed service start is still owned by uninstall.
112
- manifest.serviceInstalled = true; saveManifest(root, manifest);
113
- activate(manifest);
114
- report.background = "service manager registered";
115
- }
116
- if (manifest.serviceInstalled) {
117
- try {
118
- await checkHealth(config, env, 15000);
119
- report.background = "authenticated MCP health check passed";
120
- } catch { report.background = "not ready; run --check after startup and inspect the service manager"; process.exitCode = 1; }
121
- }
122
109
  console.log(JSON.stringify(report, null, 2));
123
- console.log(`Config: ${join(root, "config.json")}\nSecrets (never printed): ${join(root, "environment.json")}\nNative login: use ${join(components, "node_modules/.bin")} executables: pi then /login; codex login; opencode auth login. No login is performed by setup.\nConsole: http://127.0.0.1:${config.port}/admin/ (use the separate AGENT_TASK_ADMIN_TOKEN from the private environment file; never place it in a URL). Configure coordinator credentials there or in environment.json, and audit native executor permissions before setting unattendedApproved=true. No permissions are granted by setup. Restart the service manually after changes.\nLinux: user service requires lingering for logout survival (administrator decision). macOS: LaunchAgent survives terminal close, not user logout; log in again to restart. Neither platform executes during sleep/shutdown.`);
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
+ }
124
115
  } catch (error) {
125
116
  console.error(error.message); process.exitCode = 1;
126
117
  } finally { unlock?.(); }
@@ -1,17 +0,0 @@
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/admin.js DELETED
@@ -1,31 +0,0 @@
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 DELETED
@@ -1,113 +0,0 @@
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
@@ -1,23 +0,0 @@
1
- import { readPrivateText } from "./private-files.js";
2
- export function parseEnvironment(text) {
3
- let value;
4
- try {
5
- value = JSON.parse(text);
6
- }
7
- catch {
8
- throw new Error("Invalid environment file JSON");
9
- }
10
- if (!value || typeof value !== "object" || Array.isArray(value) || Object.entries(value).some(([key, item]) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || ["__proto__", "constructor", "prototype"].includes(key) ||
11
- typeof item !== "string" || item.includes("\0")))
12
- throw new Error("Invalid environment file");
13
- return value;
14
- }
15
- export function readEnvironment(path) {
16
- return parseEnvironment(readPrivateText(path));
17
- }
18
- /** Explicit file mode never falls back to inherited shell credentials. */
19
- export function baseEnvironment(source) {
20
- return Object.fromEntries(["HOME", "PATH", "LANG", "LC_ALL", "TZ", "TMPDIR"]
21
- .filter(key => source[key] !== undefined).map(key => [key, source[key]]));
22
- }
23
- //# sourceMappingURL=environment.js.map