@agent-team-foundation/first-tree-hub 0.8.2 → 0.8.4

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,7 +1,7 @@
1
1
  import { m as __toESM } from "./esm-CYu4tXXn.mjs";
2
2
  import { C as setConfigValue, S as serverConfigSchema, _ 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, x as resolveConfigReadonly } from "./bootstrap-99vUYmLs.mjs";
3
3
  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, t as adapterAttrs, u as observabilityPlugin, v as withWsMessageSpan, y as FIRST_TREE_HUB_ATTR } from "./observability-CJzDFY_G-CmvgUuzc.mjs";
4
- import { $ as updateAgentRuntimeConfigSchema, A as createMemberSchema, B as notificationQuerySchema, C as agentTypeSchema$1, D as createAdapterMappingSchema, E as createAdapterConfigSchema, F as inboxPollQuerySchema, G as sendMessageSchema, H as refreshTokenSchema, I as isRedactedEnvValue, J as sessionEventMessageSchema, K as sendToAgentSchema, L as linkTaskChatSchema, M as createTaskSchema, N as delegateFeishuUserSchema, O as createAgentSchema, P as dryRunAgentRuntimeConfigSchema, Q as updateAdapterConfigSchema, R as loginSchema, S as agentRuntimeConfigPayloadSchema$1, T as connectTokenExchangeSchema, U as runtimeStateMessageSchema, V as paginationQuerySchema, W as selfServiceFeishuBotSchema, X as sessionStateMessageSchema, Y as sessionEventSchema$1, Z as taskListQuerySchema, _ as addParticipantSchema, a as AGENT_SELECTOR_HEADER$1, at as wsAuthFrameSchema, b as agentBindRequestSchema, c as AGENT_TYPES, d as SYSTEM_CONFIG_DEFAULTS, et as updateAgentSchema, 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 updateTaskStatusSchema, j as createOrganizationSchema, k as createChatSchema, l as AGENT_VISIBILITY, m as TASK_STATUSES, nt as updateOrganizationSchema, o as AGENT_SOURCES, p as TASK_HEALTH_SIGNALS, q as sessionCompletionMessageSchema, rt as updateSystemConfigSchema, s as AGENT_STATUSES, tt as updateMemberSchema, u as DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD, v as adminCreateTaskSchema, w as clientRegisterSchema, x as agentPinnedMessageSchema$1, y as adminUpdateTaskSchema, z as messageSourceSchema$1 } from "./feishu-BOISS0DK.mjs";
4
+ import { $ as updateAgentRuntimeConfigSchema, A as createMemberSchema, B as notificationQuerySchema, C as agentTypeSchema$1, D as createAdapterMappingSchema, E as createAdapterConfigSchema, F as inboxPollQuerySchema, G as sendMessageSchema, H as refreshTokenSchema, I as isRedactedEnvValue, J as sessionEventMessageSchema, K as sendToAgentSchema, L as linkTaskChatSchema, M as createTaskSchema, N as delegateFeishuUserSchema, O as createAgentSchema, P as dryRunAgentRuntimeConfigSchema, Q as updateAdapterConfigSchema, R as loginSchema, S as agentRuntimeConfigPayloadSchema$1, T as connectTokenExchangeSchema, U as runtimeStateMessageSchema, V as paginationQuerySchema, W as selfServiceFeishuBotSchema, X as sessionStateMessageSchema, Y as sessionEventSchema$1, Z as taskListQuerySchema, _ as addParticipantSchema, a as AGENT_SELECTOR_HEADER$1, at as wsAuthFrameSchema, b as agentBindRequestSchema, c as AGENT_TYPES, d as SYSTEM_CONFIG_DEFAULTS, et as updateAgentSchema, 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 updateTaskStatusSchema, j as createOrganizationSchema, k as createChatSchema, l as AGENT_VISIBILITY, m as TASK_STATUSES, nt as updateOrganizationSchema, o as AGENT_SOURCES, p as TASK_HEALTH_SIGNALS, q as sessionCompletionMessageSchema, rt as updateSystemConfigSchema, s as AGENT_STATUSES, tt as updateMemberSchema, u as DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD, v as adminCreateTaskSchema, w as clientRegisterSchema, x as agentPinnedMessageSchema$1, y as adminUpdateTaskSchema, z as messageSourceSchema$1 } from "./feishu-OezhDY7x.mjs";
5
5
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
6
6
  import { dirname, isAbsolute, join, resolve } from "node:path";
7
7
  import { ZodError, z } from "zod";
@@ -12,7 +12,7 @@ import { homedir, hostname, platform, userInfo } from "node:os";
12
12
  import { EventEmitter } from "node:events";
13
13
  import WebSocket from "ws";
14
14
  import { query } from "@anthropic-ai/claude-agent-sdk";
15
- import { execFileSync, execSync, spawn } from "node:child_process";
15
+ import { execFileSync, execSync, spawn, spawnSync } from "node:child_process";
16
16
  import bcrypt from "bcrypt";
17
17
  import { and, asc, count, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
18
18
  import { drizzle } from "drizzle-orm/postgres-js";
@@ -714,7 +714,13 @@ z.object({
714
714
  organizationId: z.string(),
715
715
  agents: z.record(z.string(), z.array(pulseBucketSchema).length(32))
716
716
  });
717
- const sessionEventKind = z.enum(["tool_call", "error"]);
717
+ const sessionEventKind = z.enum([
718
+ "tool_call",
719
+ "error",
720
+ "assistant_text",
721
+ "thinking",
722
+ "turn_end"
723
+ ]);
718
724
  const toolCallEventPayload = z.object({
719
725
  toolUseId: z.string(),
720
726
  name: z.string(),
@@ -735,20 +741,61 @@ const errorEventPayload = z.object({
735
741
  ]),
736
742
  message: z.string().max(2e3)
737
743
  });
738
- const sessionEventSchema = z.discriminatedUnion("kind", [z.object({
739
- kind: z.literal("tool_call"),
740
- payload: toolCallEventPayload
741
- }), z.object({
742
- kind: z.literal("error"),
743
- payload: errorEventPayload
744
- })]);
744
+ /**
745
+ * A text block emitted by the model within an assistant message. These are
746
+ * transient "in-progress" events used to render the assistant's reply body
747
+ * while a turn is still running. The final turn result is forwarded as a
748
+ * regular chat message (not an event); the frontend hides all assistant_text
749
+ * events for turns that have completed (i.e. once `turn_end` has been emitted).
750
+ */
751
+ const assistantTextEventPayload = z.object({ text: z.string().max(8e3) });
752
+ /**
753
+ * Marker emitted when the model produces a `thinking` content block.
754
+ * We intentionally do NOT persist the thinking content — only a presence
755
+ * signal so the UI can render a lightweight "Thinking…" status indicator.
756
+ */
757
+ const thinkingEventPayload = z.object({});
758
+ /**
759
+ * Turn boundary marker. Emitted once per completed query turn, regardless of
760
+ * success/failure, so the frontend can group events into turns and collapse
761
+ * completed turns to show only the final result message.
762
+ */
763
+ const turnEndEventPayload = z.object({ status: z.enum(["success", "error"]) });
764
+ const sessionEventSchema = z.discriminatedUnion("kind", [
765
+ z.object({
766
+ kind: z.literal("tool_call"),
767
+ payload: toolCallEventPayload
768
+ }),
769
+ z.object({
770
+ kind: z.literal("error"),
771
+ payload: errorEventPayload
772
+ }),
773
+ z.object({
774
+ kind: z.literal("assistant_text"),
775
+ payload: assistantTextEventPayload
776
+ }),
777
+ z.object({
778
+ kind: z.literal("thinking"),
779
+ payload: thinkingEventPayload
780
+ }),
781
+ z.object({
782
+ kind: z.literal("turn_end"),
783
+ payload: turnEndEventPayload
784
+ })
785
+ ]);
745
786
  z.object({
746
787
  id: z.string(),
747
788
  agentId: z.string(),
748
789
  chatId: z.string(),
749
790
  seq: z.number().int().positive(),
750
791
  kind: sessionEventKind,
751
- payload: z.union([toolCallEventPayload, errorEventPayload]),
792
+ payload: z.union([
793
+ toolCallEventPayload,
794
+ errorEventPayload,
795
+ assistantTextEventPayload,
796
+ thinkingEventPayload,
797
+ turnEndEventPayload
798
+ ]),
752
799
  createdAt: z.string()
753
800
  });
754
801
  z.object({
@@ -2272,6 +2319,7 @@ function cleanWorkspaces(workspaceRoot, activeChatIds, ttlMs = DEFAULT_WORKSPACE
2272
2319
  }
2273
2320
  const MAX_RETRIES = 2;
2274
2321
  const TOOL_RESULT_PREVIEW_LIMIT = 400;
2322
+ const ASSISTANT_TEXT_EVENT_LIMIT = 8e3;
2275
2323
  function extractContentBlocks(message) {
2276
2324
  if (!message || typeof message !== "object") return [];
2277
2325
  const inner = message.message;
@@ -2289,6 +2337,15 @@ function isToolResultBlock(block) {
2289
2337
  const b = block;
2290
2338
  return b.type === "tool_result" && typeof b.tool_use_id === "string";
2291
2339
  }
2340
+ function isTextBlock(block) {
2341
+ if (!block || typeof block !== "object") return false;
2342
+ const b = block;
2343
+ return b.type === "text" && typeof b.text === "string";
2344
+ }
2345
+ function isThinkingBlock(block) {
2346
+ if (!block || typeof block !== "object") return false;
2347
+ return block.type === "thinking";
2348
+ }
2292
2349
  function isResultMessage(message) {
2293
2350
  if (!message || typeof message !== "object") return false;
2294
2351
  const m = message;
@@ -2331,31 +2388,39 @@ function createToolCallProcessor(emit) {
2331
2388
  onMessage(message) {
2332
2389
  if (!message || typeof message !== "object") return;
2333
2390
  const type = message.type;
2334
- if (type === "assistant") for (const block of extractContentBlocks(message)) {
2335
- if (!isToolUseBlock(block)) continue;
2336
- pending.set(block.id, {
2337
- toolUseId: block.id,
2338
- name: block.name,
2339
- args: block.input,
2340
- startedAt: Date.now()
2391
+ if (type === "assistant") {
2392
+ for (const block of extractContentBlocks(message)) if (isToolUseBlock(block)) {
2393
+ pending.set(block.id, {
2394
+ toolUseId: block.id,
2395
+ name: block.name,
2396
+ args: block.input,
2397
+ startedAt: Date.now()
2398
+ });
2399
+ emit({
2400
+ kind: "tool_call",
2401
+ payload: {
2402
+ toolUseId: block.id,
2403
+ name: block.name,
2404
+ args: block.input,
2405
+ status: "pending"
2406
+ }
2407
+ });
2408
+ } else if (isTextBlock(block)) {
2409
+ const text = block.text.trim();
2410
+ if (text.length === 0) continue;
2411
+ emit({
2412
+ kind: "assistant_text",
2413
+ payload: { text: text.slice(0, ASSISTANT_TEXT_EVENT_LIMIT) }
2414
+ });
2415
+ } else if (isThinkingBlock(block)) emit({
2416
+ kind: "thinking",
2417
+ payload: {}
2341
2418
  });
2342
- }
2343
- else if (type === "user") {
2419
+ } else if (type === "user") {
2344
2420
  for (const block of extractContentBlocks(message)) if (isToolResultBlock(block)) pairResult(block);
2345
2421
  }
2346
2422
  },
2347
2423
  flush() {
2348
- if (pending.size === 0) return;
2349
- for (const entry of pending.values()) emit({
2350
- kind: "tool_call",
2351
- payload: {
2352
- toolUseId: entry.toolUseId,
2353
- name: entry.name,
2354
- args: entry.args,
2355
- status: "pending",
2356
- durationMs: Date.now() - entry.startedAt
2357
- }
2358
- });
2359
2424
  pending.clear();
2360
2425
  }
2361
2426
  };
@@ -2561,25 +2626,37 @@ const createClaudeCodeHandler = (config) => {
2561
2626
  retryCount = 0;
2562
2627
  if (message.result && sessionCtx.chatId) {
2563
2628
  const resultText = message.result;
2564
- sessionCtx.sdk.sendMessage(sessionCtx.chatId, {
2565
- format: "text",
2566
- content: resultText
2567
- }).then(() => {
2629
+ try {
2630
+ await sessionCtx.sdk.sendMessage(sessionCtx.chatId, {
2631
+ format: "text",
2632
+ content: resultText
2633
+ });
2568
2634
  sessionCtx.log("Result forwarded to chat");
2569
2635
  sessionCtx.reportSessionCompletion();
2570
- }).catch((err) => {
2636
+ sessionCtx.emitEvent({
2637
+ kind: "turn_end",
2638
+ payload: { status: "success" }
2639
+ });
2640
+ } catch (err) {
2571
2641
  const reason = err instanceof Error ? err.message : String(err);
2572
2642
  sessionCtx.log(`Failed to forward result: ${reason}`);
2573
- const message = `Result forward failed: ${reason}\n---\n${resultText.slice(0, 1500)}`.slice(0, 2e3);
2643
+ const forwardErrMessage = `Result forward failed: ${reason}\n---\n${resultText.slice(0, 1500)}`.slice(0, 2e3);
2574
2644
  sessionCtx.emitEvent({
2575
2645
  kind: "error",
2576
2646
  payload: {
2577
2647
  source: "runtime",
2578
- message
2648
+ message: forwardErrMessage
2579
2649
  }
2580
2650
  });
2581
- });
2582
- }
2651
+ sessionCtx.emitEvent({
2652
+ kind: "turn_end",
2653
+ payload: { status: "error" }
2654
+ });
2655
+ }
2656
+ } else sessionCtx.emitEvent({
2657
+ kind: "turn_end",
2658
+ payload: { status: "success" }
2659
+ });
2583
2660
  } else {
2584
2661
  const errors = message.errors ? message.errors.join("; ") : message.subtype;
2585
2662
  const errorLog = `Query result error: ${errors} (subtype=${message.subtype}, turns=${message.num_turns ?? "?"}, duration=${message.duration_ms ?? "?"}ms)`;
@@ -2591,6 +2668,10 @@ const createClaudeCodeHandler = (config) => {
2591
2668
  message: errors
2592
2669
  }
2593
2670
  });
2671
+ sessionCtx.emitEvent({
2672
+ kind: "turn_end",
2673
+ payload: { status: "error" }
2674
+ });
2594
2675
  }
2595
2676
  sessionCtx.setRuntimeState("idle");
2596
2677
  }
@@ -4521,7 +4602,7 @@ async function onboardCreate(args) {
4521
4602
  }
4522
4603
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
4523
4604
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
4524
- const { bindFeishuBot } = await import("./feishu-BOISS0DK.mjs").then((n) => n.r);
4605
+ const { bindFeishuBot } = await import("./feishu-OezhDY7x.mjs").then((n) => n.r);
4525
4606
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
4526
4607
  if (!targetAgentUuid) process.stderr.write(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
4527
4608
  else {
@@ -4662,7 +4743,7 @@ function setNestedByDot(obj, dotPath, value) {
4662
4743
  if (lastKey !== void 0) current[lastKey] = value;
4663
4744
  }
4664
4745
  //#endregion
4665
- //#region ../server/dist/app-DJEePmWL.mjs
4746
+ //#region ../server/dist/app-BGneEeZO.mjs
4666
4747
  var __defProp = Object.defineProperty;
4667
4748
  var __exportAll = (all, no_symbols) => {
4668
4749
  let target = {};
@@ -7742,13 +7823,20 @@ async function appendEvent(db, agentId, chatId, event) {
7742
7823
  throw new Error(`session_events seq contention on ${agentId}/${chatId}`);
7743
7824
  }
7744
7825
  /**
7745
- * List events for a session in `seq asc` order with cursor pagination.
7746
- * `cursor` is the last seen `seq`; pass it as-is on the next page.
7826
+ * List events for a session with cursor pagination.
7827
+ *
7828
+ * - `direction: "asc"` (default) walks oldest → newest; cursor is the last
7829
+ * seq seen on the previous page (next page starts at seq > cursor).
7830
+ * - `direction: "desc"` walks newest → oldest; cursor is the last seq seen
7831
+ * on the previous page (next page starts at seq < cursor). The chat UI
7832
+ * uses desc so its turn-grouping filter always sees the most recent
7833
+ * `turn_end` even when the chat has thousands of events.
7747
7834
  */
7748
7835
  async function listEvents(db, agentId, chatId, options) {
7749
7836
  const limit = Math.min(Math.max(options?.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT);
7837
+ const direction = options?.direction ?? "asc";
7750
7838
  const conditions = [eq(sessionEvents.agentId, agentId), eq(sessionEvents.chatId, chatId)];
7751
- if (options?.cursor !== void 0) conditions.push(gt(sessionEvents.seq, options.cursor));
7839
+ if (options?.cursor !== void 0) conditions.push(direction === "desc" ? lt(sessionEvents.seq, options.cursor) : gt(sessionEvents.seq, options.cursor));
7752
7840
  const rows = await db.select({
7753
7841
  id: sessionEvents.id,
7754
7842
  agentId: sessionEvents.agentId,
@@ -7757,7 +7845,7 @@ async function listEvents(db, agentId, chatId, options) {
7757
7845
  kind: sessionEvents.kind,
7758
7846
  payload: sessionEvents.payload,
7759
7847
  createdAt: sessionEvents.createdAt
7760
- }).from(sessionEvents).where(and(...conditions)).orderBy(asc(sessionEvents.seq)).limit(limit + 1);
7848
+ }).from(sessionEvents).where(and(...conditions)).orderBy(direction === "desc" ? desc(sessionEvents.seq) : asc(sessionEvents.seq)).limit(limit + 1);
7761
7849
  const hasMore = rows.length > limit;
7762
7850
  const items = (hasMore ? rows.slice(0, limit) : rows).map(rowToEvent);
7763
7851
  const last = items[items.length - 1];
@@ -7820,16 +7908,23 @@ async function adminSessionRoutes(app) {
7820
7908
  await assertChatAccess(app.db, scope, request.params.chatId);
7821
7909
  return getSession(app.db, request.params.agentId, request.params.chatId);
7822
7910
  });
7823
- /** GET /admin/sessions/agents/:agentId/:chatId/events — session event stream, paged by `seq` */
7911
+ /**
7912
+ * GET /admin/sessions/agents/:agentId/:chatId/events — session event stream,
7913
+ * paged by `seq`. `direction=desc` returns newest-first; the chat UI uses
7914
+ * this so its turn-grouping filter always sees the latest `turn_end`
7915
+ * regardless of total event count.
7916
+ */
7824
7917
  app.get("/agents/:agentId/:chatId/events", async (request) => {
7825
7918
  const scope = memberScope(request);
7826
7919
  await assertAgentVisible(app.db, scope, request.params.agentId);
7827
7920
  await assertChatAccess(app.db, scope, request.params.chatId);
7828
7921
  const limit = request.query.limit !== void 0 ? Number.parseInt(request.query.limit, 10) : void 0;
7829
7922
  const cursor = request.query.cursor !== void 0 ? Number.parseInt(request.query.cursor, 10) : void 0;
7923
+ const direction = request.query.direction === "desc" ? "desc" : "asc";
7830
7924
  return listEvents(app.db, request.params.agentId, request.params.chatId, {
7831
7925
  limit: Number.isFinite(limit) ? limit : void 0,
7832
- cursor: Number.isFinite(cursor) ? cursor : void 0
7926
+ cursor: Number.isFinite(cursor) ? cursor : void 0,
7927
+ direction
7833
7928
  });
7834
7929
  });
7835
7930
  /** POST /admin/sessions/agents/:agentId/:chatId/suspend — suspend a session */
@@ -11796,6 +11891,32 @@ function resolveWebDist() {
11796
11891
  }
11797
11892
  //#endregion
11798
11893
  //#region src/core/service-install.ts
11894
+ /**
11895
+ * Run a subprocess capturing stderr so failures surface a meaningful error
11896
+ * instead of Node's opaque "Command failed". Used for launchctl/systemctl —
11897
+ * anywhere the stderr message is diagnostically crucial.
11898
+ */
11899
+ function runCapture(program, args, timeoutMs) {
11900
+ const res = spawnSync(program, args, {
11901
+ encoding: "utf-8",
11902
+ timeout: timeoutMs,
11903
+ stdio: [
11904
+ "ignore",
11905
+ "pipe",
11906
+ "pipe"
11907
+ ]
11908
+ });
11909
+ if (res.status === 0) return { ok: true };
11910
+ return {
11911
+ ok: false,
11912
+ stderr: (res.stderr ?? "").trim(),
11913
+ code: res.status
11914
+ };
11915
+ }
11916
+ function sleepSync(ms) {
11917
+ const shared = new Int32Array(new SharedArrayBuffer(4));
11918
+ Atomics.wait(shared, 0, 0, ms);
11919
+ }
11799
11920
  const LAUNCHD_LABEL = "dev.first-tree-hub.client";
11800
11921
  const SYSTEMD_UNIT = "first-tree-hub-client.service";
11801
11922
  const LOG_DIR = join(DEFAULT_HOME_DIR$1, "logs");
@@ -11903,30 +12024,56 @@ function launchctlDomainTarget() {
11903
12024
  }
11904
12025
  function launchdState() {
11905
12026
  if (!existsSync(launchdPlistPath())) return { state: "not-installed" };
11906
- try {
11907
- const out = execFileSync("launchctl", ["print", `${launchctlDomainTarget()}/${LAUNCHD_LABEL}`], {
11908
- encoding: "utf-8",
11909
- timeout: 5e3
11910
- });
11911
- const stateLine = out.split(/\r?\n/).find((l) => l.trim().startsWith("state ="));
11912
- const pidLine = out.split(/\r?\n/).find((l) => l.trim().startsWith("pid ="));
11913
- if (stateLine?.includes("running")) {
11914
- const pid = pidLine?.split("=")[1]?.trim();
11915
- return {
11916
- state: "active",
11917
- detail: pid ? `pid ${pid}` : "running"
11918
- };
11919
- }
11920
- return {
11921
- state: "inactive",
11922
- detail: stateLine?.trim() ?? "loaded"
11923
- };
11924
- } catch {
12027
+ const res = spawnSync("launchctl", ["print", `${launchctlDomainTarget()}/${LAUNCHD_LABEL}`], {
12028
+ encoding: "utf-8",
12029
+ timeout: 5e3,
12030
+ stdio: [
12031
+ "ignore",
12032
+ "pipe",
12033
+ "pipe"
12034
+ ]
12035
+ });
12036
+ if (res.status !== 0) return {
12037
+ state: "inactive",
12038
+ detail: "plist present but not loaded"
12039
+ };
12040
+ const out = res.stdout ?? "";
12041
+ const stateLine = out.split(/\r?\n/).find((l) => l.trim().startsWith("state ="));
12042
+ const pidLine = out.split(/\r?\n/).find((l) => l.trim().startsWith("pid ="));
12043
+ if (stateLine?.includes("running")) {
12044
+ const pid = pidLine?.split("=")[1]?.trim();
11925
12045
  return {
11926
- state: "inactive",
11927
- detail: "plist present but not loaded"
12046
+ state: "active",
12047
+ detail: pid ? `pid ${pid}` : "running"
11928
12048
  };
11929
12049
  }
12050
+ return {
12051
+ state: "inactive",
12052
+ detail: stateLine?.trim() ?? "loaded"
12053
+ };
12054
+ }
12055
+ /**
12056
+ * Poll `launchctl print` until the label disappears, confirming launchd has
12057
+ * finished the async eviction kicked off by `bootout`. Required because
12058
+ * `bootout` returns before the actual unload completes when the service has
12059
+ * active WebSocket connections — a follow-up `bootstrap` against a still-
12060
+ * registered label fails with `Bootstrap failed: 5: Input/output error`.
12061
+ */
12062
+ function waitForLabelEvicted(target, label, timeoutMs) {
12063
+ const deadline = Date.now() + timeoutMs;
12064
+ while (Date.now() < deadline) {
12065
+ if (spawnSync("launchctl", ["print", `${target}/${label}`], {
12066
+ encoding: "utf-8",
12067
+ timeout: 2e3,
12068
+ stdio: [
12069
+ "ignore",
12070
+ "ignore",
12071
+ "pipe"
12072
+ ]
12073
+ }).status !== 0) return true;
12074
+ sleepSync(200);
12075
+ }
12076
+ return false;
11930
12077
  }
11931
12078
  function installLaunchd() {
11932
12079
  const invocation = resolveCliInvocation();
@@ -11935,24 +12082,28 @@ function installLaunchd() {
11935
12082
  mkdirSync(dirname(plistPath), { recursive: true });
11936
12083
  writeFileSync(plistPath, renderPlist(invocation), { mode: 420 });
11937
12084
  const target = launchctlDomainTarget();
11938
- try {
11939
- execFileSync("launchctl", ["bootout", `${target}/${LAUNCHD_LABEL}`], {
11940
- stdio: "ignore",
11941
- timeout: 5e3
11942
- });
11943
- } catch {}
11944
- execFileSync("launchctl", [
11945
- "bootstrap",
11946
- target,
11947
- plistPath
11948
- ], {
11949
- stdio: "ignore",
11950
- timeout: 5e3
11951
- });
11952
- execFileSync("launchctl", ["enable", `${target}/${LAUNCHD_LABEL}`], {
11953
- stdio: "ignore",
11954
- timeout: 5e3
11955
- });
12085
+ const bootoutRes = runCapture("launchctl", ["bootout", `${target}/${LAUNCHD_LABEL}`], 15e3);
12086
+ if (!bootoutRes.ok) {
12087
+ if (!/not find|no such|not loaded/i.test(bootoutRes.stderr)) process.stderr.write(` warning: launchctl bootout: ${bootoutRes.stderr || `exit ${bootoutRes.code ?? "unknown"}`}\n`);
12088
+ }
12089
+ waitForLabelEvicted(target, LAUNCHD_LABEL, 1e4);
12090
+ let lastBootstrapErr = null;
12091
+ for (let attempt = 1; attempt <= 2; attempt++) {
12092
+ const res = runCapture("launchctl", [
12093
+ "bootstrap",
12094
+ target,
12095
+ plistPath
12096
+ ], 1e4);
12097
+ if (res.ok) {
12098
+ lastBootstrapErr = null;
12099
+ break;
12100
+ }
12101
+ lastBootstrapErr = res;
12102
+ if (attempt < 2) sleepSync(1e3);
12103
+ }
12104
+ 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\`.`);
12105
+ const enableRes = runCapture("launchctl", ["enable", `${target}/${LAUNCHD_LABEL}`], 5e3);
12106
+ if (!enableRes.ok) process.stderr.write(` warning: launchctl enable: ${enableRes.stderr || `exit ${enableRes.code ?? "unknown"}`}\n`);
11956
12107
  const { state, detail } = launchdState();
11957
12108
  return {
11958
12109
  platform: "launchd",
@@ -11965,13 +12116,8 @@ function installLaunchd() {
11965
12116
  }
11966
12117
  function uninstallLaunchd() {
11967
12118
  const plistPath = launchdPlistPath();
11968
- const target = launchctlDomainTarget();
11969
- try {
11970
- execFileSync("launchctl", ["bootout", `${target}/${LAUNCHD_LABEL}`], {
11971
- stdio: "ignore",
11972
- timeout: 5e3
11973
- });
11974
- } catch {}
12119
+ const res = runCapture("launchctl", ["bootout", `${launchctlDomainTarget()}/${LAUNCHD_LABEL}`], 15e3);
12120
+ if (!res.ok && !/not find|no such|not loaded/i.test(res.stderr)) process.stderr.write(` warning: bootout during uninstall: ${res.stderr || `exit ${res.code ?? "unknown"}`}\n`);
11975
12121
  if (existsSync(plistPath)) rmSync(plistPath);
11976
12122
  return {
11977
12123
  platform: "launchd",
@@ -12009,29 +12155,28 @@ function shellQuote(value) {
12009
12155
  }
12010
12156
  function systemdState() {
12011
12157
  if (!existsSync(systemdUnitPath())) return { state: "not-installed" };
12012
- try {
12013
- const out = execFileSync("systemctl", [
12014
- "--user",
12015
- "is-active",
12016
- SYSTEMD_UNIT
12017
- ], {
12018
- encoding: "utf-8",
12019
- timeout: 5e3
12020
- }).trim();
12021
- if (out === "active") return {
12022
- state: "active",
12023
- detail: "running"
12024
- };
12025
- return {
12026
- state: "inactive",
12027
- detail: out
12028
- };
12029
- } catch (err) {
12030
- return {
12031
- state: "inactive",
12032
- detail: (typeof err.stdout === "string" ? (err.stdout ?? "").trim() : "") || "unit present but not active"
12033
- };
12034
- }
12158
+ const res = spawnSync("systemctl", [
12159
+ "--user",
12160
+ "is-active",
12161
+ SYSTEMD_UNIT
12162
+ ], {
12163
+ encoding: "utf-8",
12164
+ timeout: 5e3,
12165
+ stdio: [
12166
+ "ignore",
12167
+ "pipe",
12168
+ "pipe"
12169
+ ]
12170
+ });
12171
+ const out = (res.stdout ?? "").trim();
12172
+ if (res.status === 0 && out === "active") return {
12173
+ state: "active",
12174
+ detail: "running"
12175
+ };
12176
+ return {
12177
+ state: "inactive",
12178
+ detail: out || "unit present but not active"
12179
+ };
12035
12180
  }
12036
12181
  function installSystemd() {
12037
12182
  const invocation = resolveCliInvocation();
@@ -12039,19 +12184,15 @@ function installSystemd() {
12039
12184
  const unitPath = systemdUnitPath();
12040
12185
  mkdirSync(dirname(unitPath), { recursive: true });
12041
12186
  writeFileSync(unitPath, renderSystemdUnit(invocation), { mode: 420 });
12042
- execFileSync("systemctl", ["--user", "daemon-reload"], {
12043
- stdio: "ignore",
12044
- timeout: 5e3
12045
- });
12046
- execFileSync("systemctl", [
12187
+ const reloadRes = runCapture("systemctl", ["--user", "daemon-reload"], 5e3);
12188
+ if (!reloadRes.ok) throw new Error(`systemctl --user daemon-reload failed: ${reloadRes.stderr || `exit ${reloadRes.code ?? "unknown"}`}`);
12189
+ const enableRes = runCapture("systemctl", [
12047
12190
  "--user",
12048
12191
  "enable",
12049
12192
  "--now",
12050
12193
  SYSTEMD_UNIT
12051
- ], {
12052
- stdio: "ignore",
12053
- timeout: 1e4
12054
- });
12194
+ ], 1e4);
12195
+ 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\`.`);
12055
12196
  const { state, detail } = systemdState();
12056
12197
  return {
12057
12198
  platform: "systemd",
@@ -12064,24 +12205,16 @@ function installSystemd() {
12064
12205
  }
12065
12206
  function uninstallSystemd() {
12066
12207
  const unitPath = systemdUnitPath();
12067
- try {
12068
- execFileSync("systemctl", [
12069
- "--user",
12070
- "disable",
12071
- "--now",
12072
- SYSTEMD_UNIT
12073
- ], {
12074
- stdio: "ignore",
12075
- timeout: 1e4
12076
- });
12077
- } catch {}
12208
+ const disableRes = runCapture("systemctl", [
12209
+ "--user",
12210
+ "disable",
12211
+ "--now",
12212
+ SYSTEMD_UNIT
12213
+ ], 1e4);
12214
+ if (!disableRes.ok && !/not found|no such|not loaded/i.test(disableRes.stderr)) process.stderr.write(` warning: systemctl disable during uninstall: ${disableRes.stderr || `exit ${disableRes.code ?? "unknown"}`}\n`);
12078
12215
  if (existsSync(unitPath)) rmSync(unitPath);
12079
- try {
12080
- execFileSync("systemctl", ["--user", "daemon-reload"], {
12081
- stdio: "ignore",
12082
- timeout: 5e3
12083
- });
12084
- } catch {}
12216
+ const reloadRes = runCapture("systemctl", ["--user", "daemon-reload"], 5e3);
12217
+ if (!reloadRes.ok) process.stderr.write(` warning: systemctl daemon-reload during uninstall: ${reloadRes.stderr || `exit ${reloadRes.code ?? "unknown"}`}\n`);
12085
12218
  return {
12086
12219
  platform: "systemd",
12087
12220
  label: SYSTEMD_UNIT,