@lobehub/cli 0.0.20 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
- import { EventEmitter } from "node:events";
3
+ import { EventEmitter, once } from "node:events";
4
4
  import { execFile, execFileSync, spawn } from "node:child_process";
5
5
  import path, { basename, extname } from "node:path";
6
6
  import fs, { createReadStream, existsSync, readFileSync, writeFileSync } from "node:fs";
@@ -5023,8 +5023,8 @@ const log$7 = {
5023
5023
 
5024
5024
  //#endregion
5025
5025
  //#region src/settings/index.ts
5026
- const LOBEHUB_DIR_NAME$1 = process.env.LOBEHUB_CLI_HOME || ".lobehub";
5027
- const SETTINGS_DIR = path.join(os.homedir(), LOBEHUB_DIR_NAME$1);
5026
+ const LOBEHUB_DIR_NAME$2 = process.env.LOBEHUB_CLI_HOME || ".lobehub";
5027
+ const SETTINGS_DIR = path.join(os.homedir(), LOBEHUB_DIR_NAME$2);
5028
5028
  const SETTINGS_FILE = path.join(SETTINGS_DIR, "settings.json");
5029
5029
  function normalizeUrl(url) {
5030
5030
  return url ? url.replace(/\/$/, "") : void 0;
@@ -5083,8 +5083,8 @@ function loadSettings() {
5083
5083
 
5084
5084
  //#endregion
5085
5085
  //#region src/auth/credentials.ts
5086
- const LOBEHUB_DIR_NAME = process.env.LOBEHUB_CLI_HOME || ".lobehub";
5087
- const CREDENTIALS_DIR = path.join(os.homedir(), LOBEHUB_DIR_NAME);
5086
+ const LOBEHUB_DIR_NAME$1 = process.env.LOBEHUB_CLI_HOME || ".lobehub";
5087
+ const CREDENTIALS_DIR = path.join(os.homedir(), LOBEHUB_DIR_NAME$1);
5088
5088
  const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json");
5089
5089
  function deriveKey() {
5090
5090
  const material = `lobehub-cli:${os.hostname()}:${os.userInfo().username}`;
@@ -5437,6 +5437,7 @@ async function streamAgentEventsViaWebSocket(options) {
5437
5437
  const jsonEvents = [];
5438
5438
  const ctx = createRenderContext();
5439
5439
  let heartbeatTimer;
5440
+ let isSettled = false;
5440
5441
  let jsonPrinted = false;
5441
5442
  const cleanup = () => {
5442
5443
  if (heartbeatTimer) clearInterval(heartbeatTimer);
@@ -5479,6 +5480,8 @@ async function streamAgentEventsViaWebSocket(options) {
5479
5480
  jsonPrinted = true;
5480
5481
  console.log(JSON.stringify(jsonEvents, null, 2));
5481
5482
  } else if (!streamOpts.json) renderEnd(agentEvent);
5483
+ if (isSettled) return;
5484
+ isSettled = true;
5482
5485
  cleanup();
5483
5486
  resolve();
5484
5487
  return;
@@ -5498,21 +5501,31 @@ async function streamAgentEventsViaWebSocket(options) {
5498
5501
  jsonPrinted = true;
5499
5502
  console.log(JSON.stringify(jsonEvents, null, 2));
5500
5503
  }
5504
+ if (isSettled) return;
5505
+ isSettled = true;
5501
5506
  cleanup();
5502
5507
  resolve();
5503
5508
  }
5504
5509
  };
5505
5510
  ws.onerror = (err) => {
5506
5511
  cleanup();
5507
- reject(err);
5512
+ if (isSettled) return;
5513
+ if (streamOpts.json && jsonEvents.length > 0 && !jsonPrinted) {
5514
+ jsonPrinted = true;
5515
+ console.log(JSON.stringify(jsonEvents, null, 2));
5516
+ }
5517
+ isSettled = true;
5518
+ reject(/* @__PURE__ */ new Error(`Agent gateway WebSocket failed: ${String(err)}`));
5508
5519
  };
5509
- ws.onclose = () => {
5520
+ ws.onclose = (event) => {
5510
5521
  if (heartbeatTimer) clearInterval(heartbeatTimer);
5522
+ if (isSettled) return;
5511
5523
  if (streamOpts.json && jsonEvents.length > 0 && !jsonPrinted) {
5512
5524
  jsonPrinted = true;
5513
5525
  console.log(JSON.stringify(jsonEvents, null, 2));
5514
5526
  }
5515
- resolve();
5527
+ isSettled = true;
5528
+ reject(/* @__PURE__ */ new Error(`Agent gateway WebSocket closed before completion: ${String(event)}`));
5516
5529
  };
5517
5530
  });
5518
5531
  }
@@ -7351,12 +7364,14 @@ const DEFAULT_AGENT_CHAT_CONFIG = {
7351
7364
  enableAgentMode: true,
7352
7365
  enableCompressHistory: true,
7353
7366
  enableContextCompression: true,
7367
+ enableFollowUpChips: false,
7354
7368
  enableHistoryCount: false,
7355
7369
  enableStreaming: true,
7356
7370
  historyCount: 20,
7357
7371
  reasoningBudgetToken: 1024,
7358
7372
  searchFCModel: DEFAULT_AGENT_SEARCH_FC_MODEL,
7359
- searchMode: "auto"
7373
+ searchMode: "auto",
7374
+ selfIteration: { enabled: false }
7360
7375
  };
7361
7376
  const DEFAULT_AGENT_CONFIG = {
7362
7377
  chatConfig: DEFAULT_AGENT_CHAT_CONFIG,
@@ -7574,11 +7589,20 @@ const DEFAULT_INPUT_COMPLETION_SYSTEM_AGENT_ITEM = {
7574
7589
  model: DEFAULT_MINI_SYSTEM_AGENT_ITEM.model,
7575
7590
  provider: DEFAULT_MINI_SYSTEM_AGENT_ITEM.provider
7576
7591
  };
7592
+ const DEFAULT_FOLLOW_UP_ACTION_SYSTEM_AGENT_ITEM = {
7593
+ enabled: false,
7594
+ model: DEFAULT_MINI_SYSTEM_AGENT_ITEM.model,
7595
+ provider: DEFAULT_MINI_SYSTEM_AGENT_ITEM.provider
7596
+ };
7577
7597
 
7578
7598
  //#endregion
7579
7599
  //#region ../../packages/const/src/discover.ts
7580
7600
  const DEFAULT_CREATED_AT = (/* @__PURE__ */ new Date()).toISOString();
7581
7601
 
7602
+ //#endregion
7603
+ //#region ../../packages/const/src/empty.ts
7604
+ const EMPTY_ARRAY = Object.freeze([]);
7605
+
7582
7606
  //#endregion
7583
7607
  //#region ../../packages/const/src/interests.ts
7584
7608
  const INTEREST_AREA_KEYS = [
@@ -16673,250 +16697,6 @@ async function resolveToken(options) {
16673
16697
  process.exit(1);
16674
16698
  }
16675
16699
 
16676
- //#endregion
16677
- //#region src/daemon/taskRegistry.ts
16678
- function getRegistryPath() {
16679
- return path.join(os.homedir(), ".lobehub", "task-registry.json");
16680
- }
16681
- function readRegistry() {
16682
- try {
16683
- return JSON.parse(fs.readFileSync(getRegistryPath(), "utf8"));
16684
- } catch {
16685
- return {};
16686
- }
16687
- }
16688
- function writeRegistry(entries) {
16689
- const dir = path.dirname(getRegistryPath());
16690
- fs.mkdirSync(dir, {
16691
- mode: 448,
16692
- recursive: true
16693
- });
16694
- fs.writeFileSync(getRegistryPath(), JSON.stringify(entries, null, 2), { mode: 384 });
16695
- }
16696
- function saveTask(entry) {
16697
- const registry = readRegistry();
16698
- registry[entry.taskId] = entry;
16699
- writeRegistry(registry);
16700
- }
16701
- function getTask(taskId) {
16702
- return readRegistry()[taskId];
16703
- }
16704
- function removeTask(taskId) {
16705
- const registry = readRegistry();
16706
- delete registry[taskId];
16707
- writeRegistry(registry);
16708
- }
16709
- function listTasks() {
16710
- return Object.values(readRegistry());
16711
- }
16712
-
16713
- //#endregion
16714
- //#region src/tools/heteroTask.ts
16715
- const DEFAULT_HERMES_PORT = 3456;
16716
- /** Resolve the absolute path to the `lh` binary to avoid PATH issues in child processes. */
16717
- function resolveLhPath() {
16718
- try {
16719
- return execFileSync("which", ["lh"], { encoding: "utf8" }).trim();
16720
- } catch {
16721
- return "lh";
16722
- }
16723
- }
16724
- function getHermesPort() {
16725
- const env = process.env.HERMES_GATEWAY_PORT;
16726
- if (env) {
16727
- const parsed = Number.parseInt(env, 10);
16728
- if (!Number.isNaN(parsed)) return parsed;
16729
- }
16730
- return DEFAULT_HERMES_PORT;
16731
- }
16732
- async function isHermesGatewayRunning(port) {
16733
- try {
16734
- return (await fetch(`http://localhost:${port}/health`)).ok;
16735
- } catch {
16736
- return false;
16737
- }
16738
- }
16739
- async function startHermesGateway(port) {
16740
- spawn("hermes", ["gateway", "start"], {
16741
- detached: true,
16742
- env: { ...process.env },
16743
- stdio: "ignore"
16744
- }).unref();
16745
- const deadline = Date.now() + 1e4;
16746
- while (Date.now() < deadline) {
16747
- await new Promise((r) => setTimeout(r, 500));
16748
- if (await isHermesGatewayRunning(port)) return;
16749
- }
16750
- throw new Error(`Hermes gateway did not start within 10s on port ${port}`);
16751
- }
16752
- async function sendAutoNotify(topicId, taskId, text, agentId) {
16753
- try {
16754
- await (await getTrpcClient()).agentNotify.notify.mutate({
16755
- agentId,
16756
- content: text,
16757
- role: "assistant",
16758
- topicId
16759
- });
16760
- } catch (err) {
16761
- log$7.error("Failed to send auto-notify:", err instanceof Error ? err.message : String(err));
16762
- }
16763
- }
16764
- /**
16765
- * Signal remote hetero task completion to the server so it can publish
16766
- * `agent_runtime_end` to the gateway WS and close the frontend subscription.
16767
- * Called on clean process exit (code=0, no signal) — error exits go through
16768
- * `sendAutoNotify` which writes an error message AND triggers completion via
16769
- * the `done` flag.
16770
- */
16771
- async function sendDoneSignal(topicId, agentId) {
16772
- try {
16773
- await (await getTrpcClient()).agentNotify.notify.mutate({
16774
- agentId,
16775
- content: "",
16776
- done: true,
16777
- role: "assistant",
16778
- topicId
16779
- });
16780
- } catch (err) {
16781
- log$7.error("Failed to send done signal:", err instanceof Error ? err.message : String(err));
16782
- }
16783
- }
16784
- /**
16785
- * Build the notify protocol injected into the first message of a new hetero-agent session.
16786
- * Tells the agent how to push updates back to the LobeHub user via `lh notify`.
16787
- */
16788
- function buildNotifyProtocol(lhPath, topicId) {
16789
- return `## Context: This task was dispatched by LobeHub
16790
-
16791
- This conversation / task was sent to you by the **LobeHub platform** on behalf of a user. You are running as a background agent; the user is waiting for your response inside the LobeHub chat interface.
16792
-
16793
- **When to call notify**: any time you have something meaningful to tell the user — a key finding, a decision you made, a result, a question, or your final answer. Think of it as speaking directly to the user in the chat window.
16794
-
16795
- **What to hide**: internal work details such as tool call sequences, file reads, intermediate command output, retries, or low-level reasoning steps. The user cares about outcomes and insights, not your step-by-step mechanics.
16796
-
16797
- ## Sending messages back to the user
16798
-
16799
- Use the \`${lhPath} notify\` command. All your updates appear as a **single message bubble** in the UI — create it once and update it in place.\n\n**Step 1 — Open the bubble on your first meaningful update** (captures the messageId):\n\`\`\`\nMSG_ID=$(${lhPath} notify --topic ${topicId} --role assistant --content "Starting..." --json | grep -o '"messageId":"[^"]*"' | cut -d'"' -f4)\n\`\`\`\n\n**Step 2 — Update the same bubble as you make progress**:\n\`\`\`\n${lhPath} notify --topic ${topicId} --role assistant --message-id "$MSG_ID" --content "Still working..."\n\`\`\`\n\n**Step 3 — Replace with your complete, final response when done**:\n\`\`\`\n${lhPath} notify --topic ${topicId} --role assistant --message-id "$MSG_ID" --content "<your full response here>"\n\`\`\`\n\nRules:\n- Always use \`--json\` on the first call and capture \`messageId\` from the output.\n- Always pass \`--message-id\` on every subsequent call so updates overwrite the same bubble.\n- Write what matters to the user — not implementation steps or internal tool calls.\n- Call notify at least once when the task is done, even if there were no intermediate updates.`;
16800
- }
16801
- async function runHeteroTask(params) {
16802
- const { agentId, agentType, cwd, operationId, prompt, taskId, topicId } = params;
16803
- const workDir = cwd || process.cwd();
16804
- const lhPath = resolveLhPath();
16805
- if (agentType === "openclaw") {
16806
- const openclawAgent = process.env.OPENCLAW_AGENT_ID ?? "main";
16807
- const enrichedPrompt = `${prompt}\n\n${buildNotifyProtocol(lhPath, topicId)}`;
16808
- for (const existing of listTasks()) if (existing.topicId === topicId && existing.agentType === "openclaw") {
16809
- try {
16810
- process.kill(existing.pid, "SIGTERM");
16811
- } catch {}
16812
- removeTask(existing.taskId);
16813
- }
16814
- const child = spawn("openclaw", [
16815
- "agent",
16816
- "--agent",
16817
- openclawAgent,
16818
- "--session-id",
16819
- topicId,
16820
- "--message",
16821
- enrichedPrompt,
16822
- "--local"
16823
- ], {
16824
- cwd: workDir,
16825
- detached: true,
16826
- env: { ...process.env },
16827
- stdio: "ignore"
16828
- });
16829
- const pid = child.pid;
16830
- if (pid === void 0) throw new Error("Failed to get PID for openclaw process");
16831
- child.unref();
16832
- saveTask({
16833
- agentId,
16834
- agentType,
16835
- operationId,
16836
- pid,
16837
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
16838
- taskId,
16839
- topicId
16840
- });
16841
- log$7.info(`OpenClaw task started: taskId=${taskId} pid=${pid} agent=${openclawAgent}`);
16842
- child.on("close", (code, signal) => {
16843
- removeTask(taskId);
16844
- if (code !== 0 || signal !== null) sendAutoNotify(topicId, taskId, signal ? `Task cancelled (signal: ${signal})` : `Task failed (exit code: ${code})`, agentId).finally(() => sendDoneSignal(topicId, agentId));
16845
- else sendDoneSignal(topicId, agentId);
16846
- });
16847
- return JSON.stringify({
16848
- pid,
16849
- taskId
16850
- });
16851
- }
16852
- if (agentType === "hermes") {
16853
- const port = getHermesPort();
16854
- if (!await isHermesGatewayRunning(port)) {
16855
- log$7.info(`Hermes gateway not running on port ${port}, starting...`);
16856
- await startHermesGateway(port);
16857
- }
16858
- const res = await fetch(`http://localhost:${port}/message`, {
16859
- body: JSON.stringify({
16860
- content: prompt,
16861
- operationId
16862
- }),
16863
- headers: { "Content-Type": "application/json" },
16864
- method: "POST"
16865
- });
16866
- if (!res.ok) throw new Error(`Hermes gateway returned ${res.status}: ${await res.text()}`);
16867
- saveTask({
16868
- agentId,
16869
- agentType,
16870
- operationId,
16871
- pid: 0,
16872
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
16873
- taskId,
16874
- topicId
16875
- });
16876
- log$7.info(`Hermes task dispatched: taskId=${taskId} operationId=${operationId}`);
16877
- return JSON.stringify({
16878
- operationId,
16879
- taskId
16880
- });
16881
- }
16882
- throw new Error(`Unsupported agentType: ${agentType}`);
16883
- }
16884
- async function cancelHeteroTask(params) {
16885
- const { signal = "SIGINT", taskId } = params;
16886
- const entry = getTask(taskId);
16887
- if (!entry) return JSON.stringify({
16888
- message: `No task found with taskId: ${taskId}`,
16889
- success: false
16890
- });
16891
- if (entry.agentType === "hermes") {
16892
- const port = getHermesPort();
16893
- try {
16894
- await fetch(`http://localhost:${port}/stop`, {
16895
- body: JSON.stringify({ operationId: entry.operationId }),
16896
- headers: { "Content-Type": "application/json" },
16897
- method: "POST"
16898
- });
16899
- } catch (err) {
16900
- log$7.warn(`Failed to send /stop to Hermes gateway: ${err instanceof Error ? err.message : String(err)}`);
16901
- }
16902
- removeTask(taskId);
16903
- await sendAutoNotify(entry.topicId, taskId, "Task cancelled", entry.agentId);
16904
- return JSON.stringify({ taskId });
16905
- }
16906
- try {
16907
- process.kill(entry.pid, signal);
16908
- } catch (err) {
16909
- log$7.warn(`Failed to send ${signal} to pid ${entry.pid}: ${err instanceof Error ? err.message : String(err)}`);
16910
- removeTask(taskId);
16911
- await sendAutoNotify(entry.topicId, taskId, "Task already completed or cancelled", entry.agentId);
16912
- }
16913
- return JSON.stringify({
16914
- pid: entry.pid,
16915
- signal,
16916
- taskId
16917
- });
16918
- }
16919
-
16920
16700
  //#endregion
16921
16701
  //#region src/tools/checkPlatformCapability.ts
16922
16702
  /**
@@ -16942,30 +16722,21 @@ async function checkPlatformCapability(params) {
16942
16722
  reason: err instanceof Error ? err.message : "openclaw not found or failed to run"
16943
16723
  };
16944
16724
  }
16945
- if (platform === "hermes") {
16946
- const port = getHermesPort();
16947
- try {
16948
- const res = await fetch(`http://localhost:${port}/health`, { signal: AbortSignal.timeout(5e3) });
16949
- if (res.ok) {
16950
- let version;
16951
- try {
16952
- version = (await res.json()).version;
16953
- } catch {}
16954
- return {
16955
- available: true,
16956
- version
16957
- };
16958
- }
16959
- return {
16960
- available: false,
16961
- reason: `Hermes gateway returned HTTP ${res.status}`
16962
- };
16963
- } catch (err) {
16964
- return {
16965
- available: false,
16966
- reason: err instanceof Error ? err.message : `Hermes gateway not reachable on port ${port}`
16967
- };
16968
- }
16725
+ if (platform === "hermes") try {
16726
+ const output = execFileSync("hermes", ["--version"], {
16727
+ encoding: "utf8",
16728
+ timeout: 5e3
16729
+ }).trim();
16730
+ const versionMatch = output.match(/v(\d+\.\d+\.\d+)/);
16731
+ return {
16732
+ available: true,
16733
+ version: versionMatch ? versionMatch[1] : output.split(/\s+/).at(-1)
16734
+ };
16735
+ } catch (err) {
16736
+ return {
16737
+ available: false,
16738
+ reason: err instanceof Error ? err.message : "hermes not found or failed to run"
16739
+ };
16969
16740
  }
16970
16741
  return {
16971
16742
  available: false,
@@ -207213,20 +206984,327 @@ function getOpenClawProfile(agentId) {
207213
206984
  };
207214
206985
  }
207215
206986
  /**
206987
+ * Read the active Hermes profile name from `hermes profile list` output.
206988
+ * The active profile is marked with ◆ in the first column.
206989
+ */
206990
+ function getActiveHermesProfileName() {
206991
+ try {
206992
+ return execFileSync("hermes", ["profile", "list"], {
206993
+ encoding: "utf8",
206994
+ timeout: 5e3
206995
+ }).match(/◆(\S+)/)?.[1];
206996
+ } catch {
206997
+ return;
206998
+ }
206999
+ }
207000
+ /**
207001
+ * Read the filesystem path of a Hermes profile from `hermes profile show <name>`.
207002
+ */
207003
+ function getHermesProfilePath(profileName) {
207004
+ try {
207005
+ return (execFileSync("hermes", [
207006
+ "profile",
207007
+ "show",
207008
+ profileName
207009
+ ], {
207010
+ encoding: "utf8",
207011
+ timeout: 5e3
207012
+ }).match(/^Path:\s+(.+)/m)?.[1]?.trim())?.replace(/^~(?=\/|$)/, os.homedir());
207013
+ } catch {
207014
+ return;
207015
+ }
207016
+ }
207017
+ /**
207018
+ * Extract a one-line description from a Hermes SOUL.md file.
207019
+ * Strips HTML comments and Markdown headings, then returns the first
207020
+ * non-empty line of actual content.
207021
+ */
207022
+ function readHermesSoulDescription(soulPath) {
207023
+ try {
207024
+ let stripped = fs.readFileSync(soulPath, "utf8");
207025
+ let previous;
207026
+ do {
207027
+ previous = stripped;
207028
+ stripped = stripped.replaceAll(/<!--[\s\S]*?-->/g, "").replaceAll(/[<>]/g, "").replaceAll(/^#+\s.*$/gm, "");
207029
+ } while (stripped !== previous);
207030
+ return stripped.split("\n").map((l) => l.trim()).find((l) => l.length > 0) || void 0;
207031
+ } catch {
207032
+ return;
207033
+ }
207034
+ }
207035
+ function getHermesProfile() {
207036
+ const profileName = getActiveHermesProfileName();
207037
+ if (!profileName) return {};
207038
+ const profilePath = getHermesProfilePath(profileName);
207039
+ return {
207040
+ avatar: "⚡",
207041
+ description: profilePath ? readHermesSoulDescription(path.join(profilePath, "SOUL.md")) : void 0,
207042
+ title: profileName
207043
+ };
207044
+ }
207045
+ /**
207216
207046
  * Fetch the agent profile (title, avatar, description) from the platform
207217
207047
  * installed on this device. Dispatched by the server via `device.getAgentProfile`.
207218
207048
  *
207219
207049
  * - openclaw: `openclaw agents list --json` for name + emoji, workspace
207220
207050
  * IDENTITY.md for description fallback
207221
- * - hermes: not yet implemented returns empty profile
207051
+ * - hermes: active profile name + SOUL.md description
207222
207052
  */
207223
207053
  async function getAgentProfile(params) {
207224
207054
  const { platform, agentId } = params;
207225
207055
  if (platform === "openclaw") return getOpenClawProfile(agentId);
207226
- if (platform === "hermes") return {};
207056
+ if (platform === "hermes") return getHermesProfile();
207227
207057
  return {};
207228
207058
  }
207229
207059
 
207060
+ //#endregion
207061
+ //#region src/daemon/taskRegistry.ts
207062
+ function getRegistryPath() {
207063
+ return path.join(os.homedir(), ".lobehub", "task-registry.json");
207064
+ }
207065
+ function readRegistry() {
207066
+ try {
207067
+ return JSON.parse(fs.readFileSync(getRegistryPath(), "utf8"));
207068
+ } catch {
207069
+ return {};
207070
+ }
207071
+ }
207072
+ function writeRegistry(entries) {
207073
+ const dir = path.dirname(getRegistryPath());
207074
+ fs.mkdirSync(dir, {
207075
+ mode: 448,
207076
+ recursive: true
207077
+ });
207078
+ fs.writeFileSync(getRegistryPath(), JSON.stringify(entries, null, 2), { mode: 384 });
207079
+ }
207080
+ function saveTask(entry) {
207081
+ const registry = readRegistry();
207082
+ registry[entry.taskId] = entry;
207083
+ writeRegistry(registry);
207084
+ }
207085
+ function getTask(taskId) {
207086
+ return readRegistry()[taskId];
207087
+ }
207088
+ function removeTask(taskId) {
207089
+ const registry = readRegistry();
207090
+ delete registry[taskId];
207091
+ writeRegistry(registry);
207092
+ }
207093
+ function listTasks() {
207094
+ return Object.values(readRegistry());
207095
+ }
207096
+
207097
+ //#endregion
207098
+ //#region src/tools/heteroTask.ts
207099
+ const LOBEHUB_DIR_NAME = process.env.LOBEHUB_CLI_HOME || ".lobehub";
207100
+ const HERMES_SESSIONS_FILE = path.join(os.homedir(), LOBEHUB_DIR_NAME, "hermes-sessions.json");
207101
+ function getHermesSessionId(topicId) {
207102
+ try {
207103
+ return JSON.parse(fs.readFileSync(HERMES_SESSIONS_FILE, "utf8"))[topicId];
207104
+ } catch {
207105
+ return;
207106
+ }
207107
+ }
207108
+ function saveHermesSessionId(topicId, sessionId) {
207109
+ let data = {};
207110
+ try {
207111
+ data = JSON.parse(fs.readFileSync(HERMES_SESSIONS_FILE, "utf8"));
207112
+ } catch {}
207113
+ data[topicId] = sessionId;
207114
+ fs.mkdirSync(path.dirname(HERMES_SESSIONS_FILE), { recursive: true });
207115
+ fs.writeFileSync(HERMES_SESSIONS_FILE, JSON.stringify(data), "utf8");
207116
+ }
207117
+ /** Resolve the absolute path to the `lh` binary to avoid PATH issues in child processes. */
207118
+ function resolveLhPath() {
207119
+ try {
207120
+ return execFileSync("which", ["lh"], { encoding: "utf8" }).trim();
207121
+ } catch {
207122
+ return "lh";
207123
+ }
207124
+ }
207125
+ async function sendAutoNotify(topicId, taskId, text, agentId) {
207126
+ try {
207127
+ await (await getTrpcClient()).agentNotify.notify.mutate({
207128
+ agentId,
207129
+ content: text,
207130
+ role: "assistant",
207131
+ topicId
207132
+ });
207133
+ } catch (err) {
207134
+ log$7.error("Failed to send auto-notify:", err instanceof Error ? err.message : String(err));
207135
+ }
207136
+ }
207137
+ /**
207138
+ * Signal remote hetero task completion to the server so it can publish
207139
+ * `agent_runtime_end` to the gateway WS and close the frontend subscription.
207140
+ * Called on clean process exit (code=0, no signal) — error exits go through
207141
+ * `sendAutoNotify` which writes an error message AND triggers completion via
207142
+ * the `done` flag.
207143
+ */
207144
+ async function sendDoneSignal(topicId, agentId) {
207145
+ try {
207146
+ await (await getTrpcClient()).agentNotify.notify.mutate({
207147
+ agentId,
207148
+ content: "",
207149
+ done: true,
207150
+ role: "assistant",
207151
+ topicId
207152
+ });
207153
+ } catch (err) {
207154
+ log$7.error("Failed to send done signal:", err instanceof Error ? err.message : String(err));
207155
+ }
207156
+ }
207157
+ /**
207158
+ * Build the notify protocol injected into the first message of a new hetero-agent session.
207159
+ * Tells the agent how to push updates back to the LobeHub user via `lh notify`.
207160
+ */
207161
+ function buildNotifyProtocol(lhPath, topicId) {
207162
+ return `## Context: This task was dispatched by LobeHub
207163
+
207164
+ This conversation / task was sent to you by the **LobeHub platform** on behalf of a user. You are running as a background agent; the user is waiting for your response inside the LobeHub chat interface.
207165
+
207166
+ **When to call notify**: any time you have something meaningful to tell the user — a key finding, a decision you made, a result, a question, or your final answer. Think of it as speaking directly to the user in the chat window.
207167
+
207168
+ **What to hide**: internal work details such as tool call sequences, file reads, intermediate command output, retries, or low-level reasoning steps. The user cares about outcomes and insights, not your step-by-step mechanics.
207169
+
207170
+ ## Sending messages back to the user
207171
+
207172
+ Use the \`${lhPath} notify\` command. All your updates appear as a **single message bubble** in the UI — create it once and update it in place.\n\n**Step 1 — Open the bubble on your first meaningful update** (captures the messageId):\n\`\`\`\nMSG_ID=$(${lhPath} notify --topic ${topicId} --role assistant --content "Starting..." --json | grep -o '"messageId":"[^"]*"' | cut -d'"' -f4)\n\`\`\`\n\n**Step 2 — Update the same bubble as you make progress**:\n\`\`\`\n${lhPath} notify --topic ${topicId} --role assistant --message-id "$MSG_ID" --content "Still working..."\n\`\`\`\n\n**Step 3 — Replace with your complete, final response when done**:\n\`\`\`\n${lhPath} notify --topic ${topicId} --role assistant --message-id "$MSG_ID" --content "<your full response here>"\n\`\`\`\n\nRules:\n- Always use \`--json\` on the first call and capture \`messageId\` from the output.\n- Always pass \`--message-id\` on every subsequent call so updates overwrite the same bubble.\n- Write what matters to the user — not implementation steps or internal tool calls.\n- Call notify at least once when the task is done, even if there were no intermediate updates.`;
207173
+ }
207174
+ async function runHeteroTask(params) {
207175
+ const { agentId, agentType, cwd, operationId, prompt, taskId, topicId } = params;
207176
+ const workDir = cwd || process.cwd();
207177
+ const lhPath = resolveLhPath();
207178
+ if (agentType === "openclaw") {
207179
+ const openclawAgent = process.env.OPENCLAW_AGENT_ID ?? "main";
207180
+ const enrichedPrompt = `${prompt}\n\n${buildNotifyProtocol(lhPath, topicId)}`;
207181
+ for (const existing of listTasks()) if (existing.topicId === topicId && existing.agentType === "openclaw") {
207182
+ try {
207183
+ process.kill(existing.pid, "SIGTERM");
207184
+ } catch {}
207185
+ removeTask(existing.taskId);
207186
+ }
207187
+ const child = spawn("openclaw", [
207188
+ "agent",
207189
+ "--agent",
207190
+ openclawAgent,
207191
+ "--session-id",
207192
+ topicId,
207193
+ "--message",
207194
+ enrichedPrompt,
207195
+ "--local"
207196
+ ], {
207197
+ cwd: workDir,
207198
+ detached: true,
207199
+ env: { ...process.env },
207200
+ stdio: "ignore"
207201
+ });
207202
+ const pid = child.pid;
207203
+ if (pid === void 0) throw new Error("Failed to get PID for openclaw process");
207204
+ child.unref();
207205
+ saveTask({
207206
+ agentId,
207207
+ agentType,
207208
+ operationId,
207209
+ pid,
207210
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
207211
+ taskId,
207212
+ topicId
207213
+ });
207214
+ log$7.info(`OpenClaw task started: taskId=${taskId} pid=${pid} agent=${openclawAgent}`);
207215
+ child.on("close", (code, signal) => {
207216
+ removeTask(taskId);
207217
+ if (code !== 0 || signal !== null) sendAutoNotify(topicId, taskId, signal ? `Task cancelled (signal: ${signal})` : `Task failed (exit code: ${code})`, agentId).finally(() => sendDoneSignal(topicId, agentId));
207218
+ else sendDoneSignal(topicId, agentId);
207219
+ });
207220
+ return JSON.stringify({
207221
+ pid,
207222
+ taskId
207223
+ });
207224
+ }
207225
+ if (agentType === "hermes") {
207226
+ for (const existing of listTasks()) if (existing.topicId === topicId && existing.agentType === "hermes") {
207227
+ try {
207228
+ process.kill(existing.pid, "SIGTERM");
207229
+ } catch {}
207230
+ removeTask(existing.taskId);
207231
+ }
207232
+ const existingSessionId = getHermesSessionId(topicId);
207233
+ const hermesArgs = [
207234
+ "chat",
207235
+ "--query",
207236
+ prompt,
207237
+ "--quiet",
207238
+ "--accept-hooks"
207239
+ ];
207240
+ if (existingSessionId) hermesArgs.push("--resume", existingSessionId);
207241
+ const child = spawn("hermes", hermesArgs, {
207242
+ cwd: workDir,
207243
+ detached: true,
207244
+ env: { ...process.env },
207245
+ stdio: [
207246
+ "ignore",
207247
+ "pipe",
207248
+ "ignore"
207249
+ ]
207250
+ });
207251
+ const pid = child.pid;
207252
+ if (pid === void 0) throw new Error("Failed to get PID for hermes process");
207253
+ child.unref();
207254
+ saveTask({
207255
+ agentId,
207256
+ agentType,
207257
+ operationId,
207258
+ pid,
207259
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
207260
+ taskId,
207261
+ topicId
207262
+ });
207263
+ log$7.info(`Hermes task started: taskId=${taskId} pid=${pid}`);
207264
+ let stdout = "";
207265
+ child.stdout.on("data", (chunk) => {
207266
+ stdout += chunk.toString();
207267
+ });
207268
+ child.on("close", (code, signal) => {
207269
+ removeTask(taskId);
207270
+ if (code !== 0 || signal !== null) {
207271
+ sendAutoNotify(topicId, taskId, signal ? `Task cancelled (signal: ${signal})` : `Task failed (exit code: ${code})`, agentId).finally(() => sendDoneSignal(topicId, agentId));
207272
+ return;
207273
+ }
207274
+ const sessionId = stdout.match(/^session_id:\s*(\S+)/m)?.[1];
207275
+ const response = stdout.replace(/^session_id:[^\n]*\n?/, "").trim();
207276
+ if (sessionId) saveHermesSessionId(topicId, sessionId);
207277
+ if (response) sendAutoNotify(topicId, taskId, response, agentId).finally(() => sendDoneSignal(topicId, agentId));
207278
+ else sendDoneSignal(topicId, agentId);
207279
+ });
207280
+ return JSON.stringify({
207281
+ pid,
207282
+ taskId
207283
+ });
207284
+ }
207285
+ throw new Error(`Unsupported agentType: ${agentType}`);
207286
+ }
207287
+ async function cancelHeteroTask(params) {
207288
+ const { signal = "SIGINT", taskId } = params;
207289
+ const entry = getTask(taskId);
207290
+ if (!entry) return JSON.stringify({
207291
+ message: `No task found with taskId: ${taskId}`,
207292
+ success: false
207293
+ });
207294
+ try {
207295
+ process.kill(entry.pid, signal);
207296
+ } catch (err) {
207297
+ log$7.warn(`Failed to send ${signal} to pid ${entry.pid}: ${err instanceof Error ? err.message : String(err)}`);
207298
+ removeTask(taskId);
207299
+ await sendAutoNotify(entry.topicId, taskId, "Task already completed or cancelled", entry.agentId);
207300
+ }
207301
+ return JSON.stringify({
207302
+ pid: entry.pid,
207303
+ signal,
207304
+ taskId
207305
+ });
207306
+ }
207307
+
207230
207308
  //#endregion
207231
207309
  //#region src/tools/shell.ts
207232
207310
  const processManager = new ShellProcessManager();
@@ -210753,83 +210831,6 @@ const spawnAgent = async (options) => {
210753
210831
  };
210754
210832
  };
210755
210833
 
210756
- //#endregion
210757
- //#region src/utils/BatchIngester.ts
210758
- var NoopIngestSink = class {
210759
- async finish(_params) {}
210760
- async ingest(_events) {}
210761
- };
210762
- const MAX_BATCH = 50;
210763
- const FLUSH_INTERVAL_MS = 250;
210764
- const MAX_RETRIES = 5;
210765
- const sleep$1 = (ms) => new Promise((r) => setTimeout(r, ms));
210766
- /**
210767
- * Buffers `AgentStreamEvent`s and flushes them in batches to an `IngestSink`.
210768
- *
210769
- * Flush triggers:
210770
- * - Buffer reaches MAX_BATCH (50) → immediate flush
210771
- * - FLUSH_INTERVAL_MS (250ms) timer fires → flush whatever is buffered
210772
- *
210773
- * Each batch is retried up to MAX_RETRIES (5) times with exponential back-off
210774
- * starting at 500ms, doubling up to 8s. After the final retry the error is
210775
- * stored and re-thrown by `drain()`, allowing the caller to call
210776
- * `sink.finish({ result: 'error' })` and exit(1).
210777
- *
210778
- * Call order: push() repeatedly → drain() once (before finish()).
210779
- */
210780
- var BatchIngester = class {
210781
- buffer = [];
210782
- fatalError = null;
210783
- inflightFlush = Promise.resolve();
210784
- timer = null;
210785
- constructor(sink) {
210786
- this.sink = sink;
210787
- }
210788
- push(event) {
210789
- if (this.fatalError) return;
210790
- this.buffer.push(event);
210791
- if (this.buffer.length >= MAX_BATCH) {
210792
- if (this.timer) {
210793
- clearTimeout(this.timer);
210794
- this.timer = null;
210795
- }
210796
- this.triggerFlush();
210797
- } else if (!this.timer) this.timer = setTimeout(() => {
210798
- this.timer = null;
210799
- this.triggerFlush();
210800
- }, FLUSH_INTERVAL_MS);
210801
- }
210802
- /** Flush remaining buffer and wait for all in-flight sends to settle. */
210803
- async drain() {
210804
- if (this.timer) {
210805
- clearTimeout(this.timer);
210806
- this.timer = null;
210807
- }
210808
- this.triggerFlush();
210809
- await this.inflightFlush;
210810
- if (this.fatalError) throw this.fatalError;
210811
- }
210812
- async sendWithRetry(batch) {
210813
- let delay = 500;
210814
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) try {
210815
- await this.sink.ingest(batch);
210816
- return;
210817
- } catch (err) {
210818
- if (attempt === MAX_RETRIES) {
210819
- this.fatalError = err instanceof Error ? err : new Error(String(err));
210820
- throw this.fatalError;
210821
- }
210822
- await sleep$1(delay);
210823
- delay = Math.min(delay * 2, 8e3);
210824
- }
210825
- }
210826
- triggerFlush() {
210827
- if (this.fatalError || this.buffer.length === 0) return;
210828
- const batch = this.buffer.splice(0);
210829
- this.inflightFlush = this.inflightFlush.then(() => this.sendWithRetry(batch)).catch(() => {});
210830
- }
210831
- };
210832
-
210833
210834
  //#endregion
210834
210835
  //#region src/utils/TrpcIngestSink.ts
210835
210836
  /**
@@ -210840,11 +210841,12 @@ var BatchIngester = class {
210840
210841
  * injected by the server before spawning the sandbox / desktop process).
210841
210842
  */
210842
210843
  var TrpcIngestSink = class {
210843
- constructor(client, agentType, operationId, topicId) {
210844
+ constructor(client, agentType, operationId, topicId, assistantMessageId) {
210844
210845
  this.client = client;
210845
210846
  this.agentType = agentType;
210846
210847
  this.operationId = operationId;
210847
210848
  this.topicId = topicId;
210849
+ this.assistantMessageId = assistantMessageId;
210848
210850
  }
210849
210851
  async finish(params) {
210850
210852
  await this.client.aiAgent.heteroFinish.mutate({
@@ -210857,6 +210859,7 @@ var TrpcIngestSink = class {
210857
210859
  async ingest(events) {
210858
210860
  await this.client.aiAgent.heteroIngest.mutate({
210859
210861
  agentType: this.agentType,
210862
+ assistantMessageId: this.assistantMessageId,
210860
210863
  events,
210861
210864
  operationId: this.operationId,
210862
210865
  topicId: this.topicId
@@ -211002,6 +211005,69 @@ const resolvePrompt = async (options) => {
211002
211005
  };
211003
211006
  return buildPromptFromText(raw, images);
211004
211007
  };
211008
+ var SerialServerIngester = class {
211009
+ accumulatedText = "";
211010
+ fatalError = null;
211011
+ inflight = Promise.resolve();
211012
+ nextSnapshotSeq = 0;
211013
+ pendingTextEvent;
211014
+ timer = null;
211015
+ constructor(sink, snapshotFlushMs = 200) {
211016
+ this.sink = sink;
211017
+ this.snapshotFlushMs = snapshotFlushMs;
211018
+ }
211019
+ push(event) {
211020
+ if (this.fatalError) return;
211021
+ if (event.type === "stream_chunk" && event.data?.chunkType === "text" && typeof event.data?.content === "string") {
211022
+ this.accumulatedText += event.data.content;
211023
+ this.pendingTextEvent = event;
211024
+ if (this.timer) clearTimeout(this.timer);
211025
+ this.timer = setTimeout(() => {
211026
+ this.timer = null;
211027
+ this.queuePendingTextSnapshot();
211028
+ }, this.snapshotFlushMs);
211029
+ return;
211030
+ }
211031
+ this.queuePendingTextSnapshot();
211032
+ this.enqueue(async () => {
211033
+ await this.sink.ingest([event]);
211034
+ });
211035
+ }
211036
+ async drain() {
211037
+ this.queuePendingTextSnapshot();
211038
+ try {
211039
+ await this.inflight;
211040
+ } catch {}
211041
+ if (this.fatalError) throw this.fatalError;
211042
+ }
211043
+ enqueue(task) {
211044
+ this.inflight = this.inflight.then(task).catch((err) => {
211045
+ this.fatalError = err instanceof Error ? err : new Error(String(err));
211046
+ throw this.fatalError;
211047
+ });
211048
+ }
211049
+ queuePendingTextSnapshot() {
211050
+ if (!this.pendingTextEvent || this.fatalError) return;
211051
+ if (this.timer) {
211052
+ clearTimeout(this.timer);
211053
+ this.timer = null;
211054
+ }
211055
+ const baseEvent = this.pendingTextEvent;
211056
+ this.pendingTextEvent = void 0;
211057
+ const snapshotEvent = {
211058
+ ...baseEvent,
211059
+ data: {
211060
+ ...baseEvent.data,
211061
+ content: this.accumulatedText,
211062
+ snapshotMode: "replace",
211063
+ snapshotSeq: ++this.nextSnapshotSeq
211064
+ }
211065
+ };
211066
+ this.enqueue(async () => {
211067
+ await this.sink.ingest([snapshotEvent]);
211068
+ });
211069
+ }
211070
+ };
211005
211071
  const exec = async (options) => {
211006
211072
  if (!SUPPORTED_AGENT_TYPES.has(options.type)) {
211007
211073
  log$7.error(`Unsupported --type "${options.type}". Supported: ${[...SUPPORTED_AGENT_TYPES].join(", ")}`);
@@ -211027,11 +211093,13 @@ const exec = async (options) => {
211027
211093
  const emitJsonl = options.render === "jsonl" || options.render === void 0 && !serverIngest;
211028
211094
  const agentType = options.type;
211029
211095
  let sink;
211030
- if (serverIngest) sink = new TrpcIngestSink(await getTrpcClient(), agentType, operationId, options.topic);
211031
- else sink = new NoopIngestSink();
211032
- const ingester = new BatchIngester(sink);
211096
+ let serverIngester;
211097
+ if (serverIngest) {
211098
+ sink = new TrpcIngestSink(await getTrpcClient(), agentType, operationId, options.topic, process.env.LOBEHUB_ASSISTANT_MESSAGE_ID);
211099
+ serverIngester = new SerialServerIngester(sink);
211100
+ }
211033
211101
  /**
211034
- * Spawn one agent process and stream all its events into `ingester`.
211102
+ * Spawn one agent process and stream all its events into the server ingester.
211035
211103
  *
211036
211104
  * When `interceptResumeErrors` is true, any `error`-type event whose
211037
211105
  * message matches `RESUME_RETRY_PATTERNS` is withheld from the
@@ -211057,6 +211125,7 @@ const exec = async (options) => {
211057
211125
  }
211058
211126
  const STDERR_CAP = 8 * 1024;
211059
211127
  let stderrContent = "";
211128
+ const stderrEnded = once(handle.stderr, "end").then(() => void 0);
211060
211129
  handle.stderr.on("data", (chunk) => {
211061
211130
  if (stderrContent.length < STDERR_CAP) stderrContent += chunk.toString();
211062
211131
  });
@@ -211069,22 +211138,22 @@ const exec = async (options) => {
211069
211138
  }
211070
211139
  interrupted = true;
211071
211140
  handle.kill("SIGINT");
211072
- if (serverIngest) try {
211073
- await ingester.drain();
211141
+ if (serverIngester && sink) try {
211142
+ await serverIngester.drain();
211074
211143
  await sink.finish({ result: "cancelled" });
211075
211144
  } catch {}
211076
211145
  };
211077
211146
  const onSigterm = async () => {
211078
211147
  handle.kill("SIGTERM");
211079
- if (serverIngest) try {
211080
- await ingester.drain();
211148
+ if (serverIngester && sink) try {
211149
+ await serverIngester.drain();
211081
211150
  await sink.finish({ result: "cancelled" });
211082
211151
  } catch {}
211083
211152
  };
211084
211153
  process.on("SIGINT", onSigint);
211085
211154
  process.on("SIGTERM", onSigterm);
211086
211155
  let resumeNotFound = false;
211087
- let ingestError = false;
211156
+ const ingestError = false;
211088
211157
  try {
211089
211158
  for await (const event of handle.events) {
211090
211159
  if (interceptResumeErrors && event.type === "error") {
@@ -211096,12 +211165,12 @@ const exec = async (options) => {
211096
211165
  }
211097
211166
  }
211098
211167
  if (emitJsonl) process.stdout.write(`${JSON.stringify(event)}\n`);
211099
- ingester.push(event);
211168
+ serverIngester?.push(event);
211100
211169
  }
211101
211170
  } catch (err) {
211102
211171
  log$7.error("Stream error from agent process:", err instanceof Error ? err.message : String(err));
211103
- if (serverIngest) try {
211104
- await ingester.drain();
211172
+ if (serverIngester && sink) try {
211173
+ await serverIngester.drain();
211105
211174
  await sink.finish({
211106
211175
  error: {
211107
211176
  message: String(err),
@@ -211116,6 +211185,7 @@ const exec = async (options) => {
211116
211185
  process.off("SIGTERM", onSigterm);
211117
211186
  }
211118
211187
  const { code, signal } = await handle.exit;
211188
+ await stderrEnded;
211119
211189
  if (interceptResumeErrors && !resumeNotFound && code !== 0 && looksLikeNeedsRetryWithoutResume(stderrContent)) resumeNotFound = true;
211120
211190
  return {
211121
211191
  code,
@@ -211147,9 +211217,9 @@ const exec = async (options) => {
211147
211217
  }, false);
211148
211218
  }
211149
211219
  const { code, signal, sessionId } = result;
211150
- if (serverIngest) {
211220
+ if (serverIngester && sink) {
211151
211221
  try {
211152
- await ingester.drain();
211222
+ await serverIngester.drain();
211153
211223
  } catch (err) {
211154
211224
  log$7.error("Failed to flush events to server:", err instanceof Error ? err.message : String(err));
211155
211225
  result = {
@@ -215037,7 +215107,11 @@ function createProgram() {
215037
215107
 
215038
215108
  //#endregion
215039
215109
  //#region src/index.ts
215040
- createProgram().parse(process.argv, { from: "node" });
215110
+ createProgram().parseAsync(process.argv, { from: "node" }).catch((error) => {
215111
+ const message = error instanceof Error ? error.message : String(error);
215112
+ log$7.error(message);
215113
+ process.exit(1);
215114
+ });
215041
215115
 
215042
215116
  //#endregion
215043
215117
  export { };
package/man/man1/lh.1 CHANGED
@@ -1,6 +1,6 @@
1
1
  .\" Code generated by `npm run man:generate`; DO NOT EDIT.
2
2
  .\" Manual command details come from the Commander command tree.
3
- .TH LH 1 "" "@lobehub/cli 0.0.20" "User Commands"
3
+ .TH LH 1 "" "@lobehub/cli 0.0.21" "User Commands"
4
4
  .SH NAME
5
5
  lh \- LobeHub CLI \- manage and connect to LobeHub services
6
6
  .SH SYNOPSIS
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/cli",
3
- "version": "0.0.20",
3
+ "version": "0.0.21",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "lh": "./dist/index.js",