agent-kanban 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.
- package/dist/client.d.ts +78 -0
- package/dist/client.js +143 -0
- package/dist/commands/link.d.ts +2 -0
- package/dist/commands/link.js +57 -0
- package/dist/commands/start.d.ts +2 -0
- package/dist/commands/start.js +50 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +31 -0
- package/dist/daemon.d.ts +7 -0
- package/dist/daemon.js +205 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +265 -0
- package/dist/links.d.ts +7 -0
- package/dist/links.js +28 -0
- package/dist/output.d.ts +7 -0
- package/dist/output.js +88 -0
- package/dist/processManager.d.ts +24 -0
- package/dist/processManager.js +189 -0
- package/dist/project.d.ts +11 -0
- package/dist/project.js +44 -0
- package/dist/skills/agent-kanban/SKILL.md +116 -0
- package/dist/types.d.ts +11 -0
- package/dist/types.js +1 -0
- package/dist/usage.d.ts +2 -0
- package/dist/usage.js +66 -0
- package/package.json +23 -0
- package/src/client.ts +165 -0
- package/src/commands/link.ts +60 -0
- package/src/commands/start.ts +56 -0
- package/src/config.ts +43 -0
- package/src/daemon.ts +244 -0
- package/src/index.ts +276 -0
- package/src/links.ts +37 -0
- package/src/output.ts +101 -0
- package/src/processManager.ts +222 -0
- package/src/types.ts +12 -0
- package/src/usage.ts +71 -0
- package/tsconfig.json +14 -0
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { UsageInfo } from "./types.js";
|
|
2
|
+
export declare class ApiClient {
|
|
3
|
+
private baseUrl;
|
|
4
|
+
private apiKey;
|
|
5
|
+
constructor();
|
|
6
|
+
private request;
|
|
7
|
+
createTask(input: Record<string, unknown>): Promise<unknown>;
|
|
8
|
+
listTasks(params?: Record<string, string>): Promise<unknown>;
|
|
9
|
+
getTask(id: string): Promise<unknown>;
|
|
10
|
+
claimTask(id: string, agentName?: string): Promise<unknown>;
|
|
11
|
+
completeTask(id: string, body: Record<string, unknown>): Promise<unknown>;
|
|
12
|
+
releaseTask(id: string): Promise<unknown>;
|
|
13
|
+
cancelTask(id: string, body?: Record<string, unknown>): Promise<unknown>;
|
|
14
|
+
reviewTask(id: string, body?: Record<string, unknown>): Promise<unknown>;
|
|
15
|
+
assignTask(id: string, agentId: string): Promise<unknown>;
|
|
16
|
+
addLog(taskId: string, detail: string, agentName?: string): Promise<unknown>;
|
|
17
|
+
registerMachine(info: {
|
|
18
|
+
name: string;
|
|
19
|
+
os: string;
|
|
20
|
+
version: string;
|
|
21
|
+
runtimes: string[];
|
|
22
|
+
}): Promise<{
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
}>;
|
|
26
|
+
heartbeat(machineId: string, info: {
|
|
27
|
+
version?: string;
|
|
28
|
+
runtimes?: string[];
|
|
29
|
+
usage_info?: UsageInfo | null;
|
|
30
|
+
}): Promise<unknown>;
|
|
31
|
+
registerAgent(agentId: string, publicKey?: string, runtime?: string, model?: string): Promise<unknown>;
|
|
32
|
+
listAgents(): Promise<unknown>;
|
|
33
|
+
createBoard(input: {
|
|
34
|
+
name: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
}): Promise<unknown>;
|
|
37
|
+
listBoards(): Promise<any[]>;
|
|
38
|
+
getBoardByName(name: string): Promise<unknown>;
|
|
39
|
+
getBoard(boardId: string): Promise<unknown>;
|
|
40
|
+
createRepository(input: {
|
|
41
|
+
name: string;
|
|
42
|
+
url: string;
|
|
43
|
+
}): Promise<unknown>;
|
|
44
|
+
listRepositories(): Promise<any[]>;
|
|
45
|
+
updateAgentUsage(agentId: string, usage: {
|
|
46
|
+
input_tokens: number;
|
|
47
|
+
output_tokens: number;
|
|
48
|
+
cache_read_tokens: number;
|
|
49
|
+
cache_creation_tokens: number;
|
|
50
|
+
cost_micro_usd: number;
|
|
51
|
+
}): Promise<unknown>;
|
|
52
|
+
sendMessage(taskId: string, body: {
|
|
53
|
+
agent_id: string;
|
|
54
|
+
role: string;
|
|
55
|
+
content: string;
|
|
56
|
+
}): Promise<unknown>;
|
|
57
|
+
getMessages(taskId: string, since?: string): Promise<any[]>;
|
|
58
|
+
}
|
|
59
|
+
export declare class AgentClient {
|
|
60
|
+
private baseUrl;
|
|
61
|
+
private agentId;
|
|
62
|
+
private privateKey;
|
|
63
|
+
constructor(baseUrl: string, agentId: string, privateKey: CryptoKey);
|
|
64
|
+
private request;
|
|
65
|
+
releaseTask(id: string): Promise<unknown>;
|
|
66
|
+
updateAgentUsage(usage: {
|
|
67
|
+
input_tokens: number;
|
|
68
|
+
output_tokens: number;
|
|
69
|
+
cache_read_tokens: number;
|
|
70
|
+
cache_creation_tokens: number;
|
|
71
|
+
cost_micro_usd: number;
|
|
72
|
+
}): Promise<unknown>;
|
|
73
|
+
sendMessage(taskId: string, body: {
|
|
74
|
+
agent_id: string;
|
|
75
|
+
role: string;
|
|
76
|
+
content: string;
|
|
77
|
+
}): Promise<unknown>;
|
|
78
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { SignJWT } from "jose";
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import { getConfigValue } from "./config.js";
|
|
4
|
+
export class ApiClient {
|
|
5
|
+
baseUrl;
|
|
6
|
+
apiKey;
|
|
7
|
+
constructor() {
|
|
8
|
+
const url = getConfigValue("api-url");
|
|
9
|
+
const key = getConfigValue("api-key");
|
|
10
|
+
if (!url)
|
|
11
|
+
throw new Error("API URL not configured. Run: agent-kanban config set api-url <url>");
|
|
12
|
+
if (!key)
|
|
13
|
+
throw new Error("API key not configured. Run: agent-kanban config set api-key <key>");
|
|
14
|
+
this.baseUrl = url.replace(/\/$/, "");
|
|
15
|
+
this.apiKey = key;
|
|
16
|
+
}
|
|
17
|
+
async request(method, path, body) {
|
|
18
|
+
const url = `${this.baseUrl}${path}`;
|
|
19
|
+
const res = await fetch(url, {
|
|
20
|
+
method,
|
|
21
|
+
headers: {
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
24
|
+
},
|
|
25
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
26
|
+
signal: AbortSignal.timeout(10000),
|
|
27
|
+
});
|
|
28
|
+
const data = await res.json();
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
const msg = data.error?.message || `HTTP ${res.status}`;
|
|
31
|
+
throw new Error(msg);
|
|
32
|
+
}
|
|
33
|
+
return data;
|
|
34
|
+
}
|
|
35
|
+
// Tasks
|
|
36
|
+
createTask(input) { return this.request("POST", "/api/tasks", input); }
|
|
37
|
+
listTasks(params) {
|
|
38
|
+
const qs = params ? "?" + new URLSearchParams(params).toString() : "";
|
|
39
|
+
return this.request("GET", `/api/tasks${qs}`);
|
|
40
|
+
}
|
|
41
|
+
getTask(id) { return this.request("GET", `/api/tasks/${id}`); }
|
|
42
|
+
claimTask(id, agentName) {
|
|
43
|
+
return this.request("POST", `/api/tasks/${id}/claim`, agentName ? { agent_id: agentName } : {});
|
|
44
|
+
}
|
|
45
|
+
completeTask(id, body) {
|
|
46
|
+
return this.request("POST", `/api/tasks/${id}/complete`, body);
|
|
47
|
+
}
|
|
48
|
+
releaseTask(id) {
|
|
49
|
+
return this.request("POST", `/api/tasks/${id}/release`);
|
|
50
|
+
}
|
|
51
|
+
cancelTask(id, body = {}) {
|
|
52
|
+
return this.request("POST", `/api/tasks/${id}/cancel`, body);
|
|
53
|
+
}
|
|
54
|
+
reviewTask(id, body = {}) {
|
|
55
|
+
return this.request("POST", `/api/tasks/${id}/review`, body);
|
|
56
|
+
}
|
|
57
|
+
assignTask(id, agentId) {
|
|
58
|
+
return this.request("POST", `/api/tasks/${id}/assign`, { agent_id: agentId });
|
|
59
|
+
}
|
|
60
|
+
addLog(taskId, detail, agentName) {
|
|
61
|
+
return this.request("POST", `/api/tasks/${taskId}/logs`, { detail, agent_id: agentName });
|
|
62
|
+
}
|
|
63
|
+
// Machines
|
|
64
|
+
registerMachine(info) {
|
|
65
|
+
return this.request("POST", "/api/machines", info);
|
|
66
|
+
}
|
|
67
|
+
heartbeat(machineId, info) {
|
|
68
|
+
return this.request("POST", `/api/machines/${machineId}/heartbeat`, info);
|
|
69
|
+
}
|
|
70
|
+
// Agents
|
|
71
|
+
registerAgent(agentId, publicKey, runtime, model) {
|
|
72
|
+
return this.request("POST", "/api/agents", { agent_id: agentId, public_key: publicKey, runtime, model });
|
|
73
|
+
}
|
|
74
|
+
listAgents() { return this.request("GET", "/api/agents"); }
|
|
75
|
+
// Boards
|
|
76
|
+
createBoard(input) {
|
|
77
|
+
return this.request("POST", "/api/boards", input);
|
|
78
|
+
}
|
|
79
|
+
listBoards() { return this.request("GET", "/api/boards"); }
|
|
80
|
+
getBoardByName(name) { return this.request("GET", `/api/boards?name=${encodeURIComponent(name)}`); }
|
|
81
|
+
getBoard(boardId) { return this.request("GET", `/api/boards/${boardId}`); }
|
|
82
|
+
// Repositories
|
|
83
|
+
createRepository(input) {
|
|
84
|
+
return this.request("POST", "/api/repositories", input);
|
|
85
|
+
}
|
|
86
|
+
listRepositories() { return this.request("GET", "/api/repositories"); }
|
|
87
|
+
// Agent usage
|
|
88
|
+
updateAgentUsage(agentId, usage) {
|
|
89
|
+
return this.request("PATCH", `/api/agents/${agentId}/usage`, usage);
|
|
90
|
+
}
|
|
91
|
+
// Messages
|
|
92
|
+
sendMessage(taskId, body) {
|
|
93
|
+
return this.request("POST", `/api/tasks/${taskId}/messages`, body);
|
|
94
|
+
}
|
|
95
|
+
getMessages(taskId, since) {
|
|
96
|
+
const qs = since ? `?since=${encodeURIComponent(since)}` : "";
|
|
97
|
+
return this.request("GET", `/api/tasks/${taskId}/messages${qs}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export class AgentClient {
|
|
101
|
+
baseUrl;
|
|
102
|
+
agentId;
|
|
103
|
+
privateKey;
|
|
104
|
+
constructor(baseUrl, agentId, privateKey) {
|
|
105
|
+
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
106
|
+
this.agentId = agentId;
|
|
107
|
+
this.privateKey = privateKey;
|
|
108
|
+
}
|
|
109
|
+
async request(method, path, body) {
|
|
110
|
+
const jwt = await new SignJWT({ sub: this.agentId, jti: randomUUID(), aud: this.baseUrl })
|
|
111
|
+
.setProtectedHeader({ alg: "EdDSA", typ: "agent+jwt" })
|
|
112
|
+
.setIssuedAt()
|
|
113
|
+
.setExpirationTime("60s")
|
|
114
|
+
.sign(this.privateKey);
|
|
115
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
116
|
+
method,
|
|
117
|
+
headers: {
|
|
118
|
+
"Content-Type": "application/json",
|
|
119
|
+
Authorization: `Bearer ${jwt}`,
|
|
120
|
+
},
|
|
121
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
122
|
+
signal: AbortSignal.timeout(10000),
|
|
123
|
+
});
|
|
124
|
+
const data = await res.json();
|
|
125
|
+
if (!res.ok) {
|
|
126
|
+
const msg = data.error?.message || `HTTP ${res.status}`;
|
|
127
|
+
throw new Error(msg);
|
|
128
|
+
}
|
|
129
|
+
return data;
|
|
130
|
+
}
|
|
131
|
+
// Tasks
|
|
132
|
+
releaseTask(id) {
|
|
133
|
+
return this.request("POST", `/api/tasks/${id}/release`);
|
|
134
|
+
}
|
|
135
|
+
// Agent usage
|
|
136
|
+
updateAgentUsage(usage) {
|
|
137
|
+
return this.request("PATCH", `/api/agents/${this.agentId}/usage`, usage);
|
|
138
|
+
}
|
|
139
|
+
// Messages
|
|
140
|
+
sendMessage(taskId, body) {
|
|
141
|
+
return this.request("POST", `/api/tasks/${taskId}/messages`, body);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
import { basename } from "path";
|
|
3
|
+
import { ApiClient } from "../client.js";
|
|
4
|
+
import { setLink } from "../links.js";
|
|
5
|
+
function getGitRepoRoot() {
|
|
6
|
+
return execSync("git rev-parse --show-toplevel", { encoding: "utf-8" }).trim();
|
|
7
|
+
}
|
|
8
|
+
function getGitRemoteUrl() {
|
|
9
|
+
try {
|
|
10
|
+
return execSync("git remote get-url origin", { encoding: "utf-8" }).trim();
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function registerLinkCommand(program) {
|
|
17
|
+
program
|
|
18
|
+
.command("link")
|
|
19
|
+
.description("Register current repo and map local directory to it")
|
|
20
|
+
.action(async () => {
|
|
21
|
+
let repoRoot;
|
|
22
|
+
try {
|
|
23
|
+
repoRoot = getGitRepoRoot();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
console.error("Not a git repository. Run this command from a git repo.");
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
const remoteUrl = getGitRemoteUrl();
|
|
30
|
+
if (!remoteUrl) {
|
|
31
|
+
console.error("No git remote found. Add an origin remote first.");
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
const client = new ApiClient();
|
|
35
|
+
let repo;
|
|
36
|
+
try {
|
|
37
|
+
repo = await client.createRepository({
|
|
38
|
+
name: basename(repoRoot),
|
|
39
|
+
url: remoteUrl,
|
|
40
|
+
});
|
|
41
|
+
console.log(`Registered repository: ${remoteUrl}`);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
if (err.message?.includes("UNIQUE")) {
|
|
45
|
+
// Already exists — find it
|
|
46
|
+
const repos = await client.listRepositories();
|
|
47
|
+
repo = repos.find((r) => r.url === remoteUrl);
|
|
48
|
+
console.log(`Repository already registered: ${remoteUrl}`);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
setLink(repo.id, repoRoot);
|
|
55
|
+
console.log(`Linked repository ${repo.id} → ${repoRoot}`);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createInterface } from "readline";
|
|
2
|
+
import { startDaemon } from "../daemon.js";
|
|
3
|
+
import { setConfigValue, getConfigValue, deleteConfigValue } from "../config.js";
|
|
4
|
+
function confirm(question) {
|
|
5
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
6
|
+
return new Promise((resolve) => {
|
|
7
|
+
rl.question(question, (answer) => {
|
|
8
|
+
rl.close();
|
|
9
|
+
resolve(answer.toLowerCase() === "y");
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
export function registerStartCommand(program) {
|
|
14
|
+
program
|
|
15
|
+
.command("start")
|
|
16
|
+
.description("Start the Machine daemon — auto-claim and execute tasks")
|
|
17
|
+
.option("--api-url <url>", "API server URL")
|
|
18
|
+
.option("--api-key <key>", "Machine API key")
|
|
19
|
+
.option("--max-concurrent <n>", "Max concurrent agents", "3")
|
|
20
|
+
.option("--agent-cli <cmd>", "Agent CLI command to spawn", "claude")
|
|
21
|
+
.option("--poll-interval <ms>", "Poll interval in ms", "10000")
|
|
22
|
+
.option("--task-timeout <ms>", "Task timeout in ms (0 to disable)", "7200000")
|
|
23
|
+
.action(async (opts) => {
|
|
24
|
+
if (opts.apiUrl)
|
|
25
|
+
setConfigValue("api-url", opts.apiUrl);
|
|
26
|
+
if (opts.apiKey) {
|
|
27
|
+
const oldKey = getConfigValue("api-key");
|
|
28
|
+
if (oldKey && oldKey !== opts.apiKey && getConfigValue("machine-id")) {
|
|
29
|
+
const machineId = getConfigValue("machine-id");
|
|
30
|
+
const yes = await confirm(`This machine is already registered (${machineId}) with a different API key.\nSwitch to the new key and re-register? [y/N] `);
|
|
31
|
+
if (!yes) {
|
|
32
|
+
console.log("Aborted.");
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
deleteConfigValue("machine-id");
|
|
36
|
+
}
|
|
37
|
+
setConfigValue("api-key", opts.apiKey);
|
|
38
|
+
}
|
|
39
|
+
if (!getConfigValue("api-url") || !getConfigValue("api-key")) {
|
|
40
|
+
console.error("API URL and key required. Pass --api-url and --api-key, or set via: ak config set api-url <url>");
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
await startDaemon({
|
|
44
|
+
maxConcurrent: parseInt(opts.maxConcurrent, 10),
|
|
45
|
+
agentCli: opts.agentCli,
|
|
46
|
+
pollInterval: parseInt(opts.pollInterval, 10),
|
|
47
|
+
taskTimeout: parseInt(opts.taskTimeout, 10),
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
interface Config {
|
|
2
|
+
"api-url"?: string;
|
|
3
|
+
"api-key"?: string;
|
|
4
|
+
"machine-id"?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function readConfig(): Config;
|
|
7
|
+
export declare function writeConfig(config: Config): void;
|
|
8
|
+
export declare function getConfigValue(key: string): string | undefined;
|
|
9
|
+
export declare function setConfigValue(key: string, value: string): void;
|
|
10
|
+
export declare function deleteConfigValue(key: string): void;
|
|
11
|
+
export declare const PID_FILE: string;
|
|
12
|
+
export {};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
const CONFIG_DIR = join(homedir(), ".agent-kanban");
|
|
5
|
+
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
6
|
+
export function readConfig() {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function writeConfig(config) {
|
|
15
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
16
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n");
|
|
17
|
+
}
|
|
18
|
+
export function getConfigValue(key) {
|
|
19
|
+
return readConfig()[key];
|
|
20
|
+
}
|
|
21
|
+
export function setConfigValue(key, value) {
|
|
22
|
+
const config = readConfig();
|
|
23
|
+
config[key] = value;
|
|
24
|
+
writeConfig(config);
|
|
25
|
+
}
|
|
26
|
+
export function deleteConfigValue(key) {
|
|
27
|
+
const config = readConfig();
|
|
28
|
+
delete config[key];
|
|
29
|
+
writeConfig(config);
|
|
30
|
+
}
|
|
31
|
+
export const PID_FILE = join(CONFIG_DIR, "daemon.pid");
|
package/dist/daemon.d.ts
ADDED
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { existsSync, writeFileSync, unlinkSync, readFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { hostname, platform, arch, release } from "os";
|
|
4
|
+
import { execSync } from "child_process";
|
|
5
|
+
import { randomUUID } from "crypto";
|
|
6
|
+
import { ApiClient, AgentClient } from "./client.js";
|
|
7
|
+
import { ProcessManager } from "./processManager.js";
|
|
8
|
+
import { getLinks, findPathForRepository } from "./links.js";
|
|
9
|
+
import { getConfigValue, setConfigValue, PID_FILE } from "./config.js";
|
|
10
|
+
import { getUsage } from "./usage.js";
|
|
11
|
+
export async function startDaemon(opts) {
|
|
12
|
+
// PID lock
|
|
13
|
+
if (existsSync(PID_FILE)) {
|
|
14
|
+
const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
|
|
15
|
+
try {
|
|
16
|
+
process.kill(pid, 0);
|
|
17
|
+
console.error(`Daemon already running (PID ${pid}). Stop it first or remove ${PID_FILE}`);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
unlinkSync(PID_FILE);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
writeFileSync(PID_FILE, String(process.pid));
|
|
25
|
+
const client = new ApiClient();
|
|
26
|
+
const links = getLinks();
|
|
27
|
+
const linkedRepoCount = Object.keys(links).length;
|
|
28
|
+
if (linkedRepoCount === 0) {
|
|
29
|
+
console.warn("[WARN] No linked repositories. Run `ak link` in your repo directories.");
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
console.log(`[INFO] Linked repositories: ${linkedRepoCount}`);
|
|
33
|
+
}
|
|
34
|
+
const pm = new ProcessManager(client, opts.agentCli, () => {
|
|
35
|
+
schedulePoll(0);
|
|
36
|
+
}, opts.taskTimeout);
|
|
37
|
+
let running = true;
|
|
38
|
+
let pollTimer = null;
|
|
39
|
+
let backoffMs = opts.pollInterval || 10000;
|
|
40
|
+
const baseInterval = opts.pollInterval || 10000;
|
|
41
|
+
const shutdown = async () => {
|
|
42
|
+
if (!running)
|
|
43
|
+
return;
|
|
44
|
+
running = false;
|
|
45
|
+
console.log("\n[INFO] Shutting down daemon...");
|
|
46
|
+
clearInterval(heartbeatInterval);
|
|
47
|
+
if (pollTimer)
|
|
48
|
+
clearTimeout(pollTimer);
|
|
49
|
+
await pm.killAll();
|
|
50
|
+
removePidFile();
|
|
51
|
+
console.log("[INFO] Daemon stopped.");
|
|
52
|
+
process.exit(0);
|
|
53
|
+
};
|
|
54
|
+
process.on("SIGINT", shutdown);
|
|
55
|
+
process.on("SIGTERM", shutdown);
|
|
56
|
+
console.log(`[INFO] Daemon started (PID ${process.pid}, max_concurrent=${opts.maxConcurrent}, agent=${opts.agentCli})`);
|
|
57
|
+
// Register machine (first run) or reuse existing
|
|
58
|
+
const machineInfo = getMachineInfo();
|
|
59
|
+
let machineId = getConfigValue("machine-id");
|
|
60
|
+
if (!machineId) {
|
|
61
|
+
const machine = await client.registerMachine(machineInfo);
|
|
62
|
+
machineId = machine.id;
|
|
63
|
+
setConfigValue("machine-id", machineId);
|
|
64
|
+
console.log(`[INFO] Machine registered: ${machineId}`);
|
|
65
|
+
}
|
|
66
|
+
await client.heartbeat(machineId, { version: machineInfo.version, runtimes: machineInfo.runtimes });
|
|
67
|
+
console.log(`[INFO] Machine online: ${machineInfo.name} (${machineInfo.os}, runtimes: ${machineInfo.runtimes.join(", ") || "none"})`);
|
|
68
|
+
const heartbeatInterval = setInterval(async () => {
|
|
69
|
+
const usageInfo = await getUsage();
|
|
70
|
+
client.heartbeat(machineId, { version: machineInfo.version, runtimes: machineInfo.runtimes, usage_info: usageInfo }).catch((err) => console.error(`[WARN] Heartbeat failed: ${err.message}`));
|
|
71
|
+
}, 30000);
|
|
72
|
+
function schedulePoll(delayMs) {
|
|
73
|
+
if (!running)
|
|
74
|
+
return;
|
|
75
|
+
if (pollTimer)
|
|
76
|
+
clearTimeout(pollTimer);
|
|
77
|
+
pollTimer = setTimeout(poll, delayMs);
|
|
78
|
+
}
|
|
79
|
+
async function poll() {
|
|
80
|
+
if (!running)
|
|
81
|
+
return;
|
|
82
|
+
try {
|
|
83
|
+
if (pm.activeCount >= opts.maxConcurrent) {
|
|
84
|
+
schedulePoll(baseInterval);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const tasks = await client.listTasks({ status: "todo" });
|
|
88
|
+
const available = tasks.filter((t) => {
|
|
89
|
+
if (t.blocked || t.assigned_to)
|
|
90
|
+
return false;
|
|
91
|
+
if (pm.hasTask(t.id))
|
|
92
|
+
return false;
|
|
93
|
+
if (!t.repository_id)
|
|
94
|
+
return false;
|
|
95
|
+
if (!findPathForRepository(t.repository_id)) {
|
|
96
|
+
console.warn(`[WARN] Skipping task ${t.id}: no linked directory for repository ${t.repository_id}`);
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
});
|
|
101
|
+
if (available.length === 0) {
|
|
102
|
+
backoffMs = baseInterval;
|
|
103
|
+
schedulePoll(baseInterval);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const task = available[0];
|
|
107
|
+
const repoDir = findPathForRepository(task.repository_id);
|
|
108
|
+
const sessionId = randomUUID();
|
|
109
|
+
const { publicKey, privateKey } = await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
|
|
110
|
+
const pubKeyJwk = await crypto.subtle.exportKey("jwk", publicKey);
|
|
111
|
+
const pubKeyBase64 = pubKeyJwk.x;
|
|
112
|
+
try {
|
|
113
|
+
await client.registerAgent(sessionId, pubKeyBase64, opts.agentCli);
|
|
114
|
+
await client.assignTask(task.id, sessionId);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
if (err.message.includes("409") || err.message.includes("assigned") || err.message.includes("blocked")) {
|
|
118
|
+
schedulePoll(1000);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
console.log(`[INFO] Assigned task ${task.id}: ${task.title} → agent ${sessionId}`);
|
|
124
|
+
if (!ensureSkill(repoDir)) {
|
|
125
|
+
console.error(`[ERROR] Skill install failed for task ${task.id}, releasing task`);
|
|
126
|
+
await client.releaseTask(task.id).catch((err) => console.error(`[ERROR] Failed to release task ${task.id} after skill failure: ${err.message}`));
|
|
127
|
+
schedulePoll(baseInterval);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const agentClient = new AgentClient(getConfigValue("api-url"), sessionId, privateKey);
|
|
131
|
+
const prompt = `You have a new task assigned to you. Task ID: ${task.id}\nFollow the agent-kanban skill workflow: claim the task, do the work, create a PR with gh, then submit for review with ak task review --pr-url <url>. Do NOT call task complete — only humans can complete tasks.`;
|
|
132
|
+
await pm.spawnAgent(task.id, sessionId, repoDir, prompt, agentClient);
|
|
133
|
+
backoffMs = baseInterval;
|
|
134
|
+
schedulePoll(1000);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
console.error(`[WARN] Poll error: ${err.message}`);
|
|
138
|
+
backoffMs = Math.min(backoffMs * 2, 60000);
|
|
139
|
+
schedulePoll(backoffMs);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
schedulePoll(0);
|
|
143
|
+
}
|
|
144
|
+
const SKILL_SOURCE = "bonaysoft/agent-kanban";
|
|
145
|
+
const SKILL_NAME = "agent-kanban";
|
|
146
|
+
function ensureSkill(repoDir) {
|
|
147
|
+
const skillFile = join(repoDir, `.claude/skills/${SKILL_NAME}/SKILL.md`);
|
|
148
|
+
try {
|
|
149
|
+
if (!existsSync(skillFile)) {
|
|
150
|
+
console.log(`[INFO] Installing skill "${SKILL_NAME}" in ${repoDir}`);
|
|
151
|
+
execSync(`npx skills add ${SKILL_SOURCE} --skill ${SKILL_NAME} --agent claude-code --agent universal -y`, {
|
|
152
|
+
cwd: repoDir,
|
|
153
|
+
stdio: "pipe",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
const result = execSync("npx skills update", { cwd: repoDir, stdio: "pipe" }).toString();
|
|
158
|
+
if (result.includes("up to date"))
|
|
159
|
+
return true;
|
|
160
|
+
console.log(`[INFO] Skill "${SKILL_NAME}" updated in ${repoDir}`);
|
|
161
|
+
}
|
|
162
|
+
execSync(`git add .claude/skills/ && git diff --cached --quiet || git commit -m "chore: update ${SKILL_NAME} skill"`, {
|
|
163
|
+
cwd: repoDir,
|
|
164
|
+
stdio: "pipe",
|
|
165
|
+
});
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
console.error(`[ERROR] Failed to ensure skill: ${err.message}`);
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function removePidFile() {
|
|
174
|
+
try {
|
|
175
|
+
unlinkSync(PID_FILE);
|
|
176
|
+
}
|
|
177
|
+
catch { /* ignore */ }
|
|
178
|
+
}
|
|
179
|
+
function detectRuntimes() {
|
|
180
|
+
const commands = [
|
|
181
|
+
["claude", "Claude Code"],
|
|
182
|
+
["codex", "Codex"],
|
|
183
|
+
["gemini", "Gemini CLI"],
|
|
184
|
+
];
|
|
185
|
+
const found = [];
|
|
186
|
+
for (const [cmd, label] of commands) {
|
|
187
|
+
try {
|
|
188
|
+
execSync(`which ${cmd}`, { stdio: "ignore" });
|
|
189
|
+
found.push(label);
|
|
190
|
+
}
|
|
191
|
+
catch { /* not installed */ }
|
|
192
|
+
}
|
|
193
|
+
return found;
|
|
194
|
+
}
|
|
195
|
+
function getMachineInfo() {
|
|
196
|
+
const os = `${platform()} ${arch()} ${release()}`;
|
|
197
|
+
const runtimes = detectRuntimes();
|
|
198
|
+
let version = "unknown";
|
|
199
|
+
try {
|
|
200
|
+
const pkg = JSON.parse(readFileSync(join(import.meta.dirname, "../package.json"), "utf-8"));
|
|
201
|
+
version = pkg.version;
|
|
202
|
+
}
|
|
203
|
+
catch { /* ignore */ }
|
|
204
|
+
return { name: hostname(), os, version, runtimes };
|
|
205
|
+
}
|
package/dist/index.d.ts
ADDED