@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
@@ -0,0 +1,191 @@
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";
4
+ import { dirname, join, resolve, isAbsolute, basename } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { fileURLToPath } from "node:url";
7
+ import { setTimeout as delay } from "node:timers/promises";
8
+
9
+ export const packageRoot = realpathSync(fileURLToPath(new URL("..", import.meta.url)));
10
+
11
+ export const label = "com.agent-task.service";
12
+ export const unit = "agent-task.service";
13
+ export const packages = {
14
+ pi: ["@earendil-works/pi-coding-agent@1.3.0", "pi-acp@0.0.34"],
15
+ codex: ["@agentclientprotocol/codex-acp@2.2.2"],
16
+ opencode: ["opencode-ai@1.19.3"],
17
+ };
18
+ export function argumentsFor(argv) {
19
+ const flags = new Set(["prepare-only", "service", "check", "probe", "resume", "help"]), options = {};
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const key = argv[i].replace(/^--/, "");
22
+ if (!argv[i].startsWith("--") || key in options) throw new Error("Invalid or repeated option");
23
+ if (flags.has(key)) options[key] = true;
24
+ else if (["home", "executors", "cwd", "provider", "model", "port"].includes(key) && argv[i + 1] && !argv[i + 1].startsWith("--")) options[key] = argv[++i];
25
+ else throw new Error("Unknown option or missing value: " + key);
26
+ }
27
+ return options;
28
+ }
29
+ export function installRoot(options) {
30
+ if (!["linux", "darwin"].includes(process.platform)) throw new Error("Only Linux and macOS are supported");
31
+ const [major, minor] = process.versions.node.split(".").map(Number);
32
+ if (major < 22 || (major === 22 && minor < 19)) throw new Error("Node.js 22.19+ is required");
33
+ const path = options.home ?? join(homedir(), process.platform === "darwin" ? "Library/Application Support/agent-task" : ".local/share/agent-task");
34
+ if (!isAbsolute(path) || /[\x00-\x1f\x7f]/.test(path)) throw new Error("Installation home must be an absolute path without control characters");
35
+ let parent = resolve(path);
36
+ const tail = [];
37
+ while (!existsSync(parent)) { tail.unshift(basename(parent)); parent = dirname(parent); }
38
+ return join(realpathSync(parent), ...tail);
39
+ }
40
+ export function privateFile(path) {
41
+ const stat = lstatSync(path);
42
+ if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o077)) throw new Error("Expected a private, owned regular file: " + path);
43
+ }
44
+ export function json(path) {
45
+ privateFile(path);
46
+ try { return JSON.parse(readFileSync(path, "utf8")); }
47
+ catch { throw new Error("Cannot read valid JSON from " + path); } // JSON parser diagnostics can quote secrets.
48
+ }
49
+ export function createFile(path, text) {
50
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
51
+ writeFileSync(path, text, { flag: "wx", mode: 0o600 });
52
+ }
53
+ export function run(command, args, options = {}) {
54
+ const env = Object.fromEntries(["HOME", "PATH", "LANG", "LC_ALL", "TZ", "TMPDIR"].filter(key => process.env[key] !== undefined).map(key => [key, process.env[key]]));
55
+ const result = spawnSync(command, args, { encoding: "utf8", timeout: 300000, stdio: "pipe", env, ...options });
56
+ if (result.error || result.status !== 0) throw new Error("Command failed (diagnostics withheld; may contain credentials): " + command + " " + args[0]);
57
+ return result.stdout?.trim();
58
+ }
59
+ export function operationLock(root) {
60
+ const db = new DatabaseSync(join(root, "setup-lock.sqlite"));
61
+ try { db.exec("PRAGMA busy_timeout=0; BEGIN EXCLUSIVE"); }
62
+ catch { db.close(); throw new Error("Another setup/uninstall operation owns this installation"); }
63
+ return () => db.close();
64
+ }
65
+ export function readManifest(root) {
66
+ if (realpathSync(root) !== root || lstatSync(root).uid !== process.getuid() || (lstatSync(root).mode & 0o077)) throw new Error("Installation home must be canonical, owned and private");
67
+ const value = json(join(root, "installation.json"));
68
+ if (value.project !== "agent-task" || value.root !== root || value.platform !== process.platform || value.home !== homedir() || typeof value.node !== "string" || !isAbsolute(value.node) || value.app !== packageRoot) throw new Error("Installation ownership does not match this user/platform");
69
+ return value;
70
+ }
71
+ export function saveManifest(root, manifest) {
72
+ // Preserve the last committed inventory even if a disk-full write fails.
73
+ const temporary = join(root, `installation-${process.pid}.tmp`);
74
+ let fd;
75
+ try {
76
+ fd = openSync(temporary, "wx", 0o600);
77
+ writeFileSync(fd, JSON.stringify(manifest, null, 2) + "\n"); fsyncSync(fd);
78
+ closeSync(fd); fd = undefined;
79
+ renameSync(temporary, join(root, "installation.json"));
80
+ const directory = openSync(root, "r");
81
+ try { fsyncSync(directory); } finally { closeSync(directory); }
82
+ } finally {
83
+ if (fd !== undefined) closeSync(fd);
84
+ if (existsSync(temporary)) unlinkSync(temporary);
85
+ }
86
+ }
87
+ 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() });
172
+ }
173
+ export function inventory(root, relative) {
174
+ const path = join(root, relative), stat = lstatSync(path);
175
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return [relative];
176
+ return readdirSync(path).flatMap(name => inventory(root, join(relative, name))).concat(relative);
177
+ }
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
+ }
@@ -0,0 +1,17 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
+ import { version } from "../dist/version.js";
4
+ const [tool, input = "{}"] = process.argv.slice(2);
5
+ if (!tool || !process.env.AGENT_TASK_TOKEN) {
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");
7
+ process.exit(2);
8
+ }
9
+ const client = new Client({ name: "agent-task-command", version });
10
+ try {
11
+ await client.connect(new StreamableHTTPClientTransport(new URL(process.env.AGENT_TASK_URL ?? "http://127.0.0.1:8765/mcp"),
12
+ { requestInit: { headers: { Authorization: "Bearer " + process.env.AGENT_TASK_TOKEN } } }));
13
+ const result = tool === "--list" ? await client.listTools() : await client.callTool({ name: tool, arguments: JSON.parse(input) });
14
+ console.log(JSON.stringify(result, null, 2));
15
+ if ("isError" in result && result.isError) process.exitCode = 1;
16
+ } catch (error) { console.error(error.message); process.exitCode = 1; }
17
+ finally { await client.close(); }
@@ -0,0 +1,34 @@
1
+ // Real ACP initialization only: no session/new, prompt, login, or model request.
2
+ import { spawn } from "node:child_process";
3
+ import { Readable, Writable } from "node:stream";
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";
8
+ const [file, id] = process.argv.slice(2);
9
+ if (!file || !id) { console.error("Usage: node scripts/probe-agent.mjs <config.json> <executorId>"); process.exit(2); }
10
+ const config = loadConfig(file);
11
+ const executor = config.executors.find(value => value.id === id);
12
+ 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"],
15
+ });
16
+ child.stderr.resume();
17
+ let connection;
18
+ const closed = new Promise(resolve => child.once("close", resolve));
19
+ child.once("error", error => { console.error("ACP executable could not start"); connection?.close(error); });
20
+ const timer = setTimeout(() => {
21
+ connection?.close(new Error("ACP initialization timed out"));
22
+ void terminateGroup(child);
23
+ }, 10000);
24
+ try {
25
+ connection = await acp.client({ name: "agent-task-probe" }).connect(acp.ndJsonStream(
26
+ Writable.toWeb(child.stdin), Readable.toWeb(child.stdout), { maxMessageBytes: 1048576 }));
27
+ const result = await connection.agent.request(acp.methods.agent.initialize, {
28
+ protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: {},
29
+ clientInfo: { name: "agent-task-probe", version },
30
+ });
31
+ if (result.protocolVersion !== acp.PROTOCOL_VERSION) throw new Error("Incompatible ACP protocol version");
32
+ console.log(JSON.stringify(result, null, 2));
33
+ } catch { console.error("ACP initialize failed; inspect native configuration in a private terminal. No task was sent."); process.exitCode = 1; }
34
+ finally { clearTimeout(timer); connection?.close(); await terminateGroup(child); await closed; }
@@ -0,0 +1,14 @@
1
+ // Same process as the service: the service manager supervises the actual HTTP owner.
2
+ import { join } from "node:path";
3
+ import { readManifest, runtimeBaseEnv } from "./install-lib.mjs";
4
+
5
+ process.umask(0o077);
6
+ const root = process.argv[2];
7
+ if (!root || process.argv.length !== 3) throw new Error("Expected installation home");
8
+ const manifest = readManifest(root);
9
+ 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");
@@ -0,0 +1,29 @@
1
+ import { createInterface } from "node:readline/promises";
2
+
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");
5
+ const input = createInterface({ input: process.stdin, output: process.stdout });
6
+ const abort = new AbortController();
7
+ input.on("SIGINT", () => abort.abort());
8
+ const ask = async (question, fallback) => (await input.question(`${question}${fallback ? " [" + fallback + "]" : ""}: `, { signal: abort.signal })).trim() || fallback;
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");
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;
16
+ }
17
+ if (options.provider) options.model ??= await ask("Coordinator model ID", "");
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
+ if (await ask("Write private configuration and install selected components? (yes/no)", "no") !== "yes") throw new Error("Setup cancelled; no configuration written");
25
+ } catch (error) {
26
+ if (abort.signal.aborted) throw new Error("Setup cancelled; no configuration written");
27
+ throw error;
28
+ } finally { input.close(); }
29
+ }
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, realpathSync, lstatSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { homedir } from "node:os";
6
+ import { randomBytes } from "node:crypto";
7
+ import { argumentsFor, installRoot, packages, createFile, json, run, operationLock, readManifest, saveManifest,
8
+ runtimeEnv, activate, inventory, verifyServiceFile, packageRoot, checkHealth } from "./install-lib.mjs";
9
+ import { setupWizard } from "./setup-wizard.mjs";
10
+
11
+ process.umask(0o077);
12
+ let unlock;
13
+ try {
14
+ const options = argumentsFor(process.argv.slice(2));
15
+ 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.");
17
+ process.exit(0);
18
+ }
19
+ if (options.service && (options["prepare-only"] || options.check)) throw new Error("--service cannot be combined with --prepare-only or --check");
20
+ if (options.probe && !options.check) throw new Error("--probe requires --check");
21
+ const root = installRoot(options), manifestPath = join(root, "installation.json");
22
+ if (!existsSync(manifestPath) && !options.check && !options.resume && (!options.executors || !options.cwd)) await setupWizard(options, root);
23
+ if (!existsSync(root)) mkdirSync(root, { recursive: true, mode: 0o700 });
24
+ if (realpathSync(root) !== root || lstatSync(root).uid !== process.getuid() || (lstatSync(root).mode & 0o077)) throw new Error("Installation home must be canonical, owned and private");
25
+ unlock = operationLock(root);
26
+ let manifest;
27
+ if (existsSync(manifestPath)) {
28
+ manifest = readManifest(root);
29
+ 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)");
31
+ } else {
32
+ 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");
34
+ const selected = options.executors === "none" ? [] : options.executors.split(",");
35
+ if (selected.some(id => !Object.hasOwn(packages, id)) || new Set(selected).size !== selected.length) throw new Error("Invalid executor selection");
36
+ if (!options.cwd.startsWith("/") || !lstatSync(realpathSync(options.cwd)).isDirectory()) throw new Error("cwd must be an existing absolute directory");
37
+ const cwd = realpathSync(options.cwd);
38
+ if ([root, packageRoot].some(path => cwd === path || cwd.startsWith(path + "/"))) throw new Error("Workspace must be outside the installation home and npm package");
39
+ 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");
40
+ const port = Number(options.port ?? 8765);
41
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid port");
42
+ // Reject unowned artifacts. This script never adopts or clears an existing installation.
43
+ for (const name of ["components", "config.json", "environment.json", "data"]) {
44
+ if (existsSync(join(root, name))) throw new Error("Existing unowned installation artifact: " + name);
45
+ }
46
+ manifest = { project: "agent-task", root, home: homedir(), platform: process.platform, node: process.execPath, app: packageRoot,
47
+ selected, cwd, port, coordinator: options.provider ? { provider: options.provider, model: options.model } : undefined,
48
+ phase: "installing", serviceInstalled: false, files: [] };
49
+ createFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
50
+ }
51
+ verifyServiceFile(manifest);
52
+ 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)");
54
+ if (manifest.phase !== "ready") {
55
+ if (manifest.serviceInstalled) throw new Error("Stop/uninstall the existing service before rebuilding components");
56
+ for (const name of ["components"]) {
57
+ if (!existsSync(join(root, name))) continue;
58
+ const known = new Set(manifest.files);
59
+ if (inventory(root, name).some(file => !known.has(file))) throw new Error("Uninventoried component files exist; refusing to overwrite them during resume");
60
+ }
61
+ try {
62
+ console.log("Using the prebuilt npm runtime. No source checkout or compilation required.");
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 into private components (not global executables).");
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
+ 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,
80
+ 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");
84
+ manifest.phase = "ready";
85
+ } finally {
86
+ manifest.files = ["components"].filter(name => existsSync(join(root, name))).flatMap(name => inventory(root, name));
87
+ saveManifest(root, manifest);
88
+ }
89
+ }
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);
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 });
99
+ }
100
+ const report = { platform: process.platform, dependencies: "ready", configuration: "valid", background: "not-checked",
101
+ coordinator: config.coordinator ? "configured; authentication not verified" : "not configured; task admission disabled",
102
+ executors: config.executors.map(executor => ({ id: executor.id, authorized: executor.unattendedApproved,
103
+ handshake: "not checked", execution: "not verified by setup" })) };
104
+ if (options.probe) {
105
+ 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)"; }
107
+ catch { entry.handshake = "failed; inspect native dependencies/configuration separately"; process.exitCode = 1; }
108
+ }
109
+ }
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
+ 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.`);
124
+ } catch (error) {
125
+ console.error(error.message); process.exitCode = 1;
126
+ } finally { unlock?.(); }
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, realpathSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { argumentsFor, installRoot, readManifest, saveManifest, operationLock, deactivate, removeInventory, json } from "./install-lib.mjs";
6
+
7
+ process.umask(0o077);
8
+ const options = argumentsFor(process.argv.slice(2));
9
+ if (options.help) {
10
+ console.log("Usage: agent-task uninstall [--home ABSOLUTE_PRIVATE_DIR]\nStops/removes the owned service and installed components only. Keeps configuration, credentials, logs, task history and workspaces.");
11
+ process.exit(0);
12
+ }
13
+ let unlock, release;
14
+ try {
15
+ if (Object.keys(options).some(key => key !== "home")) throw new Error("Only --home is accepted");
16
+ const root = installRoot(options);
17
+ if (!existsSync(root)) { console.log("Nothing installed; nothing removed."); process.exit(0); }
18
+ readManifest(root);
19
+ unlock = operationLock(root);
20
+ const manifest = readManifest(root);
21
+ deactivate(manifest);
22
+ manifest.serviceInstalled = false; saveManifest(root, manifest);
23
+ if (existsSync(join(root, "config.json"))) {
24
+ const config = json(join(root, "config.json"));
25
+ const protectedPaths = [config.dataDir, ...config.executors.flatMap(executor => [executor.cwd, ...executor.resultReadRoots])];
26
+ // Verify the service is stopped, including any manually started owner.
27
+ const lockModule = join(manifest.app, "dist/lock.js");
28
+ if (existsSync(lockModule)) {
29
+ const { acquireLock } = await import(pathToFileURL(lockModule).href);
30
+ release = await acquireLock(config.dataDir);
31
+ const storeModule = join(manifest.app, "dist/task-storage.js");
32
+ if (existsSync(join(config.dataDir, "tasks.sqlite")) && existsSync(storeModule)) {
33
+ const { TaskStore } = await import(pathToFileURL(storeModule).href);
34
+ const store = new TaskStore(config.dataDir);
35
+ try {
36
+ for (const task of store.definitions()) protectedPaths.push(task.workspace, task.input.workspace, ...Object.values(task.executors).map(executor => executor.cwd));
37
+ for (const run of store.rounds()) for (const node of run.nodes) protectedPaths.push(node.workspace);
38
+ } finally { store.close(); }
39
+ }
40
+ } else if (manifest.phase !== "uninstalled") throw new Error("Cannot verify data ownership; resume setup before uninstall");
41
+ for (const path of protectedPaths.filter(Boolean)) {
42
+ const canonical = existsSync(path) ? realpathSync(path) : path;
43
+ const components = join(root, "components");
44
+ if (canonical === components || canonical.startsWith(components + "/")) {
45
+ throw new Error("Installed components are referenced as data/workspace; refusing to delete them");
46
+ }
47
+ }
48
+ }
49
+ removeInventory(root, manifest.files);
50
+ manifest.phase = "uninstalled"; manifest.files = []; saveManifest(root, manifest);
51
+ console.log("Owned service and inventoried components removed. Configuration, environment.json, logs, data, workspaces and all native/shared executor authentication are preserved. Repeated uninstall is safe.");
52
+ } catch (error) { console.error(error.message); process.exitCode = 1; }
53
+ finally { await release?.(); unlock?.(); }