@gethmy/agent 1.0.1 → 1.0.3

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/README.md CHANGED
@@ -4,10 +4,11 @@ Push-based agent daemon for [Harmony](https://gethmy.com). Watches board assignm
4
4
 
5
5
  ## Prerequisites
6
6
 
7
- - [Node.js](https://nodejs.org) >= 18 or [Bun](https://bun.sh) >= 1.0.0
8
- - [Claude CLI](https://docs.anthropic.com/en/docs/claude-code)
7
+ - [Node.js](https://nodejs.org) >= 20 or [Bun](https://bun.sh) >= 1.0
8
+ - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI installed
9
9
  - Git
10
10
  - A [Harmony](https://gethmy.com) account with an API key
11
+ - [@gethmy/mcp](https://www.npmjs.com/package/@gethmy/mcp) configured (`npx @gethmy/mcp setup`)
11
12
 
12
13
  ## Installation
13
14
 
@@ -1,9 +1,17 @@
1
1
  import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
2
2
  import type { Card, Label } from "@harmony/shared";
3
3
  /**
4
- * Check if a card already has the approved label (case-insensitive).
4
+ * Build a label lookup map from board-level label definitions.
5
5
  */
6
- export declare function hasApprovedLabel(cardLabels: Label[], approvedLabel: string): boolean;
6
+ export declare function buildLabelMap(boardLabels: Label[]): Map<string, Label>;
7
+ /**
8
+ * Resolve a card's `labelIds` to full Label objects using a label map.
9
+ */
10
+ export declare function resolveCardLabels(card: Card, labelMap: Map<string, Label>): Label[];
11
+ /**
12
+ * Check if a card has a label by name (case-insensitive).
13
+ */
14
+ export declare function hasLabel(cardLabels: Label[], labelName: string): boolean;
7
15
  /**
8
16
  * Move a card to a column by name. Fetches the board to resolve column ID.
9
17
  */
@@ -1,10 +1,29 @@
1
1
  import { log } from "./log.js";
2
2
  const TAG = "board";
3
3
  /**
4
- * Check if a card already has the approved label (case-insensitive).
4
+ * Build a label lookup map from board-level label definitions.
5
5
  */
6
- export function hasApprovedLabel(cardLabels, approvedLabel) {
7
- return cardLabels.some((l) => l.name.toLowerCase() === approvedLabel.toLowerCase());
6
+ export function buildLabelMap(boardLabels) {
7
+ const map = new Map();
8
+ for (const label of boardLabels) {
9
+ map.set(label.id, label);
10
+ }
11
+ return map;
12
+ }
13
+ /**
14
+ * Resolve a card's `labelIds` to full Label objects using a label map.
15
+ */
16
+ export function resolveCardLabels(card, labelMap) {
17
+ const ids = card.labelIds ?? [];
18
+ return ids
19
+ .map((id) => labelMap.get(id))
20
+ .filter((l) => l !== undefined);
21
+ }
22
+ /**
23
+ * Check if a card has a label by name (case-insensitive).
24
+ */
25
+ export function hasLabel(cardLabels, labelName) {
26
+ return cardLabels.some((l) => l.name.toLowerCase() === labelName.toLowerCase());
8
27
  }
9
28
  /**
10
29
  * Move a card to a column by name. Fetches the board to resolve column ID.
@@ -1,7 +1,14 @@
1
1
  import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
2
2
  import type { Card } from "@harmony/shared";
3
+ import type { CostUpdate } from "./stream-parser.js";
3
4
  import { type AgentConfig } from "./types.js";
5
+ export interface SessionStats {
6
+ filesEdited: number;
7
+ filesRead: number;
8
+ toolCalls: number;
9
+ cost: CostUpdate | null;
10
+ }
4
11
  /**
5
12
  * Post-work pipeline: push branch, create PR, move card, post summary.
6
13
  */
7
- export declare function runCompletion(client: HarmonyApiClient, card: Card, branchName: string, worktreePath: string, config: AgentConfig, workerId?: number): Promise<void>;
14
+ export declare function runCompletion(client: HarmonyApiClient, card: Card, branchName: string, worktreePath: string, config: AgentConfig, workerId?: number, sessionStats?: SessionStats): Promise<void>;
@@ -6,11 +6,10 @@ import { AGENT_NAME, agentIdentifier } from "./types.js";
6
6
  import { attemptAutoFix, reportFindings, runVerification, } from "./verification.js";
7
7
  import { cleanupWorktree } from "./worktree.js";
8
8
  const TAG = "completion";
9
- // ============ COMPLETION PIPELINE ============
10
9
  /**
11
10
  * Post-work pipeline: push branch, create PR, move card, post summary.
12
11
  */
13
- export async function runCompletion(client, card, branchName, worktreePath, config, workerId = 0) {
12
+ export async function runCompletion(client, card, branchName, worktreePath, config, workerId = 0, sessionStats) {
14
13
  // Check if there are any commits on the branch
15
14
  const hasCommits = checkHasCommits(worktreePath, config.worktree.baseBranch);
16
15
  if (!hasCommits) {
@@ -76,7 +75,7 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
76
75
  }
77
76
  // 5. Post summary — always includes branch, optionally PR link
78
77
  if (config.completion.postSummary) {
79
- await postSummary(client, card, branchName, worktreePath, prUrl, config.worktree.baseBranch);
78
+ await postSummary(client, card, branchName, worktreePath, prUrl, config.worktree.baseBranch, sessionStats);
80
79
  }
81
80
  // 6. End agent session
82
81
  await client.endAgentSession(card.id, {
@@ -96,7 +95,7 @@ function checkHasCommits(worktreePath, baseBranch) {
96
95
  return false;
97
96
  }
98
97
  }
99
- async function postSummary(client, card, branchName, worktreePath, prUrl, baseBranch) {
98
+ async function postSummary(client, card, branchName, worktreePath, prUrl, baseBranch, sessionStats) {
100
99
  // Build commit summary
101
100
  let commitLog = "";
102
101
  try {
@@ -112,6 +111,17 @@ async function postSummary(client, card, branchName, worktreePath, prUrl, baseBr
112
111
  parts.push(`PR: ${prUrl}`);
113
112
  }
114
113
  parts.push(`Branch: \`${branchName}\``);
114
+ if (sessionStats) {
115
+ const statParts = [];
116
+ statParts.push(`${sessionStats.toolCalls} tool calls`);
117
+ statParts.push(`${sessionStats.filesEdited} files edited`);
118
+ statParts.push(`${sessionStats.filesRead} files read`);
119
+ if (sessionStats.cost) {
120
+ statParts.push(`$${sessionStats.cost.totalCostUsd.toFixed(2)} cost`);
121
+ statParts.push(`${sessionStats.cost.numTurns} turns`);
122
+ }
123
+ parts.push(`Stats: ${statParts.join(" · ")}`);
124
+ }
115
125
  if (commitLog) {
116
126
  parts.push(`\n\`\`\`\n${commitLog}\n\`\`\``);
117
127
  }
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { hasApprovedLabel } from "./board-helpers.js";
2
+ import { buildLabelMap, hasLabel, resolveCardLabels } from "./board-helpers.js";
3
3
  import { createApiClient, fetchRealtimeCredentials, loadDaemonConfig, } from "./config.js";
4
4
  import { detectGitProvider, validateGitProviderCli } from "./git-pr.js";
5
5
  import { log } from "./log.js";
@@ -101,9 +101,11 @@ async function main() {
101
101
  if (config.agent.review.enabled && config.agent.review.mergeMonitor) {
102
102
  mergeMonitor = new MergeMonitor(client, config.projectId, config.agent);
103
103
  }
104
- // Create watcher (broadcast events from harmony-api)
104
+ // Create watcher (broadcast events from harmony-api + agent commands from UI)
105
105
  const watcher = new Watcher(realtimeCreds, config.projectId, async (event) => {
106
106
  await handleBroadcast(event, client, pool, config, agentUserId);
107
+ }, async (command) => {
108
+ await pool.handleAgentCommand(command.cardId, command.command);
107
109
  });
108
110
  // Wire up shutdown
109
111
  let shuttingDown = false;
@@ -160,7 +162,7 @@ async function handleBroadcast(event, client, pool, config, agentUserId) {
160
162
  */
161
163
  async function tryEnqueueCard(cardId, client, pool, config) {
162
164
  const { card } = (await client.getCard(cardId));
163
- const board = await client.getBoard(config.projectId);
165
+ const board = await client.getBoard(config.projectId, { summary: true });
164
166
  const columns = board.columns;
165
167
  const column = columns.find((c) => c.id === card.column_id);
166
168
  if (!column) {
@@ -177,12 +179,14 @@ async function tryEnqueueCard(cardId, client, pool, config) {
177
179
  return;
178
180
  }
179
181
  const mode = isReviewColumn ? "review" : "implement";
180
- const cardLabels = card.labels ?? [];
182
+ // Resolve card labels from labelIds using board-level label definitions
183
+ const labelMap = buildLabelMap((board.labels ?? []));
184
+ const cardLabels = resolveCardLabels(card, labelMap);
181
185
  const subtasks = card.subtasks ?? [];
182
186
  // Skip already-approved cards in review mode
183
187
  if (mode === "review" &&
184
188
  config.agent.review.approvedLabel &&
185
- hasApprovedLabel(cardLabels, config.agent.review.approvedLabel)) {
189
+ hasLabel(cardLabels, config.agent.review.approvedLabel)) {
186
190
  log.debug(TAG, `Card #${card.short_id} already has "${config.agent.review.approvedLabel}" — skipping review`);
187
191
  return;
188
192
  }
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- import { addLabelByName, hasApprovedLabel, moveCardToColumn, } from "./board-helpers.js";
3
+ import { addLabelByName, buildLabelMap, hasLabel, moveCardToColumn, resolveCardLabels, } from "./board-helpers.js";
4
4
  import { checkPrMergeStatus, detectGitProvider, extractPrUrl, } from "./git-pr.js";
5
5
  import { log } from "./log.js";
6
6
  import { extractBranchFromDescription } from "./review-worktree.js";
@@ -54,27 +54,39 @@ export class MergeMonitor {
54
54
  }
55
55
  async tick() {
56
56
  try {
57
- const board = await this.client.getBoard(this.projectId);
57
+ const board = await this.client.getBoard(this.projectId, {
58
+ labelName: this.config.review.approvedLabel,
59
+ });
58
60
  const cards = (board.cards ?? []);
59
61
  const columns = (board.columns ?? []);
62
+ // Build label lookup (id → Label) to resolve card.labelIds
63
+ const labelMap = buildLabelMap((board.labels ?? []));
60
64
  // Find review column IDs
61
65
  const reviewColumnIds = new Set(columns
62
66
  .filter((c) => this.config.review.pickupColumns.some((name) => name.toLowerCase() === c.name.toLowerCase()))
63
67
  .map((c) => c.id));
64
- // Filter cards in review columns that have the "Ready to Merge" label
68
+ // Cards are already pre-filtered by the approved label server-side via
69
+ // the `labelName` param. The column + label checks below are kept as
70
+ // defense-in-depth against API changes or stale cache.
65
71
  const approvedLabel = this.config.review.approvedLabel;
66
- const candidates = cards.filter((c) => !c.archived_at &&
67
- reviewColumnIds.has(c.column_id) &&
68
- hasApprovedLabel((c.labels ?? []), approvedLabel));
69
- if (candidates.length === 0) {
72
+ const candidatesWithLabels = [];
73
+ for (const c of cards) {
74
+ if (c.archived_at || !reviewColumnIds.has(c.column_id))
75
+ continue;
76
+ const labels = resolveCardLabels(c, labelMap);
77
+ if (hasLabel(labels, approvedLabel)) {
78
+ candidatesWithLabels.push({ card: c, labels });
79
+ }
80
+ }
81
+ if (candidatesWithLabels.length === 0) {
70
82
  log.debug(TAG, "No Ready to Merge cards found");
71
83
  return;
72
84
  }
73
85
  // Process max 5 per tick to respect rate limits
74
- const batch = candidates.slice(0, 5);
86
+ const batch = candidatesWithLabels.slice(0, 5);
75
87
  log.debug(TAG, `Checking ${batch.length} Ready to Merge card(s)`);
76
88
  // Check PR states concurrently (async, non-blocking)
77
- const results = await Promise.allSettled(batch.map(async (card) => {
89
+ const results = await Promise.allSettled(batch.map(async ({ card, labels }) => {
78
90
  const prUrl = extractPrUrl(card.description ?? null);
79
91
  if (!prUrl) {
80
92
  log.debug(TAG, `#${card.short_id} has no PR URL — skipping`);
@@ -83,7 +95,7 @@ export class MergeMonitor {
83
95
  const state = await checkPrMergeStatus(prUrl, this.cwd, this.provider);
84
96
  if (state === "merged") {
85
97
  log.info(TAG, `#${card.short_id} PR merged — completing`);
86
- await this.completeMergedCard(card);
98
+ await this.completeMergedCard(card, labels);
87
99
  }
88
100
  else {
89
101
  log.debug(TAG, `#${card.short_id} PR state: ${state}`);
@@ -99,7 +111,7 @@ export class MergeMonitor {
99
111
  log.error(TAG, `Tick failed: ${err instanceof Error ? err.message : err}`);
100
112
  }
101
113
  }
102
- async completeMergedCard(card) {
114
+ async completeMergedCard(card, resolvedLabels) {
103
115
  // 1. Move to Done — bail if this fails since subsequent steps assume card is in Done
104
116
  try {
105
117
  await moveCardToColumn(this.client, card, this.config.review.moveToColumn);
@@ -112,8 +124,7 @@ export class MergeMonitor {
112
124
  await addLabelByName(this.client, card, this.config.review.mergedLabel, this.config.review.mergedLabelColor);
113
125
  // 3. Remove "Ready to Merge" label
114
126
  const approvedLabelName = this.config.review.approvedLabel.toLowerCase();
115
- const cardLabels = (card.labels ?? []);
116
- const approvedLabelObj = cardLabels.find((l) => l.name.toLowerCase() === approvedLabelName);
127
+ const approvedLabelObj = resolvedLabels.find((l) => l.name.toLowerCase() === approvedLabelName);
117
128
  if (approvedLabelObj) {
118
129
  try {
119
130
  await this.client.removeLabelFromCard(card.id, approvedLabelObj.id);
@@ -123,19 +134,20 @@ export class MergeMonitor {
123
134
  log.warn(TAG, `Failed to remove label: ${err instanceof Error ? err.message : err}`);
124
135
  }
125
136
  }
126
- // 4. Append merge timestamp to description (idempotent)
137
+ // 4. Mark done + append merge timestamp to description (idempotent)
127
138
  const existing = card.description || "";
128
- if (!existing.includes("Merged at")) {
129
- try {
139
+ const alreadyStamped = existing.includes("Merged at");
140
+ try {
141
+ const update = { done: true };
142
+ if (!alreadyStamped) {
130
143
  const timestamp = new Date().toISOString();
131
144
  const separator = existing ? "\n" : "";
132
- await this.client.updateCard(card.id, {
133
- description: `${existing}${separator}Merged at ${timestamp}`,
134
- });
135
- }
136
- catch (err) {
137
- log.warn(TAG, `Failed to update description: ${err instanceof Error ? err.message : err}`);
145
+ update.description = `${existing}${separator}Merged at ${timestamp}`;
138
146
  }
147
+ await this.client.updateCard(card.id, update);
148
+ }
149
+ catch (err) {
150
+ log.warn(TAG, `Failed to update card: ${err instanceof Error ? err.message : err}`);
139
151
  }
140
152
  // 5. Best-effort: clean up local branch (uses shared extraction with validation)
141
153
  const branchName = extractBranchFromDescription(card.description);
package/dist/pool.d.ts CHANGED
@@ -27,6 +27,10 @@ export declare class Pool {
27
27
  * Get all card IDs that are either queued or active.
28
28
  */
29
29
  knownCardIds(): Set<string>;
30
+ /**
31
+ * Handle an agent command (pause/resume/stop) for a specific card.
32
+ */
33
+ handleAgentCommand(cardId: string, command: "pause" | "resume" | "stop"): Promise<void>;
30
34
  /**
31
35
  * Gracefully shutdown all workers.
32
36
  */
package/dist/pool.js CHANGED
@@ -99,6 +99,29 @@ export class Pool {
99
99
  }
100
100
  return ids;
101
101
  }
102
+ /**
103
+ * Handle an agent command (pause/resume/stop) for a specific card.
104
+ */
105
+ async handleAgentCommand(cardId, command) {
106
+ const worker = this.implWorkers.find((w) => w.cardId === cardId && w.isActive) ??
107
+ this.reviewWorkers.find((w) => w.cardId === cardId && w.isActive);
108
+ if (!worker) {
109
+ log.debug(TAG, `No active worker for card ${cardId}, ignoring ${command}`);
110
+ return;
111
+ }
112
+ log.info(TAG, `Agent command: ${command} → worker ${worker.id} (card ${cardId})`);
113
+ switch (command) {
114
+ case "pause":
115
+ await worker.pause();
116
+ break;
117
+ case "resume":
118
+ await worker.resume();
119
+ break;
120
+ case "stop":
121
+ await worker.cancel();
122
+ break;
123
+ }
124
+ }
102
125
  /**
103
126
  * Gracefully shutdown all workers.
104
127
  */
@@ -1,5 +1,5 @@
1
1
  import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
2
- import type { StreamParser } from "./stream-parser.js";
2
+ import type { CostUpdate, StreamParser } from "./stream-parser.js";
3
3
  export declare class ProgressTracker {
4
4
  private client;
5
5
  private cardId;
@@ -10,11 +10,17 @@ export declare class ProgressTracker {
10
10
  private hasEdited;
11
11
  private lastUpdateAt;
12
12
  private pendingUpdate;
13
+ private pendingTask;
13
14
  private heartbeatTimer;
14
15
  private stopped;
16
+ private lastAction;
15
17
  private subtaskTotal;
16
18
  private subtaskCompleted;
17
19
  private subtaskMode;
20
+ private filesEdited;
21
+ private filesRead;
22
+ private lastCost;
23
+ private recentActions;
18
24
  constructor(client: HarmonyApiClient, cardId: string, workerId: number, subtasks: {
19
25
  completed: boolean;
20
26
  }[]);
@@ -26,14 +32,33 @@ export declare class ProgressTracker {
26
32
  * Stop all timers and flush any pending update.
27
33
  */
28
34
  stop(): void;
35
+ /** Get a summary of the session stats. */
36
+ get stats(): {
37
+ filesEdited: number;
38
+ filesRead: number;
39
+ toolCalls: number;
40
+ cost: CostUpdate | null;
41
+ };
29
42
  private onToolStart;
30
43
  private onToolEnd;
44
+ private onText;
31
45
  private transitionTo;
32
46
  private incrementProgress;
33
47
  private currentTaskLabel;
48
+ /**
49
+ * Build a human-readable description of what a tool call is doing.
50
+ */
51
+ private describeToolAction;
52
+ /**
53
+ * Strip absolute paths to show only meaningful segments from src/ or packages/.
54
+ */
55
+ private shortPath;
34
56
  private scheduleUpdate;
57
+ private pushRecentAction;
35
58
  private sendUpdate;
36
59
  private startHeartbeat;
37
- private phaseOrder;
38
- private extractBashCommand;
60
+ /**
61
+ * Safely extract a string property from an unknown tool input.
62
+ */
63
+ private extractString;
39
64
  }