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.
@@ -0,0 +1,222 @@
1
+ import { spawn, type ChildProcess } from "child_process";
2
+ import type { ApiClient, AgentClient } from "./client.js";
3
+
4
+ // Agent Process Lifecycle:
5
+ // SPAWN → stdin task notification → RUNNING
6
+ // agent claims task via CLI, works, completes via CLI
7
+ // stdout (stream-json) → parse events → POST /messages
8
+ // EXIT(0) → log success → cleanup
9
+ // EXIT(N) → POST /release (crash recovery) → cleanup
10
+ // KILL (shutdown) → POST /release → cleanup
11
+
12
+ export interface AgentProcess {
13
+ taskId: string;
14
+ sessionId: string;
15
+ process: ChildProcess;
16
+ agentClient?: AgentClient;
17
+ timeoutTimer?: ReturnType<typeof setTimeout>;
18
+ }
19
+
20
+ export class ProcessManager {
21
+ private agents = new Map<string, AgentProcess>();
22
+ private client: ApiClient;
23
+ private agentCli: string;
24
+ private onSlotFreed: () => void;
25
+ private taskTimeoutMs: number;
26
+
27
+ constructor(client: ApiClient, agentCli: string, onSlotFreed: () => void, taskTimeoutMs = 2 * 60 * 60 * 1000) {
28
+ this.client = client;
29
+ this.agentCli = agentCli;
30
+ this.onSlotFreed = onSlotFreed;
31
+ this.taskTimeoutMs = taskTimeoutMs;
32
+ }
33
+
34
+ get activeCount(): number {
35
+ return this.agents.size;
36
+ }
37
+
38
+ hasTask(taskId: string): boolean {
39
+ return this.agents.has(taskId);
40
+ }
41
+
42
+ async spawnAgent(
43
+ taskId: string,
44
+ sessionId: string,
45
+ cwd: string,
46
+ taskContext: string,
47
+ agentClient?: AgentClient,
48
+ ): Promise<void> {
49
+ const args = [
50
+ "--print",
51
+ "--verbose",
52
+ "--input-format", "stream-json",
53
+ "--output-format", "stream-json",
54
+ "--dangerously-skip-permissions",
55
+ "--session-id", sessionId,
56
+ "-w",
57
+ ];
58
+
59
+ let proc: ChildProcess;
60
+ try {
61
+ proc = spawn(this.agentCli, args, {
62
+ cwd,
63
+ stdio: ["pipe", "pipe", "pipe"],
64
+ env: { ...process.env },
65
+ });
66
+ } catch (err: any) {
67
+ console.error(`[ERROR] Failed to spawn ${this.agentCli}: ${err.message}`);
68
+ await this.releaseTask(taskId);
69
+ return;
70
+ }
71
+
72
+ if (!proc.pid) {
73
+ console.error(`[ERROR] ${this.agentCli} not found or failed to start`);
74
+ await this.releaseTask(taskId);
75
+ return;
76
+ }
77
+
78
+ const agent: AgentProcess = { taskId, sessionId, process: proc, agentClient };
79
+ this.agents.set(taskId, agent);
80
+
81
+ // Kill agent if it exceeds the task timeout
82
+ if (this.taskTimeoutMs > 0) {
83
+ agent.timeoutTimer = setTimeout(() => {
84
+ console.warn(`[WARN] Agent for task ${taskId} exceeded timeout (${Math.round(this.taskTimeoutMs / 60000)}m), killing`);
85
+ proc.kill("SIGTERM");
86
+ }, this.taskTimeoutMs);
87
+ }
88
+
89
+ // Send task context as JSON message via stdin, then close
90
+ proc.on("spawn", () => {
91
+ const payload = JSON.stringify({
92
+ type: "user",
93
+ message: { role: "user", content: taskContext },
94
+ });
95
+ proc.stdin?.write(payload + "\n");
96
+ proc.stdin?.end();
97
+ });
98
+
99
+ // Parse stdout (stream-json): each line is a JSON event
100
+ let stdoutBuffer = "";
101
+ proc.stdout?.on("data", (chunk: Buffer) => {
102
+ stdoutBuffer += chunk.toString();
103
+ const lines = stdoutBuffer.split("\n");
104
+ stdoutBuffer = lines.pop() || "";
105
+ for (const line of lines) {
106
+ if (!line.trim()) continue;
107
+ try {
108
+ const event = JSON.parse(line);
109
+ this.handleEvent(taskId, sessionId, event, agentClient);
110
+ } catch { /* non-JSON line, skip */ }
111
+ }
112
+ });
113
+
114
+ // Capture stderr for crash diagnostics
115
+ let stderrBuffer = "";
116
+ proc.stderr?.on("data", (chunk: Buffer) => {
117
+ stderrBuffer += chunk.toString();
118
+ if (stderrBuffer.length > 10000) stderrBuffer = stderrBuffer.slice(-5000);
119
+ });
120
+
121
+ // Handle exit
122
+ proc.on("close", async (code) => {
123
+ // Clear timeout timer
124
+ if (agent.timeoutTimer) clearTimeout(agent.timeoutTimer);
125
+
126
+ // Flush remaining stdout
127
+ if (stdoutBuffer.trim()) {
128
+ try {
129
+ const event = JSON.parse(stdoutBuffer);
130
+ this.handleEvent(taskId, sessionId, event, agentClient);
131
+ } catch { /* skip */ }
132
+ }
133
+
134
+ this.agents.delete(taskId);
135
+
136
+ if (code === 0) {
137
+ console.log(`[INFO] Agent finished task ${taskId}`);
138
+ } else {
139
+ console.warn(`[WARN] Agent crashed on task ${taskId} (exit ${code})`);
140
+ if (stderrBuffer.trim()) {
141
+ const lastLines = stderrBuffer.trim().split("\n").slice(-5).join("\n");
142
+ console.warn(` stderr: ${lastLines}`);
143
+ }
144
+ await this.releaseTask(taskId);
145
+ }
146
+
147
+ this.onSlotFreed();
148
+ });
149
+
150
+ proc.on("error", async (err) => {
151
+ console.error(`[ERROR] Agent process error for task ${taskId}: ${err.message}`);
152
+ if (agent.timeoutTimer) clearTimeout(agent.timeoutTimer);
153
+ this.agents.delete(taskId);
154
+ await this.releaseTask(taskId);
155
+ this.onSlotFreed();
156
+ });
157
+
158
+ console.log(`[INFO] Spawned ${this.agentCli} (session=${sessionId}) for task ${taskId} in ${cwd}`);
159
+ }
160
+
161
+ async killAll(): Promise<void> {
162
+ const entries = [...this.agents.entries()];
163
+ for (const [taskId, agent] of entries) {
164
+ console.log(`[INFO] Killing agent for task ${taskId}`);
165
+ if (agent.timeoutTimer) clearTimeout(agent.timeoutTimer);
166
+ agent.process.kill("SIGTERM");
167
+ this.agents.delete(taskId);
168
+ await this.releaseTask(taskId);
169
+ }
170
+ }
171
+
172
+ private handleEvent(taskId: string, sessionId: string, event: any, agentClient?: AgentClient): void {
173
+ // Extract text from assistant messages → post as agent chat messages
174
+ if (event.type === "assistant" && Array.isArray(event.message?.content)) {
175
+ for (const block of event.message.content) {
176
+ if (block.type === "text" && block.text) {
177
+ this.postMessage(taskId, sessionId, "agent", block.text, agentClient).catch(() => {});
178
+ }
179
+ }
180
+ }
181
+ // Report usage on result event
182
+ if (event.type === "result") {
183
+ const cost = event.total_cost_usd || 0;
184
+ const usage = event.usage || {};
185
+ console.log(`[INFO] Agent result for task ${taskId}: cost=$${cost.toFixed(4)}`);
186
+ const usageData = {
187
+ input_tokens: usage.input_tokens || 0,
188
+ output_tokens: usage.output_tokens || 0,
189
+ cache_read_tokens: usage.cache_read_input_tokens || 0,
190
+ cache_creation_tokens: usage.cache_creation_input_tokens || 0,
191
+ 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);
207
+ }
208
+ }
209
+
210
+ private async releaseTask(taskId: string, retries = 3): Promise<void> {
211
+ for (let i = 0; i < retries; i++) {
212
+ try {
213
+ await this.client.releaseTask(taskId);
214
+ return;
215
+ } catch (err: any) {
216
+ console.error(`[WARN] Failed to release task ${taskId} (attempt ${i + 1}/${retries}): ${err.message}`);
217
+ if (i < retries - 1) await new Promise((r) => setTimeout(r, 1000 * (i + 1)));
218
+ }
219
+ }
220
+ console.error(`[ERROR] Could not release task ${taskId} after ${retries} attempts. Task will remain locked until stale detection.`);
221
+ }
222
+ }
package/src/types.ts ADDED
@@ -0,0 +1,12 @@
1
+ export interface UsageWindow {
2
+ utilization: number;
3
+ resets_at: string;
4
+ }
5
+
6
+ export interface UsageInfo {
7
+ five_hour?: UsageWindow;
8
+ seven_day?: UsageWindow;
9
+ seven_day_sonnet?: UsageWindow;
10
+ seven_day_opus?: UsageWindow;
11
+ updated_at: string;
12
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { execSync } from "child_process";
2
+ import { readFileSync } from "fs";
3
+ import { join } from "path";
4
+ import { homedir, platform } from "os";
5
+ import type { UsageInfo } from "./types.js";
6
+
7
+ const CREDENTIALS_PATH = join(homedir(), ".claude", ".credentials.json");
8
+ const USAGE_API = "https://api.anthropic.com/api/oauth/usage";
9
+ const CACHE_TTL_MS = 5 * 60 * 1000;
10
+
11
+ let cachedUsage: UsageInfo | null = null;
12
+ let cachedAt = 0;
13
+ let cachedToken: string | null = null;
14
+
15
+ function parseToken(raw: string): string | null {
16
+ const creds = JSON.parse(raw);
17
+ return creds.claudeAiOauth?.accessToken || null;
18
+ }
19
+
20
+ function readOAuthToken(): string | null {
21
+ if (cachedToken) return cachedToken;
22
+ try {
23
+ if (platform() === "darwin") {
24
+ const raw = execSync('security find-generic-password -s "Claude Code-credentials" -w', { stdio: ["pipe", "pipe", "pipe"] }).toString().trim();
25
+ cachedToken = parseToken(raw);
26
+ } else {
27
+ cachedToken = parseToken(readFileSync(CREDENTIALS_PATH, "utf-8"));
28
+ }
29
+ return cachedToken;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export async function getUsage(): Promise<UsageInfo | null> {
36
+ if (cachedUsage && Date.now() - cachedAt < CACHE_TTL_MS) {
37
+ return cachedUsage;
38
+ }
39
+
40
+ const token = readOAuthToken();
41
+ if (!token) return cachedUsage;
42
+
43
+ try {
44
+ const res = await fetch(USAGE_API, {
45
+ headers: {
46
+ Authorization: `Bearer ${token}`,
47
+ "anthropic-beta": "oauth-2025-04-20",
48
+ },
49
+ signal: AbortSignal.timeout(5000),
50
+ });
51
+
52
+ if (!res.ok) {
53
+ console.error(`[WARN] Usage API returned ${res.status}`);
54
+ return cachedUsage;
55
+ }
56
+
57
+ const data = await res.json() as Record<string, { utilization: number; resets_at: string }>;
58
+ cachedUsage = {
59
+ ...data.five_hour && { five_hour: data.five_hour },
60
+ ...data.seven_day && { seven_day: data.seven_day },
61
+ ...data.seven_day_sonnet && { seven_day_sonnet: data.seven_day_sonnet },
62
+ ...data.seven_day_opus && { seven_day_opus: data.seven_day_opus },
63
+ updated_at: new Date().toISOString(),
64
+ };
65
+ cachedAt = Date.now();
66
+ return cachedUsage;
67
+ } catch (err: any) {
68
+ console.error(`[WARN] Failed to fetch usage: ${err.message}`);
69
+ return cachedUsage;
70
+ }
71
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "nodenext",
5
+ "moduleResolution": "nodenext",
6
+ "declaration": true,
7
+ "outDir": "./dist",
8
+ "rootDir": "./src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true
12
+ },
13
+ "include": ["src"]
14
+ }