@gethmy/agent 1.2.0 → 1.3.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/cli.js +6 -2
- package/dist/config-validation.d.ts +5 -0
- package/dist/config-validation.js +16 -5
- package/dist/http-server.js +0 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +59 -19
- package/dist/merge-monitor.js +0 -1
- package/dist/pool.js +0 -2
- package/dist/reconcile.js +0 -1
- package/dist/recovery.js +0 -1
- package/dist/review-worktree.js +12 -0
- package/dist/startup-banner.d.ts +29 -0
- package/dist/startup-banner.js +143 -0
- package/dist/watcher.d.ts +15 -0
- package/dist/watcher.js +41 -4
- package/dist/worktree-gc.js +1 -2
- package/dist/worktree.js +24 -2
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -117,10 +117,14 @@ async function healthCommand() {
|
|
|
117
117
|
async function doctorCommand() {
|
|
118
118
|
const { loadDaemonConfig } = await import("./config.js");
|
|
119
119
|
const { validatePrerequisites } = await import("./index.js");
|
|
120
|
+
const { createStartupBanner } = await import("./startup-banner.js");
|
|
121
|
+
const { createRequire } = await import("node:module");
|
|
122
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
120
123
|
try {
|
|
121
124
|
const config = loadDaemonConfig();
|
|
122
|
-
const
|
|
123
|
-
|
|
125
|
+
const banner = createStartupBanner(config, version);
|
|
126
|
+
const userId = await validatePrerequisites(config, banner);
|
|
127
|
+
await banner.ready(`preflight ok — agent user ${userId}`);
|
|
124
128
|
return 0;
|
|
125
129
|
}
|
|
126
130
|
catch (err) {
|
|
@@ -16,3 +16,8 @@ export declare class ConfigValidationError extends Error {
|
|
|
16
16
|
* board before the daemon is useful.
|
|
17
17
|
*/
|
|
18
18
|
export declare function validateColumnReferences(client: HarmonyApiClient, projectId: string, config: AgentConfig): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Same checks as validateColumnReferences but returns the unique list of
|
|
21
|
+
* validated column names (used by the startup banner). Throws on mismatch.
|
|
22
|
+
*/
|
|
23
|
+
export declare function validateAndListColumns(client: HarmonyApiClient, projectId: string, config: AgentConfig): Promise<string[]>;
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { log } from "./log.js";
|
|
2
|
-
const TAG = "config-validation";
|
|
3
1
|
export class ConfigValidationError extends Error {
|
|
4
2
|
issues;
|
|
5
3
|
constructor(message, issues) {
|
|
@@ -60,7 +58,20 @@ export async function validateColumnReferences(client, projectId, config) {
|
|
|
60
58
|
const help = `Available columns: ${known.join(", ")}`;
|
|
61
59
|
throw new ConfigValidationError(`Invalid agent config — the following columns are missing:\n - ${issues.join("\n - ")}\n${help}`, issues);
|
|
62
60
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Same checks as validateColumnReferences but returns the unique list of
|
|
64
|
+
* validated column names (used by the startup banner). Throws on mismatch.
|
|
65
|
+
*/
|
|
66
|
+
export async function validateAndListColumns(client, projectId, config) {
|
|
67
|
+
await validateColumnReferences(client, projectId, config);
|
|
68
|
+
const names = [
|
|
69
|
+
...config.pickupColumns,
|
|
70
|
+
config.completion.moveToColumn,
|
|
71
|
+
config.verification.failColumn,
|
|
72
|
+
];
|
|
73
|
+
if (config.review.enabled) {
|
|
74
|
+
names.push(...config.review.pickupColumns, config.review.moveToColumn, config.review.failColumn);
|
|
75
|
+
}
|
|
76
|
+
return Array.from(new Set(names.filter(Boolean)));
|
|
66
77
|
}
|
package/dist/http-server.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type DaemonConfig } from "./config.js";
|
|
2
|
-
|
|
2
|
+
import { type StartupBanner } from "./startup-banner.js";
|
|
3
|
+
declare function validatePrerequisites(config: DaemonConfig, banner: StartupBanner): Promise<string>;
|
|
3
4
|
export { validatePrerequisites };
|
|
4
5
|
export declare function main(): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { buildLabelMap, hasLabel, resolveCardLabels } from "./board-helpers.js";
|
|
5
5
|
import { createApiClient, fetchRealtimeCredentials, loadDaemonConfig, } from "./config.js";
|
|
6
|
-
import { ConfigValidationError,
|
|
6
|
+
import { ConfigValidationError, validateAndListColumns, } from "./config-validation.js";
|
|
7
7
|
import { detectGitProvider, validateGitProviderCli } from "./git-pr.js";
|
|
8
8
|
import { HttpServer } from "./http-server.js";
|
|
9
9
|
import { log } from "./log.js";
|
|
@@ -12,6 +12,7 @@ import { Pool } from "./pool.js";
|
|
|
12
12
|
import { Reconciler } from "./reconcile.js";
|
|
13
13
|
import { recoverOrphans } from "./recovery.js";
|
|
14
14
|
import { extractBranchFromDescription } from "./review-worktree.js";
|
|
15
|
+
import { createStartupBanner } from "./startup-banner.js";
|
|
15
16
|
import { StateStore } from "./state-store.js";
|
|
16
17
|
import { verifyStreamParserFormat } from "./stream-parser-selftest.js";
|
|
17
18
|
import { AGENT_NAME } from "./types.js";
|
|
@@ -20,13 +21,13 @@ import { WorktreeGc } from "./worktree-gc.js";
|
|
|
20
21
|
const TAG = "daemon";
|
|
21
22
|
const { version: PKG_VERSION } = createRequire(import.meta.url)("../package.json");
|
|
22
23
|
// ============ STARTUP VALIDATION ============
|
|
23
|
-
async function validatePrerequisites(config) {
|
|
24
|
+
async function validatePrerequisites(config, banner) {
|
|
24
25
|
// 1. Check claude CLI
|
|
25
26
|
try {
|
|
26
27
|
const ver = execFileSync("claude", ["--version"], {
|
|
27
28
|
encoding: "utf-8",
|
|
28
29
|
}).trim();
|
|
29
|
-
|
|
30
|
+
banner.check(`Claude CLI ${ver}`);
|
|
30
31
|
}
|
|
31
32
|
catch {
|
|
32
33
|
throw new Error("Claude CLI not found. Install it: https://docs.anthropic.com/en/docs/claude-code");
|
|
@@ -34,11 +35,11 @@ async function validatePrerequisites(config) {
|
|
|
34
35
|
// 1b. Stream parser canary — if the CLI stream-json envelope has drifted,
|
|
35
36
|
// every review would silently park with a parse error (see #128).
|
|
36
37
|
verifyStreamParserFormat();
|
|
37
|
-
|
|
38
|
+
banner.check("Stream parser canary");
|
|
38
39
|
// 2. Check git provider CLI (if PR creation enabled in either pipeline)
|
|
39
40
|
if (config.agent.completion.createPR || config.agent.review.createPR) {
|
|
40
41
|
const provider = detectGitProvider();
|
|
41
|
-
|
|
42
|
+
banner.setGitProvider(provider);
|
|
42
43
|
validateGitProviderCli(provider);
|
|
43
44
|
}
|
|
44
45
|
// 3. Check git repo is clean and base branch exists
|
|
@@ -47,6 +48,7 @@ async function validatePrerequisites(config) {
|
|
|
47
48
|
encoding: "utf-8",
|
|
48
49
|
}).trim();
|
|
49
50
|
if (status) {
|
|
51
|
+
banner.fail();
|
|
50
52
|
log.warn(TAG, `Working directory has uncommitted changes:\n${status}`);
|
|
51
53
|
}
|
|
52
54
|
execFileSync("git", ["rev-parse", "--verify", `origin/${config.agent.worktree.baseBranch}`], {
|
|
@@ -61,7 +63,7 @@ async function validatePrerequisites(config) {
|
|
|
61
63
|
const client = createApiClient(config);
|
|
62
64
|
try {
|
|
63
65
|
await client.listWorkspaces();
|
|
64
|
-
|
|
66
|
+
banner.check("API key validated");
|
|
65
67
|
}
|
|
66
68
|
catch (err) {
|
|
67
69
|
throw new Error(`API key validation failed: ${err instanceof Error ? err.message : err}`);
|
|
@@ -75,7 +77,8 @@ async function validatePrerequisites(config) {
|
|
|
75
77
|
.join("\n");
|
|
76
78
|
throw new Error(`Project "${config.projectId}" not found in workspace.\nAvailable projects:\n${available}`);
|
|
77
79
|
}
|
|
78
|
-
|
|
80
|
+
banner.setProjectName(project.name);
|
|
81
|
+
banner.check(`Project access (${project.name})`);
|
|
79
82
|
// 6. Resolve the agent user ID from workspace members
|
|
80
83
|
const members = await client.getWorkspaceMembers(config.workspaceId);
|
|
81
84
|
const agentMember = members.members.find((m) => m.email === config.userEmail);
|
|
@@ -87,25 +90,24 @@ async function validatePrerequisites(config) {
|
|
|
87
90
|
// ============ MAIN DAEMON ============
|
|
88
91
|
export { validatePrerequisites };
|
|
89
92
|
export async function main() {
|
|
90
|
-
log.info(TAG, `Harmony Agent Daemon v${PKG_VERSION} starting...`);
|
|
91
93
|
// Load config
|
|
92
94
|
const config = loadDaemonConfig();
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
95
|
+
// Startup banner — collapses 21 INFO lines into a single grouped block
|
|
96
|
+
// in pretty mode. Falls through to per-line log.info in JSON mode.
|
|
97
|
+
const banner = createStartupBanner(config, PKG_VERSION);
|
|
97
98
|
// Validate
|
|
98
|
-
const agentUserId = await validatePrerequisites(config);
|
|
99
|
-
log.info(TAG, `Agent user: ${config.userEmail} (${agentUserId})`);
|
|
99
|
+
const agentUserId = await validatePrerequisites(config, banner);
|
|
100
100
|
// Create API client
|
|
101
101
|
const client = createApiClient(config);
|
|
102
102
|
// Fail fast if configured columns don't exist on the board. A stuck
|
|
103
103
|
// card with no log output is worse than a clear startup error.
|
|
104
104
|
try {
|
|
105
|
-
await
|
|
105
|
+
const cols = await validateAndListColumns(client, config.projectId, config.agent);
|
|
106
|
+
banner.check(`Columns: ${cols.join(", ")}`);
|
|
106
107
|
}
|
|
107
108
|
catch (err) {
|
|
108
109
|
if (err instanceof ConfigValidationError) {
|
|
110
|
+
banner.fail();
|
|
109
111
|
log.error(TAG, err.message);
|
|
110
112
|
process.exit(1);
|
|
111
113
|
}
|
|
@@ -117,12 +119,16 @@ export async function main() {
|
|
|
117
119
|
const daemonId = randomUUID();
|
|
118
120
|
await stateStore.setDaemon(daemonId, process.pid);
|
|
119
121
|
const outcomes = await recoverOrphans(stateStore, client, config.agent);
|
|
120
|
-
if (outcomes.length
|
|
121
|
-
|
|
122
|
+
if (outcomes.length === 0) {
|
|
123
|
+
banner.check("Recovery: no orphans");
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
const errored = outcomes.filter((o) => o.errors.length).length;
|
|
127
|
+
banner.check(`Recovery: ${outcomes.length} orphan(s) handled${errored > 0 ? `, ${errored} with errors` : ""}`);
|
|
122
128
|
}
|
|
123
129
|
// Fetch realtime credentials
|
|
124
130
|
const realtimeCreds = await fetchRealtimeCredentials(client);
|
|
125
|
-
|
|
131
|
+
banner.check("Realtime credentials");
|
|
126
132
|
// Create pool
|
|
127
133
|
const pool = new Pool(config.agent, client, config.userEmail, config.workspaceId, config.projectId, stateStore);
|
|
128
134
|
// Create reconciler
|
|
@@ -250,10 +256,44 @@ export async function main() {
|
|
|
250
256
|
await httpServer.start();
|
|
251
257
|
}
|
|
252
258
|
catch (err) {
|
|
259
|
+
banner.fail();
|
|
253
260
|
log.warn(TAG, `HTTP server failed to bind: ${err instanceof Error ? err.message : err}`);
|
|
254
261
|
}
|
|
255
262
|
}
|
|
256
|
-
|
|
263
|
+
// Banner check lines for service intervals + pool. Compose into one
|
|
264
|
+
// line each so the banner stays compact.
|
|
265
|
+
const reviewCount = config.agent.review.enabled ? 1 : 0;
|
|
266
|
+
banner.check(`Pool: ${config.agent.poolSize} impl${reviewCount > 0 ? ` + ${reviewCount} review` : ""}`);
|
|
267
|
+
const services = [
|
|
268
|
+
`Heartbeat ${config.agent.timing.reconcileIntervalMs / 1000}s`,
|
|
269
|
+
];
|
|
270
|
+
if (mergeMonitor) {
|
|
271
|
+
// MergeMonitor's interval isn't configurable in AgentConfig; matches
|
|
272
|
+
// its constructor default.
|
|
273
|
+
services.push("Merge monitor 60s");
|
|
274
|
+
}
|
|
275
|
+
services.push(`Worktree GC ${config.agent.timing.worktreeGcIntervalMs / 1000}s`);
|
|
276
|
+
banner.check(services.join(" · "));
|
|
277
|
+
// Wait briefly for realtime to fully subscribe so the banner can show
|
|
278
|
+
// its "✓ Realtime: broadcast + presence" line. If it doesn't arrive in
|
|
279
|
+
// 3s, the banner shows "✓ Realtime: connecting…" instead and the
|
|
280
|
+
// watcher's eventual subscribe lines emit normally afterwards.
|
|
281
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(() => resolve("timeout"), ms));
|
|
282
|
+
const winner = await Promise.race([
|
|
283
|
+
watcher.ready().then(() => "ready"),
|
|
284
|
+
sleep(3000),
|
|
285
|
+
]);
|
|
286
|
+
if (winner === "ready") {
|
|
287
|
+
banner.check("Realtime: broadcast + presence");
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
banner.check("Realtime: connecting…");
|
|
291
|
+
}
|
|
292
|
+
await banner.ready("watching for card assignments");
|
|
293
|
+
// Banner has rendered. Allow any realtime events that arrive *after*
|
|
294
|
+
// the banner's 3 s deadline to log normally — otherwise a slow subscribe
|
|
295
|
+
// would leave the user with no signal that realtime ever came up.
|
|
296
|
+
watcher.allowStartupLogs();
|
|
257
297
|
}
|
|
258
298
|
// ============ EVENT HANDLER ============
|
|
259
299
|
async function handleBroadcast(event, client, pool, config, agentUserId) {
|
package/dist/merge-monitor.js
CHANGED
package/dist/pool.js
CHANGED
|
@@ -29,8 +29,6 @@ export class Pool {
|
|
|
29
29
|
this.tryDispatchFor(this.reviewWorkers, this.reviewQueue, "review");
|
|
30
30
|
}, stateStore));
|
|
31
31
|
}
|
|
32
|
-
const reviewCount = this.reviewWorkers.length;
|
|
33
|
-
log.info(TAG, `Pool initialized: ${config.poolSize} impl worker(s), ${reviewCount} review worker(s)`);
|
|
34
32
|
}
|
|
35
33
|
/**
|
|
36
34
|
* Enqueue a card for processing with the given mode.
|
package/dist/reconcile.js
CHANGED
|
@@ -40,7 +40,6 @@ export class Reconciler {
|
|
|
40
40
|
this.agentConfig = agentConfig;
|
|
41
41
|
}
|
|
42
42
|
start() {
|
|
43
|
-
log.info(TAG, `Heartbeat every ${this.intervalMs / 1000}s`);
|
|
44
43
|
// Run immediately, then on interval
|
|
45
44
|
this.tick();
|
|
46
45
|
this.timer = setInterval(() => this.tick(), this.intervalMs);
|
package/dist/recovery.js
CHANGED
|
@@ -44,7 +44,6 @@ async function fetchCardSafely(client, cardId) {
|
|
|
44
44
|
export async function recoverOrphans(store, client, config) {
|
|
45
45
|
const active = store.getActiveRuns();
|
|
46
46
|
if (active.length === 0) {
|
|
47
|
-
log.info(TAG, "no orphan runs to recover");
|
|
48
47
|
return [];
|
|
49
48
|
}
|
|
50
49
|
const outcomes = [];
|
package/dist/review-worktree.js
CHANGED
|
@@ -19,6 +19,18 @@ export function checkoutExistingBranch(basePath, branchName) {
|
|
|
19
19
|
log.warn(TAG, `Review worktree already exists at ${worktreeDir}, cleaning up`);
|
|
20
20
|
cleanupWorktree(worktreeDir);
|
|
21
21
|
}
|
|
22
|
+
// Force-prune orphaned worktree metadata (dir removed externally but git
|
|
23
|
+
// still registers the branch as checked out). Default expiry is 3 months,
|
|
24
|
+
// which leaves fresh stale entries in place.
|
|
25
|
+
try {
|
|
26
|
+
execFileSync("git", ["worktree", "prune", "--expire=now"], {
|
|
27
|
+
cwd: repoRoot,
|
|
28
|
+
stdio: "pipe",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// non-fatal
|
|
33
|
+
}
|
|
22
34
|
// Fetch latest to ensure we have the remote branch
|
|
23
35
|
try {
|
|
24
36
|
execFileSync("git", ["fetch", "origin", branchName], {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Startup banner — collapses the ~21 individually-timestamped INFO lines
|
|
3
|
+
* the daemon used to emit at startup into a single grouped block in
|
|
4
|
+
* pretty/TTY mode. JSON mode is unchanged (each `check()` falls through
|
|
5
|
+
* to `log.info("daemon", msg)` so log shippers see the same records).
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const banner = createStartupBanner(config, PKG_VERSION);
|
|
9
|
+
* banner.check("Claude CLI 2.1.126");
|
|
10
|
+
* banner.setProjectName("Harmony");
|
|
11
|
+
* banner.check("API key validated");
|
|
12
|
+
* ...
|
|
13
|
+
* await banner.ready("watching for card assignments");
|
|
14
|
+
*
|
|
15
|
+
* Pretty rendering is deferred until `ready()` so the whole block lands
|
|
16
|
+
* atomically with one stderr.write — nothing can interleave inside it.
|
|
17
|
+
* If a startup error occurs and a warn/error needs to fire, call
|
|
18
|
+
* `banner.fail()` first so the banner suppresses itself and the warning
|
|
19
|
+
* isn't hidden under the block.
|
|
20
|
+
*/
|
|
21
|
+
import type { DaemonConfig } from "./config.js";
|
|
22
|
+
export interface StartupBanner {
|
|
23
|
+
setProjectName(name: string): void;
|
|
24
|
+
setGitProvider(provider: string): void;
|
|
25
|
+
check(message: string): void;
|
|
26
|
+
ready(message: string): Promise<void>;
|
|
27
|
+
fail(): void;
|
|
28
|
+
}
|
|
29
|
+
export declare function createStartupBanner(config: DaemonConfig, version: string): StartupBanner;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { isPretty, log } from "./log.js";
|
|
2
|
+
const TAG = "daemon";
|
|
3
|
+
const RULE_WIDTH = 70;
|
|
4
|
+
const ANSI = {
|
|
5
|
+
reset: "\x1b[0m",
|
|
6
|
+
dim: "\x1b[2m",
|
|
7
|
+
cyan: "\x1b[36m",
|
|
8
|
+
};
|
|
9
|
+
export function createStartupBanner(config, version) {
|
|
10
|
+
return isPretty()
|
|
11
|
+
? prettyBanner(config, version)
|
|
12
|
+
: jsonBanner(config, version);
|
|
13
|
+
}
|
|
14
|
+
function prettyBanner(config, version) {
|
|
15
|
+
const checks = [];
|
|
16
|
+
let projectName;
|
|
17
|
+
let gitProvider;
|
|
18
|
+
let failed = false;
|
|
19
|
+
let rendered = false;
|
|
20
|
+
return {
|
|
21
|
+
setProjectName(name) {
|
|
22
|
+
projectName = name;
|
|
23
|
+
},
|
|
24
|
+
setGitProvider(provider) {
|
|
25
|
+
gitProvider = provider;
|
|
26
|
+
},
|
|
27
|
+
check(message) {
|
|
28
|
+
checks.push(message);
|
|
29
|
+
},
|
|
30
|
+
fail() {
|
|
31
|
+
failed = true;
|
|
32
|
+
},
|
|
33
|
+
async ready(message) {
|
|
34
|
+
if (failed || rendered)
|
|
35
|
+
return;
|
|
36
|
+
rendered = true;
|
|
37
|
+
const block = renderPretty({
|
|
38
|
+
version,
|
|
39
|
+
config,
|
|
40
|
+
projectName,
|
|
41
|
+
gitProvider,
|
|
42
|
+
checks,
|
|
43
|
+
readyMessage: message,
|
|
44
|
+
});
|
|
45
|
+
process.stderr.write(block);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function jsonBanner(config, version) {
|
|
50
|
+
// Mirror today's structured output: one log.info per startup event.
|
|
51
|
+
// Emit the version line eagerly so JSON consumers still see it first.
|
|
52
|
+
log.info(TAG, `Harmony Agent Daemon v${version} starting...`);
|
|
53
|
+
log.info(TAG, `Project: ${config.projectId} | Pool: ${config.agent.poolSize} | Model: ${config.agent.claude.model} | Pickup: ${config.agent.pickupColumns.join(", ")}`);
|
|
54
|
+
if (config.agent.review.enabled) {
|
|
55
|
+
log.info(TAG, `Review: enabled | Columns: ${config.agent.review.pickupColumns.join(", ")} | → ${config.agent.review.moveToColumn} / ${config.agent.review.failColumn}`);
|
|
56
|
+
}
|
|
57
|
+
let failed = false;
|
|
58
|
+
return {
|
|
59
|
+
setProjectName(_name) {
|
|
60
|
+
// No-op in JSON mode — the explicit `Project access (X)` check
|
|
61
|
+
// line that follows already conveys this and avoids duplication.
|
|
62
|
+
},
|
|
63
|
+
setGitProvider(provider) {
|
|
64
|
+
log.info(TAG, `Git provider: ${provider}`);
|
|
65
|
+
},
|
|
66
|
+
check(message) {
|
|
67
|
+
log.info(TAG, message);
|
|
68
|
+
},
|
|
69
|
+
fail() {
|
|
70
|
+
failed = true;
|
|
71
|
+
},
|
|
72
|
+
async ready(message) {
|
|
73
|
+
if (failed)
|
|
74
|
+
return;
|
|
75
|
+
log.info(TAG, message);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function renderPretty(input) {
|
|
80
|
+
const { version, config, projectName, gitProvider, checks, readyMessage } = input;
|
|
81
|
+
const lines = [];
|
|
82
|
+
lines.push("");
|
|
83
|
+
lines.push(titleRule(`Harmony Agent Daemon v${version}`));
|
|
84
|
+
lines.push("");
|
|
85
|
+
for (const row of configRows(config, projectName, gitProvider)) {
|
|
86
|
+
lines.push(` ${dim(row.label.padEnd(9))} ${row.value}`);
|
|
87
|
+
}
|
|
88
|
+
lines.push("");
|
|
89
|
+
for (const msg of checks) {
|
|
90
|
+
lines.push(` ${cyan("✓")} ${msg}`);
|
|
91
|
+
}
|
|
92
|
+
lines.push("");
|
|
93
|
+
lines.push(`${cyan("▶")} ${cyan("Ready")} — ${readyMessage}`);
|
|
94
|
+
lines.push(dim("─".repeat(RULE_WIDTH)));
|
|
95
|
+
lines.push("");
|
|
96
|
+
return lines.join("\n");
|
|
97
|
+
}
|
|
98
|
+
function configRows(config, projectName, gitProvider) {
|
|
99
|
+
const rows = [];
|
|
100
|
+
const projectLabel = projectName
|
|
101
|
+
? `${projectName} (${shortenId(config.projectId)})`
|
|
102
|
+
: shortenId(config.projectId);
|
|
103
|
+
rows.push({ label: "Project", value: projectLabel });
|
|
104
|
+
rows.push({ label: "User", value: config.userEmail });
|
|
105
|
+
const reviewEnabled = config.agent.review.enabled;
|
|
106
|
+
const poolDesc = reviewEnabled
|
|
107
|
+
? `Pool ${config.agent.poolSize} impl + 1 review`
|
|
108
|
+
: `Pool ${config.agent.poolSize} impl`;
|
|
109
|
+
const flowDesc = reviewEnabled
|
|
110
|
+
? `Pickup ${config.agent.pickupColumns[0]} → ${config.agent.completion.moveToColumn} → ${config.agent.review.moveToColumn}`
|
|
111
|
+
: `Pickup ${config.agent.pickupColumns.join(", ")}`;
|
|
112
|
+
rows.push({
|
|
113
|
+
label: "Model",
|
|
114
|
+
value: `${config.agent.claude.model} · ${poolDesc} · ${flowDesc}`,
|
|
115
|
+
});
|
|
116
|
+
const tail = [];
|
|
117
|
+
if (gitProvider)
|
|
118
|
+
tail.push(gitProvider);
|
|
119
|
+
if (config.agent.http.enabled) {
|
|
120
|
+
tail.push(`HTTP http://${config.agent.http.bindAddr}:${config.agent.http.port}`);
|
|
121
|
+
}
|
|
122
|
+
if (tail.length > 0) {
|
|
123
|
+
rows.push({ label: "Git", value: tail.join(" · ") });
|
|
124
|
+
}
|
|
125
|
+
return rows;
|
|
126
|
+
}
|
|
127
|
+
function titleRule(title) {
|
|
128
|
+
const prefix = "─── ";
|
|
129
|
+
const surround = ` `;
|
|
130
|
+
const suffix = "─".repeat(Math.max(3, RULE_WIDTH - prefix.length - title.length - surround.length));
|
|
131
|
+
return dim(`${prefix}${title}${surround}${suffix}`);
|
|
132
|
+
}
|
|
133
|
+
function shortenId(id) {
|
|
134
|
+
if (id.length <= 8)
|
|
135
|
+
return id;
|
|
136
|
+
return `${id.slice(0, 8)}…`;
|
|
137
|
+
}
|
|
138
|
+
function dim(s) {
|
|
139
|
+
return `${ANSI.dim}${s}${ANSI.reset}`;
|
|
140
|
+
}
|
|
141
|
+
function cyan(s) {
|
|
142
|
+
return `${ANSI.cyan}${s}${ANSI.reset}`;
|
|
143
|
+
}
|
package/dist/watcher.d.ts
CHANGED
|
@@ -31,7 +31,22 @@ export declare class Watcher {
|
|
|
31
31
|
private supabase;
|
|
32
32
|
private daemonId;
|
|
33
33
|
private connected;
|
|
34
|
+
private presenceTracked;
|
|
35
|
+
private suppressStartupLogs;
|
|
36
|
+
private readyResolve;
|
|
37
|
+
private readyPromise;
|
|
34
38
|
get isConnected(): boolean;
|
|
39
|
+
/** Resolves once both broadcast subscription is active AND presence is tracked. */
|
|
40
|
+
ready(): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Re-enable startup-state log lines. The startup banner suppresses them
|
|
43
|
+
* by default in pretty mode (it absorbs subscribe/presence into one
|
|
44
|
+
* "✓ Realtime" line). After the banner has rendered, main() calls this
|
|
45
|
+
* so any realtime events that arrive *after* the banner — e.g. a slow
|
|
46
|
+
* subscribe that missed the banner's 3 s deadline — log normally.
|
|
47
|
+
*/
|
|
48
|
+
allowStartupLogs(): void;
|
|
49
|
+
private maybeResolveReady;
|
|
35
50
|
constructor(credentials: RealtimeCredentials, projectId: string, identity: PresenceIdentity, onCardBroadcast: CardBroadcastHandler, onAgentCommand?: AgentCommandHandler | undefined);
|
|
36
51
|
start(): Promise<void>;
|
|
37
52
|
stop(): Promise<void>;
|
package/dist/watcher.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { createClient } from "@supabase/supabase-js";
|
|
3
|
-
import { log } from "./log.js";
|
|
3
|
+
import { isPretty, log } from "./log.js";
|
|
4
4
|
const TAG = "watcher";
|
|
5
5
|
/**
|
|
6
6
|
* Subscribes to Supabase broadcast events on the board channel.
|
|
@@ -18,9 +18,35 @@ export class Watcher {
|
|
|
18
18
|
supabase = null;
|
|
19
19
|
daemonId = randomUUID();
|
|
20
20
|
connected = false;
|
|
21
|
+
presenceTracked = false;
|
|
22
|
+
suppressStartupLogs = true;
|
|
23
|
+
readyResolve = null;
|
|
24
|
+
readyPromise = new Promise((resolve) => {
|
|
25
|
+
this.readyResolve = resolve;
|
|
26
|
+
});
|
|
21
27
|
get isConnected() {
|
|
22
28
|
return this.connected;
|
|
23
29
|
}
|
|
30
|
+
/** Resolves once both broadcast subscription is active AND presence is tracked. */
|
|
31
|
+
ready() {
|
|
32
|
+
return this.readyPromise;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Re-enable startup-state log lines. The startup banner suppresses them
|
|
36
|
+
* by default in pretty mode (it absorbs subscribe/presence into one
|
|
37
|
+
* "✓ Realtime" line). After the banner has rendered, main() calls this
|
|
38
|
+
* so any realtime events that arrive *after* the banner — e.g. a slow
|
|
39
|
+
* subscribe that missed the banner's 3 s deadline — log normally.
|
|
40
|
+
*/
|
|
41
|
+
allowStartupLogs() {
|
|
42
|
+
this.suppressStartupLogs = false;
|
|
43
|
+
}
|
|
44
|
+
maybeResolveReady() {
|
|
45
|
+
if (this.connected && this.presenceTracked && this.readyResolve) {
|
|
46
|
+
this.readyResolve();
|
|
47
|
+
this.readyResolve = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
24
50
|
constructor(credentials, projectId, identity, onCardBroadcast, onAgentCommand) {
|
|
25
51
|
this.credentials = credentials;
|
|
26
52
|
this.projectId = projectId;
|
|
@@ -29,7 +55,11 @@ export class Watcher {
|
|
|
29
55
|
this.onAgentCommand = onAgentCommand;
|
|
30
56
|
}
|
|
31
57
|
async start() {
|
|
32
|
-
|
|
58
|
+
// In pretty mode the startup banner absorbs realtime readiness into
|
|
59
|
+
// its "✓ Realtime" line, so this connect message is redundant noise.
|
|
60
|
+
if (!isPretty()) {
|
|
61
|
+
log.info(TAG, "Connecting to Supabase realtime (broadcast)...");
|
|
62
|
+
}
|
|
33
63
|
this.supabase = createClient(this.credentials.supabaseUrl, this.credentials.supabaseAnonKey);
|
|
34
64
|
// Presence channel — separate from the broadcast channel to avoid
|
|
35
65
|
// conflicting with frontend BoardContext's board-{projectId} subscription
|
|
@@ -62,7 +92,10 @@ export class Watcher {
|
|
|
62
92
|
.subscribe((status) => {
|
|
63
93
|
if (status === "SUBSCRIBED") {
|
|
64
94
|
this.connected = true;
|
|
65
|
-
|
|
95
|
+
if (!isPretty() || !this.suppressStartupLogs) {
|
|
96
|
+
log.info(TAG, "Broadcast subscription active");
|
|
97
|
+
}
|
|
98
|
+
this.maybeResolveReady();
|
|
66
99
|
}
|
|
67
100
|
else if (status === "CHANNEL_ERROR") {
|
|
68
101
|
this.connected = false;
|
|
@@ -92,7 +125,11 @@ export class Watcher {
|
|
|
92
125
|
agentIdentifier: this.identity.agentIdentifier,
|
|
93
126
|
agentName: this.identity.agentName,
|
|
94
127
|
});
|
|
95
|
-
|
|
128
|
+
if (!isPretty() || !this.suppressStartupLogs) {
|
|
129
|
+
log.info(TAG, "Presence tracked on board-presence channel");
|
|
130
|
+
}
|
|
131
|
+
this.presenceTracked = true;
|
|
132
|
+
this.maybeResolveReady();
|
|
96
133
|
}
|
|
97
134
|
});
|
|
98
135
|
this.presenceChannel = presenceChannel;
|
package/dist/worktree-gc.js
CHANGED
|
@@ -78,7 +78,7 @@ export function runWorktreeGc(basePath, store, opts = {}) {
|
|
|
78
78
|
}
|
|
79
79
|
// Prune any stale metadata leftover from deleted worktrees.
|
|
80
80
|
try {
|
|
81
|
-
execFileSync("git", ["worktree", "prune"], {
|
|
81
|
+
execFileSync("git", ["worktree", "prune", "--expire=now"], {
|
|
82
82
|
cwd: repoRoot,
|
|
83
83
|
stdio: "pipe",
|
|
84
84
|
});
|
|
@@ -107,7 +107,6 @@ export class WorktreeGc {
|
|
|
107
107
|
this.intervalMs = intervalMs;
|
|
108
108
|
}
|
|
109
109
|
start() {
|
|
110
|
-
log.info(TAG, `worktree GC every ${this.intervalMs / 1000}s`);
|
|
111
110
|
// Run once at startup, then on interval.
|
|
112
111
|
this.tick();
|
|
113
112
|
this.timer = setInterval(() => this.tick(), this.intervalMs);
|
package/dist/worktree.js
CHANGED
|
@@ -20,8 +20,10 @@ export function createWorktree(basePath, baseBranch, branchName) {
|
|
|
20
20
|
// Prune stale worktree metadata. If a previous daemon crashed or its
|
|
21
21
|
// worktree dir was deleted externally, git may still think the branch is
|
|
22
22
|
// checked out, which blocks `git branch -D` and `git worktree add`.
|
|
23
|
+
// `--expire=now` overrides `gc.worktreePruneExpire` (default 3 months) so
|
|
24
|
+
// freshly-orphaned entries are removed immediately.
|
|
23
25
|
try {
|
|
24
|
-
execFileSync("git", ["worktree", "prune"], {
|
|
26
|
+
execFileSync("git", ["worktree", "prune", "--expire=now"], {
|
|
25
27
|
cwd: repoRoot,
|
|
26
28
|
stdio: "pipe",
|
|
27
29
|
});
|
|
@@ -58,6 +60,26 @@ export function createWorktree(basePath, baseBranch, branchName) {
|
|
|
58
60
|
// another registered worktree), force-delete the branch and retry.
|
|
59
61
|
const msg = err instanceof Error ? err.message : String(err);
|
|
60
62
|
log.warn(TAG, `worktree add failed, attempting forced recovery: ${msg}`);
|
|
63
|
+
// Remove any registered worktree at this path (phantom or otherwise).
|
|
64
|
+
try {
|
|
65
|
+
execFileSync("git", ["worktree", "remove", worktreeDir, "--force"], {
|
|
66
|
+
cwd: repoRoot,
|
|
67
|
+
stdio: "pipe",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// best-effort
|
|
72
|
+
}
|
|
73
|
+
// Force-prune any stale worktree admin entries referencing this branch.
|
|
74
|
+
try {
|
|
75
|
+
execFileSync("git", ["worktree", "prune", "--expire=now"], {
|
|
76
|
+
cwd: repoRoot,
|
|
77
|
+
stdio: "pipe",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// best-effort
|
|
82
|
+
}
|
|
61
83
|
try {
|
|
62
84
|
execFileSync("git", ["branch", "-D", branchName], {
|
|
63
85
|
cwd: repoRoot,
|
|
@@ -112,7 +134,7 @@ export function cleanupWorktree(worktreePath, branchName) {
|
|
|
112
134
|
}
|
|
113
135
|
// Prune stale worktree entries
|
|
114
136
|
try {
|
|
115
|
-
execFileSync("git", ["worktree", "prune"], {
|
|
137
|
+
execFileSync("git", ["worktree", "prune", "--expire=now"], {
|
|
116
138
|
cwd: repoRoot,
|
|
117
139
|
stdio: "pipe",
|
|
118
140
|
});
|