@lobehub/cli 0.0.15 → 0.0.16

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
@@ -11374,6 +11374,25 @@ const inferAttachmentType = (mimeType) => {
11374
11374
  return "file";
11375
11375
  };
11376
11376
  /**
11377
+ * Resolve a list of `--attachment` flag values into `AttachmentInput[]`. Each
11378
+ * entry is either a URL or a local file path. Returns `undefined` when no
11379
+ * flags were passed so callers can omit the field on the wire entirely (the
11380
+ * TRPC schema treats absent vs empty differently). Bails the process on
11381
+ * load failures — a silently-dropped attachment would be worse than a
11382
+ * loud error here.
11383
+ */
11384
+ const resolveAttachmentFlags = async (flags) => {
11385
+ if (flags.length === 0) return void 0;
11386
+ const out = [];
11387
+ for (const raw of flags) try {
11388
+ out.push(await parseAttachmentArg(raw));
11389
+ } catch (error) {
11390
+ log$7.error(`Failed to load attachment "${raw}": ${error.message}`);
11391
+ process.exit(1);
11392
+ }
11393
+ return out;
11394
+ };
11395
+ /**
11377
11396
  * Parse a single `--attachment <value>` argument. Accepted forms:
11378
11397
  * - `https://…` / `http://…` → fetchUrl, type inferred from extension
11379
11398
  * - any other string → treated as a local file path;
@@ -11399,22 +11418,29 @@ const parseAttachmentArg = async (raw) => {
11399
11418
  type: inferAttachmentType(mimeType)
11400
11419
  };
11401
11420
  };
11421
+ /**
11422
+ * Resolve the `<botIdOrAtKey>` positional argument into a `{ botId? |
11423
+ * messengerInstallationId? }` shape that matches the TRPC send procedures'
11424
+ * `exactly-one-of` constraint.
11425
+ *
11426
+ * Convention: a value prefixed with `@` is treated as a System Bot
11427
+ * messenger installation id (e.g. `@inst_abc123`); anything else is a
11428
+ * per-agent bot id. The `@` was chosen because `agent_bot_providers`.id is
11429
+ * always a UUID — no UUID starts with `@`, so the prefix unambiguously
11430
+ * disambiguates without breaking the existing UUID-only call sites.
11431
+ */
11432
+ const resolveSendTargetArg = (value) => {
11433
+ if (value.startsWith("@")) return { messengerInstallationId: value.slice(1) };
11434
+ return { botId: value };
11435
+ };
11402
11436
  function registerBotMessageCommands(bot) {
11403
11437
  const message = bot.command("message").description("Send and manage messages on connected platforms");
11404
- message.command("send <botId>").description("Send a message to a channel").requiredOption("--target <channelId>", "Target channel / conversation ID").requiredOption("--message <text>", "Message content").option("--attachment <pathOrUrl>", "Attach a file by local path or remote URL (repeatable). Local paths are base64-encoded; http(s) URLs are passed as fetchUrl.", collectOptions, []).option("--reply-to <messageId>", "Reply to a specific message").option("--json", "Output JSON").action(async (botId, options) => {
11405
- let attachments;
11406
- if (options.attachment.length > 0) {
11407
- attachments = [];
11408
- for (const raw of options.attachment) try {
11409
- attachments.push(await parseAttachmentArg(raw));
11410
- } catch (error) {
11411
- log$7.error(`Failed to load attachment "${raw}": ${error.message}`);
11412
- process.exit(1);
11413
- }
11414
- }
11438
+ message.command("send <botIdOrAtKey>").description("Send a message to a channel. Pass a per-agent bot id, or \"@<messenger-install-id>\" to send through a System Bot messenger installation (see `lh bot messengers list`).").requiredOption("--target <channelId>", "Target channel / conversation ID").requiredOption("--message <text>", "Message content").option("--attachment <pathOrUrl>", "Attach a file by local path or remote URL (repeatable). Local paths are base64-encoded; http(s) URLs are passed as fetchUrl.", collectOptions, []).option("--reply-to <messageId>", "Reply to a specific message").option("--json", "Output JSON").action(async (botIdOrAtKey, options) => {
11439
+ const attachments = await resolveAttachmentFlags(options.attachment);
11440
+ const target = resolveSendTargetArg(botIdOrAtKey);
11415
11441
  const result = await (await getTrpcClient()).botMessage.sendMessage.mutate({
11442
+ ...target,
11416
11443
  attachments,
11417
- botId,
11418
11444
  channelId: options.target,
11419
11445
  content: options.message,
11420
11446
  replyTo: options.replyTo
@@ -11427,6 +11453,23 @@ function registerBotMessageCommands(bot) {
11427
11453
  const suffix = attachments?.length ? ` with ${attachments.length} attachment(s)` : "";
11428
11454
  console.log(`${import_picocolors.default.green("✓")} Message sent${r.messageId ? ` (${import_picocolors.default.dim(r.messageId)})` : ""}${suffix}`);
11429
11455
  });
11456
+ message.command("dm <botIdOrAtKey>").description("Send a direct message to a platform user. Pass a per-agent bot id, or \"@<messenger-install-id>\" for a System Bot install.").requiredOption("--user-id <id>", "Target user ID on the platform").requiredOption("--message <text>", "Message content").option("--attachment <pathOrUrl>", "Attach a file by local path or remote URL (repeatable). Local paths are base64-encoded; http(s) URLs are passed as fetchUrl.", collectOptions, []).option("--json", "Output JSON").action(async (botIdOrAtKey, options) => {
11457
+ const attachments = await resolveAttachmentFlags(options.attachment);
11458
+ const target = resolveSendTargetArg(botIdOrAtKey);
11459
+ const result = await (await getTrpcClient()).botMessage.sendDirectMessage.mutate({
11460
+ ...target,
11461
+ attachments,
11462
+ content: options.message,
11463
+ userId: options.userId
11464
+ });
11465
+ if (options.json) {
11466
+ outputJson(result);
11467
+ return;
11468
+ }
11469
+ const r = result;
11470
+ const suffix = attachments?.length ? ` with ${attachments.length} attachment(s)` : "";
11471
+ console.log(`${import_picocolors.default.green("✓")} DM sent${r.messageId ? ` (${import_picocolors.default.dim(r.messageId)})` : ""}${suffix}`);
11472
+ });
11430
11473
  message.command("read <botId>").description("Read messages from a channel").requiredOption("--target <channelId>", "Target channel / conversation ID").option("--limit <n>", "Max messages to fetch", String(50)).option("--before <messageId>", "Read messages before this ID").option("--after <messageId>", "Read messages after this ID").option("--start-time <timestamp>", "Start time as Unix seconds (Feishu/Lark)").option("--end-time <timestamp>", "End time as Unix seconds (Feishu/Lark)").option("--cursor <token>", "Pagination cursor from a previous response (Feishu/Lark)").option("--json", "Output JSON").action(async (botId, options) => {
11431
11474
  const result = await (await getTrpcClient()).botMessage.readMessages.query({
11432
11475
  after: options.after,
@@ -11627,13 +11670,17 @@ function registerBotMessageCommands(bot) {
11627
11670
  "MESSAGES"
11628
11671
  ]);
11629
11672
  });
11630
- thread.command("reply <botId>").description("Reply to a thread").requiredOption("--thread-id <id>", "Thread ID").requiredOption("--message <text>", "Reply content").action(async (botId, options) => {
11673
+ thread.command("reply <botIdOrAtKey>").description("Reply to a thread. Pass a per-agent bot id, or \"@<messenger-install-id>\" for a System Bot install.").requiredOption("--thread-id <id>", "Thread ID").requiredOption("--message <text>", "Reply content").option("--attachment <pathOrUrl>", "Attach a file by local path or remote URL (repeatable). Local paths are base64-encoded; http(s) URLs are passed as fetchUrl.", collectOptions, []).action(async (botIdOrAtKey, options) => {
11674
+ const attachments = await resolveAttachmentFlags(options.attachment);
11675
+ const target = resolveSendTargetArg(botIdOrAtKey);
11631
11676
  const r = await (await getTrpcClient()).botMessage.replyToThread.mutate({
11632
- botId,
11677
+ ...target,
11678
+ attachments,
11633
11679
  content: options.message,
11634
11680
  threadId: options.threadId
11635
11681
  });
11636
- console.log(`${import_picocolors.default.green("✓")} Reply sent${r.messageId ? ` (${import_picocolors.default.dim(r.messageId)})` : ""}`);
11682
+ const suffix = attachments?.length ? ` with ${attachments.length} attachment(s)` : "";
11683
+ console.log(`${import_picocolors.default.green("✓")} Reply sent${r.messageId ? ` (${import_picocolors.default.dim(r.messageId)})` : ""}${suffix}`);
11637
11684
  });
11638
11685
  const channel = message.command("channel").description("Manage channels");
11639
11686
  channel.command("list <botId>").description("List channels").option("--server-id <id>", "Server / workspace ID").option("--filter <type>", "Filter by type").option("--json", "Output JSON").action(async (botId, options) => {
@@ -11696,6 +11743,178 @@ function collectOptions(value, previous) {
11696
11743
  return [...previous, value];
11697
11744
  }
11698
11745
 
11746
+ //#endregion
11747
+ //#region src/commands/botMessengers.ts
11748
+ const PLATFORMS = [
11749
+ "telegram",
11750
+ "slack",
11751
+ "discord"
11752
+ ];
11753
+ const validatePlatform = (value) => {
11754
+ if (!PLATFORMS.includes(value)) throw new Error(`Unknown messenger platform: ${value}. Valid values: ${PLATFORMS.join(", ")}.`);
11755
+ return value;
11756
+ };
11757
+ function registerBotMessengersCommands(bot) {
11758
+ const messengers = bot.command("messengers").description("Manage System Bot messenger installations (Slack workspaces, Discord guilds, Telegram) and per-user account links");
11759
+ messengers.command("list").description("List all System Bot installations the current user has connected.").option("--json", "Output JSON").action(async (options) => {
11760
+ const installations = await (await getTrpcClient()).messenger.listMyInstallations.query();
11761
+ if (options.json) {
11762
+ outputJson(installations);
11763
+ return;
11764
+ }
11765
+ if (installations.length === 0) {
11766
+ console.log("No System Bot installations connected.");
11767
+ console.log(`\nRun ${import_picocolors.default.dim("lh bot messengers platforms")} to see what's available, then install via ${import_picocolors.default.dim("Settings → Messenger")} (OAuth requires a browser).`);
11768
+ return;
11769
+ }
11770
+ printTable(installations.map((i) => [
11771
+ i.id || "",
11772
+ i.platform || "",
11773
+ i.tenantName || i.tenantId || "(global)",
11774
+ i.applicationId || "",
11775
+ i.installedAt ? new Date(i.installedAt).toISOString().slice(0, 10) : ""
11776
+ ]), [
11777
+ "INSTALLATION ID",
11778
+ "PLATFORM",
11779
+ "TENANT",
11780
+ "APP ID",
11781
+ "INSTALLED"
11782
+ ]);
11783
+ console.log(`\nUse ${import_picocolors.default.dim("@<INSTALLATION ID>")} as the positional argument on ${import_picocolors.default.dim("lh bot message send/dm/thread reply")} to route through a System Bot install.`);
11784
+ });
11785
+ messengers.command("view <installationId>").description("Show detail for one installation.").option("--json", "Output JSON").action(async (installationId, options) => {
11786
+ const install = (await (await getTrpcClient()).messenger.listMyInstallations.query()).find((i) => i.id === installationId);
11787
+ if (!install) {
11788
+ if (options.json) outputJson(null);
11789
+ else console.error(import_picocolors.default.red(`Installation not found: ${installationId}`));
11790
+ process.exit(1);
11791
+ return;
11792
+ }
11793
+ if (options.json) {
11794
+ outputJson(install);
11795
+ return;
11796
+ }
11797
+ console.log(`${import_picocolors.default.bold("Installation")} ${import_picocolors.default.dim(install.id)}`);
11798
+ console.log(` Platform: ${install.platform}`);
11799
+ console.log(` Tenant: ${install.tenantName || install.tenantId || "(global)"}`);
11800
+ if (install.tenantId && install.tenantName) console.log(` Tenant ID: ${install.tenantId}`);
11801
+ console.log(` Application ID: ${install.applicationId}`);
11802
+ if (install.scope) console.log(` OAuth Scope: ${install.scope}`);
11803
+ if (install.installedAt) console.log(` Installed: ${new Date(install.installedAt).toISOString()}`);
11804
+ if (install.enterpriseId) console.log(` Enterprise ID: ${install.enterpriseId}`);
11805
+ if (install.isEnterpriseInstall) console.log(` Enterprise: yes`);
11806
+ });
11807
+ messengers.command("uninstall <installationId>").description("Revoke a workspace install. AFFECTS EVERY USER IN THAT WORKSPACE — for Slack this freezes the bot; for Discord it removes the audit entry (a guild admin must remove the bot separately). To disconnect only your own account, use `bot messengers links unlink`.").option("--yes", "Skip confirmation prompt").action(async (installationId, options) => {
11808
+ const client = await getTrpcClient();
11809
+ if (!options.yes) {
11810
+ const install = (await client.messenger.listMyInstallations.query()).find((i) => i.id === installationId);
11811
+ const label = install ? `${install.platform} (${install.tenantName || install.tenantId || "global"})` : installationId;
11812
+ if (!await confirm(`${import_picocolors.default.yellow("⚠")} Uninstall ${import_picocolors.default.bold(label)} — this revokes the install for the whole workspace. Continue?`)) {
11813
+ console.log("Aborted.");
11814
+ return;
11815
+ }
11816
+ }
11817
+ await client.messenger.uninstallInstallation.mutate({ installationId });
11818
+ console.log(`${import_picocolors.default.green("✓")} Installation ${import_picocolors.default.dim(installationId)} revoked.`);
11819
+ });
11820
+ messengers.command("platforms").description("List the platforms available for System Bot OAuth install.").option("--json", "Output JSON").action(async (options) => {
11821
+ const platforms = await (await getTrpcClient()).messenger.availablePlatforms.query();
11822
+ if (options.json) {
11823
+ outputJson(platforms);
11824
+ return;
11825
+ }
11826
+ if (platforms.length === 0) {
11827
+ console.log("No System Bot platforms are configured on this deployment.");
11828
+ return;
11829
+ }
11830
+ printTable(platforms.map((p) => [
11831
+ p.id || "",
11832
+ p.name || "",
11833
+ p.appId || "",
11834
+ p.botUsername || ""
11835
+ ]), [
11836
+ "ID",
11837
+ "NAME",
11838
+ "APP ID",
11839
+ "BOT USERNAME"
11840
+ ]);
11841
+ console.log(`\nInstalls are initiated via ${import_picocolors.default.dim("Settings → Messenger")} in the web UI (OAuth needs a browser).`);
11842
+ });
11843
+ const links = messengers.command("links").description("Manage per-user account links — routing of inbound IM to your agents");
11844
+ links.command("list").description("List all your account links across platforms and tenants.").option("--json", "Output JSON").action(async (options) => {
11845
+ const linkRows = await (await getTrpcClient()).messenger.listMyLinks.query();
11846
+ if (options.json) {
11847
+ outputJson(linkRows);
11848
+ return;
11849
+ }
11850
+ if (linkRows.length === 0) {
11851
+ console.log("No account links yet. Complete verify-im on a platform first.");
11852
+ return;
11853
+ }
11854
+ printTable(linkRows.map((l) => [
11855
+ l.platform || "",
11856
+ l.tenantId || "(global)",
11857
+ l.activeAgentId || import_picocolors.default.dim("(unset)"),
11858
+ l.platformUsername || l.platformUserId || ""
11859
+ ]), [
11860
+ "PLATFORM",
11861
+ "TENANT",
11862
+ "ACTIVE AGENT",
11863
+ "PLATFORM USER"
11864
+ ]);
11865
+ });
11866
+ links.command("view <platform>").description("Show one account link.").option("--tenant <id>", "Tenant scope (Slack workspace id). Omit for global-bot platforms.").option("--json", "Output JSON").action(async (platform, options) => {
11867
+ const client = await getTrpcClient();
11868
+ const platformValidated = validatePlatform(platform);
11869
+ const link = await client.messenger.getMyLink.query({
11870
+ platform: platformValidated,
11871
+ tenantId: options.tenant
11872
+ });
11873
+ if (!link) {
11874
+ console.error(import_picocolors.default.red(`No link found for ${platform}${options.tenant ? ` (tenant ${options.tenant})` : ""}`));
11875
+ process.exit(1);
11876
+ return;
11877
+ }
11878
+ if (options.json) {
11879
+ outputJson(link);
11880
+ return;
11881
+ }
11882
+ console.log(`${import_picocolors.default.bold("Link")} ${import_picocolors.default.dim(link.platform)}`);
11883
+ if (link.tenantId) console.log(` Tenant ID: ${link.tenantId}`);
11884
+ console.log(` Platform User ID: ${link.platformUserId}`);
11885
+ if (link.platformUsername) console.log(` Platform User: ${link.platformUsername}`);
11886
+ console.log(` Active Agent: ${link.activeAgentId ?? import_picocolors.default.dim("(unset)")}`);
11887
+ });
11888
+ links.command("set-agent <platform>").description("Change which agent receives inbound IM on a platform link.").requiredOption("--agent <id>", "Agent id to route to, or \"none\" to clear the active agent.").option("--tenant <id>", "Tenant scope (Slack workspace id). Omit for global-bot platforms.").action(async (platform, options) => {
11889
+ const client = await getTrpcClient();
11890
+ const platformValidated = validatePlatform(platform);
11891
+ const agentId = options.agent === "none" ? null : options.agent;
11892
+ await client.messenger.setActiveAgent.mutate({
11893
+ agentId,
11894
+ platform: platformValidated,
11895
+ tenantId: options.tenant
11896
+ });
11897
+ const scope = options.tenant ? ` (tenant ${options.tenant})` : "";
11898
+ const target = agentId === null ? "cleared" : `set to agent ${import_picocolors.default.dim(agentId)}`;
11899
+ console.log(`${import_picocolors.default.green("✓")} Active agent for ${platform}${scope} ${target}.`);
11900
+ });
11901
+ links.command("unlink <platform>").description("Remove your account link for a platform. Workspace install is unaffected — colleagues can still use the bot.").option("--tenant <id>", "Tenant scope (Slack workspace id). Omit for global-bot platforms.").option("--yes", "Skip confirmation prompt").action(async (platform, options) => {
11902
+ const client = await getTrpcClient();
11903
+ const platformValidated = validatePlatform(platform);
11904
+ if (!options.yes) {
11905
+ if (!await confirm(`Unlink your account from ${import_picocolors.default.bold(platform)}${options.tenant ? ` (tenant ${options.tenant})` : ""}?`)) {
11906
+ console.log("Aborted.");
11907
+ return;
11908
+ }
11909
+ }
11910
+ await client.messenger.unlink.mutate({
11911
+ platform: platformValidated,
11912
+ tenantId: options.tenant
11913
+ });
11914
+ console.log(`${import_picocolors.default.green("✓")} Unlinked.`);
11915
+ });
11916
+ }
11917
+
11699
11918
  //#endregion
11700
11919
  //#region src/commands/bot.ts
11701
11920
  const DM_POLICIES = [
@@ -12004,6 +12223,7 @@ function registerWatchKeywordsCommand(bot) {
12004
12223
  function registerBotCommand(program) {
12005
12224
  const bot = program.command("bot").description("Manage bot integrations");
12006
12225
  registerBotMessageCommands(bot);
12226
+ registerBotMessengersCommands(bot);
12007
12227
  bot.command("platforms").description("List supported platforms and their required credentials").option("--json", "Output JSON").action(async (options) => {
12008
12228
  const platforms = await (await getTrpcClient()).agentBotProvider.listPlatforms.query();
12009
12229
  if (options.json) {
@@ -16453,6 +16673,318 @@ async function resolveToken(options) {
16453
16673
  process.exit(1);
16454
16674
  }
16455
16675
 
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
+
16710
+ //#endregion
16711
+ //#region src/tools/heteroTask.ts
16712
+ const DEFAULT_HERMES_PORT = 3456;
16713
+ /** Resolve the absolute path to the `lh` binary to avoid PATH issues in child processes. */
16714
+ function resolveLhPath() {
16715
+ try {
16716
+ return execFileSync("which", ["lh"], { encoding: "utf8" }).trim();
16717
+ } catch {
16718
+ return "lh";
16719
+ }
16720
+ }
16721
+ /**
16722
+ * Check whether an openclaw session already exists for the given topicId.
16723
+ * The session key format is `agent:<agentId>:explicit:<sessionId>`.
16724
+ * Returns false on any error so that callers default to injecting the full protocol.
16725
+ */
16726
+ function openclawSessionExists(agentId, topicId) {
16727
+ try {
16728
+ const raw = execFileSync("openclaw", [
16729
+ "sessions",
16730
+ "--agent",
16731
+ agentId,
16732
+ "--json"
16733
+ ], { encoding: "utf8" });
16734
+ const data = JSON.parse(raw);
16735
+ const expectedKey = `agent:${agentId}:explicit:${topicId}`;
16736
+ return data.sessions?.some((s) => s.key === expectedKey) ?? false;
16737
+ } catch {
16738
+ return false;
16739
+ }
16740
+ }
16741
+ function getHermesPort() {
16742
+ const env = process.env.HERMES_GATEWAY_PORT;
16743
+ if (env) {
16744
+ const parsed = Number.parseInt(env, 10);
16745
+ if (!Number.isNaN(parsed)) return parsed;
16746
+ }
16747
+ return DEFAULT_HERMES_PORT;
16748
+ }
16749
+ async function isHermesGatewayRunning(port) {
16750
+ try {
16751
+ return (await fetch(`http://localhost:${port}/health`)).ok;
16752
+ } catch {
16753
+ return false;
16754
+ }
16755
+ }
16756
+ async function startHermesGateway(port) {
16757
+ spawn("hermes", ["gateway", "start"], {
16758
+ detached: true,
16759
+ env: { ...process.env },
16760
+ stdio: "ignore"
16761
+ }).unref();
16762
+ const deadline = Date.now() + 1e4;
16763
+ while (Date.now() < deadline) {
16764
+ await new Promise((r) => setTimeout(r, 500));
16765
+ if (await isHermesGatewayRunning(port)) return;
16766
+ }
16767
+ throw new Error(`Hermes gateway did not start within 10s on port ${port}`);
16768
+ }
16769
+ async function sendAutoNotify(topicId, taskId, text, agentId) {
16770
+ try {
16771
+ await (await getTrpcClient()).agentNotify.notify.mutate({
16772
+ agentId,
16773
+ content: text,
16774
+ role: "assistant",
16775
+ topicId
16776
+ });
16777
+ } catch (err) {
16778
+ log$7.error("Failed to send auto-notify:", err instanceof Error ? err.message : String(err));
16779
+ }
16780
+ }
16781
+ /**
16782
+ * Signal remote hetero task completion to the server so it can publish
16783
+ * `agent_runtime_end` to the gateway WS and close the frontend subscription.
16784
+ * Called on clean process exit (code=0, no signal) — error exits go through
16785
+ * `sendAutoNotify` which writes an error message AND triggers completion via
16786
+ * the `done` flag.
16787
+ */
16788
+ async function sendDoneSignal(topicId, agentId) {
16789
+ try {
16790
+ await (await getTrpcClient()).agentNotify.notify.mutate({
16791
+ agentId,
16792
+ content: "",
16793
+ done: true,
16794
+ role: "assistant",
16795
+ topicId
16796
+ });
16797
+ } catch (err) {
16798
+ log$7.error("Failed to send done signal:", err instanceof Error ? err.message : String(err));
16799
+ }
16800
+ }
16801
+ /**
16802
+ * Build the notify protocol injected into the first message of a new hetero-agent session.
16803
+ * Tells the agent how to push updates back to the LobeHub user via `lh notify`.
16804
+ */
16805
+ function buildNotifyProtocol(lhPath, topicId) {
16806
+ return `## Context: This task was dispatched by LobeHub
16807
+
16808
+ 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.
16809
+
16810
+ **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.
16811
+
16812
+ **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.
16813
+
16814
+ ## Sending messages back to the user
16815
+
16816
+ 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.`;
16817
+ }
16818
+ async function runHeteroTask(params) {
16819
+ const { agentId, agentType, cwd, operationId, prompt, taskId, topicId } = params;
16820
+ const workDir = cwd || process.cwd();
16821
+ const lhPath = resolveLhPath();
16822
+ if (agentType === "openclaw") {
16823
+ const openclawAgent = process.env.OPENCLAW_AGENT_ID ?? "main";
16824
+ const child = spawn("openclaw", [
16825
+ "agent",
16826
+ "--agent",
16827
+ openclawAgent,
16828
+ "--session-id",
16829
+ topicId,
16830
+ "--message",
16831
+ !openclawSessionExists(openclawAgent, topicId) ? `${prompt}\n\n${buildNotifyProtocol(lhPath, topicId)}` : prompt,
16832
+ "--local"
16833
+ ], {
16834
+ cwd: workDir,
16835
+ detached: true,
16836
+ env: { ...process.env },
16837
+ stdio: "ignore"
16838
+ });
16839
+ const pid = child.pid;
16840
+ if (pid === void 0) throw new Error("Failed to get PID for openclaw process");
16841
+ child.unref();
16842
+ saveTask({
16843
+ agentId,
16844
+ agentType,
16845
+ operationId,
16846
+ pid,
16847
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
16848
+ taskId,
16849
+ topicId
16850
+ });
16851
+ log$7.info(`OpenClaw task started: taskId=${taskId} pid=${pid} agent=${openclawAgent}`);
16852
+ child.on("close", (code, signal) => {
16853
+ removeTask(taskId);
16854
+ if (code !== 0 || signal !== null) {
16855
+ sendAutoNotify(topicId, taskId, signal ? `Task cancelled (signal: ${signal})` : `Task failed (exit code: ${code})`, agentId);
16856
+ sendDoneSignal(topicId, agentId);
16857
+ } else sendDoneSignal(topicId, agentId);
16858
+ });
16859
+ return JSON.stringify({
16860
+ pid,
16861
+ taskId
16862
+ });
16863
+ }
16864
+ if (agentType === "hermes") {
16865
+ const port = getHermesPort();
16866
+ if (!await isHermesGatewayRunning(port)) {
16867
+ log$7.info(`Hermes gateway not running on port ${port}, starting...`);
16868
+ await startHermesGateway(port);
16869
+ }
16870
+ const res = await fetch(`http://localhost:${port}/message`, {
16871
+ body: JSON.stringify({
16872
+ content: prompt,
16873
+ operationId
16874
+ }),
16875
+ headers: { "Content-Type": "application/json" },
16876
+ method: "POST"
16877
+ });
16878
+ if (!res.ok) throw new Error(`Hermes gateway returned ${res.status}: ${await res.text()}`);
16879
+ saveTask({
16880
+ agentId,
16881
+ agentType,
16882
+ operationId,
16883
+ pid: 0,
16884
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
16885
+ taskId,
16886
+ topicId
16887
+ });
16888
+ log$7.info(`Hermes task dispatched: taskId=${taskId} operationId=${operationId}`);
16889
+ return JSON.stringify({
16890
+ operationId,
16891
+ taskId
16892
+ });
16893
+ }
16894
+ throw new Error(`Unsupported agentType: ${agentType}`);
16895
+ }
16896
+ async function cancelHeteroTask(params) {
16897
+ const { signal = "SIGINT", taskId } = params;
16898
+ const entry = getTask(taskId);
16899
+ if (!entry) return JSON.stringify({
16900
+ message: `No task found with taskId: ${taskId}`,
16901
+ success: false
16902
+ });
16903
+ if (entry.agentType === "hermes") {
16904
+ const port = getHermesPort();
16905
+ try {
16906
+ await fetch(`http://localhost:${port}/stop`, {
16907
+ body: JSON.stringify({ operationId: entry.operationId }),
16908
+ headers: { "Content-Type": "application/json" },
16909
+ method: "POST"
16910
+ });
16911
+ } catch (err) {
16912
+ log$7.warn(`Failed to send /stop to Hermes gateway: ${err instanceof Error ? err.message : String(err)}`);
16913
+ }
16914
+ removeTask(taskId);
16915
+ await sendAutoNotify(entry.topicId, taskId, "Task cancelled", entry.agentId);
16916
+ return JSON.stringify({ taskId });
16917
+ }
16918
+ try {
16919
+ process.kill(entry.pid, signal);
16920
+ } catch (err) {
16921
+ log$7.warn(`Failed to send ${signal} to pid ${entry.pid}: ${err instanceof Error ? err.message : String(err)}`);
16922
+ removeTask(taskId);
16923
+ await sendAutoNotify(entry.topicId, taskId, "Task already completed or cancelled", entry.agentId);
16924
+ }
16925
+ return JSON.stringify({
16926
+ pid: entry.pid,
16927
+ signal,
16928
+ taskId
16929
+ });
16930
+ }
16931
+
16932
+ //#endregion
16933
+ //#region src/tools/checkPlatformCapability.ts
16934
+ /**
16935
+ * Probe whether a specific agent platform is available on this device.
16936
+ * Dispatched by the server via `device.checkCapability` tRPC procedure.
16937
+ *
16938
+ * - openclaw: runs `openclaw --version` and parses the output
16939
+ * - hermes: hits the gateway health endpoint on the configured port
16940
+ */
16941
+ async function checkPlatformCapability(params) {
16942
+ const { platform } = params;
16943
+ if (platform === "openclaw") try {
16944
+ return {
16945
+ available: true,
16946
+ version: execFileSync("openclaw", ["--version"], {
16947
+ encoding: "utf8",
16948
+ timeout: 5e3
16949
+ }).trim().split(/\s+/).at(-1)
16950
+ };
16951
+ } catch (err) {
16952
+ return {
16953
+ available: false,
16954
+ reason: err instanceof Error ? err.message : "openclaw not found or failed to run"
16955
+ };
16956
+ }
16957
+ if (platform === "hermes") {
16958
+ const port = getHermesPort();
16959
+ try {
16960
+ const res = await fetch(`http://localhost:${port}/health`, { signal: AbortSignal.timeout(5e3) });
16961
+ if (res.ok) {
16962
+ let version;
16963
+ try {
16964
+ version = (await res.json()).version;
16965
+ } catch {}
16966
+ return {
16967
+ available: true,
16968
+ version
16969
+ };
16970
+ }
16971
+ return {
16972
+ available: false,
16973
+ reason: `Hermes gateway returned HTTP ${res.status}`
16974
+ };
16975
+ } catch (err) {
16976
+ return {
16977
+ available: false,
16978
+ reason: err instanceof Error ? err.message : `Hermes gateway not reachable on port ${port}`
16979
+ };
16980
+ }
16981
+ }
16982
+ return {
16983
+ available: false,
16984
+ reason: `Unknown platform: ${platform}`
16985
+ };
16986
+ }
16987
+
16456
16988
  //#endregion
16457
16989
  //#region node_modules/.pnpm/diff@8.0.4/node_modules/diff/libesm/diff/base.js
16458
16990
  var Diff = class {
@@ -205897,6 +206429,11 @@ const TEXT_READABLE_FILE_TYPES = [
205897
206429
  "scala",
205898
206430
  "groovy",
205899
206431
  "gradle",
206432
+ "tex",
206433
+ "sty",
206434
+ "cls",
206435
+ "bib",
206436
+ "bbl",
205900
206437
  "log",
205901
206438
  "sql",
205902
206439
  "patch",
@@ -206605,239 +207142,6 @@ async function runCommand$1({ command, cwd, description, env: extraEnv, run_in_b
206605
207142
  }
206606
207143
  }
206607
207144
 
206608
- //#endregion
206609
- //#region src/daemon/taskRegistry.ts
206610
- function getRegistryPath() {
206611
- return path.join(os.homedir(), ".lobehub", "task-registry.json");
206612
- }
206613
- function readRegistry() {
206614
- try {
206615
- return JSON.parse(fs.readFileSync(getRegistryPath(), "utf8"));
206616
- } catch {
206617
- return {};
206618
- }
206619
- }
206620
- function writeRegistry(entries) {
206621
- const dir = path.dirname(getRegistryPath());
206622
- fs.mkdirSync(dir, {
206623
- mode: 448,
206624
- recursive: true
206625
- });
206626
- fs.writeFileSync(getRegistryPath(), JSON.stringify(entries, null, 2), { mode: 384 });
206627
- }
206628
- function saveTask(entry) {
206629
- const registry = readRegistry();
206630
- registry[entry.taskId] = entry;
206631
- writeRegistry(registry);
206632
- }
206633
- function getTask(taskId) {
206634
- return readRegistry()[taskId];
206635
- }
206636
- function removeTask(taskId) {
206637
- const registry = readRegistry();
206638
- delete registry[taskId];
206639
- writeRegistry(registry);
206640
- }
206641
-
206642
- //#endregion
206643
- //#region src/tools/heteroTask.ts
206644
- const DEFAULT_HERMES_PORT = 3456;
206645
- /** Resolve the absolute path to the `lh` binary to avoid PATH issues in child processes. */
206646
- function resolveLhPath() {
206647
- try {
206648
- return execFileSync("which", ["lh"], { encoding: "utf8" }).trim();
206649
- } catch {
206650
- return "lh";
206651
- }
206652
- }
206653
- /**
206654
- * Check whether an openclaw session already exists for the given topicId.
206655
- * The session key format is `agent:<agentId>:explicit:<sessionId>`.
206656
- * Returns false on any error so that callers default to injecting the full protocol.
206657
- */
206658
- function openclawSessionExists(agentId, topicId) {
206659
- try {
206660
- const raw = execFileSync("openclaw", [
206661
- "sessions",
206662
- "--agent",
206663
- agentId,
206664
- "--json"
206665
- ], { encoding: "utf8" });
206666
- const data = JSON.parse(raw);
206667
- const expectedKey = `agent:${agentId}:explicit:${topicId}`;
206668
- return data.sessions?.some((s) => s.key === expectedKey) ?? false;
206669
- } catch {
206670
- return false;
206671
- }
206672
- }
206673
- function getHermesPort() {
206674
- const env = process.env.HERMES_GATEWAY_PORT;
206675
- if (env) {
206676
- const parsed = Number.parseInt(env, 10);
206677
- if (!Number.isNaN(parsed)) return parsed;
206678
- }
206679
- return DEFAULT_HERMES_PORT;
206680
- }
206681
- async function isHermesGatewayRunning(port) {
206682
- try {
206683
- return (await fetch(`http://localhost:${port}/health`)).ok;
206684
- } catch {
206685
- return false;
206686
- }
206687
- }
206688
- async function startHermesGateway(port) {
206689
- spawn("hermes", ["gateway", "start"], {
206690
- detached: true,
206691
- env: { ...process.env },
206692
- stdio: "ignore"
206693
- }).unref();
206694
- const deadline = Date.now() + 1e4;
206695
- while (Date.now() < deadline) {
206696
- await new Promise((r) => setTimeout(r, 500));
206697
- if (await isHermesGatewayRunning(port)) return;
206698
- }
206699
- throw new Error(`Hermes gateway did not start within 10s on port ${port}`);
206700
- }
206701
- async function sendAutoNotify(topicId, taskId, text, agentId) {
206702
- try {
206703
- await (await getTrpcClient()).agentNotify.notify.mutate({
206704
- agentId,
206705
- content: text,
206706
- role: "assistant",
206707
- topicId
206708
- });
206709
- } catch (err) {
206710
- log$7.error("Failed to send auto-notify:", err instanceof Error ? err.message : String(err));
206711
- }
206712
- }
206713
- /**
206714
- * Build the notify protocol injected into the first message of a new hetero-agent session.
206715
- * Tells the agent how to push updates back to the LobeHub user via `lh notify`.
206716
- */
206717
- function buildNotifyProtocol(lhPath, topicId) {
206718
- return `## Context: This task was dispatched by LobeHub
206719
-
206720
- 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.
206721
-
206722
- **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.
206723
-
206724
- **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.
206725
-
206726
- ## Sending messages back to the user
206727
-
206728
- 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.`;
206729
- }
206730
- async function runHeteroTask(params) {
206731
- const { agentId, agentType, cwd, operationId, prompt, taskId, topicId } = params;
206732
- const workDir = cwd || process.cwd();
206733
- const lhPath = resolveLhPath();
206734
- if (agentType === "openclaw") {
206735
- const openclawAgent = process.env.OPENCLAW_AGENT_ID ?? "main";
206736
- const child = spawn("openclaw", [
206737
- "agent",
206738
- "--agent",
206739
- openclawAgent,
206740
- "--session-id",
206741
- topicId,
206742
- "--message",
206743
- !openclawSessionExists(openclawAgent, topicId) ? `${prompt}\n\n${buildNotifyProtocol(lhPath, topicId)}` : prompt,
206744
- "--local"
206745
- ], {
206746
- cwd: workDir,
206747
- detached: true,
206748
- env: { ...process.env },
206749
- stdio: "ignore"
206750
- });
206751
- const pid = child.pid;
206752
- if (pid === void 0) throw new Error("Failed to get PID for openclaw process");
206753
- child.unref();
206754
- saveTask({
206755
- agentId,
206756
- agentType,
206757
- operationId,
206758
- pid,
206759
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
206760
- taskId,
206761
- topicId
206762
- });
206763
- log$7.info(`OpenClaw task started: taskId=${taskId} pid=${pid} agent=${openclawAgent}`);
206764
- child.on("close", (code, signal) => {
206765
- removeTask(taskId);
206766
- if (code !== 0 || signal !== null) sendAutoNotify(topicId, taskId, signal ? `Task cancelled (signal: ${signal})` : `Task failed (exit code: ${code})`, agentId);
206767
- });
206768
- return JSON.stringify({
206769
- pid,
206770
- taskId
206771
- });
206772
- }
206773
- if (agentType === "hermes") {
206774
- const port = getHermesPort();
206775
- if (!await isHermesGatewayRunning(port)) {
206776
- log$7.info(`Hermes gateway not running on port ${port}, starting...`);
206777
- await startHermesGateway(port);
206778
- }
206779
- const res = await fetch(`http://localhost:${port}/message`, {
206780
- body: JSON.stringify({
206781
- content: prompt,
206782
- operationId
206783
- }),
206784
- headers: { "Content-Type": "application/json" },
206785
- method: "POST"
206786
- });
206787
- if (!res.ok) throw new Error(`Hermes gateway returned ${res.status}: ${await res.text()}`);
206788
- saveTask({
206789
- agentId,
206790
- agentType,
206791
- operationId,
206792
- pid: 0,
206793
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
206794
- taskId,
206795
- topicId
206796
- });
206797
- log$7.info(`Hermes task dispatched: taskId=${taskId} operationId=${operationId}`);
206798
- return JSON.stringify({
206799
- operationId,
206800
- taskId
206801
- });
206802
- }
206803
- throw new Error(`Unsupported agentType: ${agentType}`);
206804
- }
206805
- async function cancelHeteroTask(params) {
206806
- const { signal = "SIGINT", taskId } = params;
206807
- const entry = getTask(taskId);
206808
- if (!entry) return JSON.stringify({
206809
- message: `No task found with taskId: ${taskId}`,
206810
- success: false
206811
- });
206812
- if (entry.agentType === "hermes") {
206813
- const port = getHermesPort();
206814
- try {
206815
- await fetch(`http://localhost:${port}/stop`, {
206816
- body: JSON.stringify({ operationId: entry.operationId }),
206817
- headers: { "Content-Type": "application/json" },
206818
- method: "POST"
206819
- });
206820
- } catch (err) {
206821
- log$7.warn(`Failed to send /stop to Hermes gateway: ${err instanceof Error ? err.message : String(err)}`);
206822
- }
206823
- removeTask(taskId);
206824
- await sendAutoNotify(entry.topicId, taskId, "Task cancelled", entry.agentId);
206825
- return JSON.stringify({ taskId });
206826
- }
206827
- try {
206828
- process.kill(entry.pid, signal);
206829
- } catch (err) {
206830
- log$7.warn(`Failed to send ${signal} to pid ${entry.pid}: ${err instanceof Error ? err.message : String(err)}`);
206831
- removeTask(taskId);
206832
- await sendAutoNotify(entry.topicId, taskId, "Task already completed or cancelled", entry.agentId);
206833
- }
206834
- return JSON.stringify({
206835
- pid: entry.pid,
206836
- signal,
206837
- taskId
206838
- });
206839
- }
206840
-
206841
207145
  //#endregion
206842
207146
  //#region src/tools/shell.ts
206843
207147
  const processManager = new ShellProcessManager();
@@ -206861,6 +207165,7 @@ async function killCommand(params) {
206861
207165
  //#region src/tools/index.ts
206862
207166
  const methodMap = {
206863
207167
  cancelHeteroTask,
207168
+ checkPlatformCapability,
206864
207169
  editFile: editLocalFile,
206865
207170
  getCommandOutput,
206866
207171
  globFiles: globLocalFiles,
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.15" "User Commands"
3
+ .TH LH 1 "" "@lobehub/cli 0.0.16" "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.15",
3
+ "version": "0.0.16",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "lh": "./dist/index.js",