@orgloop/agentctl 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +228 -0
  3. package/dist/adapters/claude-code.d.ts +83 -0
  4. package/dist/adapters/claude-code.js +783 -0
  5. package/dist/adapters/openclaw.d.ts +88 -0
  6. package/dist/adapters/openclaw.js +297 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +808 -0
  9. package/dist/client/daemon-client.d.ts +6 -0
  10. package/dist/client/daemon-client.js +81 -0
  11. package/dist/compat-shim.d.ts +2 -0
  12. package/dist/compat-shim.js +15 -0
  13. package/dist/core/types.d.ts +68 -0
  14. package/dist/core/types.js +2 -0
  15. package/dist/daemon/fuse-engine.d.ts +30 -0
  16. package/dist/daemon/fuse-engine.js +118 -0
  17. package/dist/daemon/launchagent.d.ts +7 -0
  18. package/dist/daemon/launchagent.js +49 -0
  19. package/dist/daemon/lock-manager.d.ts +16 -0
  20. package/dist/daemon/lock-manager.js +71 -0
  21. package/dist/daemon/metrics.d.ts +20 -0
  22. package/dist/daemon/metrics.js +72 -0
  23. package/dist/daemon/server.d.ts +33 -0
  24. package/dist/daemon/server.js +283 -0
  25. package/dist/daemon/session-tracker.d.ts +28 -0
  26. package/dist/daemon/session-tracker.js +121 -0
  27. package/dist/daemon/state.d.ts +61 -0
  28. package/dist/daemon/state.js +126 -0
  29. package/dist/daemon/supervisor.d.ts +24 -0
  30. package/dist/daemon/supervisor.js +79 -0
  31. package/dist/hooks.d.ts +19 -0
  32. package/dist/hooks.js +39 -0
  33. package/dist/merge.d.ts +24 -0
  34. package/dist/merge.js +65 -0
  35. package/dist/migration/migrate-locks.d.ts +5 -0
  36. package/dist/migration/migrate-locks.js +41 -0
  37. package/dist/worktree.d.ts +24 -0
  38. package/dist/worktree.js +65 -0
  39. package/package.json +60 -0
@@ -0,0 +1,79 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ /**
6
+ * Daemon supervisor — launches the daemon in foreground mode and
7
+ * restarts it on crash with exponential backoff (1s, 2s, 4s... cap 5min).
8
+ * Resets backoff after stable uptime.
9
+ */
10
+ export async function runSupervisor(opts) {
11
+ const minBackoff = opts.minBackoffMs ?? 1000;
12
+ const maxBackoff = opts.maxBackoffMs ?? 300_000;
13
+ const stableUptime = opts.stableUptimeMs ?? 60_000;
14
+ let currentBackoff = minBackoff;
15
+ let running = true;
16
+ // Write supervisor PID file
17
+ const supervisorPidPath = path.join(opts.configDir, "supervisor.pid");
18
+ await fs.writeFile(supervisorPidPath, String(process.pid));
19
+ const cleanup = async () => {
20
+ running = false;
21
+ await fs.rm(supervisorPidPath, { force: true }).catch(() => { });
22
+ };
23
+ process.on("SIGTERM", async () => {
24
+ await cleanup();
25
+ process.exit(0);
26
+ });
27
+ process.on("SIGINT", async () => {
28
+ await cleanup();
29
+ process.exit(0);
30
+ });
31
+ const logDir = opts.configDir;
32
+ await fs.mkdir(logDir, { recursive: true });
33
+ while (running) {
34
+ const startTime = Date.now();
35
+ const stdoutFd = await fs.open(path.join(logDir, "daemon.stdout.log"), "a");
36
+ const stderrFd = await fs.open(path.join(logDir, "daemon.stderr.log"), "a");
37
+ const child = spawn(opts.nodePath, [
38
+ opts.cliPath,
39
+ "daemon",
40
+ "start",
41
+ "--foreground",
42
+ "--metrics-port",
43
+ String(opts.metricsPort),
44
+ ], {
45
+ stdio: ["ignore", stdoutFd.fd, stderrFd.fd],
46
+ });
47
+ // Wait for child to exit
48
+ const exitCode = await new Promise((resolve) => {
49
+ child.on("exit", (code) => resolve(code));
50
+ child.on("error", () => resolve(1));
51
+ });
52
+ await stdoutFd.close();
53
+ await stderrFd.close();
54
+ if (!running)
55
+ break;
56
+ const uptime = Date.now() - startTime;
57
+ // Reset backoff if daemon ran long enough to be considered stable
58
+ if (uptime >= stableUptime) {
59
+ currentBackoff = minBackoff;
60
+ }
61
+ console.error(`Daemon exited (code ${exitCode}) after ${Math.round(uptime / 1000)}s. Restarting in ${Math.round(currentBackoff / 1000)}s...`);
62
+ await new Promise((r) => setTimeout(r, currentBackoff));
63
+ // Exponential backoff (double, capped)
64
+ currentBackoff = Math.min(currentBackoff * 2, maxBackoff);
65
+ }
66
+ }
67
+ /** Read the supervisor PID from disk and check if it's alive */
68
+ export async function getSupervisorPid(configDir) {
69
+ const dir = configDir || path.join(os.homedir(), ".agentctl");
70
+ try {
71
+ const raw = await fs.readFile(path.join(dir, "supervisor.pid"), "utf-8");
72
+ const pid = Number.parseInt(raw.trim(), 10);
73
+ process.kill(pid, 0); // Check liveness
74
+ return pid;
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
@@ -0,0 +1,19 @@
1
+ import type { LifecycleHooks } from "./core/types.js";
2
+ export type HookPhase = "onCreate" | "onComplete" | "preMerge" | "postMerge";
3
+ export interface HookContext {
4
+ sessionId: string;
5
+ cwd: string;
6
+ adapter: string;
7
+ branch?: string;
8
+ exitCode?: number;
9
+ }
10
+ /**
11
+ * Run a lifecycle hook script if defined.
12
+ * Hook scripts receive context via environment variables:
13
+ * AGENTCTL_SESSION_ID, AGENTCTL_CWD, AGENTCTL_ADAPTER,
14
+ * AGENTCTL_BRANCH, AGENTCTL_EXIT_CODE
15
+ */
16
+ export declare function runHook(hooks: LifecycleHooks | undefined, phase: HookPhase, ctx: HookContext): Promise<{
17
+ stdout: string;
18
+ stderr: string;
19
+ } | null>;
package/dist/hooks.js ADDED
@@ -0,0 +1,39 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execAsync = promisify(exec);
4
+ /**
5
+ * Run a lifecycle hook script if defined.
6
+ * Hook scripts receive context via environment variables:
7
+ * AGENTCTL_SESSION_ID, AGENTCTL_CWD, AGENTCTL_ADAPTER,
8
+ * AGENTCTL_BRANCH, AGENTCTL_EXIT_CODE
9
+ */
10
+ export async function runHook(hooks, phase, ctx) {
11
+ if (!hooks)
12
+ return null;
13
+ const script = hooks[phase];
14
+ if (!script)
15
+ return null;
16
+ const env = {
17
+ ...process.env,
18
+ AGENTCTL_SESSION_ID: ctx.sessionId,
19
+ AGENTCTL_CWD: ctx.cwd,
20
+ AGENTCTL_ADAPTER: ctx.adapter,
21
+ };
22
+ if (ctx.branch)
23
+ env.AGENTCTL_BRANCH = ctx.branch;
24
+ if (ctx.exitCode != null)
25
+ env.AGENTCTL_EXIT_CODE = String(ctx.exitCode);
26
+ try {
27
+ const result = await execAsync(script, {
28
+ cwd: ctx.cwd,
29
+ env,
30
+ timeout: 60_000,
31
+ });
32
+ return { stdout: result.stdout, stderr: result.stderr };
33
+ }
34
+ catch (err) {
35
+ const e = err;
36
+ console.error(`Hook ${phase} failed:`, e.message);
37
+ return { stdout: e.stdout || "", stderr: e.stderr || "" };
38
+ }
39
+ }
@@ -0,0 +1,24 @@
1
+ export interface MergeOpts {
2
+ /** Working directory of the session */
3
+ cwd: string;
4
+ /** Commit message (auto-generated if omitted) */
5
+ message?: string;
6
+ /** Whether to remove worktree after push */
7
+ removeWorktree?: boolean;
8
+ /** The main repo path (needed for worktree removal) */
9
+ repoPath?: string;
10
+ }
11
+ export interface MergeResult {
12
+ committed: boolean;
13
+ pushed: boolean;
14
+ prUrl?: string;
15
+ worktreeRemoved: boolean;
16
+ }
17
+ /**
18
+ * Merge + cleanup workflow:
19
+ * 1. Commit uncommitted changes
20
+ * 2. Push to remote
21
+ * 3. Open PR via `gh`
22
+ * 4. Optionally remove worktree
23
+ */
24
+ export declare function mergeSession(opts: MergeOpts): Promise<MergeResult>;
package/dist/merge.js ADDED
@@ -0,0 +1,65 @@
1
+ import { exec, execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execAsync = promisify(exec);
4
+ const execFileAsync = promisify(execFile);
5
+ /**
6
+ * Merge + cleanup workflow:
7
+ * 1. Commit uncommitted changes
8
+ * 2. Push to remote
9
+ * 3. Open PR via `gh`
10
+ * 4. Optionally remove worktree
11
+ */
12
+ export async function mergeSession(opts) {
13
+ const { cwd } = opts;
14
+ const result = {
15
+ committed: false,
16
+ pushed: false,
17
+ worktreeRemoved: false,
18
+ };
19
+ // 1. Check for uncommitted changes
20
+ const { stdout: status } = await execFileAsync("git", ["status", "--porcelain"], { cwd });
21
+ if (status.trim()) {
22
+ // Stage all changes and commit
23
+ await execFileAsync("git", ["add", "-A"], { cwd });
24
+ const message = opts.message || "chore: commit agent session work (via agentctl merge)";
25
+ await execFileAsync("git", ["commit", "-m", message], { cwd });
26
+ result.committed = true;
27
+ }
28
+ // 2. Get current branch name
29
+ const { stdout: branchRaw } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd });
30
+ const branch = branchRaw.trim();
31
+ // 3. Push to remote
32
+ try {
33
+ await execFileAsync("git", ["push", "-u", "origin", branch], { cwd });
34
+ result.pushed = true;
35
+ }
36
+ catch (err) {
37
+ console.error("Push failed:", err.message);
38
+ return result;
39
+ }
40
+ // 4. Open PR via gh (best effort)
41
+ try {
42
+ const { stdout: prOut } = await execAsync(`gh pr create --fill --head ${branch} 2>&1 || gh pr view --json url -q .url 2>&1`, { cwd });
43
+ // Extract URL from output
44
+ const urlMatch = prOut.match(/https:\/\/github\.com\/[^\s]+/);
45
+ if (urlMatch) {
46
+ result.prUrl = urlMatch[0];
47
+ }
48
+ }
49
+ catch {
50
+ // gh not available or PR already exists
51
+ }
52
+ // 5. Optionally remove worktree
53
+ if (opts.removeWorktree && opts.repoPath) {
54
+ try {
55
+ await execFileAsync("git", ["worktree", "remove", "--force", cwd], {
56
+ cwd: opts.repoPath,
57
+ });
58
+ result.worktreeRemoved = true;
59
+ }
60
+ catch (err) {
61
+ console.error("Worktree removal failed:", err.message);
62
+ }
63
+ }
64
+ return result;
65
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * One-time migration from ~/.openclaw/locks/locks.json to ~/.agentctl/locks.json.
3
+ * Idempotent — skips if target already exists or source is missing.
4
+ */
5
+ export declare function migrateLocks(configDir?: string): Promise<number>;
@@ -0,0 +1,41 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ /**
5
+ * One-time migration from ~/.openclaw/locks/locks.json to ~/.agentctl/locks.json.
6
+ * Idempotent — skips if target already exists or source is missing.
7
+ */
8
+ export async function migrateLocks(configDir) {
9
+ const targetDir = configDir || path.join(os.homedir(), ".agentctl");
10
+ const oldPath = path.join(os.homedir(), ".openclaw", "locks", "locks.json");
11
+ const newPath = path.join(targetDir, "locks.json");
12
+ // Skip if already migrated
13
+ if (await fileExists(newPath))
14
+ return 0;
15
+ // Skip if no old file
16
+ if (!(await fileExists(oldPath)))
17
+ return 0;
18
+ const oldData = JSON.parse(await fs.readFile(oldPath, "utf-8"));
19
+ // Transform old format → new format
20
+ // Old format: array of { directory, lockedBy, reason, lockedAt }
21
+ const newLocks = (Array.isArray(oldData) ? oldData : []).map((old) => ({
22
+ directory: old.directory,
23
+ type: "manual",
24
+ lockedBy: (old.lockedBy || old.by || "unknown"),
25
+ reason: (old.reason || ""),
26
+ lockedAt: old.lockedAt || new Date().toISOString(),
27
+ }));
28
+ await fs.mkdir(targetDir, { recursive: true });
29
+ await fs.writeFile(newPath, JSON.stringify(newLocks, null, 2));
30
+ console.log(`Migrated ${newLocks.length} locks from ${oldPath}`);
31
+ return newLocks.length;
32
+ }
33
+ async function fileExists(p) {
34
+ try {
35
+ await fs.access(p);
36
+ return true;
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
@@ -0,0 +1,24 @@
1
+ export interface WorktreeCreateOpts {
2
+ /** Path to the main repo (e.g. ~/code/myproject) */
3
+ repo: string;
4
+ /** Branch name (e.g. charlie/feature-name) */
5
+ branch: string;
6
+ }
7
+ export interface WorktreeInfo {
8
+ /** Absolute path to the worktree directory */
9
+ path: string;
10
+ /** Branch name */
11
+ branch: string;
12
+ /** The base repo path */
13
+ repo: string;
14
+ }
15
+ /**
16
+ * Create a git worktree for the given repo + branch.
17
+ * Worktree is placed at `<repo>-<branch-slug>` (sibling directory).
18
+ * If the worktree already exists, returns its info without creating a new one.
19
+ */
20
+ export declare function createWorktree(opts: WorktreeCreateOpts): Promise<WorktreeInfo>;
21
+ /**
22
+ * Remove a git worktree.
23
+ */
24
+ export declare function removeWorktree(repo: string, worktreePath: string): Promise<void>;
@@ -0,0 +1,65 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+ const execFileAsync = promisify(execFile);
6
+ /**
7
+ * Create a git worktree for the given repo + branch.
8
+ * Worktree is placed at `<repo>-<branch-slug>` (sibling directory).
9
+ * If the worktree already exists, returns its info without creating a new one.
10
+ */
11
+ export async function createWorktree(opts) {
12
+ const repoResolved = path.resolve(opts.repo);
13
+ const slug = opts.branch.replace(/\//g, "-");
14
+ const worktreePath = `${repoResolved}-${slug}`;
15
+ // Check if worktree already exists (check filesystem + git)
16
+ try {
17
+ // Quick filesystem check — if the .git file exists in the worktree dir, it's a worktree
18
+ const gitFile = path.join(worktreePath, ".git");
19
+ try {
20
+ await fs.access(gitFile);
21
+ return { path: worktreePath, branch: opts.branch, repo: repoResolved };
22
+ }
23
+ catch {
24
+ // Not on disk yet — check git's worktree list (handles symlink/realpath differences)
25
+ }
26
+ // Validate this is actually a git repo
27
+ await execFileAsync("git", ["rev-parse", "--git-dir"], {
28
+ cwd: repoResolved,
29
+ });
30
+ }
31
+ catch {
32
+ throw new Error(`Not a git repository: ${repoResolved}`);
33
+ }
34
+ // Check if branch already exists
35
+ let branchExists = false;
36
+ try {
37
+ await execFileAsync("git", ["rev-parse", "--verify", opts.branch], {
38
+ cwd: repoResolved,
39
+ });
40
+ branchExists = true;
41
+ }
42
+ catch {
43
+ // Branch doesn't exist yet
44
+ }
45
+ if (branchExists) {
46
+ // Branch exists — create worktree checking it out
47
+ await execFileAsync("git", ["worktree", "add", worktreePath, opts.branch], {
48
+ cwd: repoResolved,
49
+ });
50
+ }
51
+ else {
52
+ // Branch doesn't exist — create new branch from HEAD
53
+ await execFileAsync("git", ["worktree", "add", "-b", opts.branch, worktreePath], { cwd: repoResolved });
54
+ }
55
+ return { path: worktreePath, branch: opts.branch, repo: repoResolved };
56
+ }
57
+ /**
58
+ * Remove a git worktree.
59
+ */
60
+ export async function removeWorktree(repo, worktreePath) {
61
+ const repoResolved = path.resolve(repo);
62
+ await execFileAsync("git", ["worktree", "remove", "--force", worktreePath], {
63
+ cwd: repoResolved,
64
+ });
65
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@orgloop/agentctl",
3
+ "version": "1.0.0",
4
+ "description": "Universal agent supervision interface — monitor and control AI coding agents from a single CLI",
5
+ "type": "module",
6
+ "bin": {
7
+ "agentctl": "./dist/cli.js",
8
+ "agent-ctl": "./dist/compat-shim.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "dev": "tsx src/cli.ts",
18
+ "test": "vitest run",
19
+ "test:watch": "vitest",
20
+ "typecheck": "tsc --noEmit",
21
+ "lint": "biome check src/",
22
+ "lint:fix": "biome check --write src/",
23
+ "format": "biome format --write src/",
24
+ "prepare": "git config core.hooksPath .githooks"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/OrgLoop/agentctl.git"
29
+ },
30
+ "homepage": "https://github.com/c-h-/agentctl#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/c-h-/agentctl/issues"
33
+ },
34
+ "keywords": [
35
+ "cli",
36
+ "agent",
37
+ "ai",
38
+ "supervisor",
39
+ "claude",
40
+ "coding-agent"
41
+ ],
42
+ "author": "Charlie Hulcher <charlie.hulcher@gmail.com>",
43
+ "license": "MIT",
44
+ "engines": {
45
+ "node": ">=20"
46
+ },
47
+ "dependencies": {
48
+ "@types/node": "^25.2.3",
49
+ "commander": "^14.0.3",
50
+ "tsx": "^4.21.0",
51
+ "typescript": "^5.9.3",
52
+ "vitest": "^4.0.18"
53
+ },
54
+ "devDependencies": {
55
+ "@biomejs/biome": "^2.4.2"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ }
60
+ }