@agent-team-foundation/first-tree-hub 0.9.10 → 0.10.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.
@@ -1,15 +1,14 @@
1
1
  import { m as __toESM } from "./esm-CYu4tXXn.mjs";
2
- import { _ as withSpan, a as endWsConnectionSpan, b as require_pino, c as messageAttrs, d as rootLogger$1, g as startWsConnectionSpan, i as currentTraceId, n as applyLoggerConfig, o as getFastifyOtelPlugin, p as setWsConnectionAttrs, r as createLogger$1, t as adapterAttrs, u as observabilityPlugin, v as withWsMessageSpan, y as FIRST_TREE_HUB_ATTR } from "./observability-DV_fQKqV-CuLWzBxQ.mjs";
3
- import { s as formatPrettyEntry$1, t as LOG_LEVELS$1, u as parseLogLevel$1 } from "./logger-core-BTmvdflj-DhdipBkV.mjs";
4
- import { C as serverConfigSchema, S as resolveConfigReadonly, _ as loadAgents, d as DEFAULT_HOME_DIR$1, f as agentConfigSchema, g as initConfig, i as loadCredentials, l as DEFAULT_CONFIG_DIR, m as collectMissingPrompts, n as ensureFreshAccessToken, o as resolveServerUrl, p as clientConfigSchema, s as saveAgentConfig, u as DEFAULT_DATA_DIR$1, v as migrateLegacyHome, w as setConfigValue } from "./bootstrap-hh_PkTu6.mjs";
5
- import { $ as sessionStateMessageSchema, A as createMemberSchema, B as loginSchema, C as agentTypeSchema$1, D as createAdapterMappingSchema, E as createAdapterConfigSchema, F as extractMentions, G as runtimeStateMessageSchema, H as notificationQuerySchema, I as imageInlineContentSchema, J as sendToAgentSchema, K as selfServiceFeishuBotSchema, L as inboxPollQuerySchema, M as createTaskSchema, N as delegateFeishuUserSchema, O as createAgentSchema, P as dryRunAgentRuntimeConfigSchema, Q as sessionReconcileRequestSchema, R as isRedactedEnvValue, S as agentRuntimeConfigPayloadSchema$1, T as connectTokenExchangeSchema, U as paginationQuerySchema, V as messageSourceSchema$1, W as refreshTokenSchema, X as sessionEventMessageSchema, Y as sessionCompletionMessageSchema, Z as sessionEventSchema$1, _ as addParticipantSchema, a as AGENT_SELECTOR_HEADER$1, at as updateMemberSchema, b as agentBindRequestSchema, c as AGENT_TYPES, ct as updateTaskStatusSchema, d as SYSTEM_CONFIG_DEFAULTS, et as taskListQuerySchema, f as TASK_CREATOR_TYPES, g as WS_AUTH_FRAME_TIMEOUT_MS, h as TASK_TERMINAL_STATUSES, i as AGENT_BIND_REJECT_REASONS, it as updateChatSchema, j as createOrganizationSchema, k as createChatSchema, l as AGENT_VISIBILITY, lt as wsAuthFrameSchema, m as TASK_STATUSES, nt as updateAgentRuntimeConfigSchema, o as AGENT_SOURCES, ot as updateOrganizationSchema, p as TASK_HEALTH_SIGNALS, q as sendMessageSchema, rt as updateAgentSchema, s as AGENT_STATUSES, st as updateSystemConfigSchema, tt as updateAdapterConfigSchema, u as DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD, v as adminCreateTaskSchema, w as clientRegisterSchema, x as agentPinnedMessageSchema$1, y as adminUpdateTaskSchema, z as linkTaskChatSchema } from "./feishu-BJaN64iR.mjs";
2
+ import { _ as withSpan, a as endWsConnectionSpan, b as require_pino, c as messageAttrs, d as rootLogger$1, g as startWsConnectionSpan, i as currentTraceId, n as applyLoggerConfig, o as getFastifyOtelPlugin, p as setWsConnectionAttrs, r as createLogger$1, t as adapterAttrs, u as observabilityPlugin, v as withWsMessageSpan, y as FIRST_TREE_HUB_ATTR } from "./observability-DV_fQKqV-oxfXX6Z2.mjs";
3
+ import { C as serverConfigSchema, S as resolveConfigReadonly, _ as loadAgents, d as DEFAULT_HOME_DIR$1, f as agentConfigSchema, g as initConfig, i as loadCredentials, l as DEFAULT_CONFIG_DIR, m as collectMissingPrompts, n as ensureFreshAccessToken, o as resolveServerUrl, p as clientConfigSchema, s as saveAgentConfig, u as DEFAULT_DATA_DIR$1, v as migrateLegacyHome, w as setConfigValue } from "./bootstrap-CtVqQA8a.mjs";
4
+ import { $ as sessionEventMessageSchema, A as createChatSchema, B as isReservedAgentName$1, C as agentRuntimeConfigPayloadSchema$1, D as createAdapterConfigSchema, E as connectTokenExchangeSchema, F as dryRunAgentRuntimeConfigSchema, G as paginationQuerySchema, H as loginSchema, I as extractMentions, J as scanMentionTokens, K as refreshTokenSchema, L as imageInlineContentSchema, M as createOrganizationSchema, N as createTaskSchema, O as createAdapterMappingSchema, P as delegateFeishuUserSchema, Q as sessionCompletionMessageSchema, R as inboxPollQuerySchema, S as agentPinnedMessageSchema$1, T as clientRegisterSchema, U as messageSourceSchema$1, V as linkTaskChatSchema, W as notificationQuerySchema, X as sendMessageSchema, Y as selfServiceFeishuBotSchema, Z as sendToAgentSchema, _ as WS_AUTH_FRAME_TIMEOUT_MS, a as AGENT_NAME_REGEX$1, at as updateAgentRuntimeConfigSchema, b as adminUpdateTaskSchema, c as AGENT_STATUSES, ct as updateMemberSchema, d as DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD, dt as updateTaskStatusSchema, et as sessionEventSchema$1, f as SYSTEM_CONFIG_DEFAULTS, ft as wsAuthFrameSchema, g as TASK_TERMINAL_STATUSES, h as TASK_STATUSES, i as AGENT_BIND_REJECT_REASONS, it as updateAdapterConfigSchema, j as createMemberSchema, k as createAgentSchema, l as AGENT_TYPES, lt as updateOrganizationSchema, m as TASK_HEALTH_SIGNALS, nt as sessionStateMessageSchema, o as AGENT_SELECTOR_HEADER$1, ot as updateAgentSchema, p as TASK_CREATOR_TYPES, q as runtimeStateMessageSchema, rt as taskListQuerySchema, s as AGENT_SOURCES, st as updateChatSchema, tt as sessionReconcileRequestSchema, u as AGENT_VISIBILITY, ut as updateSystemConfigSchema, v as addParticipantSchema, w as agentTypeSchema$1, x as agentBindRequestSchema, y as adminCreateTaskSchema, z as isRedactedEnvValue } from "./feishu-DEmwoNn_.mjs";
6
5
  import { createRequire } from "node:module";
7
6
  import { ZodError, z } from "zod";
8
7
  import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
9
8
  import { Writable } from "node:stream";
10
9
  import { homedir, hostname, platform, tmpdir, userInfo } from "node:os";
11
10
  import { EventEmitter } from "node:events";
12
- import { closeSync, copyFileSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, unwatchFile, watch, watchFile, writeFileSync, writeSync } from "node:fs";
11
+ import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync, writeSync } from "node:fs";
13
12
  import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
14
13
  import WebSocket from "ws";
15
14
  import { mkdir, writeFile } from "node:fs/promises";
@@ -32,7 +31,6 @@ import Fastify from "fastify";
32
31
  import { bigserial, boolean, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique, uniqueIndex } from "drizzle-orm/pg-core";
33
32
  import { SignJWT, jwtVerify } from "jose";
34
33
  import { Client, EventDispatcher, LoggerLevel, WSClient } from "@larksuiteoapi/node-sdk";
35
- import { createInterface } from "node:readline";
36
34
  //#region ../client/dist/observability-B4kO005X.mjs
37
35
  var import_pino = /* @__PURE__ */ __toESM(require_pino(), 1);
38
36
  /**
@@ -430,10 +428,33 @@ const agentTypeSchema = z.enum([
430
428
  const agentVisibilitySchema = z.enum(["private", "organization"]);
431
429
  const agentSourceSchema = z.enum(["admin-api", "portal"]);
432
430
  z.enum(["active", "suspended"]);
431
+ /**
432
+ * Agent-name rules (see docs/agent-naming-design.md §3.1):
433
+ * - Lowercase ASCII slug, hyphens + underscores allowed.
434
+ * - Must start with alphanumeric: `-` / `_` as first char collide with
435
+ * CLI flag parsing and markdown list syntax.
436
+ * - 1–64 chars — aligned with `MENTION_REGEX` so any valid name can be
437
+ * @-mentioned in chat. Older rows created under the previous 1–100
438
+ * regex are grandfathered; the tight rule only gates new creates.
439
+ */
440
+ const AGENT_NAME_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
441
+ const RESERVED_AGENT_NAMES_SET = new Set([
442
+ "admin",
443
+ "agent",
444
+ "first-tree",
445
+ "hub",
446
+ "me",
447
+ "null",
448
+ "system",
449
+ "undefined"
450
+ ]);
451
+ function isReservedAgentName(name) {
452
+ return RESERVED_AGENT_NAMES_SET.has(name);
453
+ }
433
454
  z.object({
434
- name: z.string().min(1).max(100).regex(/^[a-z0-9_-]+$/, "Only lowercase alphanumeric, hyphens, and underscores").optional(),
455
+ name: z.string().min(1).max(64).regex(AGENT_NAME_REGEX, "Must start with a letter or digit and contain only lowercase letters, digits, hyphens (-), and underscores (_). Max 64 chars.").refine((n) => !isReservedAgentName(n), { message: "That agent name is reserved — pick a different one." }).optional(),
435
456
  type: agentTypeSchema,
436
- displayName: z.string().max(200).optional(),
457
+ displayName: z.string().min(1).max(200).optional(),
437
458
  delegateMention: z.string().max(100).optional(),
438
459
  organizationId: z.string().max(100).optional(),
439
460
  source: agentSourceSchema.optional(),
@@ -444,7 +465,7 @@ z.object({
444
465
  });
445
466
  z.object({
446
467
  type: agentTypeSchema.optional(),
447
- displayName: z.string().max(200).nullable().optional(),
468
+ displayName: z.string().min(1).max(200).optional(),
448
469
  delegateMention: z.string().max(100).nullable().optional(),
449
470
  visibility: agentVisibilitySchema.optional(),
450
471
  metadata: z.record(z.string(), z.unknown()).optional(),
@@ -456,7 +477,7 @@ z.object({
456
477
  name: z.string().nullable(),
457
478
  organizationId: z.string(),
458
479
  type: agentTypeSchema,
459
- displayName: z.string().nullable(),
480
+ displayName: z.string(),
460
481
  delegateMention: z.string().nullable(),
461
482
  inboxId: z.string(),
462
483
  status: z.string(),
@@ -483,7 +504,7 @@ const agentPinnedMessageSchema = z.object({
483
504
  type: z.literal("agent:pinned"),
484
505
  agentId: z.string(),
485
506
  name: z.string().nullable(),
486
- displayName: z.string().nullable(),
507
+ displayName: z.string(),
487
508
  agentType: agentTypeSchema
488
509
  });
489
510
  /**
@@ -661,7 +682,7 @@ const chatParticipantSchema = z.object({
661
682
  });
662
683
  chatParticipantSchema.extend({
663
684
  name: z.string().nullable(),
664
- displayName: z.string().nullable(),
685
+ displayName: z.string(),
665
686
  type: z.string()
666
687
  });
667
688
  z.object({
@@ -814,6 +835,23 @@ const inReplyToSnapshotSchema = z.object({
814
835
  /** Per-chat participation mode exposed to the recipient runtime. */
815
836
  const participantModeSchema = z.enum(["full", "mention_only"]);
816
837
  /**
838
+ * Lightweight snapshot of an earlier message in the same chat that the
839
+ * recipient missed (because it was `mention_only` + not @mentioned). Server
840
+ * attaches a list of these to the next active delivery in the chat so the
841
+ * agent's prompt carries enough context to reply meaningfully.
842
+ *
843
+ * Smaller than `messageSchema` on purpose — drops fields that don't help the
844
+ * LLM (replyTo envelopes, source) and aren't safe to leak across recipients.
845
+ */
846
+ const precedingMessageSchema = z.object({
847
+ id: z.string(),
848
+ senderId: z.string(),
849
+ format: z.string(),
850
+ content: z.unknown(),
851
+ metadata: z.record(z.string(), z.unknown()).default({}),
852
+ createdAt: z.string()
853
+ });
854
+ /**
817
855
  * Wire format for messages routed FROM the Hub TO a client runtime.
818
856
  *
819
857
  * Adds `configVersion` so the client can compare against its locally cached
@@ -829,11 +867,17 @@ const participantModeSchema = z.enum(["full", "mention_only"]);
829
867
  *
830
868
  * `inReplyToSnapshot` is populated when `inReplyTo` resolves to an existing
831
869
  * message; runtime uses it to suppress self-reply echo on direct chats.
870
+ *
871
+ * `precedingMessages` is a (possibly empty) list of older messages in the
872
+ * same chat that this recipient did not previously receive (silent inbox
873
+ * context). The runtime renders them as "earlier in chat" before the
874
+ * triggering message — see proposals/group-chat-ux-improvements §1.
832
875
  */
833
876
  const clientMessageSchema = messageSchema.extend({
834
877
  configVersion: z.number().int().positive(),
835
878
  recipientMode: participantModeSchema.default("full"),
836
- inReplyToSnapshot: inReplyToSnapshotSchema.default(null)
879
+ inReplyToSnapshot: inReplyToSnapshotSchema.default(null),
880
+ precedingMessages: z.array(precedingMessageSchema).default([])
837
881
  });
838
882
  z.enum([
839
883
  "pending",
@@ -1875,7 +1919,7 @@ var ClientConnection = class extends EventEmitter {
1875
1919
  });
1876
1920
  const agent = {
1877
1921
  agentId,
1878
- displayName: msg.displayName ?? null,
1922
+ displayName: msg.displayName ?? agentId,
1879
1923
  agentType: msg.agentType ?? "personal_assistant",
1880
1924
  sdk
1881
1925
  };
@@ -3659,13 +3703,32 @@ function resolveSenderLabel(senderId, participants) {
3659
3703
  * content is serialised to JSON — handlers that want to feed structured
3660
3704
  * content some other way should opt out and format themselves.
3661
3705
  *
3706
+ * If the server attached `precedingMessages` (silent group-chat history the
3707
+ * recipient missed because it was `mention_only` and not @mentioned), prepend
3708
+ * them under an `[Earlier in chat]` block so the LLM sees what came before
3709
+ * the @mention that woke this turn — see proposals/group-chat-ux-improvements §1.
3710
+ *
3662
3711
  * Async because the participant list may need a server round-trip on first
3663
3712
  * use; subsequent messages in the same session hit the cache.
3664
3713
  */
3665
3714
  async function formatInboundContent(message, participants) {
3666
3715
  const rawContent = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
3667
- if (!message.senderId) return rawContent;
3668
- return `[From: ${resolveSenderLabel(message.senderId, await participants.get())}]\n\n${rawContent}`;
3716
+ const preceding = message.precedingMessages ?? [];
3717
+ let header = "";
3718
+ if (preceding.length > 0) {
3719
+ const ps = await participants.get();
3720
+ const lines = ["[Earlier in chat — context you missed]"];
3721
+ for (const p of preceding) {
3722
+ const label = resolveSenderLabel(p.senderId, ps);
3723
+ const text = typeof p.content === "string" ? p.content : JSON.stringify(p.content);
3724
+ lines.push(`[From: ${label}] ${text}`);
3725
+ }
3726
+ lines.push("", "[Now — message that woke you]");
3727
+ header = `${lines.join("\n")}\n\n`;
3728
+ }
3729
+ if (!message.senderId) return `${header}${rawContent}`;
3730
+ const label = resolveSenderLabel(message.senderId, await participants.get());
3731
+ return `${header}[From: ${label}]\n\n${rawContent}`;
3669
3732
  }
3670
3733
  /**
3671
3734
  * Deduplicator — bounded set of recently seen IDs.
@@ -4295,7 +4358,8 @@ var SessionManager = class {
4295
4358
  senderId: msg.senderId,
4296
4359
  format: msg.format,
4297
4360
  content: msg.content,
4298
- metadata: msg.metadata
4361
+ metadata: msg.metadata,
4362
+ precedingMessages: msg.precedingMessages ?? []
4299
4363
  };
4300
4364
  }
4301
4365
  loadPersistedSessions() {
@@ -4386,7 +4450,7 @@ var AgentSlot = class {
4386
4450
  const sdk = (await this.clientConnection.bindAgent(this.config.agentId, this.config.runtimeType ?? this.config.type, this.config.runtimeVersion)).sdk;
4387
4451
  this.sdk = sdk;
4388
4452
  const agent = await sdk.register();
4389
- this.logger.info({ displayName: agent.displayName ?? agent.agentId }, "agent bound");
4453
+ this.logger.info({ displayName: agent.displayName }, "agent bound");
4390
4454
  if (agent.type === "human") {
4391
4455
  this.logger.info("server reports type=human — message processing disabled");
4392
4456
  return agent;
@@ -5373,696 +5437,881 @@ function getContainerPassword() {
5373
5437
  throw new Error("Cannot determine PostgreSQL password from container");
5374
5438
  }
5375
5439
  //#endregion
5376
- //#region src/core/doctor.ts
5377
- function getServerConfig() {
5378
- return resolveConfigReadonly({
5379
- schema: serverConfigSchema,
5380
- role: "server"
5381
- });
5382
- }
5383
- function getClientConfig() {
5384
- return resolveConfigReadonly({
5385
- schema: clientConfigSchema,
5386
- role: "client"
5440
+ //#region src/core/service-install.ts
5441
+ /**
5442
+ * Run a subprocess capturing stderr so failures surface a meaningful error
5443
+ * instead of Node's opaque "Command failed". Used for launchctl/systemctl —
5444
+ * anywhere the stderr message is diagnostically crucial.
5445
+ */
5446
+ function runCapture(program, args, timeoutMs) {
5447
+ const res = spawnSync(program, args, {
5448
+ encoding: "utf-8",
5449
+ timeout: timeoutMs,
5450
+ stdio: [
5451
+ "ignore",
5452
+ "pipe",
5453
+ "pipe"
5454
+ ]
5387
5455
  });
5388
- }
5389
- function get(obj, dotPath) {
5390
- const parts = dotPath.split(".");
5391
- let current = obj;
5392
- for (const part of parts) {
5393
- if (current === null || current === void 0 || typeof current !== "object") return void 0;
5394
- current = current[part];
5395
- }
5396
- return current;
5397
- }
5398
- function checkNodeVersion() {
5399
- const version = process.versions.node;
5400
- const [major] = version.split(".").map(Number);
5401
- const ok = major !== void 0 && major >= 22;
5456
+ if (res.status === 0) return { ok: true };
5402
5457
  return {
5403
- label: "Node.js",
5404
- ok,
5405
- detail: ok ? `v${version}` : `v${version} (requires >= 22.16)`
5458
+ ok: false,
5459
+ stderr: (res.stderr ?? "").trim(),
5460
+ code: res.status
5406
5461
  };
5407
5462
  }
5408
- function checkDocker() {
5463
+ function sleepSync(ms) {
5464
+ const shared = new Int32Array(new SharedArrayBuffer(4));
5465
+ Atomics.wait(shared, 0, 0, ms);
5466
+ }
5467
+ const LAUNCHD_LABEL = "dev.first-tree-hub.client";
5468
+ const SYSTEMD_UNIT = "first-tree-hub-client.service";
5469
+ const LOG_DIR = join(DEFAULT_HOME_DIR$1, "logs");
5470
+ function whichBin(name) {
5409
5471
  try {
5472
+ return execFileSync(process.platform === "win32" ? "where" : "which", [name], {
5473
+ encoding: "utf-8",
5474
+ timeout: 3e3
5475
+ }).split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0] ?? null;
5476
+ } catch {
5477
+ return null;
5478
+ }
5479
+ }
5480
+ /**
5481
+ * Resolve how the service should launch the CLI.
5482
+ *
5483
+ * Prefers the installed `first-tree-hub` bin on PATH (usually a shim under
5484
+ * /usr/local/bin or ~/.npm-global/bin). Falls back to invoking the current
5485
+ * Node interpreter against the running script (handles `pnpm dev`, tsx, and
5486
+ * dev-only global installs).
5487
+ */
5488
+ function resolveCliInvocation() {
5489
+ const bin = whichBin("first-tree-hub");
5490
+ if (bin && isAbsolute(bin)) try {
5410
5491
  return {
5411
- label: "Docker",
5412
- ok: true,
5413
- detail: execFileSync("docker", ["--version"], {
5414
- encoding: "utf-8",
5415
- timeout: 5e3
5416
- }).trim().replace("Docker version ", "v").split(",")[0] ?? ""
5492
+ kind: "bin",
5493
+ program: realpathSync(bin)
5417
5494
  };
5418
5495
  } catch {
5419
5496
  return {
5420
- label: "Docker",
5421
- ok: false,
5422
- detail: "not found (optional — needed for auto PG provisioning)"
5497
+ kind: "bin",
5498
+ program: bin
5423
5499
  };
5424
5500
  }
5425
- }
5426
- function checkServerConfig() {
5427
- const hasFile = existsSync(join(DEFAULT_CONFIG_DIR, "server.yaml"));
5428
- const hasEnv = !!process.env.FIRST_TREE_HUB_DATABASE_URL;
5429
- if (hasFile && hasEnv) return {
5430
- label: "Config",
5431
- ok: true,
5432
- detail: "config file + env vars"
5433
- };
5434
- if (hasFile) return {
5435
- label: "Config",
5436
- ok: true,
5437
- detail: join(DEFAULT_CONFIG_DIR, "server.yaml")
5438
- };
5439
- if (hasEnv) return {
5440
- label: "Config",
5441
- ok: true,
5442
- detail: "via environment variables"
5443
- };
5501
+ const script = process.argv[1];
5502
+ if (!script) throw new Error("Cannot resolve CLI entry point (process.argv[1] is empty).");
5503
+ const scriptAbs = isAbsolute(script) ? script : join(process.cwd(), script);
5444
5504
  return {
5445
- label: "Config",
5446
- ok: false,
5447
- detail: "no config file or env vars found"
5505
+ kind: "node",
5506
+ program: process.execPath,
5507
+ args: [scriptAbs]
5448
5508
  };
5449
5509
  }
5450
- async function checkDatabase() {
5451
- const dbUrl = get(getServerConfig(), "database.url");
5452
- if (typeof dbUrl !== "string" || !dbUrl) return {
5453
- label: "Database",
5454
- ok: false,
5455
- detail: "not configured (FIRST_TREE_HUB_DATABASE_URL or config file)"
5510
+ function ensureLogDir() {
5511
+ mkdirSync(LOG_DIR, {
5512
+ recursive: true,
5513
+ mode: 448
5514
+ });
5515
+ }
5516
+ function launchdPlistPath() {
5517
+ return join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
5518
+ }
5519
+ function renderPlist(invocation) {
5520
+ const argsXml = (invocation.kind === "bin" ? [
5521
+ invocation.program,
5522
+ "client",
5523
+ "start",
5524
+ "--no-interactive"
5525
+ ] : [
5526
+ invocation.program,
5527
+ ...invocation.args,
5528
+ "client",
5529
+ "start",
5530
+ "--no-interactive"
5531
+ ]).map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
5532
+ const stdoutFallback = join(LOG_DIR, "client.stdout.log");
5533
+ const stderrFallback = join(LOG_DIR, "client.stderr.log");
5534
+ return `<?xml version="1.0" encoding="UTF-8"?>
5535
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTD/PropertyList-1.0.dtd">
5536
+ <plist version="1.0">
5537
+ <dict>
5538
+ <key>Label</key>
5539
+ <string>${LAUNCHD_LABEL}</string>
5540
+ <key>ProgramArguments</key>
5541
+ <array>
5542
+ ${argsXml}
5543
+ </array>
5544
+ <key>EnvironmentVariables</key>
5545
+ <dict>
5546
+ <key>PATH</key>
5547
+ <string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
5548
+ <key>FIRST_TREE_HUB_SERVICE_MODE</key>
5549
+ <string>1</string>
5550
+ </dict>
5551
+ <key>RunAtLoad</key>
5552
+ <true/>
5553
+ <key>KeepAlive</key>
5554
+ <dict>
5555
+ <key>SuccessfulExit</key>
5556
+ <false/>
5557
+ </dict>
5558
+ <key>ThrottleInterval</key>
5559
+ <integer>10</integer>
5560
+ <key>StandardOutPath</key>
5561
+ <string>${escapeXml(stdoutFallback)}</string>
5562
+ <key>StandardErrorPath</key>
5563
+ <string>${escapeXml(stderrFallback)}</string>
5564
+ </dict>
5565
+ </plist>
5566
+ `;
5567
+ }
5568
+ function escapeXml(value) {
5569
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
5570
+ }
5571
+ function launchctlDomainTarget() {
5572
+ return `gui/${userInfo().uid}`;
5573
+ }
5574
+ function launchdState() {
5575
+ if (!existsSync(launchdPlistPath())) return { state: "not-installed" };
5576
+ const res = spawnSync("launchctl", ["print", `${launchctlDomainTarget()}/${LAUNCHD_LABEL}`], {
5577
+ encoding: "utf-8",
5578
+ timeout: 5e3,
5579
+ stdio: [
5580
+ "ignore",
5581
+ "pipe",
5582
+ "pipe"
5583
+ ]
5584
+ });
5585
+ if (res.status !== 0) return {
5586
+ state: "inactive",
5587
+ detail: "plist present but not loaded"
5456
5588
  };
5457
- try {
5458
- const { default: pg } = await import("postgres");
5459
- const sql = pg(dbUrl, {
5460
- max: 1,
5461
- connect_timeout: 5,
5462
- idle_timeout: 1
5463
- });
5464
- await sql.unsafe("SELECT 1");
5465
- await sql.end();
5466
- return {
5467
- label: "Database",
5468
- ok: true,
5469
- detail: "connected"
5470
- };
5471
- } catch (err) {
5589
+ const out = res.stdout ?? "";
5590
+ const stateLine = out.split(/\r?\n/).find((l) => l.trim().startsWith("state ="));
5591
+ const pidLine = out.split(/\r?\n/).find((l) => l.trim().startsWith("pid ="));
5592
+ if (stateLine?.includes("running")) {
5593
+ const pid = pidLine?.split("=")[1]?.trim();
5472
5594
  return {
5473
- label: "Database",
5474
- ok: false,
5475
- detail: `unreachable — ${(err instanceof Error ? err.message : String(err)).slice(0, 80)}`
5595
+ state: "active",
5596
+ detail: pid ? `pid ${pid}` : "running"
5476
5597
  };
5477
5598
  }
5599
+ return {
5600
+ state: "inactive",
5601
+ detail: stateLine?.trim() ?? "loaded"
5602
+ };
5478
5603
  }
5479
- async function checkServerHealth() {
5480
- const config = getServerConfig();
5481
- const host = get(config, "server.host") ?? "127.0.0.1";
5482
- const port = get(config, "server.port") ?? 8e3;
5483
- const url = `http://${host}:${port}/healthz`;
5484
- try {
5485
- const res = await fetch(url, { signal: AbortSignal.timeout(3e3) });
5486
- if (res.ok) return {
5487
- label: "Server Health",
5488
- ok: true,
5489
- detail: `running at ${host}:${port}`
5490
- };
5491
- return {
5492
- label: "Server Health",
5493
- ok: false,
5494
- detail: `unhealthy (HTTP ${res.status}) at ${host}:${port}`
5495
- };
5496
- } catch {
5497
- return {
5498
- label: "Server Health",
5499
- ok: false,
5500
- detail: `not running at ${host}:${port}`
5501
- };
5604
+ /**
5605
+ * Poll `launchctl print` until the label disappears, confirming launchd has
5606
+ * finished the async eviction kicked off by `bootout`. Required because
5607
+ * `bootout` returns before the actual unload completes when the service has
5608
+ * active WebSocket connections — a follow-up `bootstrap` against a still-
5609
+ * registered label fails with `Bootstrap failed: 5: Input/output error`.
5610
+ */
5611
+ function waitForLabelEvicted(target, label, timeoutMs) {
5612
+ const deadline = Date.now() + timeoutMs;
5613
+ while (Date.now() < deadline) {
5614
+ if (spawnSync("launchctl", ["print", `${target}/${label}`], {
5615
+ encoding: "utf-8",
5616
+ timeout: 2e3,
5617
+ stdio: [
5618
+ "ignore",
5619
+ "ignore",
5620
+ "pipe"
5621
+ ]
5622
+ }).status !== 0) return true;
5623
+ sleepSync(200);
5502
5624
  }
5625
+ return false;
5503
5626
  }
5504
- function checkClientConfig() {
5505
- const hasFile = existsSync(join(DEFAULT_CONFIG_DIR, "client.yaml"));
5506
- const hasEnv = !!process.env.FIRST_TREE_HUB_SERVER_URL;
5507
- if (hasFile && hasEnv) return {
5508
- label: "Config",
5509
- ok: true,
5510
- detail: "config file + env vars"
5511
- };
5512
- if (hasFile) return {
5513
- label: "Config",
5514
- ok: true,
5515
- detail: join(DEFAULT_CONFIG_DIR, "client.yaml")
5516
- };
5517
- if (hasEnv) return {
5518
- label: "Config",
5519
- ok: true,
5520
- detail: "via environment variables"
5521
- };
5522
- return {
5523
- label: "Config",
5524
- ok: false,
5525
- detail: "no config file or env vars found"
5526
- };
5527
- }
5528
- async function checkServerReachable() {
5529
- const serverUrl = get(getClientConfig(), "server.url");
5530
- if (typeof serverUrl !== "string" || !serverUrl) return {
5531
- label: "Server URL",
5532
- ok: false,
5533
- detail: "not configured (FIRST_TREE_HUB_SERVER_URL or config file)"
5534
- };
5535
- try {
5536
- const res = await fetch(`${serverUrl}/healthz`, { signal: AbortSignal.timeout(5e3) });
5537
- if (res.ok) return {
5538
- label: "Server URL",
5539
- ok: true,
5540
- detail: serverUrl
5541
- };
5542
- return {
5543
- label: "Server URL",
5544
- ok: false,
5545
- detail: `unhealthy (HTTP ${res.status}) at ${serverUrl}`
5546
- };
5547
- } catch {
5548
- return {
5549
- label: "Server URL",
5550
- ok: false,
5551
- detail: `unreachable at ${serverUrl}`
5552
- };
5627
+ function installLaunchd() {
5628
+ const invocation = resolveCliInvocation();
5629
+ ensureLogDir();
5630
+ const plistPath = launchdPlistPath();
5631
+ mkdirSync(dirname(plistPath), { recursive: true });
5632
+ writeFileSync(plistPath, renderPlist(invocation), { mode: 420 });
5633
+ const target = launchctlDomainTarget();
5634
+ const bootoutRes = runCapture("launchctl", ["bootout", `${target}/${LAUNCHD_LABEL}`], 15e3);
5635
+ if (!bootoutRes.ok) {
5636
+ if (!/not find|no such|not loaded/i.test(bootoutRes.stderr)) print.line(` warning: launchctl bootout: ${bootoutRes.stderr || `exit ${bootoutRes.code ?? "unknown"}`}\n`);
5553
5637
  }
5554
- }
5555
- function checkAgentConfigs() {
5556
- const agentsDir = join(DEFAULT_CONFIG_DIR, "agents");
5557
- if (!existsSync(agentsDir)) return {
5558
- label: "Agents",
5559
- ok: false,
5560
- detail: "no agents configured"
5561
- };
5562
- try {
5563
- const agents = loadAgents({
5564
- schema: agentConfigSchema,
5565
- agentsDir
5566
- });
5567
- if (agents.size === 0) return {
5568
- label: "Agents",
5569
- ok: false,
5570
- detail: "no agents configured"
5571
- };
5572
- const names = [...agents.keys()].join(", ");
5573
- return {
5574
- label: "Agents",
5575
- ok: true,
5576
- detail: `${agents.size} configured (${names})`
5577
- };
5578
- } catch {
5579
- return {
5580
- label: "Agents",
5581
- ok: false,
5582
- detail: "error reading agent configs"
5583
- };
5638
+ waitForLabelEvicted(target, LAUNCHD_LABEL, 1e4);
5639
+ let lastBootstrapErr = null;
5640
+ for (let attempt = 1; attempt <= 2; attempt++) {
5641
+ const res = runCapture("launchctl", [
5642
+ "bootstrap",
5643
+ target,
5644
+ plistPath
5645
+ ], 1e4);
5646
+ if (res.ok) {
5647
+ lastBootstrapErr = null;
5648
+ break;
5649
+ }
5650
+ lastBootstrapErr = res;
5651
+ if (attempt < 2) sleepSync(1e3);
5584
5652
  }
5585
- }
5586
- async function checkWebSocket() {
5587
- const serverUrl = get(getClientConfig(), "server.url");
5588
- if (typeof serverUrl !== "string" || !serverUrl) return {
5589
- label: "WebSocket",
5590
- ok: false,
5591
- detail: "cannot check (no server URL)"
5653
+ if (lastBootstrapErr) throw new Error(`launchctl bootstrap failed: ${lastBootstrapErr.stderr || `exit ${lastBootstrapErr.code ?? "unknown"}`}\n Command: launchctl bootstrap ${target} ${plistPath}\n Recovery: \`launchctl bootout ${target}/${LAUNCHD_LABEL}\` then \`first-tree-hub client connect <server-url>\`.`);
5654
+ const enableRes = runCapture("launchctl", ["enable", `${target}/${LAUNCHD_LABEL}`], 5e3);
5655
+ if (!enableRes.ok) print.line(` warning: launchctl enable: ${enableRes.stderr || `exit ${enableRes.code ?? "unknown"}`}\n`);
5656
+ const { state, detail } = launchdState();
5657
+ return {
5658
+ platform: "launchd",
5659
+ label: LAUNCHD_LABEL,
5660
+ unitPath: plistPath,
5661
+ logDir: LOG_DIR,
5662
+ state,
5663
+ detail
5592
5664
  };
5593
- const wsUrl = serverUrl.replace(/^http/, "ws");
5594
- try {
5595
- if ((await fetch(`${serverUrl}/healthz`, { signal: AbortSignal.timeout(3e3) })).ok) return {
5596
- label: "WebSocket",
5597
- ok: true,
5598
- detail: `${wsUrl} (server reachable)`
5599
- };
5600
- return {
5601
- label: "WebSocket",
5602
- ok: false,
5603
- detail: "server not healthy"
5604
- };
5605
- } catch {
5606
- return {
5607
- label: "WebSocket",
5608
- ok: false,
5609
- detail: `server unreachable at ${serverUrl}`
5610
- };
5611
- }
5612
5665
  }
5613
- function printResults(results) {
5614
- for (const r of results) {
5615
- const icon = r.ok ? "" : "✗";
5616
- print.line(` ${icon} ${r.label.padEnd(22)} ${r.detail}\n`);
5617
- }
5618
- blank();
5619
- const failures = results.filter((r) => !r.ok);
5620
- if (failures.length === 0) print.line(" All checks passed.\n");
5621
- else print.line(` ${failures.length} issue(s) found.\n`);
5622
- blank();
5666
+ function uninstallLaunchd() {
5667
+ const plistPath = launchdPlistPath();
5668
+ const res = runCapture("launchctl", ["bootout", `${launchctlDomainTarget()}/${LAUNCHD_LABEL}`], 15e3);
5669
+ if (!res.ok && !/not find|no such|not loaded/i.test(res.stderr)) print.line(` warning: bootout during uninstall: ${res.stderr || `exit ${res.code ?? "unknown"}`}\n`);
5670
+ if (existsSync(plistPath)) rmSync(plistPath);
5671
+ return {
5672
+ platform: "launchd",
5673
+ label: LAUNCHD_LABEL,
5674
+ unitPath: plistPath,
5675
+ logDir: LOG_DIR,
5676
+ state: "not-installed"
5677
+ };
5623
5678
  }
5624
- //#endregion
5625
- //#region src/core/migrate.ts
5626
- /**
5627
- * Resolve the drizzle migrations directory.
5628
- * 1. npm install: embedded at dist/drizzle/ (relative to the built CLI)
5629
- * 2. Monorepo dev: resolved from @first-tree-hub/server package
5630
- */
5631
- function resolveMigrationsFolder() {
5632
- const embeddedPath = join(dirname(fileURLToPath(import.meta.url)), "..", "drizzle");
5633
- if (existsSync(embeddedPath)) return embeddedPath;
5634
- return join(dirname(fileURLToPath(import.meta.resolve("@first-tree-hub/server/package.json"))), "drizzle");
5679
+ function systemdUnitPath() {
5680
+ return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "systemd", "user", SYSTEMD_UNIT);
5635
5681
  }
5636
- /**
5637
- * Validate that migration journal timestamps are strictly increasing.
5638
- * Drizzle silently skips migrations whose `when` is <= the last applied
5639
- * timestamp, which causes missing columns/tables with no error.
5640
- */
5641
- function validateJournalOrder(migrationsFolder) {
5642
- const journalPath = join(migrationsFolder, "meta", "_journal.json");
5643
- if (!existsSync(journalPath)) return;
5644
- const journal = JSON.parse(readFileSync(journalPath, "utf-8"));
5645
- let prevWhen = 0;
5646
- let prevTag = "";
5647
- for (const entry of journal.entries) {
5648
- if (entry.when <= prevWhen) throw new Error(`Migration journal timestamps are not monotonically increasing:\n "${prevTag}" (when: ${prevWhen}) >= "${entry.tag}" (when: ${entry.when})\n Drizzle will silently skip "${entry.tag}". Fix the 'when' values in:\n ${journalPath}`);
5649
- prevWhen = entry.when;
5650
- prevTag = entry.tag;
5651
- }
5682
+ function renderSystemdUnit(invocation) {
5683
+ return `[Unit]
5684
+ Description=First Tree Hub Client
5685
+ After=network-online.target
5686
+ Wants=network-online.target
5687
+
5688
+ [Service]
5689
+ Type=simple
5690
+ ExecStart=${invocation.kind === "bin" ? `${shellQuote(invocation.program)} client start --no-interactive` : `${shellQuote(invocation.program)} ${invocation.args.map(shellQuote).join(" ")} client start --no-interactive`}
5691
+ Restart=always
5692
+ RestartSec=10
5693
+ StandardOutput=append:${join(LOG_DIR, "client.stdout.log")}
5694
+ StandardError=append:${join(LOG_DIR, "client.stderr.log")}
5695
+ Environment=PATH=/usr/local/bin:/usr/bin:/bin
5696
+ Environment=FIRST_TREE_HUB_SERVICE_MODE=1
5697
+
5698
+ [Install]
5699
+ WantedBy=default.target
5700
+ `;
5652
5701
  }
5653
- /**
5654
- * Run Drizzle database migrations.
5655
- */
5656
- async function runMigrations(databaseUrl) {
5657
- const migrationsFolder = resolveMigrationsFolder();
5658
- validateJournalOrder(migrationsFolder);
5659
- const client = postgres(databaseUrl, { max: 1 });
5660
- const db = drizzle(client);
5661
- try {
5662
- await migrate(db, { migrationsFolder });
5663
- } finally {
5664
- await client.end();
5665
- }
5666
- const countClient = postgres(databaseUrl, { max: 1 });
5667
- try {
5668
- return (await countClient`
5669
- SELECT count(*)::int AS count
5670
- FROM information_schema.tables
5671
- WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
5672
- `)[0].count;
5673
- } finally {
5674
- await countClient.end();
5675
- }
5702
+ function shellQuote(value) {
5703
+ if (/^[A-Za-z0-9_\-./:=]+$/.test(value)) return value;
5704
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
5676
5705
  }
5677
- //#endregion
5678
- //#region src/core/service-install.ts
5679
- /**
5680
- * Run a subprocess capturing stderr so failures surface a meaningful error
5681
- * instead of Node's opaque "Command failed". Used for launchctl/systemctl —
5682
- * anywhere the stderr message is diagnostically crucial.
5683
- */
5684
- function runCapture(program, args, timeoutMs) {
5685
- const res = spawnSync(program, args, {
5706
+ function systemdState() {
5707
+ if (!existsSync(systemdUnitPath())) return { state: "not-installed" };
5708
+ const res = spawnSync("systemctl", [
5709
+ "--user",
5710
+ "is-active",
5711
+ SYSTEMD_UNIT
5712
+ ], {
5686
5713
  encoding: "utf-8",
5687
- timeout: timeoutMs,
5714
+ timeout: 5e3,
5688
5715
  stdio: [
5689
5716
  "ignore",
5690
5717
  "pipe",
5691
5718
  "pipe"
5692
5719
  ]
5693
5720
  });
5694
- if (res.status === 0) return { ok: true };
5721
+ const out = (res.stdout ?? "").trim();
5722
+ if (res.status === 0 && out === "active") return {
5723
+ state: "active",
5724
+ detail: "running"
5725
+ };
5695
5726
  return {
5696
- ok: false,
5697
- stderr: (res.stderr ?? "").trim(),
5698
- code: res.status
5727
+ state: "inactive",
5728
+ detail: out || "unit present but not active"
5729
+ };
5730
+ }
5731
+ function installSystemd() {
5732
+ const invocation = resolveCliInvocation();
5733
+ ensureLogDir();
5734
+ const unitPath = systemdUnitPath();
5735
+ mkdirSync(dirname(unitPath), { recursive: true });
5736
+ writeFileSync(unitPath, renderSystemdUnit(invocation), { mode: 420 });
5737
+ const reloadRes = runCapture("systemctl", ["--user", "daemon-reload"], 5e3);
5738
+ if (!reloadRes.ok) throw new Error(`systemctl --user daemon-reload failed: ${reloadRes.stderr || `exit ${reloadRes.code ?? "unknown"}`}`);
5739
+ const enableRes = runCapture("systemctl", [
5740
+ "--user",
5741
+ "enable",
5742
+ "--now",
5743
+ SYSTEMD_UNIT
5744
+ ], 1e4);
5745
+ if (!enableRes.ok) throw new Error(`systemctl --user enable --now ${SYSTEMD_UNIT} failed: ${enableRes.stderr || `exit ${enableRes.code ?? "unknown"}`}\n Recovery: \`systemctl --user stop ${SYSTEMD_UNIT}\` then \`first-tree-hub client connect <server-url>\`.`);
5746
+ const { state, detail } = systemdState();
5747
+ return {
5748
+ platform: "systemd",
5749
+ label: SYSTEMD_UNIT,
5750
+ unitPath,
5751
+ logDir: LOG_DIR,
5752
+ state,
5753
+ detail
5754
+ };
5755
+ }
5756
+ function uninstallSystemd() {
5757
+ const unitPath = systemdUnitPath();
5758
+ const disableRes = runCapture("systemctl", [
5759
+ "--user",
5760
+ "disable",
5761
+ "--now",
5762
+ SYSTEMD_UNIT
5763
+ ], 1e4);
5764
+ if (!disableRes.ok && !/not found|no such|not loaded/i.test(disableRes.stderr)) print.line(` warning: systemctl disable during uninstall: ${disableRes.stderr || `exit ${disableRes.code ?? "unknown"}`}\n`);
5765
+ if (existsSync(unitPath)) rmSync(unitPath);
5766
+ const reloadRes = runCapture("systemctl", ["--user", "daemon-reload"], 5e3);
5767
+ if (!reloadRes.ok) print.line(` warning: systemctl daemon-reload during uninstall: ${reloadRes.stderr || `exit ${reloadRes.code ?? "unknown"}`}\n`);
5768
+ return {
5769
+ platform: "systemd",
5770
+ label: SYSTEMD_UNIT,
5771
+ unitPath,
5772
+ logDir: LOG_DIR,
5773
+ state: "not-installed"
5699
5774
  };
5700
5775
  }
5701
- function sleepSync(ms) {
5702
- const shared = new Int32Array(new SharedArrayBuffer(4));
5703
- Atomics.wait(shared, 0, 0, ms);
5704
- }
5705
- const LAUNCHD_LABEL = "dev.first-tree-hub.client";
5706
- const SYSTEMD_UNIT = "first-tree-hub-client.service";
5707
- const LOG_DIR$1 = join(DEFAULT_HOME_DIR$1, "logs");
5708
- function whichBin(name) {
5709
- try {
5710
- return execFileSync(process.platform === "win32" ? "where" : "which", [name], {
5711
- encoding: "utf-8",
5712
- timeout: 3e3
5713
- }).split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0] ?? null;
5714
- } catch {
5715
- return null;
5716
- }
5776
+ /** Is background-service install supported on the current platform? */
5777
+ function isServiceSupported() {
5778
+ return process.platform === "darwin" || process.platform === "linux";
5717
5779
  }
5718
5780
  /**
5719
- * Resolve how the service should launch the CLI.
5781
+ * Install the background service for the current platform.
5720
5782
  *
5721
- * Prefers the installed `first-tree-hub` bin on PATH (usually a shim under
5722
- * /usr/local/bin or ~/.npm-global/bin). Falls back to invoking the current
5723
- * Node interpreter against the running script (handles `pnpm dev`, tsx, and
5724
- * dev-only global installs).
5783
+ * @throws {Error} if the platform is not supported or the service manager fails.
5725
5784
  */
5726
- function resolveCliInvocation() {
5727
- const bin = whichBin("first-tree-hub");
5728
- if (bin && isAbsolute(bin)) try {
5785
+ function installClientService() {
5786
+ if (process.platform === "darwin") return installLaunchd();
5787
+ if (process.platform === "linux") return installSystemd();
5788
+ throw new Error(`Background service install is not supported on ${process.platform}. Run \`first-tree-hub client start\` manually to keep the computer online.`);
5789
+ }
5790
+ /** Report the current service state without modifying anything. */
5791
+ function getClientServiceStatus() {
5792
+ if (process.platform === "darwin") {
5793
+ const { state, detail } = launchdState();
5729
5794
  return {
5730
- kind: "bin",
5731
- program: realpathSync(bin)
5795
+ platform: "launchd",
5796
+ label: LAUNCHD_LABEL,
5797
+ unitPath: launchdPlistPath(),
5798
+ logDir: LOG_DIR,
5799
+ state,
5800
+ detail
5732
5801
  };
5733
- } catch {
5802
+ }
5803
+ if (process.platform === "linux") {
5804
+ const { state, detail } = systemdState();
5734
5805
  return {
5735
- kind: "bin",
5736
- program: bin
5806
+ platform: "systemd",
5807
+ label: SYSTEMD_UNIT,
5808
+ unitPath: systemdUnitPath(),
5809
+ logDir: LOG_DIR,
5810
+ state,
5811
+ detail
5737
5812
  };
5738
5813
  }
5739
- const script = process.argv[1];
5740
- if (!script) throw new Error("Cannot resolve CLI entry point (process.argv[1] is empty).");
5741
- const scriptAbs = isAbsolute(script) ? script : join(process.cwd(), script);
5742
5814
  return {
5743
- kind: "node",
5744
- program: process.execPath,
5745
- args: [scriptAbs]
5815
+ platform: "unsupported",
5816
+ label: "",
5817
+ unitPath: "",
5818
+ logDir: LOG_DIR,
5819
+ state: "not-installed",
5820
+ detail: `platform ${process.platform} not supported`
5746
5821
  };
5747
5822
  }
5748
- function ensureLogDir() {
5749
- mkdirSync(LOG_DIR$1, {
5750
- recursive: true,
5751
- mode: 448
5823
+ /** Uninstall the background service. No-op if not installed. */
5824
+ function uninstallClientService() {
5825
+ if (process.platform === "darwin") return uninstallLaunchd();
5826
+ if (process.platform === "linux") return uninstallSystemd();
5827
+ return getClientServiceStatus();
5828
+ }
5829
+ //#endregion
5830
+ //#region src/core/doctor.ts
5831
+ function getServerConfig() {
5832
+ return resolveConfigReadonly({
5833
+ schema: serverConfigSchema,
5834
+ role: "server"
5752
5835
  });
5753
5836
  }
5754
- function launchdPlistPath() {
5755
- return join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
5837
+ function getClientConfig() {
5838
+ return resolveConfigReadonly({
5839
+ schema: clientConfigSchema,
5840
+ role: "client"
5841
+ });
5756
5842
  }
5757
- function renderPlist(invocation) {
5758
- const argsXml = (invocation.kind === "bin" ? [
5759
- invocation.program,
5760
- "client",
5761
- "start",
5762
- "--no-interactive"
5763
- ] : [
5764
- invocation.program,
5765
- ...invocation.args,
5766
- "client",
5767
- "start",
5768
- "--no-interactive"
5769
- ]).map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
5770
- const stdoutFallback = join(LOG_DIR$1, "client.stdout.log");
5771
- const stderrFallback = join(LOG_DIR$1, "client.stderr.log");
5772
- return `<?xml version="1.0" encoding="UTF-8"?>
5773
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTD/PropertyList-1.0.dtd">
5774
- <plist version="1.0">
5775
- <dict>
5776
- <key>Label</key>
5777
- <string>${LAUNCHD_LABEL}</string>
5778
- <key>ProgramArguments</key>
5779
- <array>
5780
- ${argsXml}
5781
- </array>
5782
- <key>EnvironmentVariables</key>
5783
- <dict>
5784
- <key>PATH</key>
5785
- <string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
5786
- <key>FIRST_TREE_HUB_SERVICE_MODE</key>
5787
- <string>1</string>
5788
- </dict>
5789
- <key>RunAtLoad</key>
5790
- <true/>
5791
- <key>KeepAlive</key>
5792
- <dict>
5793
- <key>SuccessfulExit</key>
5794
- <false/>
5795
- </dict>
5796
- <key>ThrottleInterval</key>
5797
- <integer>10</integer>
5798
- <key>StandardOutPath</key>
5799
- <string>${escapeXml(stdoutFallback)}</string>
5800
- <key>StandardErrorPath</key>
5801
- <string>${escapeXml(stderrFallback)}</string>
5802
- </dict>
5803
- </plist>
5804
- `;
5843
+ function get(obj, dotPath) {
5844
+ const parts = dotPath.split(".");
5845
+ let current = obj;
5846
+ for (const part of parts) {
5847
+ if (current === null || current === void 0 || typeof current !== "object") return void 0;
5848
+ current = current[part];
5849
+ }
5850
+ return current;
5805
5851
  }
5806
- function escapeXml(value) {
5807
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
5852
+ function checkNodeVersion() {
5853
+ const version = process.versions.node;
5854
+ const [major] = version.split(".").map(Number);
5855
+ const ok = major !== void 0 && major >= 22;
5856
+ return {
5857
+ label: "Node.js",
5858
+ ok,
5859
+ detail: ok ? `v${version}` : `v${version} (requires >= 22.16)`
5860
+ };
5808
5861
  }
5809
- function launchctlDomainTarget() {
5810
- return `gui/${userInfo().uid}`;
5862
+ function checkDocker() {
5863
+ try {
5864
+ return {
5865
+ label: "Docker",
5866
+ ok: true,
5867
+ detail: execFileSync("docker", ["--version"], {
5868
+ encoding: "utf-8",
5869
+ timeout: 5e3
5870
+ }).trim().replace("Docker version ", "v").split(",")[0] ?? ""
5871
+ };
5872
+ } catch {
5873
+ return {
5874
+ label: "Docker",
5875
+ ok: false,
5876
+ detail: "not found (optional — needed for auto PG provisioning)"
5877
+ };
5878
+ }
5811
5879
  }
5812
- function launchdState() {
5813
- if (!existsSync(launchdPlistPath())) return { state: "not-installed" };
5814
- const res = spawnSync("launchctl", ["print", `${launchctlDomainTarget()}/${LAUNCHD_LABEL}`], {
5815
- encoding: "utf-8",
5816
- timeout: 5e3,
5817
- stdio: [
5818
- "ignore",
5819
- "pipe",
5820
- "pipe"
5821
- ]
5822
- });
5823
- if (res.status !== 0) return {
5824
- state: "inactive",
5825
- detail: "plist present but not loaded"
5880
+ function checkServerConfig() {
5881
+ const hasFile = existsSync(join(DEFAULT_CONFIG_DIR, "server.yaml"));
5882
+ const hasEnv = !!process.env.FIRST_TREE_HUB_DATABASE_URL;
5883
+ if (hasFile && hasEnv) return {
5884
+ label: "Config",
5885
+ ok: true,
5886
+ detail: "config file + env vars"
5826
5887
  };
5827
- const out = res.stdout ?? "";
5828
- const stateLine = out.split(/\r?\n/).find((l) => l.trim().startsWith("state ="));
5829
- const pidLine = out.split(/\r?\n/).find((l) => l.trim().startsWith("pid ="));
5830
- if (stateLine?.includes("running")) {
5831
- const pid = pidLine?.split("=")[1]?.trim();
5888
+ if (hasFile) return {
5889
+ label: "Config",
5890
+ ok: true,
5891
+ detail: join(DEFAULT_CONFIG_DIR, "server.yaml")
5892
+ };
5893
+ if (hasEnv) return {
5894
+ label: "Config",
5895
+ ok: true,
5896
+ detail: "via environment variables"
5897
+ };
5898
+ return {
5899
+ label: "Config",
5900
+ ok: false,
5901
+ detail: "no config file or env vars found"
5902
+ };
5903
+ }
5904
+ async function checkDatabase() {
5905
+ const dbUrl = get(getServerConfig(), "database.url");
5906
+ if (typeof dbUrl !== "string" || !dbUrl) return {
5907
+ label: "Database",
5908
+ ok: false,
5909
+ detail: "not configured (FIRST_TREE_HUB_DATABASE_URL or config file)"
5910
+ };
5911
+ try {
5912
+ const { default: pg } = await import("postgres");
5913
+ const sql = pg(dbUrl, {
5914
+ max: 1,
5915
+ connect_timeout: 5,
5916
+ idle_timeout: 1
5917
+ });
5918
+ await sql.unsafe("SELECT 1");
5919
+ await sql.end();
5920
+ return {
5921
+ label: "Database",
5922
+ ok: true,
5923
+ detail: "connected"
5924
+ };
5925
+ } catch (err) {
5926
+ return {
5927
+ label: "Database",
5928
+ ok: false,
5929
+ detail: `unreachable — ${(err instanceof Error ? err.message : String(err)).slice(0, 80)}`
5930
+ };
5931
+ }
5932
+ }
5933
+ async function checkServerHealth() {
5934
+ const config = getServerConfig();
5935
+ const host = get(config, "server.host") ?? "127.0.0.1";
5936
+ const port = get(config, "server.port") ?? 8e3;
5937
+ const url = `http://${host}:${port}/healthz`;
5938
+ try {
5939
+ const res = await fetch(url, { signal: AbortSignal.timeout(3e3) });
5940
+ if (res.ok) return {
5941
+ label: "Server Health",
5942
+ ok: true,
5943
+ detail: `running at ${host}:${port}`
5944
+ };
5832
5945
  return {
5833
- state: "active",
5834
- detail: pid ? `pid ${pid}` : "running"
5946
+ label: "Server Health",
5947
+ ok: false,
5948
+ detail: `unhealthy (HTTP ${res.status}) at ${host}:${port}`
5949
+ };
5950
+ } catch {
5951
+ return {
5952
+ label: "Server Health",
5953
+ ok: false,
5954
+ detail: `not running at ${host}:${port}`
5835
5955
  };
5836
5956
  }
5957
+ }
5958
+ function checkClientConfig() {
5959
+ const hasFile = existsSync(join(DEFAULT_CONFIG_DIR, "client.yaml"));
5960
+ const hasEnv = !!process.env.FIRST_TREE_HUB_SERVER_URL;
5961
+ if (hasFile && hasEnv) return {
5962
+ label: "Config",
5963
+ ok: true,
5964
+ detail: "config file + env vars"
5965
+ };
5966
+ if (hasFile) return {
5967
+ label: "Config",
5968
+ ok: true,
5969
+ detail: join(DEFAULT_CONFIG_DIR, "client.yaml")
5970
+ };
5971
+ if (hasEnv) return {
5972
+ label: "Config",
5973
+ ok: true,
5974
+ detail: "via environment variables"
5975
+ };
5837
5976
  return {
5838
- state: "inactive",
5839
- detail: stateLine?.trim() ?? "loaded"
5977
+ label: "Config",
5978
+ ok: false,
5979
+ detail: "no config file or env vars found"
5840
5980
  };
5841
5981
  }
5842
- /**
5843
- * Poll `launchctl print` until the label disappears, confirming launchd has
5844
- * finished the async eviction kicked off by `bootout`. Required because
5845
- * `bootout` returns before the actual unload completes when the service has
5846
- * active WebSocket connections — a follow-up `bootstrap` against a still-
5847
- * registered label fails with `Bootstrap failed: 5: Input/output error`.
5848
- */
5849
- function waitForLabelEvicted(target, label, timeoutMs) {
5850
- const deadline = Date.now() + timeoutMs;
5851
- while (Date.now() < deadline) {
5852
- if (spawnSync("launchctl", ["print", `${target}/${label}`], {
5853
- encoding: "utf-8",
5854
- timeout: 2e3,
5855
- stdio: [
5856
- "ignore",
5857
- "ignore",
5858
- "pipe"
5859
- ]
5860
- }).status !== 0) return true;
5861
- sleepSync(200);
5982
+ async function checkServerReachable() {
5983
+ const serverUrl = get(getClientConfig(), "server.url");
5984
+ if (typeof serverUrl !== "string" || !serverUrl) return {
5985
+ label: "Server URL",
5986
+ ok: false,
5987
+ detail: "not configured (FIRST_TREE_HUB_SERVER_URL or config file)"
5988
+ };
5989
+ try {
5990
+ const res = await fetch(`${serverUrl}/healthz`, { signal: AbortSignal.timeout(5e3) });
5991
+ if (res.ok) return {
5992
+ label: "Server URL",
5993
+ ok: true,
5994
+ detail: serverUrl
5995
+ };
5996
+ return {
5997
+ label: "Server URL",
5998
+ ok: false,
5999
+ detail: `unhealthy (HTTP ${res.status}) at ${serverUrl}`
6000
+ };
6001
+ } catch {
6002
+ return {
6003
+ label: "Server URL",
6004
+ ok: false,
6005
+ detail: `unreachable at ${serverUrl}`
6006
+ };
5862
6007
  }
5863
- return false;
5864
6008
  }
5865
- function installLaunchd() {
5866
- const invocation = resolveCliInvocation();
5867
- ensureLogDir();
5868
- const plistPath = launchdPlistPath();
5869
- mkdirSync(dirname(plistPath), { recursive: true });
5870
- writeFileSync(plistPath, renderPlist(invocation), { mode: 420 });
5871
- const target = launchctlDomainTarget();
5872
- const bootoutRes = runCapture("launchctl", ["bootout", `${target}/${LAUNCHD_LABEL}`], 15e3);
5873
- if (!bootoutRes.ok) {
5874
- if (!/not find|no such|not loaded/i.test(bootoutRes.stderr)) print.line(` warning: launchctl bootout: ${bootoutRes.stderr || `exit ${bootoutRes.code ?? "unknown"}`}\n`);
5875
- }
5876
- waitForLabelEvicted(target, LAUNCHD_LABEL, 1e4);
5877
- let lastBootstrapErr = null;
5878
- for (let attempt = 1; attempt <= 2; attempt++) {
5879
- const res = runCapture("launchctl", [
5880
- "bootstrap",
5881
- target,
5882
- plistPath
5883
- ], 1e4);
5884
- if (res.ok) {
5885
- lastBootstrapErr = null;
5886
- break;
5887
- }
5888
- lastBootstrapErr = res;
5889
- if (attempt < 2) sleepSync(1e3);
5890
- }
5891
- if (lastBootstrapErr) throw new Error(`launchctl bootstrap failed: ${lastBootstrapErr.stderr || `exit ${lastBootstrapErr.code ?? "unknown"}`}\n Command: launchctl bootstrap ${target} ${plistPath}\n Recovery: \`launchctl bootout ${target}/${LAUNCHD_LABEL}\` then \`first-tree-hub service install\`.`);
5892
- const enableRes = runCapture("launchctl", ["enable", `${target}/${LAUNCHD_LABEL}`], 5e3);
5893
- if (!enableRes.ok) print.line(` warning: launchctl enable: ${enableRes.stderr || `exit ${enableRes.code ?? "unknown"}`}\n`);
5894
- const { state, detail } = launchdState();
5895
- return {
5896
- platform: "launchd",
5897
- label: LAUNCHD_LABEL,
5898
- unitPath: plistPath,
5899
- logDir: LOG_DIR$1,
5900
- state,
5901
- detail
6009
+ function checkAgentConfigs() {
6010
+ const agentsDir = join(DEFAULT_CONFIG_DIR, "agents");
6011
+ if (!existsSync(agentsDir)) return {
6012
+ label: "Agents",
6013
+ ok: false,
6014
+ detail: "no agents configured"
5902
6015
  };
6016
+ try {
6017
+ const agents = loadAgents({
6018
+ schema: agentConfigSchema,
6019
+ agentsDir
6020
+ });
6021
+ if (agents.size === 0) return {
6022
+ label: "Agents",
6023
+ ok: false,
6024
+ detail: "no agents configured"
6025
+ };
6026
+ const names = [...agents.keys()].join(", ");
6027
+ return {
6028
+ label: "Agents",
6029
+ ok: true,
6030
+ detail: `${agents.size} configured (${names})`
6031
+ };
6032
+ } catch {
6033
+ return {
6034
+ label: "Agents",
6035
+ ok: false,
6036
+ detail: "error reading agent configs"
6037
+ };
6038
+ }
5903
6039
  }
5904
- function uninstallLaunchd() {
5905
- const plistPath = launchdPlistPath();
5906
- const res = runCapture("launchctl", ["bootout", `${launchctlDomainTarget()}/${LAUNCHD_LABEL}`], 15e3);
5907
- if (!res.ok && !/not find|no such|not loaded/i.test(res.stderr)) print.line(` warning: bootout during uninstall: ${res.stderr || `exit ${res.code ?? "unknown"}`}\n`);
5908
- if (existsSync(plistPath)) rmSync(plistPath);
6040
+ function checkBackgroundService() {
6041
+ const info = getClientServiceStatus();
6042
+ if (info.platform === "unsupported") return {
6043
+ label: "Background service",
6044
+ ok: true,
6045
+ detail: `not supported on ${process.platform} — runs inline`
6046
+ };
6047
+ if (info.state === "active") return {
6048
+ label: "Background service",
6049
+ ok: true,
6050
+ detail: `running (${info.platform}${info.detail ? `, ${info.detail}` : ""}); logs at ${info.logDir}`
6051
+ };
6052
+ if (info.state === "inactive") return {
6053
+ label: "Background service",
6054
+ ok: false,
6055
+ detail: `installed but not running${info.detail ? ` — ${info.detail}` : ""}; unit at ${info.unitPath}`
6056
+ };
5909
6057
  return {
5910
- platform: "launchd",
5911
- label: LAUNCHD_LABEL,
5912
- unitPath: plistPath,
5913
- logDir: LOG_DIR$1,
5914
- state: "not-installed"
6058
+ label: "Background service",
6059
+ ok: false,
6060
+ detail: "not installed — re-run `first-tree-hub client connect <url>` to install"
5915
6061
  };
5916
6062
  }
5917
- function systemdUnitPath() {
5918
- return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "systemd", "user", SYSTEMD_UNIT);
5919
- }
5920
- function renderSystemdUnit(invocation) {
5921
- return `[Unit]
5922
- Description=First Tree Hub Client
5923
- After=network-online.target
5924
- Wants=network-online.target
5925
-
5926
- [Service]
5927
- Type=simple
5928
- ExecStart=${invocation.kind === "bin" ? `${shellQuote(invocation.program)} client start --no-interactive` : `${shellQuote(invocation.program)} ${invocation.args.map(shellQuote).join(" ")} client start --no-interactive`}
5929
- Restart=always
5930
- RestartSec=10
5931
- StandardOutput=append:${join(LOG_DIR$1, "client.stdout.log")}
5932
- StandardError=append:${join(LOG_DIR$1, "client.stderr.log")}
5933
- Environment=PATH=/usr/local/bin:/usr/bin:/bin
5934
- Environment=FIRST_TREE_HUB_SERVICE_MODE=1
5935
-
5936
- [Install]
5937
- WantedBy=default.target
5938
- `;
6063
+ async function checkWebSocket() {
6064
+ const serverUrl = get(getClientConfig(), "server.url");
6065
+ if (typeof serverUrl !== "string" || !serverUrl) return {
6066
+ label: "WebSocket",
6067
+ ok: false,
6068
+ detail: "cannot check (no server URL)"
6069
+ };
6070
+ const wsUrl = serverUrl.replace(/^http/, "ws");
6071
+ try {
6072
+ if ((await fetch(`${serverUrl}/healthz`, { signal: AbortSignal.timeout(3e3) })).ok) return {
6073
+ label: "WebSocket",
6074
+ ok: true,
6075
+ detail: `${wsUrl} (server reachable)`
6076
+ };
6077
+ return {
6078
+ label: "WebSocket",
6079
+ ok: false,
6080
+ detail: "server not healthy"
6081
+ };
6082
+ } catch {
6083
+ return {
6084
+ label: "WebSocket",
6085
+ ok: false,
6086
+ detail: `server unreachable at ${serverUrl}`
6087
+ };
6088
+ }
5939
6089
  }
5940
- function shellQuote(value) {
5941
- if (/^[A-Za-z0-9_\-./:=]+$/.test(value)) return value;
5942
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
6090
+ function printResults(results) {
6091
+ for (const r of results) {
6092
+ const icon = r.ok ? "" : "";
6093
+ print.line(` ${icon} ${r.label.padEnd(22)} ${r.detail}\n`);
6094
+ }
6095
+ blank();
6096
+ const failures = results.filter((r) => !r.ok);
6097
+ if (failures.length === 0) print.line(" All checks passed.\n");
6098
+ else print.line(` ${failures.length} issue(s) found.\n`);
6099
+ blank();
5943
6100
  }
5944
- function systemdState() {
5945
- if (!existsSync(systemdUnitPath())) return { state: "not-installed" };
5946
- const res = spawnSync("systemctl", [
5947
- "--user",
5948
- "is-active",
5949
- SYSTEMD_UNIT
5950
- ], {
5951
- encoding: "utf-8",
5952
- timeout: 5e3,
5953
- stdio: [
5954
- "ignore",
5955
- "pipe",
5956
- "pipe"
5957
- ]
5958
- });
5959
- const out = (res.stdout ?? "").trim();
5960
- if (res.status === 0 && out === "active") return {
5961
- state: "active",
5962
- detail: "running"
5963
- };
5964
- return {
5965
- state: "inactive",
5966
- detail: out || "unit present but not active"
5967
- };
6101
+ //#endregion
6102
+ //#region src/core/migrate.ts
6103
+ /**
6104
+ * Resolve the drizzle migrations directory.
6105
+ * 1. npm install: embedded at dist/drizzle/ (relative to the built CLI)
6106
+ * 2. Monorepo dev: resolved from @first-tree-hub/server package
6107
+ */
6108
+ function resolveMigrationsFolder() {
6109
+ const embeddedPath = join(dirname(fileURLToPath(import.meta.url)), "..", "drizzle");
6110
+ if (existsSync(embeddedPath)) return embeddedPath;
6111
+ return join(dirname(fileURLToPath(import.meta.resolve("@first-tree-hub/server/package.json"))), "drizzle");
5968
6112
  }
5969
- function installSystemd() {
5970
- const invocation = resolveCliInvocation();
5971
- ensureLogDir();
5972
- const unitPath = systemdUnitPath();
5973
- mkdirSync(dirname(unitPath), { recursive: true });
5974
- writeFileSync(unitPath, renderSystemdUnit(invocation), { mode: 420 });
5975
- const reloadRes = runCapture("systemctl", ["--user", "daemon-reload"], 5e3);
5976
- if (!reloadRes.ok) throw new Error(`systemctl --user daemon-reload failed: ${reloadRes.stderr || `exit ${reloadRes.code ?? "unknown"}`}`);
5977
- const enableRes = runCapture("systemctl", [
5978
- "--user",
5979
- "enable",
5980
- "--now",
5981
- SYSTEMD_UNIT
5982
- ], 1e4);
5983
- if (!enableRes.ok) throw new Error(`systemctl --user enable --now ${SYSTEMD_UNIT} failed: ${enableRes.stderr || `exit ${enableRes.code ?? "unknown"}`}\n Recovery: \`systemctl --user stop ${SYSTEMD_UNIT}\` then \`first-tree-hub service install\`.`);
5984
- const { state, detail } = systemdState();
5985
- return {
5986
- platform: "systemd",
5987
- label: SYSTEMD_UNIT,
5988
- unitPath,
5989
- logDir: LOG_DIR$1,
5990
- state,
5991
- detail
5992
- };
6113
+ /**
6114
+ * Validate that migration journal timestamps are strictly increasing.
6115
+ * Drizzle silently skips migrations whose `when` is <= the last applied
6116
+ * timestamp, which causes missing columns/tables with no error.
6117
+ */
6118
+ function validateJournalOrder(migrationsFolder) {
6119
+ const journalPath = join(migrationsFolder, "meta", "_journal.json");
6120
+ if (!existsSync(journalPath)) return;
6121
+ const journal = JSON.parse(readFileSync(journalPath, "utf-8"));
6122
+ let prevWhen = 0;
6123
+ let prevTag = "";
6124
+ for (const entry of journal.entries) {
6125
+ if (entry.when <= prevWhen) throw new Error(`Migration journal timestamps are not monotonically increasing:\n "${prevTag}" (when: ${prevWhen}) >= "${entry.tag}" (when: ${entry.when})\n Drizzle will silently skip "${entry.tag}". Fix the 'when' values in:\n ${journalPath}`);
6126
+ prevWhen = entry.when;
6127
+ prevTag = entry.tag;
6128
+ }
5993
6129
  }
5994
- function uninstallSystemd() {
5995
- const unitPath = systemdUnitPath();
5996
- const disableRes = runCapture("systemctl", [
5997
- "--user",
5998
- "disable",
5999
- "--now",
6000
- SYSTEMD_UNIT
6001
- ], 1e4);
6002
- if (!disableRes.ok && !/not found|no such|not loaded/i.test(disableRes.stderr)) print.line(` warning: systemctl disable during uninstall: ${disableRes.stderr || `exit ${disableRes.code ?? "unknown"}`}\n`);
6003
- if (existsSync(unitPath)) rmSync(unitPath);
6004
- const reloadRes = runCapture("systemctl", ["--user", "daemon-reload"], 5e3);
6005
- if (!reloadRes.ok) print.line(` warning: systemctl daemon-reload during uninstall: ${reloadRes.stderr || `exit ${reloadRes.code ?? "unknown"}`}\n`);
6006
- return {
6007
- platform: "systemd",
6008
- label: SYSTEMD_UNIT,
6009
- unitPath,
6010
- logDir: LOG_DIR$1,
6011
- state: "not-installed"
6012
- };
6130
+ /**
6131
+ * Run Drizzle database migrations.
6132
+ */
6133
+ async function runMigrations(databaseUrl) {
6134
+ const migrationsFolder = resolveMigrationsFolder();
6135
+ validateJournalOrder(migrationsFolder);
6136
+ const client = postgres(databaseUrl, { max: 1 });
6137
+ const db = drizzle(client);
6138
+ try {
6139
+ await migrate(db, { migrationsFolder });
6140
+ } finally {
6141
+ await client.end();
6142
+ }
6143
+ const countClient = postgres(databaseUrl, { max: 1 });
6144
+ try {
6145
+ return (await countClient`
6146
+ SELECT count(*)::int AS count
6147
+ FROM information_schema.tables
6148
+ WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
6149
+ `)[0].count;
6150
+ } finally {
6151
+ await countClient.end();
6152
+ }
6013
6153
  }
6014
- /** Is background-service install supported on the current platform? */
6015
- function isServiceSupported() {
6016
- return process.platform === "darwin" || process.platform === "linux";
6154
+ //#endregion
6155
+ //#region src/core/migrate-agent-dirs.ts
6156
+ function createApiNameResolver(serverUrl, getAccessToken) {
6157
+ let cache = null;
6158
+ const PAGE_SIZE = 100;
6159
+ const MAX_PAGES = 50;
6160
+ async function ensureCache() {
6161
+ if (cache) return cache;
6162
+ const token = await getAccessToken();
6163
+ const map = /* @__PURE__ */ new Map();
6164
+ let cursor = null;
6165
+ for (let page = 0; page < MAX_PAGES; page++) {
6166
+ const qs = new URLSearchParams({ limit: String(PAGE_SIZE) });
6167
+ if (cursor) qs.set("cursor", cursor);
6168
+ const res = await fetch(`${serverUrl}/api/v1/admin/agents?${qs.toString()}`, {
6169
+ method: "GET",
6170
+ headers: { Authorization: `Bearer ${token}` },
6171
+ signal: AbortSignal.timeout(1e4)
6172
+ });
6173
+ if (!res.ok) throw new Error(`admin agents list returned HTTP ${res.status}`);
6174
+ const body = await res.json();
6175
+ for (const row of body.items) map.set(row.uuid, row.name);
6176
+ if (!body.nextCursor) break;
6177
+ cursor = body.nextCursor;
6178
+ }
6179
+ cache = map;
6180
+ return map;
6181
+ }
6182
+ return { async resolveName(agentId) {
6183
+ return (await ensureCache()).get(agentId) ?? null;
6184
+ } };
6017
6185
  }
6018
6186
  /**
6019
- * Install the background service for the current platform.
6020
- *
6021
- * @throws {Error} if the platform is not supported or the service manager fails.
6187
+ * Read the `agentId` field out of a single `agent.yaml` with minimal
6188
+ * parsing. Unlike `loadAgents`, which Zod-validates every entry and
6189
+ * throws on the first malformed file (aborting migration for *every*
6190
+ * other agent below it), this helper scopes failures per-dir: a broken
6191
+ * file returns `null` and the caller logs + skips that dir only.
6022
6192
  */
6023
- function installClientService() {
6024
- if (process.platform === "darwin") return installLaunchd();
6025
- if (process.platform === "linux") return installSystemd();
6026
- throw new Error(`Background service install is not supported on ${process.platform}. Run \`first-tree-hub client start\` manually to keep the computer online.`);
6193
+ function readAgentId(agentYamlPath) {
6194
+ try {
6195
+ const parsed = parse(readFileSync(agentYamlPath, "utf-8"));
6196
+ if (parsed && typeof parsed === "object" && "agentId" in parsed) {
6197
+ const id = parsed.agentId;
6198
+ if (typeof id === "string" && id.length > 0) return id;
6199
+ }
6200
+ return null;
6201
+ } catch {
6202
+ return null;
6203
+ }
6027
6204
  }
6028
- /** Report the current service state without modifying anything. */
6029
- function getClientServiceStatus() {
6030
- if (process.platform === "darwin") {
6031
- const { state, detail } = launchdState();
6205
+ /**
6206
+ * Walk `agentsDir`, for each local agent compare the dir name to the
6207
+ * server's canonical `name`, and rename the dir + workspaces/sessions
6208
+ * entries when they differ. Returns a summary so the caller can decide
6209
+ * whether to print additional context.
6210
+ */
6211
+ async function migrateLocalAgentDirs(opts) {
6212
+ const { agentsDir, workspacesDir, sessionsDir, resolver } = opts;
6213
+ const result = {
6214
+ scanned: 0,
6215
+ renamed: 0,
6216
+ skipped: 0,
6217
+ errors: 0
6218
+ };
6219
+ if (!existsSync(agentsDir)) return result;
6220
+ let dirNames;
6221
+ try {
6222
+ dirNames = readdirSync(agentsDir).filter((name) => {
6223
+ try {
6224
+ return statSync(join(agentsDir, name)).isDirectory();
6225
+ } catch {
6226
+ return false;
6227
+ }
6228
+ });
6229
+ } catch (err) {
6230
+ const msg = err instanceof Error ? err.message : String(err);
6231
+ print.status("⚠️", `agent-dir migration: unable to enumerate ${agentsDir}: ${msg}`);
6032
6232
  return {
6033
- platform: "launchd",
6034
- label: LAUNCHD_LABEL,
6035
- unitPath: launchdPlistPath(),
6036
- logDir: LOG_DIR$1,
6037
- state,
6038
- detail
6233
+ ...result,
6234
+ errors: result.errors + 1
6039
6235
  };
6040
6236
  }
6041
- if (process.platform === "linux") {
6042
- const { state, detail } = systemdState();
6043
- return {
6044
- platform: "systemd",
6045
- label: SYSTEMD_UNIT,
6046
- unitPath: systemdUnitPath(),
6047
- logDir: LOG_DIR$1,
6048
- state,
6049
- detail
6050
- };
6237
+ const finalDirNames = new Set(dirNames);
6238
+ for (const dirName of dirNames) {
6239
+ const agentYamlPath = join(agentsDir, dirName, "agent.yaml");
6240
+ const agentId = readAgentId(agentYamlPath);
6241
+ if (!agentId) {
6242
+ if (existsSync(agentYamlPath)) {
6243
+ print.status("⚠️", `agent-dir migration: unreadable ${agentYamlPath}; skipping this dir.`);
6244
+ result.errors += 1;
6245
+ }
6246
+ continue;
6247
+ }
6248
+ result.scanned += 1;
6249
+ let serverName;
6250
+ try {
6251
+ serverName = await resolver.resolveName(agentId);
6252
+ } catch (err) {
6253
+ const msg = err instanceof Error ? err.message : String(err);
6254
+ const hint = msg.includes("403") ? " (likely a non-admin account — migration skipped)" : "";
6255
+ print.status("⚠️", `agent-dir migration: failed to resolve "${dirName}" (${agentId}): ${msg}${hint}`);
6256
+ result.errors += 1;
6257
+ return result;
6258
+ }
6259
+ if (!serverName) {
6260
+ result.skipped += 1;
6261
+ continue;
6262
+ }
6263
+ if (serverName === dirName) continue;
6264
+ const oldDir = join(agentsDir, dirName);
6265
+ const newDir = join(agentsDir, serverName);
6266
+ if (existsSync(newDir)) {
6267
+ print.status("⚠️", `agent-dir migration: cannot rename "${dirName}" → "${serverName}" — target already exists. Skipping.`);
6268
+ result.skipped += 1;
6269
+ continue;
6270
+ }
6271
+ try {
6272
+ renameSync(oldDir, newDir);
6273
+ finalDirNames.delete(dirName);
6274
+ finalDirNames.add(serverName);
6275
+ } catch (err) {
6276
+ const msg = err instanceof Error ? err.message : String(err);
6277
+ print.status("⚠️", `agent-dir migration: config dir rename failed for "${dirName}": ${msg}`);
6278
+ result.errors += 1;
6279
+ continue;
6280
+ }
6281
+ const oldWorkspace = join(workspacesDir, dirName);
6282
+ const newWorkspace = join(workspacesDir, serverName);
6283
+ if (existsSync(oldWorkspace)) try {
6284
+ if (existsSync(newWorkspace)) print.status("⚠️", `agent-dir migration: workspace target "${serverName}" already exists; leaving old "${dirName}" in place for manual cleanup.`);
6285
+ else if (statSync(oldWorkspace).isDirectory()) renameSync(oldWorkspace, newWorkspace);
6286
+ } catch (err) {
6287
+ const msg = err instanceof Error ? err.message : String(err);
6288
+ print.status("⚠️", `agent-dir migration: workspace rename failed for "${dirName}": ${msg}`);
6289
+ result.errors += 1;
6290
+ }
6291
+ const oldSessions = join(sessionsDir, `${dirName}.json`);
6292
+ const newSessions = join(sessionsDir, `${serverName}.json`);
6293
+ if (existsSync(oldSessions)) try {
6294
+ if (existsSync(newSessions)) print.status("⚠️", `agent-dir migration: sessions target "${serverName}.json" already exists; leaving old "${dirName}.json" in place for manual cleanup.`);
6295
+ else renameSync(oldSessions, newSessions);
6296
+ } catch (err) {
6297
+ const msg = err instanceof Error ? err.message : String(err);
6298
+ print.status("⚠️", `agent-dir migration: sessions rename failed for "${dirName}": ${msg}`);
6299
+ result.errors += 1;
6300
+ }
6301
+ print.status("", `agent "${dirName}" renamed to "${serverName}" to match hub`);
6302
+ result.renamed += 1;
6051
6303
  }
6052
- return {
6053
- platform: "unsupported",
6054
- label: "",
6055
- unitPath: "",
6056
- logDir: LOG_DIR$1,
6057
- state: "not-installed",
6058
- detail: `platform ${process.platform} not supported`
6059
- };
6060
- }
6061
- /** Uninstall the background service. No-op if not installed. */
6062
- function uninstallClientService() {
6063
- if (process.platform === "darwin") return uninstallLaunchd();
6064
- if (process.platform === "linux") return uninstallSystemd();
6065
- return getClientServiceStatus();
6304
+ try {
6305
+ const orphanWs = existsSync(workspacesDir) ? readdirSync(workspacesDir).filter((d) => !finalDirNames.has(d)) : [];
6306
+ const orphanSessions = existsSync(sessionsDir) ? readdirSync(sessionsDir).filter((f) => f.endsWith(".json") && !finalDirNames.has(f.slice(0, -5))) : [];
6307
+ if (orphanWs.length > 0 || orphanSessions.length > 0) {
6308
+ const parts = [];
6309
+ if (orphanWs.length > 0) parts.push(`workspaces: ${orphanWs.join(", ")}`);
6310
+ if (orphanSessions.length > 0) parts.push(`sessions: ${orphanSessions.join(", ")}`);
6311
+ print.status("", `orphaned local state detected (${parts.join("; ")}). Run \`first-tree-hub agent workspace clean\` to reclaim disk.`);
6312
+ }
6313
+ } catch {}
6314
+ return result;
6066
6315
  }
6067
6316
  //#endregion
6068
6317
  //#region src/core/migrate-home.ts
@@ -6098,7 +6347,7 @@ function runHomeMigration() {
6098
6347
  }
6099
6348
  print.line(`[first-tree-hub] Copied client home to new layout: ${result.from} → ${result.to}\n (Legacy directory preserved as a backup — delete it manually once you've verified the new location works.)\n`);
6100
6349
  if (process.argv.includes("--no-interactive")) {
6101
- print.line("[first-tree-hub] Note: running as background service — skipped auto re-register to avoid self-termination.\n Run `first-tree-hub client service install` from a terminal to refresh log paths.\n");
6350
+ print.line("[first-tree-hub] Note: running as background service — skipped auto re-register to avoid self-termination.\n Service paths will refresh on the next `first-tree-hub client connect <url>`.\n");
6102
6351
  return;
6103
6352
  }
6104
6353
  const status = getClientServiceStatus();
@@ -6108,7 +6357,7 @@ function runHomeMigration() {
6108
6357
  print.line(`[first-tree-hub] Re-registered background service with new home paths.\n`);
6109
6358
  } catch (err) {
6110
6359
  const msg = err instanceof Error ? err.message : String(err);
6111
- print.line(`[first-tree-hub] WARNING: home migration succeeded but re-registering the background service failed: ${msg}\n Run \`first-tree-hub client service install\` to refresh log paths.\n`);
6360
+ print.line(`[first-tree-hub] WARNING: home migration succeeded but re-registering the background service failed: ${msg}\n Re-run \`first-tree-hub client connect <url>\` to refresh service paths.\n`);
6112
6361
  }
6113
6362
  }
6114
6363
  //#endregion
@@ -6253,8 +6502,9 @@ async function onboardCreate(args) {
6253
6502
  metadata: Object.keys(metadata).length > 0 ? metadata : void 0,
6254
6503
  clientId: args.type === "human" ? void 0 : args.clientId
6255
6504
  });
6256
- print.line(`Agent "${args.id}" created (uuid ${primary.uuid}).\n`);
6257
- if (args.type !== "human") saveAgentConfig(args.id, primary.uuid, "claude-code");
6505
+ const primaryLocalName = primary.name ?? args.id;
6506
+ print.line(`Agent "${primaryLocalName}" created (uuid ${primary.uuid}).\n`);
6507
+ if (args.type !== "human") saveAgentConfig(primaryLocalName, primary.uuid, "claude-code");
6258
6508
  let assistantUuid = null;
6259
6509
  if (args.assistant) {
6260
6510
  print.line(`Creating assistant "${args.assistant}"...\n`);
@@ -6270,8 +6520,9 @@ async function onboardCreate(args) {
6270
6520
  clientId: args.clientId
6271
6521
  });
6272
6522
  assistantUuid = assistant.uuid;
6273
- saveAgentConfig(args.assistant, assistant.uuid, "claude-code");
6274
- print.line(`Assistant "${args.assistant}" ready.\n`);
6523
+ const assistantLocalName = assistant.name ?? args.assistant;
6524
+ saveAgentConfig(assistantLocalName, assistant.uuid, "claude-code");
6525
+ print.line(`Assistant "${assistantLocalName}" ready.\n`);
6275
6526
  } catch (err) {
6276
6527
  const msg = err instanceof Error ? err.message : String(err);
6277
6528
  print.line(`Warning: Failed to create assistant "${args.assistant}": ${msg}\n`);
@@ -6279,7 +6530,7 @@ async function onboardCreate(args) {
6279
6530
  }
6280
6531
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
6281
6532
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
6282
- const { bindFeishuBot } = await import("./feishu-BJaN64iR.mjs").then((n) => n.r);
6533
+ const { bindFeishuBot } = await import("./feishu-DEmwoNn_.mjs").then((n) => n.r);
6283
6534
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
6284
6535
  if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
6285
6536
  else {
@@ -6355,18 +6606,40 @@ async function promptMissingFields(options) {
6355
6606
  return results;
6356
6607
  }
6357
6608
  /**
6358
- * Interactive add agent simple two-field prompt.
6359
- */
6360
- async function promptAddAgent() {
6609
+ * Interactive / scripted "add this agent to the local client".
6610
+ *
6611
+ * Phase 3 of the agent-naming refactor removed the free-form local
6612
+ * alias — the local config dir is keyed by the server-authoritative
6613
+ * `agent.name` slug. This helper only asks the user for the agent UUID
6614
+ * (or takes it via `opts.agentId`), then fetches the canonical name
6615
+ * from the Hub. A `name` comes back null only if the agent was
6616
+ * tombstoned server-side, in which case the caller must refuse the
6617
+ * add (there's nothing sensible to key the local dir on).
6618
+ */
6619
+ async function promptAddAgent(opts = {}) {
6620
+ if (loadCredentials() === null) throw new Error("Not connected. Run `first-tree-hub client connect <server-url>` first.");
6621
+ let serverUrl;
6622
+ try {
6623
+ serverUrl = resolveServerUrl(process.env.FIRST_TREE_HUB_SERVER_URL);
6624
+ } catch (err) {
6625
+ const msg = err instanceof Error ? err.message : String(err);
6626
+ throw new Error(`${msg} Run \`first-tree-hub client connect\` or set FIRST_TREE_HUB_SERVER_URL.`);
6627
+ }
6628
+ const agentId = opts.agentId ?? await input({
6629
+ message: "Agent UUID on the Hub:",
6630
+ validate: (v) => v.length > 0 ? true : "Agent UUID is required"
6631
+ });
6632
+ const token = await ensureFreshAccessToken();
6633
+ const res = await fetch(`${serverUrl}/api/v1/admin/agents/${encodeURIComponent(agentId)}`, {
6634
+ headers: { Authorization: `Bearer ${token}` },
6635
+ signal: AbortSignal.timeout(1e4)
6636
+ });
6637
+ if (!res.ok) throw new Error(`Failed to look up agent ${agentId}: HTTP ${res.status}`);
6638
+ const body = await res.json();
6639
+ if (!body.name) throw new Error(`Agent ${agentId} has no hub name (tombstoned or never named). Cannot add a local config without a name.`);
6361
6640
  return {
6362
- name: await input({
6363
- message: "Local alias:",
6364
- validate: (v) => /^[a-z0-9][a-z0-9-]*$/.test(v) ? true : "Lowercase alphanumeric and hyphens only"
6365
- }),
6366
- agentId: await input({
6367
- message: "Agent UUID on the Hub:",
6368
- validate: (v) => v.length > 0 ? true : "Agent UUID is required"
6369
- })
6641
+ name: body.name,
6642
+ agentId
6370
6643
  };
6371
6644
  }
6372
6645
  async function askPrompt(dotPath, prompt) {
@@ -7177,7 +7450,7 @@ function createFeedbackHandler(config) {
7177
7450
  return { handle };
7178
7451
  }
7179
7452
  //#endregion
7180
- //#region ../server/dist/app-RWQiJW6_.mjs
7453
+ //#region ../server/dist/app-DugUZNsw.mjs
7181
7454
  var __defProp = Object.defineProperty;
7182
7455
  var __exportAll = (all, no_symbols) => {
7183
7456
  let target = {};
@@ -7244,7 +7517,7 @@ const agents = pgTable("agents", {
7244
7517
  name: text("name"),
7245
7518
  organizationId: text("organization_id").notNull().references(() => organizations.id),
7246
7519
  type: text("type").notNull(),
7247
- displayName: text("display_name"),
7520
+ displayName: text("display_name").notNull(),
7248
7521
  delegateMention: text("delegate_mention"),
7249
7522
  inboxId: text("inbox_id").unique().notNull(),
7250
7523
  status: text("status").notNull().default("active"),
@@ -8207,6 +8480,7 @@ async function createAgent(db, data) {
8207
8480
  const uuid = uuidv7();
8208
8481
  const name = data.name ?? null;
8209
8482
  if (name?.startsWith(RESERVED_AGENT_NAME_PREFIX)) throw new BadRequestError(`Agent name "${name}" is reserved — names starting with "${RESERVED_AGENT_NAME_PREFIX}" are Hub-internal`);
8483
+ if (name && isReservedAgentName$1(name)) throw new BadRequestError(`Agent name "${name}" is reserved — pick a different one.`);
8210
8484
  const inboxId = `inbox_${uuid}`;
8211
8485
  let orgId;
8212
8486
  let managerId;
@@ -8234,13 +8508,14 @@ async function createAgent(db, data) {
8234
8508
  if (org && org.maxAgents > 0) {
8235
8509
  if (((await db.select({ value: count() }).from(agents).where(and(eq(agents.organizationId, orgId), ne(agents.status, AGENT_STATUSES.DELETED))))[0]?.value ?? 0) >= org.maxAgents) throw new ForbiddenError(`Organization "${orgId}" has reached its agent limit (${org.maxAgents}). Upgrade your plan or delete unused agents.`);
8236
8510
  }
8511
+ const resolvedDisplayName = data.displayName?.trim() || name || "Unnamed Agent";
8237
8512
  try {
8238
8513
  const [agent] = await db.insert(agents).values({
8239
8514
  uuid,
8240
8515
  name,
8241
8516
  organizationId: orgId,
8242
8517
  type: data.type,
8243
- displayName: data.displayName ?? null,
8518
+ displayName: resolvedDisplayName,
8244
8519
  delegateMention: data.delegateMention ?? null,
8245
8520
  inboxId,
8246
8521
  source: data.source ?? null,
@@ -8262,6 +8537,21 @@ async function createAgent(db, data) {
8262
8537
  throw err;
8263
8538
  }
8264
8539
  }
8540
+ async function checkAgentNameAvailability(db, orgId, name) {
8541
+ if (!AGENT_NAME_REGEX$1.test(name)) return {
8542
+ available: false,
8543
+ reason: "invalid"
8544
+ };
8545
+ if (isReservedAgentName$1(name) || name.startsWith(RESERVED_AGENT_NAME_PREFIX)) return {
8546
+ available: false,
8547
+ reason: "reserved"
8548
+ };
8549
+ const [existing] = await db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.organizationId, orgId), eq(agents.name, name), ne(agents.status, AGENT_STATUSES.DELETED))).limit(1);
8550
+ return existing ? {
8551
+ available: false,
8552
+ reason: "taken"
8553
+ } : { available: true };
8554
+ }
8265
8555
  async function getAgent(db, uuid) {
8266
8556
  const [agent] = await db.select().from(agents).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).limit(1);
8267
8557
  if (!agent) throw new NotFoundError(`Agent "${uuid}" not found`);
@@ -9163,26 +9453,33 @@ const inboxEntries = pgTable("inbox_entries", {
9163
9453
  messageId: text("message_id").notNull().references(() => messages.id),
9164
9454
  chatId: text("chat_id"),
9165
9455
  status: text("status").notNull().default("pending"),
9456
+ notify: boolean("notify").notNull().default(true),
9166
9457
  retryCount: integer("retry_count").notNull().default(0),
9167
9458
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
9168
9459
  deliveredAt: timestamp("delivered_at", { withTimezone: true }),
9169
9460
  ackedAt: timestamp("acked_at", { withTimezone: true })
9170
- }, (table) => [unique("uq_inbox_delivery").on(table.inboxId, table.messageId, table.chatId), index("idx_inbox_pending").on(table.inboxId, table.createdAt)]);
9171
- async function sendMessage(db, chatId, senderId, data) {
9461
+ }, (table) => [
9462
+ unique("uq_inbox_delivery").on(table.inboxId, table.messageId, table.chatId),
9463
+ index("idx_inbox_pending").on(table.inboxId, table.createdAt),
9464
+ index("idx_inbox_pending_notify").on(table.inboxId, table.createdAt).where(sql`status = 'pending' AND notify = true`),
9465
+ index("idx_inbox_chat_silent").on(table.inboxId, table.chatId, table.notify, table.status)
9466
+ ]);
9467
+ async function sendMessage(db, chatId, senderId, data, options = {}) {
9172
9468
  return withSpan("inbox.enqueue", messageAttrs({
9173
9469
  chatId,
9174
9470
  senderAgentId: senderId,
9175
9471
  source: data.source ?? void 0
9176
- }), () => sendMessageInner(db, chatId, senderId, data));
9472
+ }), () => sendMessageInner(db, chatId, senderId, data, options));
9177
9473
  }
9178
- async function sendMessageInner(db, chatId, senderId, data) {
9474
+ async function sendMessageInner(db, chatId, senderId, data, options) {
9179
9475
  return db.transaction(async (tx) => {
9180
- const participants = await tx.select({
9476
+ const [participants, [chatRow]] = await Promise.all([tx.select({
9181
9477
  agentId: chatParticipants.agentId,
9182
9478
  inboxId: agents.inboxId,
9183
9479
  mode: chatParticipants.mode,
9184
9480
  name: agents.name
9185
- }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId));
9481
+ }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId)), tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1)]);
9482
+ const chatType = chatRow?.type ?? null;
9186
9483
  if (data.replyToInbox !== void 0 && data.replyToInbox !== null) {
9187
9484
  const [senderRow] = await tx.select({ inboxId: agents.inboxId }).from(agents).where(eq(agents.uuid, senderId)).limit(1);
9188
9485
  if (!senderRow || senderRow.inboxId !== data.replyToInbox) throw new BadRequestError("replyToInbox must reference the sender's own inbox");
@@ -9197,13 +9494,32 @@ async function sendMessageInner(db, chatId, senderId, data) {
9197
9494
  ...incomingMeta,
9198
9495
  mentions: mergedMentions
9199
9496
  } : incomingMeta;
9497
+ if (options.enforceGroupMention && chatType === "group") {
9498
+ if (mergedMentions.filter((id) => id !== senderId).length === 0) throw new BadRequestError("Sending to a group chat requires an explicit @mention. Use `agent send <name>` to message a single agent, or @<name> in the content to address one or more group members.");
9499
+ }
9500
+ let outboundContent = data.content;
9501
+ if (options.normalizeMentionsInContent && typeof outboundContent === "string") {
9502
+ const present = new Set(scanMentionTokens(outboundContent));
9503
+ const missingNames = [];
9504
+ for (const id of mergedMentions) {
9505
+ if (id === senderId) continue;
9506
+ const p = participants.find((q) => q.agentId === id);
9507
+ if (!p?.name) continue;
9508
+ if (present.has(p.name.toLowerCase())) continue;
9509
+ missingNames.push(p.name);
9510
+ }
9511
+ if (missingNames.length > 0) {
9512
+ const prefix = missingNames.map((n) => `@${n}`).join(" ");
9513
+ outboundContent = outboundContent.length > 0 ? `${prefix} ${outboundContent}` : prefix;
9514
+ }
9515
+ }
9200
9516
  const messageId = randomUUID();
9201
9517
  const [msg] = await tx.insert(messages).values({
9202
9518
  id: messageId,
9203
9519
  chatId,
9204
9520
  senderId,
9205
9521
  format: data.format,
9206
- content: data.content,
9522
+ content: outboundContent,
9207
9523
  metadata: metadataToStore,
9208
9524
  replyToInbox: data.replyToInbox ?? null,
9209
9525
  replyToChat: data.replyToChat ?? null,
@@ -9211,13 +9527,14 @@ async function sendMessageInner(db, chatId, senderId, data) {
9211
9527
  source: data.source ?? null
9212
9528
  }).returning();
9213
9529
  const mentionSet = new Set(mergedMentions);
9214
- const entries = participants.filter((p) => p.agentId !== senderId).filter((p) => p.mode !== "mention_only" || mentionSet.has(p.agentId)).map((p) => ({
9530
+ const entries = participants.filter((p) => p.agentId !== senderId).map((p) => ({
9215
9531
  inboxId: p.inboxId,
9216
9532
  messageId,
9217
- chatId
9533
+ chatId,
9534
+ notify: p.mode !== "mention_only" || mentionSet.has(p.agentId)
9218
9535
  }));
9219
9536
  if (entries.length > 0) await tx.insert(inboxEntries).values(entries);
9220
- const recipients = entries.map((e) => e.inboxId);
9537
+ const recipients = entries.filter((e) => e.notify).map((e) => e.inboxId);
9221
9538
  if (data.inReplyTo) {
9222
9539
  const [original] = await tx.select({
9223
9540
  replyToInbox: messages.replyToInbox,
@@ -9248,14 +9565,23 @@ async function sendToAgent(db, senderUuid, targetName, data) {
9248
9565
  if (!sender) throw new NotFoundError(`Agent "${senderUuid}" not found`);
9249
9566
  const [target] = await db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.organizationId, sender.organizationId), eq(agents.name, targetName), ne(agents.status, "deleted"))).limit(1);
9250
9567
  if (!target) throw new NotFoundError(`Agent "${targetName}" not found${/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(targetName) ? " — `agent send` expects an agent NAME, not a uuid. Run `first-tree-hub agent list` to see available names." : ""}`);
9251
- return sendMessage(db, (await findOrCreateDirectChat(db, senderUuid, target.uuid)).id, senderUuid, {
9568
+ const chat = await findOrCreateDirectChat(db, senderUuid, target.uuid);
9569
+ const incomingMeta = data.metadata ?? {};
9570
+ const existingMentionsRaw = incomingMeta.mentions;
9571
+ const existingMentions = Array.isArray(existingMentionsRaw) ? existingMentionsRaw.filter((m) => typeof m === "string") : [];
9572
+ const mergedMentions = existingMentions.includes(target.uuid) ? existingMentions : [...existingMentions, target.uuid];
9573
+ const metadata = {
9574
+ ...incomingMeta,
9575
+ mentions: mergedMentions
9576
+ };
9577
+ return sendMessage(db, chat.id, senderUuid, {
9252
9578
  format: data.format,
9253
9579
  content: data.content,
9254
- metadata: data.metadata,
9580
+ metadata,
9255
9581
  replyToInbox: data.replyToInbox,
9256
9582
  replyToChat: data.replyToChat,
9257
9583
  source: data.source
9258
- });
9584
+ }, { normalizeMentionsInContent: true });
9259
9585
  }
9260
9586
  async function editMessage(db, chatId, messageId, senderId, data) {
9261
9587
  const [msg] = await db.select().from(messages).where(eq(messages.id, messageId)).limit(1);
@@ -9516,6 +9842,18 @@ async function adminAgentRoutes(app) {
9516
9842
  nextCursor: result.nextCursor
9517
9843
  };
9518
9844
  });
9845
+ /**
9846
+ * Pre-create availability probe for the web creation form. The caller types
9847
+ * an agent name; we answer whether the POST would succeed (`available: true`)
9848
+ * or why it would fail (`invalid` / `reserved` / `taken`) without actually
9849
+ * inserting a row. The regular POST still validates authoritatively — this is
9850
+ * a pure UX convenience. Scoped to the caller's org, so two orgs can each
9851
+ * have a `coder` without one blocking the other.
9852
+ */
9853
+ app.get("/names/:name/availability", async (request) => {
9854
+ const scope = memberScope(request);
9855
+ return await checkAgentNameAvailability(app.db, scope.organizationId, request.params.name);
9856
+ });
9519
9857
  app.post("/", async (request, reply) => {
9520
9858
  const scope = memberScope(request);
9521
9859
  const body = createAgentSchema.parse(request.body);
@@ -9987,7 +10325,7 @@ async function adminChatRoutes(app) {
9987
10325
  ...body,
9988
10326
  source: "hub_ui"
9989
10327
  });
9990
- const result = await sendMessage(app.db, chatId, member.agentId, prepared);
10328
+ const result = await sendMessage(app.db, chatId, member.agentId, prepared, { enforceGroupMention: true });
9991
10329
  notifyRecipients(app.notifier, result.recipients, result.message.id);
9992
10330
  return reply.status(201).send({
9993
10331
  id: result.message.id,
@@ -11810,7 +12148,7 @@ async function buildClientMessagePayloadsForInbox(db, inboxId, items) {
11810
12148
  replyToChat: o.replyToChat
11811
12149
  });
11812
12150
  }
11813
- return items.map(({ entryChatId, message: m }) => ({
12151
+ return items.map(({ entryChatId, message: m, precedingMessages = [] }) => ({
11814
12152
  id: m.id,
11815
12153
  chatId: m.chatId,
11816
12154
  senderId: m.senderId,
@@ -11824,7 +12162,8 @@ async function buildClientMessagePayloadsForInbox(db, inboxId, items) {
11824
12162
  createdAt: m.createdAt,
11825
12163
  configVersion: version,
11826
12164
  recipientMode: modeByChat.get(entryChatId ?? m.chatId) ?? "full",
11827
- inReplyToSnapshot: m.inReplyTo ? snapshotById.get(m.inReplyTo) ?? null : null
12165
+ inReplyToSnapshot: m.inReplyTo ? snapshotById.get(m.inReplyTo) ?? null : null,
12166
+ precedingMessages
11828
12167
  }));
11829
12168
  }
11830
12169
  async function resolveAgentId(db, source) {
@@ -11835,6 +12174,7 @@ async function resolveAgentId(db, source) {
11835
12174
  }
11836
12175
  const DEFAULT_INBOX_TIMEOUT_SECONDS = 300;
11837
12176
  const DEFAULT_MAX_RETRY_COUNT = 3;
12177
+ const PRECEDING_CONTEXT_WINDOW_SECONDS = 1440 * 60;
11838
12178
  async function pollInbox(db, inboxId, limit) {
11839
12179
  return withSpan("inbox.deliver", {
11840
12180
  "inbox.id": inboxId,
@@ -11848,7 +12188,7 @@ async function pollInboxInner(db, inboxId, limit) {
11848
12188
  SET status = 'delivered', delivered_at = NOW()
11849
12189
  WHERE id IN (
11850
12190
  SELECT id FROM inbox_entries
11851
- WHERE inbox_id = ${inboxId} AND status = 'pending'
12191
+ WHERE inbox_id = ${inboxId} AND status = 'pending' AND notify = true
11852
12192
  ORDER BY created_at
11853
12193
  LIMIT ${limit}
11854
12194
  FOR UPDATE SKIP LOCKED
@@ -11856,6 +12196,8 @@ async function pollInboxInner(db, inboxId, limit) {
11856
12196
  RETURNING *
11857
12197
  `);
11858
12198
  if (claimed.length === 0) return [];
12199
+ claimed.sort((a, b) => a.created_at.localeCompare(b.created_at));
12200
+ const precedingByEntryId = await collectPrecedingContext(tx, inboxId, claimed);
11859
12201
  const messageIds = claimed.map((e) => e.message_id);
11860
12202
  const msgs = await tx.select().from(messages).where(inArray(messages.id, messageIds));
11861
12203
  const msgMap = new Map(msgs.map((m) => [m.id, m]));
@@ -11864,6 +12206,7 @@ async function pollInboxInner(db, inboxId, limit) {
11864
12206
  if (!msg) throw new Error(`Unexpected: message ${entry.message_id} not found`);
11865
12207
  return {
11866
12208
  entryChatId: entry.chat_id,
12209
+ precedingMessages: precedingByEntryId.get(entry.id) ?? [],
11867
12210
  message: {
11868
12211
  id: msg.id,
11869
12212
  chatId: msg.chatId,
@@ -11897,6 +12240,69 @@ async function pollInboxInner(db, inboxId, limit) {
11897
12240
  });
11898
12241
  });
11899
12242
  }
12243
+ /**
12244
+ * Per claimed trigger: SELECT silent (notify=false) pending rows in the same
12245
+ * chat that occurred between the previous trigger in this batch (or beginning
12246
+ * of time) and this trigger, capped by `PRECEDING_CONTEXT_MAX_ENTRIES` and
12247
+ * `PRECEDING_CONTEXT_WINDOW_SECONDS`. Returned messages are oldest-first.
12248
+ *
12249
+ * Side effect: bulk-ack ALL silent pending rows in each chat with
12250
+ * created_at < latest_trigger.created_at — including ones that fell outside
12251
+ * the window/cap. Otherwise stale silent rows would accumulate and re-load
12252
+ * on every poll.
12253
+ */
12254
+ async function collectPrecedingContext(tx, inboxId, triggers) {
12255
+ const result = /* @__PURE__ */ new Map();
12256
+ const byChat = /* @__PURE__ */ new Map();
12257
+ for (const t of triggers) {
12258
+ if (t.chat_id === null) continue;
12259
+ const list = byChat.get(t.chat_id) ?? [];
12260
+ list.push(t);
12261
+ byChat.set(t.chat_id, list);
12262
+ }
12263
+ for (const [chatId, chatTriggers] of byChat) {
12264
+ chatTriggers.sort((a, b) => a.created_at.localeCompare(b.created_at));
12265
+ let prevCreatedAt = null;
12266
+ for (const trigger of chatTriggers) {
12267
+ const preceding = (await tx.execute(sql`
12268
+ SELECT ie.id, m.id AS message_id, m.sender_id, m.format, m.content, m.metadata,
12269
+ m.created_at
12270
+ FROM inbox_entries ie
12271
+ JOIN messages m ON m.id = ie.message_id
12272
+ WHERE ie.inbox_id = ${inboxId}
12273
+ AND ie.chat_id = ${chatId}
12274
+ AND ie.status = 'pending'
12275
+ AND ie.notify = false
12276
+ AND ie.created_at < ${trigger.created_at}
12277
+ ${prevCreatedAt === null ? sql`` : sql`AND ie.created_at > ${prevCreatedAt}`}
12278
+ AND ie.created_at > NOW() - make_interval(secs => ${PRECEDING_CONTEXT_WINDOW_SECONDS})
12279
+ ORDER BY ie.created_at DESC
12280
+ LIMIT ${50}
12281
+ FOR UPDATE OF ie SKIP LOCKED
12282
+ `)).map((r) => ({
12283
+ id: r.message_id,
12284
+ senderId: r.sender_id,
12285
+ format: r.format,
12286
+ content: r.content,
12287
+ metadata: r.metadata ?? {},
12288
+ createdAt: r.created_at
12289
+ })).reverse();
12290
+ result.set(trigger.id, preceding);
12291
+ prevCreatedAt = trigger.created_at;
12292
+ }
12293
+ const latestTrigger = chatTriggers[chatTriggers.length - 1];
12294
+ if (latestTrigger) await tx.execute(sql`
12295
+ UPDATE inbox_entries
12296
+ SET status = 'acked', acked_at = NOW()
12297
+ WHERE inbox_id = ${inboxId}
12298
+ AND chat_id = ${chatId}
12299
+ AND status = 'pending'
12300
+ AND notify = false
12301
+ AND created_at < ${latestTrigger.created_at}
12302
+ `);
12303
+ }
12304
+ return result;
12305
+ }
11900
12306
  async function ackEntry$2(db, entryId, inboxId) {
11901
12307
  return withSpan("inbox.ack", {
11902
12308
  [FIRST_TREE_HUB_ATTR.INBOX_ENTRY_ID]: String(entryId),
@@ -11935,6 +12341,49 @@ async function resetTimedOutEntries(db, timeoutSeconds = DEFAULT_INBOX_TIMEOUT_S
11935
12341
  failed: failedResult.length
11936
12342
  };
11937
12343
  }
12344
+ /** Default age (30 days) past which silent rows that no notify-true delivery
12345
+ * ever picked up are physically deleted. */
12346
+ const SILENT_ROW_GC_MAX_AGE_SECONDS = 720 * 60 * 60;
12347
+ /**
12348
+ * Garbage-collect silent inbox rows so the table doesn't grow forever in
12349
+ * chats where a `mention_only` agent is never @mentioned.
12350
+ *
12351
+ * Two cleanup paths:
12352
+ *
12353
+ * 1. `notify=false AND status='acked'` of any age — these are fully
12354
+ * consumed (either bundled into a previous trigger or aged out via the
12355
+ * bulk-ack in `collectPrecedingContext`); keep them only as long as
12356
+ * the corresponding message rows we link to. The unique constraint
12357
+ * `(inbox_id, message_id, chat_id)` means leaving them around blocks
12358
+ * legitimate retries with the same key.
12359
+ *
12360
+ * 2. `notify=false AND status='pending' AND created_at < NOW() - maxAge` —
12361
+ * stale silent rows that no trigger ever caught up with. After 30
12362
+ * days they're useless as preceding context (the @mention almost
12363
+ * certainly already happened or the chat went dormant).
12364
+ *
12365
+ * Returns the number of rows deleted in each bucket so the background task
12366
+ * can log meaningful counts.
12367
+ */
12368
+ async function pruneStaleSilentEntries(db, maxAgeSeconds = SILENT_ROW_GC_MAX_AGE_SECONDS) {
12369
+ const ackedResult = await db.execute(sql`
12370
+ DELETE FROM inbox_entries
12371
+ WHERE notify = false
12372
+ AND status = 'acked'
12373
+ RETURNING id
12374
+ `);
12375
+ const staleResult = await db.execute(sql`
12376
+ DELETE FROM inbox_entries
12377
+ WHERE notify = false
12378
+ AND status = 'pending'
12379
+ AND created_at < NOW() - make_interval(secs => ${maxAgeSeconds})
12380
+ RETURNING id
12381
+ `);
12382
+ return {
12383
+ ackedDeleted: ackedResult.length,
12384
+ stalePendingDeleted: staleResult.length
12385
+ };
12386
+ }
11938
12387
  async function agentInboxRoutes(app) {
11939
12388
  app.get("/", async (request) => {
11940
12389
  const identity = requireAgent(request);
@@ -11982,7 +12431,10 @@ async function agentMessageRoutes(app) {
11982
12431
  await assertParticipant(app.db, request.params.chatId, identity.uuid);
11983
12432
  const body = sendMessageSchema.parse(request.body);
11984
12433
  const prepared = await prepareImageOutbound(app.db, app.notifier, request.params.chatId, body);
11985
- const { message: msg, recipients } = await sendMessage(app.db, request.params.chatId, identity.uuid, prepared);
12434
+ const { message: msg, recipients } = await sendMessage(app.db, request.params.chatId, identity.uuid, prepared, {
12435
+ enforceGroupMention: true,
12436
+ normalizeMentionsInContent: true
12437
+ });
11986
12438
  notifyRecipients(app.notifier, recipients, msg.id);
11987
12439
  return reply.status(201).send({
11988
12440
  ...msg,
@@ -14095,6 +14547,11 @@ function createBackgroundTasks(app, instanceId, adapterManager, kaelRuntime) {
14095
14547
  const timeoutSeconds = configs.inbox_timeout_seconds ?? 300;
14096
14548
  const maxRetries = configs.max_retry_count ?? 3;
14097
14549
  await resetTimedOutEntries(app.db, timeoutSeconds, maxRetries);
14550
+ const pruned = await pruneStaleSilentEntries(app.db);
14551
+ if (pruned.ackedDeleted > 0 || pruned.stalePendingDeleted > 0) log.debug({
14552
+ ackedDeleted: pruned.ackedDeleted,
14553
+ stalePendingDeleted: pruned.stalePendingDeleted
14554
+ }, "pruned silent inbox rows");
14098
14555
  } catch (err) {
14099
14556
  log.error({ err }, "failed to reset timed-out inbox entries");
14100
14557
  }
@@ -15031,7 +15488,7 @@ async function startServer(options) {
15031
15488
  instanceId: `srv_${randomUUID().slice(0, 8)}`,
15032
15489
  commandVersion: COMMAND_VERSION
15033
15490
  };
15034
- const { initTelemetry, shutdownTelemetry } = await import("./observability-hDEdrmMS.mjs");
15491
+ const { initTelemetry, shutdownTelemetry } = await import("./observability-DDkJwSKv.mjs");
15035
15492
  await initTelemetry(serverConfig.observability.tracing, config.instanceId);
15036
15493
  const app = await buildApp(config);
15037
15494
  const shutdown = async () => {
@@ -15081,175 +15538,6 @@ function resolveWebDist() {
15081
15538
  } catch {}
15082
15539
  }
15083
15540
  //#endregion
15084
- //#region src/core/service-logs.ts
15085
- const LOG_DIR = join(DEFAULT_HOME_DIR$1, "logs");
15086
- const PRIMARY_LOG = join(LOG_DIR, "client.log");
15087
- const FALLBACK_STDOUT = join(LOG_DIR, "client.stdout.log");
15088
- const FALLBACK_STDERR = join(LOG_DIR, "client.stderr.log");
15089
- /**
15090
- * Duration string → milliseconds. Accepts `10s`, `5m`, `2h`, `1d`; rejects
15091
- * everything else. Keeps the parser tiny rather than pulling in a library —
15092
- * the `--since` flag is the only consumer.
15093
- */
15094
- function parseDuration(input) {
15095
- const match = /^(\d+)\s*(s|m|h|d)$/.exec(input.trim());
15096
- if (!match) throw new Error(`invalid duration "${input}" (expected e.g. 30s, 5m, 2h, 1d)`);
15097
- return Number(match[1]) * ({
15098
- s: 1e3,
15099
- m: 6e4,
15100
- h: 36e5,
15101
- d: 864e5
15102
- }[match[2]] ?? 0);
15103
- }
15104
- const LEVEL_RANK = {
15105
- trace: 10,
15106
- debug: 20,
15107
- info: 30,
15108
- warn: 40,
15109
- error: 50,
15110
- fatal: 60
15111
- };
15112
- /** Rotated log files, newest-first. Missing files are silently skipped. */
15113
- function listLogFilesNewestFirst() {
15114
- const files = [];
15115
- if (existsSync(PRIMARY_LOG)) files.push(PRIMARY_LOG);
15116
- for (let i = 1;; i++) {
15117
- const p = `${PRIMARY_LOG}.${i}`;
15118
- if (!existsSync(p)) break;
15119
- files.push(p);
15120
- }
15121
- return files;
15122
- }
15123
- /** Supervisor fallback files (raw stdout/stderr, not NDJSON). Missing files skipped. */
15124
- function listFallbackFiles() {
15125
- const files = [];
15126
- if (existsSync(FALLBACK_STDERR)) files.push(FALLBACK_STDERR);
15127
- if (existsSync(FALLBACK_STDOUT)) files.push(FALLBACK_STDOUT);
15128
- return files;
15129
- }
15130
- function matchesFilters(obj, minLevel, cutoffMs) {
15131
- if (minLevel !== void 0) {
15132
- const lvl = typeof obj.level === "number" ? obj.level : NaN;
15133
- if (!Number.isFinite(lvl) || lvl < minLevel) return false;
15134
- }
15135
- if (cutoffMs !== void 0) {
15136
- const t = parseLogTime(obj.time);
15137
- if (t === null || t < cutoffMs) return false;
15138
- }
15139
- return true;
15140
- }
15141
- /** Logger writes `time` as a local-ish string (`YYYY-MM-DD HH:mm:ss`). */
15142
- function parseLogTime(value) {
15143
- if (typeof value === "number") return value;
15144
- if (typeof value !== "string") return null;
15145
- const iso = value.replace(" ", "T");
15146
- const ms = Date.parse(iso);
15147
- return Number.isFinite(ms) ? ms : null;
15148
- }
15149
- function renderLine(line, json) {
15150
- if (!line.trim()) return null;
15151
- if (json) return `${line}\n`;
15152
- try {
15153
- return formatPrettyEntry$1(line);
15154
- } catch {
15155
- return `${line}\n`;
15156
- }
15157
- }
15158
- function processLogLine(line, minLevel, cutoffMs, json) {
15159
- let obj;
15160
- try {
15161
- obj = JSON.parse(line);
15162
- } catch {
15163
- return json ? null : `${line}\n`;
15164
- }
15165
- if (!matchesFilters(obj, minLevel, cutoffMs)) return null;
15166
- return renderLine(line, json);
15167
- }
15168
- async function readFileLines(path, minLevel, cutoffMs, json) {
15169
- const rl = createInterface({ input: createReadStream(path, { encoding: "utf8" }) });
15170
- for await (const line of rl) {
15171
- const rendered = processLogLine(line, minLevel, cutoffMs, json);
15172
- if (rendered) process.stdout.write(rendered);
15173
- }
15174
- }
15175
- /**
15176
- * Read a supervisor fallback file (launchd / systemd stdout/stderr capture).
15177
- * These are plain text, not NDJSON: level and time filters don't apply, so we
15178
- * honour `--since` by dropping the whole file when its mtime predates the
15179
- * cutoff and otherwise pass every line through. In pretty mode each line is
15180
- * tagged with the source so operators can tell it apart from pino output; in
15181
- * `--json` mode we emit a synthetic record so NDJSON consumers keep one
15182
- * object per line.
15183
- */
15184
- async function readFallbackFile(path, cutoffMs, json) {
15185
- try {
15186
- const mtime = statSync(path).mtimeMs;
15187
- if (cutoffMs !== void 0 && mtime < cutoffMs) return;
15188
- } catch {
15189
- return;
15190
- }
15191
- const source = path.endsWith(".stderr.log") ? "supervisor:stderr" : "supervisor:stdout";
15192
- const rl = createInterface({ input: createReadStream(path, { encoding: "utf8" }) });
15193
- for await (const line of rl) {
15194
- if (!line.trim()) continue;
15195
- if (json) process.stdout.write(`${JSON.stringify({
15196
- source,
15197
- line
15198
- })}\n`);
15199
- else process.stdout.write(`[${source}] ${line}\n`);
15200
- }
15201
- }
15202
- /**
15203
- * Print existing log history, applying filters. `--tail` then switches to
15204
- * follow mode and keeps printing new lines as the active file grows; rotation
15205
- * is not handled during the tail (a follow-up rotation will simply stop
15206
- * emitting new lines — operator can re-run the command).
15207
- */
15208
- async function showServiceLogs(options) {
15209
- if (!existsSync(LOG_DIR)) {
15210
- print.status("logs", `directory not found: ${LOG_DIR}`);
15211
- return;
15212
- }
15213
- const minLevel = options.level ? LEVEL_RANK[options.level] : void 0;
15214
- const cutoffMs = options.sinceMs !== void 0 ? Date.now() - options.sinceMs : void 0;
15215
- for (const f of listFallbackFiles()) await readFallbackFile(f, cutoffMs, options.json);
15216
- const files = listLogFilesNewestFirst().reverse();
15217
- for (const f of files) await readFileLines(f, minLevel, cutoffMs, options.json);
15218
- if (!options.tail) return;
15219
- if (!existsSync(PRIMARY_LOG)) print.status("tail", "waiting for client.log to appear...");
15220
- await new Promise((resolve) => {
15221
- let position = existsSync(PRIMARY_LOG) ? statSync(PRIMARY_LOG).size : 0;
15222
- const onChange = () => {
15223
- if (!existsSync(PRIMARY_LOG)) return;
15224
- const current = statSync(PRIMARY_LOG).size;
15225
- if (current < position) position = 0;
15226
- if (current <= position) return;
15227
- const stream = createReadStream(PRIMARY_LOG, {
15228
- start: position,
15229
- end: current - 1,
15230
- encoding: "utf8"
15231
- });
15232
- position = current;
15233
- createInterface({ input: stream }).on("line", (line) => {
15234
- const rendered = processLogLine(line, minLevel, cutoffMs, options.json);
15235
- if (rendered) process.stdout.write(rendered);
15236
- });
15237
- };
15238
- watchFile(PRIMARY_LOG, { interval: 500 }, onChange);
15239
- process.once("SIGINT", () => {
15240
- unwatchFile(PRIMARY_LOG, onChange);
15241
- resolve();
15242
- });
15243
- });
15244
- }
15245
- /** Validated flag parsers the CLI layer can reuse without re-doing the work. */
15246
- function validateLevel(value) {
15247
- if (value === void 0) return void 0;
15248
- const parsed = parseLogLevel$1(value);
15249
- if (parsed.fellBack) throw new Error(`invalid --level "${value}" (expected one of ${LOG_LEVELS$1.join(", ")})`);
15250
- return parsed.level;
15251
- }
15252
- //#endregion
15253
15541
  //#region src/core/update.ts
15254
15542
  const PACKAGE_NAME = "@agent-team-foundation/first-tree-hub";
15255
15543
  /**
@@ -15426,4 +15714,4 @@ function createExecuteUpdate({ managed }) {
15426
15714
  };
15427
15715
  }
15428
15716
  //#endregion
15429
- export { configureClientLoggerForService as $, checkServerHealth as A, createOwner as B, runMigrations as C, checkDocker as D, checkDatabase as E, isDockerAvailable as F, setJsonMode as G, resolveReplyToFromEnv as H, stopPostgres as I, FirstTreeHubSDK as J, status as K, ClientRuntime as L, checkWebSocket as M, printResults as N, checkNodeVersion as O, ensurePostgres as P, applyClientLoggerConfig as Q, handleClientOrgMismatch as R, uninstallClientService as S, checkClientConfig as T, blank as U, hasUser as V, print as W, SessionRegistry as X, SdkError as Y, cleanWorkspaces as Z, runHomeMigration as _, showServiceLogs as a, isServiceSupported as b, COMMAND_VERSION as c, promptMissingFields as d, formatCheckReport as f, saveOnboardState as g, onboardCreate as h, parseDuration as i, checkServerReachable as j, checkServerConfig as k, isInteractive as l, onboardCheck as m, declineUpdate as n, validateLevel as o, loadOnboardState as p, ClientOrgMismatchError$1 as q, promptUpdate as r, startServer as s, createExecuteUpdate as t, promptAddAgent as u, getClientServiceStatus as v, checkAgentConfigs as w, resolveCliInvocation as x, installClientService as y, rotateClientIdWithBackup as z };
15717
+ export { configureClientLoggerForService as $, installClientService as A, createOwner as B, checkNodeVersion as C, checkWebSocket as D, checkServerReachable as E, isDockerAvailable as F, setJsonMode as G, resolveReplyToFromEnv as H, stopPostgres as I, FirstTreeHubSDK as J, status as K, ClientRuntime as L, resolveCliInvocation as M, uninstallClientService as N, printResults as O, ensurePostgres as P, applyClientLoggerConfig as Q, handleClientOrgMismatch as R, checkDocker as S, checkServerHealth as T, blank as U, hasUser as V, print as W, SessionRegistry as X, SdkError as Y, cleanWorkspaces as Z, runMigrations as _, COMMAND_VERSION as a, checkClientConfig as b, promptMissingFields as c, onboardCheck as d, onboardCreate as f, migrateLocalAgentDirs as g, createApiNameResolver as h, startServer as i, isServiceSupported as j, getClientServiceStatus as k, formatCheckReport as l, runHomeMigration as m, declineUpdate as n, isInteractive as o, saveOnboardState as p, ClientOrgMismatchError$1 as q, promptUpdate as r, promptAddAgent as s, createExecuteUpdate as t, loadOnboardState as u, checkAgentConfigs as v, checkServerConfig as w, checkDatabase as x, checkBackgroundService as y, rotateClientIdWithBackup as z };