agent-kanban 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { UsageInfo } from "./types.js";
2
- export declare class ApiClient {
3
- private baseUrl;
4
- private apiKey;
5
- constructor();
2
+ export declare abstract class ApiClient {
3
+ protected baseUrl: string;
4
+ constructor(baseUrl: string);
5
+ protected abstract authorize(): Promise<string>;
6
6
  private request;
7
7
  createTask(input: Record<string, unknown>): Promise<unknown>;
8
8
  listTasks(params?: Record<string, string>): Promise<unknown>;
@@ -56,23 +56,20 @@ export declare class ApiClient {
56
56
  }): Promise<unknown>;
57
57
  getMessages(taskId: string, since?: string): Promise<any[]>;
58
58
  }
59
- export declare class AgentClient {
60
- private baseUrl;
59
+ export declare class MachineClient extends ApiClient {
60
+ private apiKey;
61
+ constructor();
62
+ protected authorize(): Promise<string>;
63
+ }
64
+ export declare class AgentClient extends ApiClient {
61
65
  private agentId;
62
66
  private privateKey;
63
67
  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>;
68
+ static fromEnv(): Promise<AgentClient | null>;
69
+ protected authorize(): Promise<string>;
78
70
  }
71
+ /**
72
+ * Returns AgentClient if running as a spawned agent (AK_AGENT_* env vars),
73
+ * otherwise MachineClient (API key from config).
74
+ */
75
+ export declare function createClient(): Promise<ApiClient>;
package/dist/client.js CHANGED
@@ -3,24 +3,16 @@ import { randomUUID } from "crypto";
3
3
  import { getConfigValue } from "./config.js";
4
4
  export class ApiClient {
5
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;
6
+ constructor(baseUrl) {
7
+ this.baseUrl = baseUrl.replace(/\/$/, "");
16
8
  }
17
9
  async request(method, path, body) {
18
- const url = `${this.baseUrl}${path}`;
19
- const res = await fetch(url, {
10
+ const authorization = await this.authorize();
11
+ const res = await fetch(`${this.baseUrl}${path}`, {
20
12
  method,
21
13
  headers: {
22
14
  "Content-Type": "application/json",
23
- Authorization: `Bearer ${this.apiKey}`,
15
+ Authorization: authorization,
24
16
  },
25
17
  body: body ? JSON.stringify(body) : undefined,
26
18
  signal: AbortSignal.timeout(10000),
@@ -97,47 +89,52 @@ export class ApiClient {
97
89
  return this.request("GET", `/api/tasks/${taskId}/messages${qs}`);
98
90
  }
99
91
  }
100
- export class AgentClient {
101
- baseUrl;
92
+ export class MachineClient extends ApiClient {
93
+ apiKey;
94
+ constructor() {
95
+ const url = getConfigValue("api-url");
96
+ const key = getConfigValue("api-key");
97
+ if (!url)
98
+ throw new Error("API URL not configured. Run: agent-kanban config set api-url <url>");
99
+ if (!key)
100
+ throw new Error("API key not configured. Run: agent-kanban config set api-key <key>");
101
+ super(url);
102
+ this.apiKey = key;
103
+ }
104
+ async authorize() {
105
+ return `Bearer ${this.apiKey}`;
106
+ }
107
+ }
108
+ export class AgentClient extends ApiClient {
102
109
  agentId;
103
110
  privateKey;
104
111
  constructor(baseUrl, agentId, privateKey) {
105
- this.baseUrl = baseUrl.replace(/\/$/, "");
112
+ super(baseUrl);
106
113
  this.agentId = agentId;
107
114
  this.privateKey = privateKey;
108
115
  }
109
- async request(method, path, body) {
116
+ static async fromEnv() {
117
+ const agentId = process.env.AK_AGENT_ID;
118
+ const keyJson = process.env.AK_AGENT_KEY;
119
+ const apiUrl = process.env.AK_API_URL;
120
+ if (!agentId || !keyJson || !apiUrl)
121
+ return null;
122
+ const privateKey = await crypto.subtle.importKey("jwk", JSON.parse(keyJson), { name: "Ed25519" }, false, ["sign"]);
123
+ return new AgentClient(apiUrl, agentId, privateKey);
124
+ }
125
+ async authorize() {
110
126
  const jwt = await new SignJWT({ sub: this.agentId, jti: randomUUID(), aud: this.baseUrl })
111
127
  .setProtectedHeader({ alg: "EdDSA", typ: "agent+jwt" })
112
128
  .setIssuedAt()
113
129
  .setExpirationTime("60s")
114
130
  .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);
131
+ return `Bearer ${jwt}`;
142
132
  }
143
133
  }
134
+ /**
135
+ * Returns AgentClient if running as a spawned agent (AK_AGENT_* env vars),
136
+ * otherwise MachineClient (API key from config).
137
+ */
138
+ export async function createClient() {
139
+ return await AgentClient.fromEnv() ?? new MachineClient();
140
+ }
@@ -1,6 +1,6 @@
1
1
  import { execSync } from "child_process";
2
2
  import { basename } from "path";
3
- import { ApiClient } from "../client.js";
3
+ import { MachineClient } from "../client.js";
4
4
  import { setLink } from "../links.js";
5
5
  function getGitRepoRoot() {
6
6
  return execSync("git rev-parse --show-toplevel", { encoding: "utf-8" }).trim();
@@ -31,7 +31,7 @@ export function registerLinkCommand(program) {
31
31
  console.error("No git remote found. Add an origin remote first.");
32
32
  process.exit(1);
33
33
  }
34
- const client = new ApiClient();
34
+ const client = new MachineClient();
35
35
  let repo;
36
36
  try {
37
37
  repo = await client.createRepository({
package/dist/daemon.js CHANGED
@@ -3,7 +3,7 @@ import { join } from "path";
3
3
  import { hostname, platform, arch, release } from "os";
4
4
  import { execSync } from "child_process";
5
5
  import { randomUUID } from "crypto";
6
- import { ApiClient, AgentClient } from "./client.js";
6
+ import { MachineClient, AgentClient } from "./client.js";
7
7
  import { ProcessManager } from "./processManager.js";
8
8
  import { getLinks, findPathForRepository } from "./links.js";
9
9
  import { getConfigValue, setConfigValue, PID_FILE } from "./config.js";
@@ -22,7 +22,7 @@ export async function startDaemon(opts) {
22
22
  }
23
23
  }
24
24
  writeFileSync(PID_FILE, String(process.pid));
25
- const client = new ApiClient();
25
+ const client = new MachineClient();
26
26
  const links = getLinks();
27
27
  const linkedRepoCount = Object.keys(links).length;
28
28
  if (linkedRepoCount === 0) {
@@ -108,6 +108,7 @@ export async function startDaemon(opts) {
108
108
  const sessionId = randomUUID();
109
109
  const { publicKey, privateKey } = await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
110
110
  const pubKeyJwk = await crypto.subtle.exportKey("jwk", publicKey);
111
+ const privKeyJwk = await crypto.subtle.exportKey("jwk", privateKey);
111
112
  const pubKeyBase64 = pubKeyJwk.x;
112
113
  try {
113
114
  await client.registerAgent(sessionId, pubKeyBase64, opts.agentCli);
@@ -128,8 +129,13 @@ export async function startDaemon(opts) {
128
129
  return;
129
130
  }
130
131
  const agentClient = new AgentClient(getConfigValue("api-url"), sessionId, privateKey);
132
+ const agentEnv = {
133
+ AK_AGENT_ID: sessionId,
134
+ AK_AGENT_KEY: JSON.stringify(privKeyJwk),
135
+ AK_API_URL: getConfigValue("api-url"),
136
+ };
131
137
  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);
138
+ await pm.spawnAgent(task.id, sessionId, repoDir, prompt, agentClient, agentEnv);
133
139
  backoffMs = baseInterval;
134
140
  schedulePoll(1000);
135
141
  }
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
3
  import { setConfigValue, getConfigValue } from "./config.js";
4
- import { ApiClient } from "./client.js";
4
+ import { MachineClient, createClient } from "./client.js";
5
5
  import { getFormat, output, formatTaskList, formatBoard, formatAgentList, formatBoardList, formatRepositoryList } from "./output.js";
6
6
  import { registerLinkCommand } from "./commands/link.js";
7
7
  import { registerStartCommand } from "./commands/start.js";
@@ -44,7 +44,7 @@ taskCmd
44
44
  .option("--depends-on <ids>", "Comma-separated task IDs this depends on")
45
45
  .option("--format <format>", "Output format (json, text)")
46
46
  .action(async (opts) => {
47
- const client = new ApiClient();
47
+ const client = await createClient();
48
48
  const body = { title: opts.title };
49
49
  if (opts.description)
50
50
  body.description = opts.description;
@@ -82,7 +82,7 @@ taskCmd
82
82
  .option("--parent <id>", "Filter subtasks of a parent task")
83
83
  .option("--format <format>", "Output format (json, text)")
84
84
  .action(async (opts) => {
85
- const client = new ApiClient();
85
+ const client = await createClient();
86
86
  const params = {};
87
87
  if (opts.repo)
88
88
  params.repository_id = opts.repo;
@@ -102,7 +102,7 @@ taskCmd
102
102
  .option("--agent-name <name>", "Agent identity")
103
103
  .option("--format <format>", "Output format (json, text)")
104
104
  .action(async (id, opts) => {
105
- const client = new ApiClient();
105
+ const client = await createClient();
106
106
  const task = await client.claimTask(id, opts.agentName);
107
107
  const fmt = getFormat(opts.format);
108
108
  output(task, fmt, (t) => `Claimed task ${t.id}: ${t.title} (now in progress)`);
@@ -112,7 +112,7 @@ taskCmd
112
112
  .description("Add a log entry to a task")
113
113
  .option("--agent-name <name>", "Agent identity")
114
114
  .action(async (id, message, opts) => {
115
- const client = new ApiClient();
115
+ const client = await createClient();
116
116
  await client.addLog(id, message, opts.agentName);
117
117
  console.log("Log entry added.");
118
118
  });
@@ -122,7 +122,7 @@ taskCmd
122
122
  .option("--agent-name <name>", "Agent identity")
123
123
  .option("--format <format>", "Output format (json, text)")
124
124
  .action(async (id, opts) => {
125
- const client = new ApiClient();
125
+ const client = await createClient();
126
126
  const body = {};
127
127
  if (opts.agentName)
128
128
  body.agent_name = opts.agentName;
@@ -137,7 +137,7 @@ taskCmd
137
137
  .option("--agent-name <name>", "Agent identity")
138
138
  .option("--format <format>", "Output format (json, text)")
139
139
  .action(async (id, opts) => {
140
- const client = new ApiClient();
140
+ const client = await createClient();
141
141
  const body = {};
142
142
  if (opts.prUrl)
143
143
  body.pr_url = opts.prUrl;
@@ -155,7 +155,7 @@ taskCmd
155
155
  .option("--agent-name <name>", "Agent identity")
156
156
  .option("--format <format>", "Output format (json, text)")
157
157
  .action(async (id, opts) => {
158
- const client = new ApiClient();
158
+ const client = await createClient();
159
159
  const body = {};
160
160
  if (opts.result)
161
161
  body.result = opts.result;
@@ -174,7 +174,7 @@ agentCmd
174
174
  .description("List all agents")
175
175
  .option("--format <format>", "Output format (json, text)")
176
176
  .action(async (opts) => {
177
- const client = new ApiClient();
177
+ const client = new MachineClient();
178
178
  const agents = await client.listAgents();
179
179
  const fmt = getFormat(opts.format);
180
180
  output(agents, fmt, formatAgentList);
@@ -188,7 +188,7 @@ boardCmd
188
188
  .option("--description <desc>", "Board description")
189
189
  .option("--format <format>", "Output format (json, text)")
190
190
  .action(async (opts) => {
191
- const client = new ApiClient();
191
+ const client = new MachineClient();
192
192
  const board = await client.createBoard({ name: opts.name, description: opts.description });
193
193
  const fmt = getFormat(opts.format);
194
194
  output(board, fmt, (b) => `Created board ${b.id}: ${b.name}`);
@@ -198,7 +198,7 @@ boardCmd
198
198
  .description("List all boards")
199
199
  .option("--format <format>", "Output format (json, text)")
200
200
  .action(async (opts) => {
201
- const client = new ApiClient();
201
+ const client = new MachineClient();
202
202
  const boards = await client.listBoards();
203
203
  const fmt = getFormat(opts.format);
204
204
  output(boards, fmt, formatBoardList);
@@ -209,7 +209,7 @@ boardCmd
209
209
  .option("--board <name-or-id>", "Board name or ID (uses first if omitted)")
210
210
  .option("--format <format>", "Output format (json, text)")
211
211
  .action(async (opts) => {
212
- const client = new ApiClient();
212
+ const client = new MachineClient();
213
213
  let boardId;
214
214
  if (opts.board) {
215
215
  boardId = await resolveBoardId(client, opts.board);
@@ -244,7 +244,7 @@ repoCmd
244
244
  .requiredOption("--url <url>", "Clone URL")
245
245
  .option("--format <format>", "Output format (json, text)")
246
246
  .action(async (opts) => {
247
- const client = new ApiClient();
247
+ const client = new MachineClient();
248
248
  const repo = await client.createRepository({ name: opts.name, url: opts.url });
249
249
  const fmt = getFormat(opts.format);
250
250
  output(repo, fmt, (r) => `Added repository ${r.id}: ${r.name}`);
@@ -254,7 +254,7 @@ repoCmd
254
254
  .description("List repositories")
255
255
  .option("--format <format>", "Output format (json, text)")
256
256
  .action(async (opts) => {
257
- const client = new ApiClient();
257
+ const client = new MachineClient();
258
258
  const repos = await client.listRepositories();
259
259
  const fmt = getFormat(opts.format);
260
260
  output(repos, fmt, formatRepositoryList);
@@ -4,7 +4,7 @@ export interface AgentProcess {
4
4
  taskId: string;
5
5
  sessionId: string;
6
6
  process: ChildProcess;
7
- agentClient?: AgentClient;
7
+ agentClient: AgentClient;
8
8
  timeoutTimer?: ReturnType<typeof setTimeout>;
9
9
  }
10
10
  export declare class ProcessManager {
@@ -16,9 +16,8 @@ export declare class ProcessManager {
16
16
  constructor(client: ApiClient, agentCli: string, onSlotFreed: () => void, taskTimeoutMs?: number);
17
17
  get activeCount(): number;
18
18
  hasTask(taskId: string): boolean;
19
- spawnAgent(taskId: string, sessionId: string, cwd: string, taskContext: string, agentClient?: AgentClient): Promise<void>;
19
+ spawnAgent(taskId: string, sessionId: string, cwd: string, taskContext: string, agentClient: AgentClient, agentEnv: Record<string, string>): Promise<void>;
20
20
  killAll(): Promise<void>;
21
21
  private handleEvent;
22
- private postMessage;
23
22
  private releaseTask;
24
23
  }
@@ -17,7 +17,7 @@ export class ProcessManager {
17
17
  hasTask(taskId) {
18
18
  return this.agents.has(taskId);
19
19
  }
20
- async spawnAgent(taskId, sessionId, cwd, taskContext, agentClient) {
20
+ async spawnAgent(taskId, sessionId, cwd, taskContext, agentClient, agentEnv) {
21
21
  const args = [
22
22
  "--print",
23
23
  "--verbose",
@@ -32,7 +32,7 @@ export class ProcessManager {
32
32
  proc = spawn(this.agentCli, args, {
33
33
  cwd,
34
34
  stdio: ["pipe", "pipe", "pipe"],
35
- env: { ...process.env },
35
+ env: { ...process.env, ...agentEnv },
36
36
  });
37
37
  }
38
38
  catch (err) {
@@ -47,14 +47,12 @@ export class ProcessManager {
47
47
  }
48
48
  const agent = { taskId, sessionId, process: proc, agentClient };
49
49
  this.agents.set(taskId, agent);
50
- // Kill agent if it exceeds the task timeout
51
50
  if (this.taskTimeoutMs > 0) {
52
51
  agent.timeoutTimer = setTimeout(() => {
53
52
  console.warn(`[WARN] Agent for task ${taskId} exceeded timeout (${Math.round(this.taskTimeoutMs / 60000)}m), killing`);
54
53
  proc.kill("SIGTERM");
55
54
  }, this.taskTimeoutMs);
56
55
  }
57
- // Send task context as JSON message via stdin, then close
58
56
  proc.on("spawn", () => {
59
57
  const payload = JSON.stringify({
60
58
  type: "user",
@@ -63,7 +61,6 @@ export class ProcessManager {
63
61
  proc.stdin?.write(payload + "\n");
64
62
  proc.stdin?.end();
65
63
  });
66
- // Parse stdout (stream-json): each line is a JSON event
67
64
  let stdoutBuffer = "";
68
65
  proc.stdout?.on("data", (chunk) => {
69
66
  stdoutBuffer += chunk.toString();
@@ -79,19 +76,15 @@ export class ProcessManager {
79
76
  catch { /* non-JSON line, skip */ }
80
77
  }
81
78
  });
82
- // Capture stderr for crash diagnostics
83
79
  let stderrBuffer = "";
84
80
  proc.stderr?.on("data", (chunk) => {
85
81
  stderrBuffer += chunk.toString();
86
82
  if (stderrBuffer.length > 10000)
87
83
  stderrBuffer = stderrBuffer.slice(-5000);
88
84
  });
89
- // Handle exit
90
85
  proc.on("close", async (code) => {
91
- // Clear timeout timer
92
86
  if (agent.timeoutTimer)
93
87
  clearTimeout(agent.timeoutTimer);
94
- // Flush remaining stdout
95
88
  if (stdoutBuffer.trim()) {
96
89
  try {
97
90
  const event = JSON.parse(stdoutBuffer);
@@ -135,41 +128,25 @@ export class ProcessManager {
135
128
  }
136
129
  }
137
130
  handleEvent(taskId, sessionId, event, agentClient) {
138
- // Extract text from assistant messages → post as agent chat messages
139
131
  if (event.type === "assistant" && Array.isArray(event.message?.content)) {
140
132
  for (const block of event.message.content) {
141
133
  if (block.type === "text" && block.text) {
142
- this.postMessage(taskId, sessionId, "agent", block.text, agentClient).catch(() => { });
134
+ agentClient.sendMessage(taskId, { agent_id: sessionId, role: "agent", content: block.text })
135
+ .catch((err) => console.error(`[ERROR] Failed to send message for task ${taskId}: ${err.message}`));
143
136
  }
144
137
  }
145
138
  }
146
- // Report usage on result event
147
139
  if (event.type === "result") {
148
140
  const cost = event.total_cost_usd || 0;
149
141
  const usage = event.usage || {};
150
142
  console.log(`[INFO] Agent result for task ${taskId}: cost=$${cost.toFixed(4)}`);
151
- const usageData = {
143
+ agentClient.updateAgentUsage(sessionId, {
152
144
  input_tokens: usage.input_tokens || 0,
153
145
  output_tokens: usage.output_tokens || 0,
154
146
  cache_read_tokens: usage.cache_read_input_tokens || 0,
155
147
  cache_creation_tokens: usage.cache_creation_input_tokens || 0,
156
148
  cost_micro_usd: Math.round(cost * 1_000_000),
157
- };
158
- if (agentClient) {
159
- agentClient.updateAgentUsage(usageData).catch(() => { });
160
- }
161
- else {
162
- this.client.updateAgentUsage(sessionId, usageData).catch(() => { });
163
- }
164
- }
165
- }
166
- async postMessage(taskId, sessionId, role, content, agentClient) {
167
- const body = { agent_id: sessionId, role, content };
168
- if (agentClient) {
169
- await agentClient.sendMessage(taskId, body);
170
- }
171
- else {
172
- await this.client.sendMessage(taskId, body);
149
+ }).catch((err) => console.error(`[ERROR] Failed to report usage for task ${taskId}: ${err.message}`));
173
150
  }
174
151
  }
175
152
  async releaseTask(taskId, retries = 3) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-kanban",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "CLI for Agent Kanban — agent-first cross-project task board",
5
5
  "type": "module",
6
6
  "bin": {
package/src/client.ts CHANGED
@@ -3,26 +3,22 @@ import { randomUUID } from "crypto";
3
3
  import type { UsageInfo } from "./types.js";
4
4
  import { getConfigValue } from "./config.js";
5
5
 
6
- export class ApiClient {
7
- private baseUrl: string;
8
- private apiKey: string;
6
+ export abstract class ApiClient {
7
+ protected baseUrl: string;
9
8
 
10
- constructor() {
11
- const url = getConfigValue("api-url");
12
- const key = getConfigValue("api-key");
13
- if (!url) throw new Error("API URL not configured. Run: agent-kanban config set api-url <url>");
14
- if (!key) throw new Error("API key not configured. Run: agent-kanban config set api-key <key>");
15
- this.baseUrl = url.replace(/\/$/, "");
16
- this.apiKey = key;
9
+ constructor(baseUrl: string) {
10
+ this.baseUrl = baseUrl.replace(/\/$/, "");
17
11
  }
18
12
 
13
+ protected abstract authorize(): Promise<string>;
14
+
19
15
  private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
20
- const url = `${this.baseUrl}${path}`;
21
- const res = await fetch(url, {
16
+ const authorization = await this.authorize();
17
+ const res = await fetch(`${this.baseUrl}${path}`, {
22
18
  method,
23
19
  headers: {
24
20
  "Content-Type": "application/json",
25
- Authorization: `Bearer ${this.apiKey}`,
21
+ Authorization: authorization,
26
22
  },
27
23
  body: body ? JSON.stringify(body) : undefined,
28
24
  signal: AbortSignal.timeout(10000),
@@ -110,56 +106,59 @@ export class ApiClient {
110
106
  }
111
107
  }
112
108
 
113
- export class AgentClient {
114
- private baseUrl: string;
109
+ export class MachineClient extends ApiClient {
110
+ private apiKey: string;
111
+
112
+ constructor() {
113
+ const url = getConfigValue("api-url");
114
+ const key = getConfigValue("api-key");
115
+ if (!url) throw new Error("API URL not configured. Run: agent-kanban config set api-url <url>");
116
+ if (!key) throw new Error("API key not configured. Run: agent-kanban config set api-key <key>");
117
+ super(url);
118
+ this.apiKey = key;
119
+ }
120
+
121
+ protected async authorize(): Promise<string> {
122
+ return `Bearer ${this.apiKey}`;
123
+ }
124
+ }
125
+
126
+ export class AgentClient extends ApiClient {
115
127
  private agentId: string;
116
128
  private privateKey: CryptoKey;
117
129
 
118
130
  constructor(baseUrl: string, agentId: string, privateKey: CryptoKey) {
119
- this.baseUrl = baseUrl.replace(/\/$/, "");
131
+ super(baseUrl);
120
132
  this.agentId = agentId;
121
133
  this.privateKey = privateKey;
122
134
  }
123
135
 
124
- private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
136
+ static async fromEnv(): Promise<AgentClient | null> {
137
+ const agentId = process.env.AK_AGENT_ID;
138
+ const keyJson = process.env.AK_AGENT_KEY;
139
+ const apiUrl = process.env.AK_API_URL;
140
+ if (!agentId || !keyJson || !apiUrl) return null;
141
+
142
+ const privateKey = await crypto.subtle.importKey(
143
+ "jwk", JSON.parse(keyJson), { name: "Ed25519" } as any, false, ["sign"],
144
+ );
145
+ return new AgentClient(apiUrl, agentId, privateKey);
146
+ }
147
+
148
+ protected async authorize(): Promise<string> {
125
149
  const jwt = await new SignJWT({ sub: this.agentId, jti: randomUUID(), aud: this.baseUrl })
126
150
  .setProtectedHeader({ alg: "EdDSA", typ: "agent+jwt" })
127
151
  .setIssuedAt()
128
152
  .setExpirationTime("60s")
129
153
  .sign(this.privateKey);
130
-
131
- const res = await fetch(`${this.baseUrl}${path}`, {
132
- method,
133
- headers: {
134
- "Content-Type": "application/json",
135
- Authorization: `Bearer ${jwt}`,
136
- },
137
- body: body ? JSON.stringify(body) : undefined,
138
- signal: AbortSignal.timeout(10000),
139
- });
140
-
141
- const data = await res.json() as T & { error?: { code: string; message: string } };
142
-
143
- if (!res.ok) {
144
- const msg = (data as any).error?.message || `HTTP ${res.status}`;
145
- throw new Error(msg);
146
- }
147
-
148
- return data;
149
- }
150
-
151
- // Tasks
152
- releaseTask(id: string) {
153
- return this.request("POST", `/api/tasks/${id}/release`);
154
- }
155
-
156
- // Agent usage
157
- updateAgentUsage(usage: { input_tokens: number; output_tokens: number; cache_read_tokens: number; cache_creation_tokens: number; cost_micro_usd: number }) {
158
- return this.request("PATCH", `/api/agents/${this.agentId}/usage`, usage);
154
+ return `Bearer ${jwt}`;
159
155
  }
156
+ }
160
157
 
161
- // Messages
162
- sendMessage(taskId: string, body: { agent_id: string; role: string; content: string }) {
163
- return this.request("POST", `/api/tasks/${taskId}/messages`, body);
164
- }
158
+ /**
159
+ * Returns AgentClient if running as a spawned agent (AK_AGENT_* env vars),
160
+ * otherwise MachineClient (API key from config).
161
+ */
162
+ export async function createClient(): Promise<ApiClient> {
163
+ return await AgentClient.fromEnv() ?? new MachineClient();
165
164
  }
@@ -1,7 +1,7 @@
1
1
  import { execSync } from "child_process";
2
2
  import { basename } from "path";
3
3
  import type { Command } from "commander";
4
- import { ApiClient } from "../client.js";
4
+ import { MachineClient } from "../client.js";
5
5
  import { setLink } from "../links.js";
6
6
 
7
7
  function getGitRepoRoot(): string {
@@ -35,7 +35,7 @@ export function registerLinkCommand(program: Command) {
35
35
  process.exit(1);
36
36
  }
37
37
 
38
- const client = new ApiClient();
38
+ const client = new MachineClient();
39
39
  let repo: any;
40
40
  try {
41
41
  repo = await client.createRepository({
package/src/daemon.ts CHANGED
@@ -3,7 +3,7 @@ import { join } from "path";
3
3
  import { hostname, platform, arch, release } from "os";
4
4
  import { execSync } from "child_process";
5
5
  import { randomUUID } from "crypto";
6
- import { ApiClient, AgentClient } from "./client.js";
6
+ import { MachineClient, AgentClient } from "./client.js";
7
7
  import { ProcessManager } from "./processManager.js";
8
8
  import { getLinks, findPathForRepository } from "./links.js";
9
9
  import { getConfigValue, setConfigValue, PID_FILE } from "./config.js";
@@ -37,7 +37,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<void> {
37
37
  }
38
38
  writeFileSync(PID_FILE, String(process.pid));
39
39
 
40
- const client = new ApiClient();
40
+ const client = new MachineClient();
41
41
  const links = getLinks();
42
42
  const linkedRepoCount = Object.keys(links).length;
43
43
 
@@ -135,6 +135,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<void> {
135
135
  { name: "Ed25519" } as any, true, ["sign", "verify"]
136
136
  );
137
137
  const pubKeyJwk = await crypto.subtle.exportKey("jwk", publicKey);
138
+ const privKeyJwk = await crypto.subtle.exportKey("jwk", privateKey);
138
139
  const pubKeyBase64 = pubKeyJwk.x;
139
140
 
140
141
  try {
@@ -165,8 +166,14 @@ export async function startDaemon(opts: DaemonOptions): Promise<void> {
165
166
  privateKey,
166
167
  );
167
168
 
169
+ const agentEnv = {
170
+ AK_AGENT_ID: sessionId,
171
+ AK_AGENT_KEY: JSON.stringify(privKeyJwk),
172
+ AK_API_URL: getConfigValue("api-url")!,
173
+ };
174
+
168
175
  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.`;
169
- await pm.spawnAgent(task.id, sessionId, repoDir, prompt, agentClient);
176
+ await pm.spawnAgent(task.id, sessionId, repoDir, prompt, agentClient, agentEnv);
170
177
 
171
178
  backoffMs = baseInterval;
172
179
  schedulePoll(1000);
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
3
  import { setConfigValue, getConfigValue } from "./config.js";
4
- import { ApiClient } from "./client.js";
4
+ import { type ApiClient, MachineClient, createClient } from "./client.js";
5
5
  import { getFormat, output, formatTaskList, formatBoard, formatAgentList, formatBoardList, formatRepositoryList } from "./output.js";
6
6
  import { registerLinkCommand } from "./commands/link.js";
7
7
  import { registerStartCommand } from "./commands/start.js";
@@ -51,7 +51,7 @@ taskCmd
51
51
  .option("--depends-on <ids>", "Comma-separated task IDs this depends on")
52
52
  .option("--format <format>", "Output format (json, text)")
53
53
  .action(async (opts) => {
54
- const client = new ApiClient();
54
+ const client = await createClient();
55
55
 
56
56
  const body: Record<string, unknown> = { title: opts.title };
57
57
  if (opts.description) body.description = opts.description;
@@ -80,7 +80,7 @@ taskCmd
80
80
  .option("--parent <id>", "Filter subtasks of a parent task")
81
81
  .option("--format <format>", "Output format (json, text)")
82
82
  .action(async (opts) => {
83
- const client = new ApiClient();
83
+ const client = await createClient();
84
84
  const params: Record<string, string> = {};
85
85
  if (opts.repo) params.repository_id = opts.repo;
86
86
  if (opts.status) params.status = opts.status;
@@ -98,7 +98,7 @@ taskCmd
98
98
  .option("--agent-name <name>", "Agent identity")
99
99
  .option("--format <format>", "Output format (json, text)")
100
100
  .action(async (id, opts) => {
101
- const client = new ApiClient();
101
+ const client = await createClient();
102
102
  const task = await client.claimTask(id, opts.agentName);
103
103
  const fmt = getFormat(opts.format);
104
104
  output(task, fmt, (t: any) => `Claimed task ${t.id}: ${t.title} (now in progress)`);
@@ -109,7 +109,7 @@ taskCmd
109
109
  .description("Add a log entry to a task")
110
110
  .option("--agent-name <name>", "Agent identity")
111
111
  .action(async (id, message, opts) => {
112
- const client = new ApiClient();
112
+ const client = await createClient();
113
113
  await client.addLog(id, message, opts.agentName);
114
114
  console.log("Log entry added.");
115
115
  });
@@ -120,7 +120,7 @@ taskCmd
120
120
  .option("--agent-name <name>", "Agent identity")
121
121
  .option("--format <format>", "Output format (json, text)")
122
122
  .action(async (id, opts) => {
123
- const client = new ApiClient();
123
+ const client = await createClient();
124
124
  const body: Record<string, unknown> = {};
125
125
  if (opts.agentName) body.agent_name = opts.agentName;
126
126
  const task = await client.cancelTask(id, body);
@@ -135,7 +135,7 @@ taskCmd
135
135
  .option("--agent-name <name>", "Agent identity")
136
136
  .option("--format <format>", "Output format (json, text)")
137
137
  .action(async (id, opts) => {
138
- const client = new ApiClient();
138
+ const client = await createClient();
139
139
  const body: Record<string, unknown> = {};
140
140
  if (opts.prUrl) body.pr_url = opts.prUrl;
141
141
  if (opts.agentName) body.agent_name = opts.agentName;
@@ -152,7 +152,7 @@ taskCmd
152
152
  .option("--agent-name <name>", "Agent identity")
153
153
  .option("--format <format>", "Output format (json, text)")
154
154
  .action(async (id, opts) => {
155
- const client = new ApiClient();
155
+ const client = await createClient();
156
156
  const body: Record<string, unknown> = {};
157
157
  if (opts.result) body.result = opts.result;
158
158
  if (opts.prUrl) body.pr_url = opts.prUrl;
@@ -171,7 +171,7 @@ agentCmd
171
171
  .description("List all agents")
172
172
  .option("--format <format>", "Output format (json, text)")
173
173
  .action(async (opts) => {
174
- const client = new ApiClient();
174
+ const client = new MachineClient();
175
175
  const agents = await client.listAgents();
176
176
  const fmt = getFormat(opts.format);
177
177
  output(agents, fmt, formatAgentList);
@@ -188,7 +188,7 @@ boardCmd
188
188
  .option("--description <desc>", "Board description")
189
189
  .option("--format <format>", "Output format (json, text)")
190
190
  .action(async (opts) => {
191
- const client = new ApiClient();
191
+ const client = new MachineClient();
192
192
  const board = await client.createBoard({ name: opts.name, description: opts.description });
193
193
  const fmt = getFormat(opts.format);
194
194
  output(board, fmt, (b) => `Created board ${b.id}: ${b.name}`);
@@ -199,7 +199,7 @@ boardCmd
199
199
  .description("List all boards")
200
200
  .option("--format <format>", "Output format (json, text)")
201
201
  .action(async (opts) => {
202
- const client = new ApiClient();
202
+ const client = new MachineClient();
203
203
  const boards = await client.listBoards();
204
204
  const fmt = getFormat(opts.format);
205
205
  output(boards, fmt, formatBoardList);
@@ -211,7 +211,7 @@ boardCmd
211
211
  .option("--board <name-or-id>", "Board name or ID (uses first if omitted)")
212
212
  .option("--format <format>", "Output format (json, text)")
213
213
  .action(async (opts) => {
214
- const client = new ApiClient();
214
+ const client = new MachineClient();
215
215
  let boardId: string;
216
216
 
217
217
  if (opts.board) {
@@ -251,7 +251,7 @@ repoCmd
251
251
  .requiredOption("--url <url>", "Clone URL")
252
252
  .option("--format <format>", "Output format (json, text)")
253
253
  .action(async (opts) => {
254
- const client = new ApiClient();
254
+ const client = new MachineClient();
255
255
  const repo = await client.createRepository({ name: opts.name, url: opts.url });
256
256
  const fmt = getFormat(opts.format);
257
257
  output(repo, fmt, (r) => `Added repository ${r.id}: ${r.name}`);
@@ -262,7 +262,7 @@ repoCmd
262
262
  .description("List repositories")
263
263
  .option("--format <format>", "Output format (json, text)")
264
264
  .action(async (opts) => {
265
- const client = new ApiClient();
265
+ const client = new MachineClient();
266
266
  const repos = await client.listRepositories();
267
267
  const fmt = getFormat(opts.format);
268
268
  output(repos, fmt, formatRepositoryList);
@@ -13,7 +13,7 @@ export interface AgentProcess {
13
13
  taskId: string;
14
14
  sessionId: string;
15
15
  process: ChildProcess;
16
- agentClient?: AgentClient;
16
+ agentClient: AgentClient;
17
17
  timeoutTimer?: ReturnType<typeof setTimeout>;
18
18
  }
19
19
 
@@ -44,7 +44,8 @@ export class ProcessManager {
44
44
  sessionId: string,
45
45
  cwd: string,
46
46
  taskContext: string,
47
- agentClient?: AgentClient,
47
+ agentClient: AgentClient,
48
+ agentEnv: Record<string, string>,
48
49
  ): Promise<void> {
49
50
  const args = [
50
51
  "--print",
@@ -61,7 +62,7 @@ export class ProcessManager {
61
62
  proc = spawn(this.agentCli, args, {
62
63
  cwd,
63
64
  stdio: ["pipe", "pipe", "pipe"],
64
- env: { ...process.env },
65
+ env: { ...process.env, ...agentEnv },
65
66
  });
66
67
  } catch (err: any) {
67
68
  console.error(`[ERROR] Failed to spawn ${this.agentCli}: ${err.message}`);
@@ -78,7 +79,6 @@ export class ProcessManager {
78
79
  const agent: AgentProcess = { taskId, sessionId, process: proc, agentClient };
79
80
  this.agents.set(taskId, agent);
80
81
 
81
- // Kill agent if it exceeds the task timeout
82
82
  if (this.taskTimeoutMs > 0) {
83
83
  agent.timeoutTimer = setTimeout(() => {
84
84
  console.warn(`[WARN] Agent for task ${taskId} exceeded timeout (${Math.round(this.taskTimeoutMs / 60000)}m), killing`);
@@ -86,7 +86,6 @@ export class ProcessManager {
86
86
  }, this.taskTimeoutMs);
87
87
  }
88
88
 
89
- // Send task context as JSON message via stdin, then close
90
89
  proc.on("spawn", () => {
91
90
  const payload = JSON.stringify({
92
91
  type: "user",
@@ -96,7 +95,6 @@ export class ProcessManager {
96
95
  proc.stdin?.end();
97
96
  });
98
97
 
99
- // Parse stdout (stream-json): each line is a JSON event
100
98
  let stdoutBuffer = "";
101
99
  proc.stdout?.on("data", (chunk: Buffer) => {
102
100
  stdoutBuffer += chunk.toString();
@@ -111,19 +109,15 @@ export class ProcessManager {
111
109
  }
112
110
  });
113
111
 
114
- // Capture stderr for crash diagnostics
115
112
  let stderrBuffer = "";
116
113
  proc.stderr?.on("data", (chunk: Buffer) => {
117
114
  stderrBuffer += chunk.toString();
118
115
  if (stderrBuffer.length > 10000) stderrBuffer = stderrBuffer.slice(-5000);
119
116
  });
120
117
 
121
- // Handle exit
122
118
  proc.on("close", async (code) => {
123
- // Clear timeout timer
124
119
  if (agent.timeoutTimer) clearTimeout(agent.timeoutTimer);
125
120
 
126
- // Flush remaining stdout
127
121
  if (stdoutBuffer.trim()) {
128
122
  try {
129
123
  const event = JSON.parse(stdoutBuffer);
@@ -169,41 +163,26 @@ export class ProcessManager {
169
163
  }
170
164
  }
171
165
 
172
- private handleEvent(taskId: string, sessionId: string, event: any, agentClient?: AgentClient): void {
173
- // Extract text from assistant messages → post as agent chat messages
166
+ private handleEvent(taskId: string, sessionId: string, event: any, agentClient: AgentClient): void {
174
167
  if (event.type === "assistant" && Array.isArray(event.message?.content)) {
175
168
  for (const block of event.message.content) {
176
169
  if (block.type === "text" && block.text) {
177
- this.postMessage(taskId, sessionId, "agent", block.text, agentClient).catch(() => {});
170
+ agentClient.sendMessage(taskId, { agent_id: sessionId, role: "agent", content: block.text })
171
+ .catch((err: any) => console.error(`[ERROR] Failed to send message for task ${taskId}: ${err.message}`));
178
172
  }
179
173
  }
180
174
  }
181
- // Report usage on result event
182
175
  if (event.type === "result") {
183
176
  const cost = event.total_cost_usd || 0;
184
177
  const usage = event.usage || {};
185
178
  console.log(`[INFO] Agent result for task ${taskId}: cost=$${cost.toFixed(4)}`);
186
- const usageData = {
179
+ agentClient.updateAgentUsage(sessionId, {
187
180
  input_tokens: usage.input_tokens || 0,
188
181
  output_tokens: usage.output_tokens || 0,
189
182
  cache_read_tokens: usage.cache_read_input_tokens || 0,
190
183
  cache_creation_tokens: usage.cache_creation_input_tokens || 0,
191
184
  cost_micro_usd: Math.round(cost * 1_000_000),
192
- };
193
- if (agentClient) {
194
- agentClient.updateAgentUsage(usageData).catch(() => {});
195
- } else {
196
- this.client.updateAgentUsage(sessionId, usageData).catch(() => {});
197
- }
198
- }
199
- }
200
-
201
- private async postMessage(taskId: string, sessionId: string, role: string, content: string, agentClient?: AgentClient): Promise<void> {
202
- const body = { agent_id: sessionId, role, content };
203
- if (agentClient) {
204
- await agentClient.sendMessage(taskId, body);
205
- } else {
206
- await this.client.sendMessage(taskId, body);
185
+ }).catch((err: any) => console.error(`[ERROR] Failed to report usage for task ${taskId}: ${err.message}`));
207
186
  }
208
187
  }
209
188