@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 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 userId = await validatePrerequisites(config);
123
- process.stdout.write(`all checks passed agent user: ${userId}\n`);
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
- log.info(TAG, `Validated columns: ${Array.from(new Set(required.map((r) => r.value)))
64
- .filter(Boolean)
65
- .join(", ")}`);
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
  }
@@ -30,7 +30,6 @@ export class HttpServer {
30
30
  });
31
31
  this.server.once("error", reject);
32
32
  this.server.listen(this.opts.port, this.opts.bindAddr, () => {
33
- log.info(TAG, `listening on http://${this.opts.bindAddr}:${this.opts.port}`);
34
33
  resolve();
35
34
  });
36
35
  });
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type DaemonConfig } from "./config.js";
2
- declare function validatePrerequisites(config: DaemonConfig): Promise<string>;
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, validateColumnReferences, } from "./config-validation.js";
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
- log.info(TAG, `Claude CLI: ${ver}`);
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
- log.info(TAG, "Stream parser canary: ok");
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
- log.info(TAG, `Git provider: ${provider}`);
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
- log.info(TAG, "API key validated");
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
- log.info(TAG, `Project validated: ${project.name}`);
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
- log.info(TAG, `Project: ${config.projectId} | Pool: ${config.agent.poolSize} | Model: ${config.agent.claude.model} | Pickup: ${config.agent.pickupColumns.join(", ")}`);
94
- if (config.agent.review.enabled) {
95
- log.info(TAG, `Review: enabled | Columns: ${config.agent.review.pickupColumns.join(", ")} | → ${config.agent.review.moveToColumn} / ${config.agent.review.failColumn}`);
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 validateColumnReferences(client, config.projectId, config.agent);
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 > 0) {
121
- log.info(TAG, `recovery: ${outcomes.length} orphan(s) handled, ${outcomes.filter((o) => o.errors.length).length} had errors`);
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
- log.info(TAG, "Realtime credentials fetched");
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
- log.info(TAG, "Daemon is running. Watching for card assignments...");
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) {
@@ -29,7 +29,6 @@ export class MergeMonitor {
29
29
  }
30
30
  start() {
31
31
  this.running = true;
32
- log.info(TAG, `Merge monitor every ${this.intervalMs / 1000}s`);
33
32
  void this.scheduleNext(0);
34
33
  }
35
34
  stop() {
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 = [];
@@ -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
- log.info(TAG, "Connecting to Supabase realtime (broadcast)...");
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
- log.info(TAG, "Broadcast subscription active");
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
- log.info(TAG, "Presence tracked on board-presence channel");
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;
@@ -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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/agent",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Push-based agent daemon for Harmony — watches board assignments and spawns Claude CLI workers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",