@lobehub/cli 0.0.63 → 0.0.65

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.
Files changed (3) hide show
  1. package/dist/index.js +616 -60
  2. package/man/man1/lh.1 +1 -1
  3. package/package.json +28 -27
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import { finished, pipeline } from "node:stream/promises";
14
14
  import { Duplex, PassThrough, Readable, Transform, Writable, getDefaultHighWaterMark, pipeline as pipeline$1 } from "node:stream";
15
15
  import readline, { createInterface } from "node:readline";
16
16
  import { EventEmitter, addAbortListener, on, once, setMaxListeners } from "node:events";
17
- import fs$1, { access, chmod, copyFile, cp, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, stat, unlink as unlink$1, writeFile } from "node:fs/promises";
17
+ import fs$1, { access, chmod, copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realpath, rename, rm, stat, unlink as unlink$1, writeFile } from "node:fs/promises";
18
18
  import crypto$1, { X509Certificate, createHash, randomBytes, randomUUID, sign } from "node:crypto";
19
19
  import { text } from "node:stream/consumers";
20
20
  import { ReadableStream as ReadableStream$1 } from "node:stream/web";
@@ -23324,6 +23324,7 @@ const TRPC_NAMESPACE_API_KEY_RULES = {
23324
23324
  user: rw("user:read", "user:write"),
23325
23325
  userMemories: rw("user:read", "user:write"),
23326
23326
  userMemory: rw("user:read", "user:write"),
23327
+ userSkill: rw("agent:read", "agent:write"),
23327
23328
  verify: "blocked",
23328
23329
  video: { any: "model:invoke" },
23329
23330
  waitlist: "blocked",
@@ -292724,21 +292725,56 @@ var init_src = __esmMin((() => {
292724
292725
  //#endregion
292725
292726
  //#region ../../packages/local-file-shell/src/shell/runner.ts
292726
292727
  /**
292727
- * Node reports a missing spawn cwd as `spawn <shell> ENOENT` — blaming the
292728
- * shell binary — so the model goes off debugging a healthy shell. Check the
292729
- * directory first and name the real problem, and the machine it happened on
292730
- * (a cwd pinned on another device is the usual cause).
292728
+ * `true` = resolves to a directory; `false` = resolves to a non-directory
292729
+ * (a regular file); `undefined` = cannot be resolved at all (missing, an
292730
+ * unreadable parent, a symlink loop). Keeping the three cases apart lets the
292731
+ * report name what actually happened instead of guessing.
292731
292732
  */
292732
- const checkWorkingDirectory = async (cwd) => {
292733
+ const statIsDirectory = async (dir) => {
292733
292734
  try {
292734
- if ((await stat(cwd)).isDirectory()) return;
292735
- return `Working directory is not a directory on ${os.hostname()}: ${cwd}`;
292736
- } catch (error) {
292737
- if (error.code !== "ENOENT") return;
292738
- return `Working directory does not exist on ${os.hostname()}: ${cwd}. The shell is fine — run the command from a directory that exists on this device.`;
292735
+ return (await stat(dir)).isDirectory();
292736
+ } catch {
292737
+ return;
292738
+ }
292739
+ };
292740
+ /**
292741
+ * Resolve a requested cwd to a directory the shell can actually start in.
292742
+ *
292743
+ * A cwd pinned on another machine (a stale repo path, a moved worktree) would
292744
+ * otherwise kill every command in the run: the run cannot change its own pinned
292745
+ * cwd, callers strip a model-supplied cwd before dispatch, and re-running
292746
+ * `cd` inside the command does not help because the check happens before the
292747
+ * shell exists — so the same failure repeats forever. Un-sandboxed runs fall
292748
+ * back to a fresh isolated directory created under the OS temp root, and the
292749
+ * fallback is reported so nothing downstream mistakes it for the requested
292750
+ * directory.
292751
+ *
292752
+ * Sandboxed runs do NOT fall back: the sandbox fence (`createLocalSandboxPolicy`)
292753
+ * is built on the requested cwd, so silently spawning in another directory
292754
+ * would grant writes somewhere the user never approved. Those fail loudly.
292755
+ */
292756
+ const resolveWorkingDirectory = async (requestedCwd, sandboxRequested, fallbackRoot) => {
292757
+ const usable = await statIsDirectory(requestedCwd);
292758
+ if (usable) return { cwd: requestedCwd };
292759
+ const reason = usable === false ? `Working directory is not a directory on ${os.hostname()}: ${requestedCwd}` : `Working directory does not exist on ${os.hostname()}: ${requestedCwd}`;
292760
+ if (sandboxRequested) return { error: `${reason}. A sandboxed run must start in its requested directory — the sandbox fence is built on it — so the command was not run.` };
292761
+ try {
292762
+ const fallback = await mkdtemp(path.join(fallbackRoot ?? os.tmpdir(), "lobehub-cwd-fallback-"));
292763
+ return {
292764
+ cwd: fallback,
292765
+ notice: `${reason}. Falling back to ${fallback}.`,
292766
+ remove: async () => {
292767
+ await rm(fallback, {
292768
+ force: true,
292769
+ recursive: true
292770
+ });
292771
+ }
292772
+ };
292773
+ } catch {
292774
+ return { error: `${reason}. The shell is fine — run the command from a directory that exists on this device.` };
292739
292775
  }
292740
292776
  };
292741
- async function runCommand$1({ command, cwd, description, env: extraEnv, run_in_background, timeout = DEFAULT_OBSERVATION_TIMEOUT_MS }, { backend: backendOverride, processManager, logger, onSandboxUnavailable, sandboxPolicy, spawnProcess }) {
292777
+ async function runCommand$1({ command, cwd, description, env: extraEnv, run_in_background, timeout = DEFAULT_OBSERVATION_TIMEOUT_MS }, { backend: backendOverride, processManager, logger, onSandboxUnavailable, sandboxPolicy, spawnProcess, tmpdirOverride }) {
292742
292778
  if (!command) return {
292743
292779
  error: "command is required",
292744
292780
  success: false
@@ -292750,12 +292786,18 @@ async function runCommand$1({ command, cwd, description, env: extraEnv, run_in_b
292750
292786
  cwd,
292751
292787
  timeout
292752
292788
  });
292789
+ let effectiveCwd = cwd;
292790
+ let cwdNotice;
292791
+ let removeCwdFallback;
292753
292792
  if (cwd) {
292754
- const cwdError = await checkWorkingDirectory(cwd);
292755
- if (cwdError) return {
292756
- error: cwdError,
292793
+ const resolution = await resolveWorkingDirectory(cwd, !!sandboxPolicy, tmpdirOverride);
292794
+ if (resolution.error) return {
292795
+ error: resolution.error,
292757
292796
  success: false
292758
292797
  };
292798
+ effectiveCwd = resolution.cwd;
292799
+ cwdNotice = resolution.notice;
292800
+ removeCwdFallback = resolution.remove;
292759
292801
  }
292760
292802
  const requestedEnv = extraEnv ? {
292761
292803
  ...process.env,
@@ -292763,6 +292805,7 @@ async function runCommand$1({ command, cwd, description, env: extraEnv, run_in_b
292763
292805
  } : process.env;
292764
292806
  const shellConfig = await getShellConfig(process.platform === "win32" ? normalizeEnvVarRefs(command, requestedEnv, (await detectWindowsShell()).type) : command);
292765
292807
  let outputFiles;
292808
+ let registered = false;
292766
292809
  let releaseSandbox;
292767
292810
  let sandboxed;
292768
292811
  try {
@@ -292774,7 +292817,7 @@ async function runCommand$1({ command, cwd, description, env: extraEnv, run_in_b
292774
292817
  try {
292775
292818
  launchPlan = await createSandboxLaunchPlan({
292776
292819
  command: shellConfig,
292777
- cwd,
292820
+ cwd: effectiveCwd,
292778
292821
  env: requestedEnv,
292779
292822
  policy: sandboxPolicy
292780
292823
  });
@@ -292794,7 +292837,7 @@ async function runCommand$1({ command, cwd, description, env: extraEnv, run_in_b
292794
292837
  args: launchCommand.args,
292795
292838
  cmd: launchCommand.cmd
292796
292839
  }, {
292797
- cwd,
292840
+ cwd: effectiveCwd,
292798
292841
  env: launchEnv,
292799
292842
  outputFiles: shellOutputFiles,
292800
292843
  shellId
@@ -292818,21 +292861,27 @@ async function runCommand$1({ command, cwd, description, env: extraEnv, run_in_b
292818
292861
  shellProcess.spawnError = new Error(`Failed to start command${cwdContext}: ${error.message}`, { cause: error });
292819
292862
  shellProcess.exitCode = 1;
292820
292863
  });
292821
- handle.once("close", () => releaseSandbox?.());
292864
+ handle.once("close", () => {
292865
+ releaseSandbox?.();
292866
+ if (removeCwdFallback) removeCwdFallback().catch((error) => {
292867
+ logger?.debug(`${logPrefix} Fallback directory cleanup failed`, { error });
292868
+ });
292869
+ });
292822
292870
  processManager.register(shellId, shellProcess);
292871
+ registered = true;
292823
292872
  processManager.closeOutputFiles(shellOutputFiles);
292824
292873
  logger?.info?.(`${logPrefix} Started session`, {
292825
292874
  background: run_in_background,
292826
292875
  shellId
292827
292876
  });
292828
292877
  if (run_in_background) return {
292829
- output: "",
292878
+ output: cwdNotice ?? "",
292830
292879
  output_files: processManager.getOutputFilesInfo(shellOutputFiles),
292831
292880
  sandboxed,
292832
292881
  shell_id: shellId,
292833
292882
  success: true
292834
292883
  };
292835
- return {
292884
+ const result = {
292836
292885
  ...await processManager.getRunCommandOutput({
292837
292886
  shell_id: shellId,
292838
292887
  timeout
@@ -292840,9 +292889,17 @@ async function runCommand$1({ command, cwd, description, env: extraEnv, run_in_b
292840
292889
  sandboxed,
292841
292890
  shell_id: shellId
292842
292891
  };
292892
+ if (!cwdNotice) return result;
292893
+ const prefix = `${cwdNotice}\n\n`;
292894
+ return {
292895
+ ...result,
292896
+ output: `${prefix}${result.output ?? ""}`,
292897
+ stdout: `${prefix}${result.stdout ?? ""}`
292898
+ };
292843
292899
  } catch (error) {
292844
292900
  releaseSandbox?.();
292845
292901
  if (outputFiles) processManager.closeOutputFiles(outputFiles);
292902
+ if (removeCwdFallback && !registered) await removeCwdFallback().catch(() => {});
292846
292903
  return {
292847
292904
  error: error.message,
292848
292905
  success: false
@@ -292852,18 +292909,35 @@ async function runCommand$1({ command, cwd, description, env: extraEnv, run_in_b
292852
292909
 
292853
292910
  //#endregion
292854
292911
  //#region src/auth/apiKey.ts
292912
+ var ApiKeyValidationError = class extends Error {
292913
+ constructor(message, retryable) {
292914
+ super(message);
292915
+ this.retryable = retryable;
292916
+ this.name = "ApiKeyValidationError";
292917
+ }
292918
+ };
292855
292919
  async function getUserIdFromApiKey(apiKey, serverUrl) {
292856
292920
  const normalizedServerUrl = normalizeUrl(serverUrl) || resolveServerUrl();
292857
- const response = await fetch(`${normalizedServerUrl}/api/v1/users/me?includeCount=0`, { headers: { Authorization: `Bearer ${apiKey}` } });
292921
+ let response;
292922
+ try {
292923
+ response = await fetch(`${normalizedServerUrl}/api/v1/users/me?includeCount=0`, { headers: { Authorization: `Bearer ${apiKey}` } });
292924
+ } catch (error) {
292925
+ throw new ApiKeyValidationError(error instanceof Error ? error.message : String(error), true);
292926
+ }
292927
+ const retryableStatus = response.status >= 500 || [
292928
+ 408,
292929
+ 425,
292930
+ 429
292931
+ ].includes(response.status);
292858
292932
  let body;
292859
292933
  try {
292860
292934
  body = await response.json();
292861
292935
  } catch {
292862
- throw new Error(`Failed to parse response from ${normalizedServerUrl}/api/v1/users/me?includeCount=0.`);
292936
+ throw new ApiKeyValidationError(`Failed to parse response from ${normalizedServerUrl}/api/v1/users/me?includeCount=0.`, response.ok || retryableStatus);
292863
292937
  }
292864
- if (!response.ok || body?.success === false) throw new Error(body?.error || body?.message || `Request failed with status ${response.status}.`);
292938
+ if (!response.ok || body?.success === false) throw new ApiKeyValidationError(body?.error || body?.message || `Request failed with status ${response.status}.`, retryableStatus);
292865
292939
  const userId = body?.data?.id || body?.data?.userId;
292866
- if (!userId) throw new Error("Current user response did not include a user id.");
292940
+ if (!userId) throw new ApiKeyValidationError("Current user response did not include a user id.", true);
292867
292941
  return userId;
292868
292942
  }
292869
292943
 
@@ -292941,7 +293015,7 @@ async function resolveToken(options) {
292941
293015
  } catch (error) {
292942
293016
  const message = error instanceof Error ? error.message : String(error);
292943
293017
  log$13.error(`Failed to validate ${source.origin}: ${message}`);
292944
- process.exit(1);
293018
+ process.exit(error instanceof ApiKeyValidationError && error.retryable ? options.unavailableExitCode ?? 1 : 1);
292945
293019
  }
292946
293020
  }
292947
293021
  const result = await getValidToken();
@@ -292963,7 +293037,7 @@ async function resolveToken(options) {
292963
293037
  }
292964
293038
  const report = describeTokenLookup(result);
292965
293039
  log$13.error(report ? `${report.detail} ${report.fix}` : `No authentication found. Run '${"lh"} login' first, or set ${CLI_API_KEY_ENV}, or provide --token.`);
292966
- process.exit(1);
293040
+ process.exit(result.status === "unavailable" ? options.unavailableExitCode ?? 1 : 1);
292967
293041
  }
292968
293042
 
292969
293043
  //#endregion
@@ -296321,6 +296395,23 @@ var TerminalSessionManager = class {
296321
296395
  const SERVICE_NAME = CLI_CONNECT_SERVICE_NAME;
296322
296396
  const ENV_NAME_PATTERN = /^[A-Z_]\w*$/i;
296323
296397
  const SERVICE_ENV_FILE_NAME = "connect-service.env";
296398
+ /**
296399
+ * Exit code for workspace failures worth retrying (EX_TEMPFAIL). It sits outside the
296400
+ * unit's `SuccessExitStatus`, so `Restart=on-failure` restarts the service child.
296401
+ */
296402
+ const CONNECT_RETRYABLE_EXIT_CODE = 75;
296403
+ const PERMANENT_TRPC_ERROR_CODES = new Set([
296404
+ "BAD_REQUEST",
296405
+ "CONFLICT",
296406
+ "FORBIDDEN",
296407
+ "NOT_FOUND",
296408
+ "UNAUTHORIZED"
296409
+ ]);
296410
+ /** Exit code for a failed workspace operation (token mint, refresh, device registration): 1 for refusals, 75 otherwise. */
296411
+ function getWorkspaceFailureExitCode(error) {
296412
+ const code = error?.data?.code;
296413
+ return code && PERMANENT_TRPC_ERROR_CODES.has(code) ? 1 : 75;
296414
+ }
296324
296415
  function systemctl(args, stdio = "pipe") {
296325
296416
  const output = execFileSync("systemctl", ["--user", ...args], {
296326
296417
  encoding: "utf8",
@@ -296346,7 +296437,13 @@ function getExecErrorMessage(error) {
296346
296437
  if (stderr) return stderr;
296347
296438
  return error instanceof Error ? error.message : String(error);
296348
296439
  }
296349
- function renderUnit() {
296440
+ function buildServiceConnectArgs(options) {
296441
+ const args = ["connect", "--service-child"];
296442
+ if (options.workspace) args.push("--workspace", options.workspace);
296443
+ if (options.public) args.push("--public");
296444
+ return args;
296445
+ }
296446
+ function renderUnit(options) {
296350
296447
  const quoteSystemdValue = (value) => `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`;
296351
296448
  const escapeSystemdEnvValue = (value) => value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
296352
296449
  const homeDir = os.homedir();
@@ -296357,16 +296454,14 @@ function renderUnit() {
296357
296454
  const execStart = [
296358
296455
  process.execPath,
296359
296456
  scriptPath,
296360
- "connect",
296361
- "--service-child"
296457
+ ...buildServiceConnectArgs(options)
296362
296458
  ].map(quoteSystemdValue).join(" ");
296363
296459
  return [
296364
296460
  "[Unit]",
296365
296461
  `Description=${CLI_DISPLAY_NAME} connect service`,
296366
296462
  "After=network-online.target",
296367
296463
  "Wants=network-online.target",
296368
- "StartLimitIntervalSec=60",
296369
- "StartLimitBurst=10",
296464
+ "StartLimitIntervalSec=0",
296370
296465
  "",
296371
296466
  "[Service]",
296372
296467
  "Type=simple",
@@ -296379,7 +296474,7 @@ function renderUnit() {
296379
296474
  `StandardError=append:${logPath}`,
296380
296475
  "Restart=on-failure",
296381
296476
  "SuccessExitStatus=0 1 2",
296382
- "RestartSec=1",
296477
+ "RestartSec=5",
296383
296478
  "TimeoutStopSec=20",
296384
296479
  "",
296385
296480
  "[Install]",
@@ -296436,7 +296531,8 @@ function assertConnectServiceAuthAvailable() {
296436
296531
  if (process.env.LOBEHUB_JWT || readCliApiKeyEnv() || loadCredentials()) return;
296437
296532
  throw new Error(`No authentication found. Run '${"lh"} login' first, or set ${CLI_API_KEY_ENV} before starting the connect service.`);
296438
296533
  }
296439
- function installConnectService() {
296534
+ function installConnectService(options = {}) {
296535
+ if (options.public && !options.workspace) throw new Error("--public requires --workspace.");
296440
296536
  requireLinuxSystemd();
296441
296537
  assertNoConnectDaemonRunning();
296442
296538
  assertConnectServiceAuthAvailable();
@@ -296445,13 +296541,10 @@ function installConnectService() {
296445
296541
  mode: 448,
296446
296542
  recursive: true
296447
296543
  });
296448
- fs.writeFileSync(path.join(getUserServiceDir(), SERVICE_NAME), renderUnit(), { mode: 420 });
296544
+ fs.writeFileSync(path.join(getUserServiceDir(), SERVICE_NAME), renderUnit(options), { mode: 420 });
296449
296545
  systemctl(["daemon-reload"], "inherit");
296450
- systemctl([
296451
- "enable",
296452
- "--now",
296453
- SERVICE_NAME
296454
- ], "inherit");
296546
+ systemctl(["enable", SERVICE_NAME], "inherit");
296547
+ systemctl(["restart", SERVICE_NAME], "inherit");
296455
296548
  }
296456
296549
  function uninstallConnectService() {
296457
296550
  const unitPath = path.join(getUserServiceDir(), SERVICE_NAME);
@@ -507960,7 +508053,7 @@ const sweepLocalTraces = async () => {
507960
508053
  const SHUTDOWN_METRICS_FLUSH_MS = 3e3;
507961
508054
  const CONNECT_SERVICE_NAME = CLI_CONNECT_SERVICE_NAME;
507962
508055
  function registerConnectCommand(program) {
507963
- const connectCmd = program.command("connect").description("Connect to the device gateway and listen for tool calls").option("--token <jwt>", "JWT access token").option("--gateway <url>", "Device gateway URL").option("--device-id <id>", "Device ID (auto-generated if not provided)").option("--workspace <id>", "Enroll as a device of this workspace (admin only)").option("--public", "With --workspace: enroll into the shared pool visible to every member (default: private, visible only to you)").option("-v, --verbose", "Enable verbose logging").option("-d, --daemon", "Run as a background daemon process").option("--daemon-child", "Internal: runs as the daemon child process").option("--service-child", "Internal: runs as the system service child process").action(async (options) => {
508056
+ const connectCmd = program.command("connect").description("Connect to the device gateway and listen for tool calls").option("--token <jwt>", "JWT access token").option("--gateway <url>", "Device gateway URL").option("--device-id <id>", "Device ID (auto-generated if not provided)").option("--workspace <id>", "Enroll as a device of this workspace (workspace members)").option("--public", "With --workspace: enroll into the shared pool visible to every member (default: private, visible only to you)").option("-v, --verbose", "Enable verbose logging").option("-d, --daemon", "Run as a background daemon process").option("--daemon-child", "Internal: runs as the daemon child process").option("--service-child", "Internal: runs as the system service child process").action(async (options) => {
507964
508057
  if (options.verbose) setVerbose(true);
507965
508058
  if (options.daemon) return handleDaemonStart(options);
507966
508059
  const isServiceChild = options.serviceChild || process.env.LOBEHUB_CONNECT_SERVICE === "1";
@@ -508006,9 +508099,12 @@ function registerConnectCommand(program) {
508006
508099
  });
508007
508100
  });
508008
508101
  const serviceCmd = connectCmd.command("service").description("Manage the Linux user systemd connect service");
508009
- serviceCmd.command("install").description("Install and start the Linux user systemd connect service").action(() => {
508010
- installConnectService();
508011
- log$13.info(`Installed and started ${CONNECT_SERVICE_NAME}.`);
508102
+ serviceCmd.command("install").description("Install and start the Linux user systemd connect service").option("--workspace <id>", "Enroll as a device of this workspace (workspace members)").option("--public", "With --workspace: enroll into the shared pool visible to every member (default: private, visible only to you)").action((options) => {
508103
+ installConnectService({
508104
+ public: options.public,
508105
+ workspace: options.workspace
508106
+ });
508107
+ log$13.info(options.workspace ? `Installed and started ${CONNECT_SERVICE_NAME} for workspace ${options.workspace}.` : `Installed and started ${CONNECT_SERVICE_NAME}.`);
508012
508108
  log$13.info("Run 'lh connect service status' to inspect the service.");
508013
508109
  });
508014
508110
  serviceCmd.command("uninstall").description("Remove the Linux user systemd connect service").action(() => {
@@ -508079,7 +508175,11 @@ function buildDaemonArgs(options) {
508079
508175
  return args;
508080
508176
  }
508081
508177
  async function runConnect(options, isDaemonChild) {
508082
- let auth = await resolveToken(options);
508178
+ const unavailableExitCode = options.workspace ? 75 : void 0;
508179
+ let auth = await resolveToken({
508180
+ ...options,
508181
+ unavailableExitCode
508182
+ });
508083
508183
  const settings = loadSettings();
508084
508184
  const gatewayUrl = normalizeUrl(options.gateway) || settings?.gatewayUrl;
508085
508185
  if (!gatewayUrl && settings?.serverUrl) {
@@ -508097,11 +508197,17 @@ async function runConnect(options, isDaemonChild) {
508097
508197
  let connectToken = auth.token;
508098
508198
  let connectTokenType = auth.tokenType;
508099
508199
  if (workspaceId) {
508100
- connectToken = (await mintWorkspaceConnectToken(auth, workspaceId)).token;
508200
+ try {
508201
+ connectToken = (await mintWorkspaceConnectToken(auth, workspaceId)).token;
508202
+ } catch (err) {
508203
+ log$13.error(`Workspace connect token mint failed: ${err.message}`);
508204
+ process.exit(getWorkspaceFailureExitCode(err));
508205
+ throw new Error("process.exit", { cause: err });
508206
+ }
508101
508207
  connectTokenType = "jwt";
508102
508208
  }
508103
508209
  const refreshConnectToken = async () => {
508104
- const refreshed = await resolveToken({});
508210
+ const refreshed = await resolveToken({ unavailableExitCode });
508105
508211
  if (!refreshed) return void 0;
508106
508212
  auth = refreshed;
508107
508213
  if (workspaceId) {
@@ -508340,6 +508446,7 @@ async function runConnect(options, isDaemonChild) {
508340
508446
  let cancelRefreshTimer = startProactiveRefresh();
508341
508447
  let authFailedRefreshAttempted = false;
508342
508448
  client.on("auth_failed", async (reason) => {
508449
+ let exitCode = 1;
508343
508450
  if (connectTokenType === "jwt" && !authFailedRefreshAttempted) {
508344
508451
  authFailedRefreshAttempted = true;
508345
508452
  info(`Authentication failed (${reason}). Attempting token refresh...`);
@@ -508354,16 +508461,20 @@ async function runConnect(options, isDaemonChild) {
508354
508461
  await client.reconnect();
508355
508462
  return;
508356
508463
  }
508357
- } catch {}
508464
+ } catch (err) {
508465
+ error(`Token refresh failed: ${err.message}`);
508466
+ if (workspaceId) exitCode = getWorkspaceFailureExitCode(err);
508467
+ }
508358
508468
  }
508359
508469
  error(`Authentication failed: ${reason}`);
508360
508470
  error(`Run '${"lh"} login', or set ${CLI_API_KEY_ENV} and run '${"lh"} login --server <url>' to configure API key authentication.`);
508361
508471
  cleanup();
508362
- process.exit(1);
508472
+ process.exit(exitCode);
508363
508473
  });
508364
508474
  client.on("auth_expired", async () => {
508365
508475
  if (connectTokenType === "apiKey") return;
508366
508476
  info("Authentication expired. Attempting to refresh token...");
508477
+ let exitCode = 1;
508367
508478
  try {
508368
508479
  const newToken = await refreshConnectToken();
508369
508480
  if (newToken) {
@@ -508373,10 +508484,13 @@ async function runConnect(options, isDaemonChild) {
508373
508484
  await client.reconnect();
508374
508485
  return;
508375
508486
  }
508376
- } catch {}
508487
+ } catch (err) {
508488
+ error(`Token refresh failed: ${err.message}`);
508489
+ if (workspaceId) exitCode = getWorkspaceFailureExitCode(err);
508490
+ }
508377
508491
  error("Could not refresh token. Run 'lh login' to re-authenticate.");
508378
508492
  cleanup();
508379
- process.exit(1);
508493
+ process.exit(exitCode);
508380
508494
  });
508381
508495
  client.on("replaced", () => {
508382
508496
  error(`Another '${"lh"} connect' on this machine took over this device's gateway connection. Only one can be connected at a time; stopping this one.`);
@@ -508411,7 +508525,7 @@ async function runConnect(options, isDaemonChild) {
508411
508525
  if (workspaceId) {
508412
508526
  error(`Workspace device registration failed: ${err.message}`);
508413
508527
  cleanup();
508414
- process.exit(1);
508528
+ process.exit(getWorkspaceFailureExitCode(err));
508415
508529
  }
508416
508530
  error(`Device registration failed (non-fatal): ${err.message}`);
508417
508531
  }
@@ -512440,6 +512554,7 @@ const admissionHint = {
512440
512554
  pending_decision: "a decision gate is still waiting on an answer",
512441
512555
  review_changed: "task review feedback changed after this turn started",
512442
512556
  stale_input: "the graph moved after this turn started, so the plan would apply to stale input",
512557
+ started_from_draft: "the Goal was started from its draft, which settled this turn; the next turn plans further work",
512443
512558
  turn_owner: "this Goal has no matching planning turn",
512444
512559
  turn_settled: "this turn's run is no longer running",
512445
512560
  wait_unsettled: "unfinished work must be settled before waiting",
@@ -512695,10 +512810,12 @@ function registerGoalCommand$1(program) {
512695
512810
  };
512696
512811
  const result = fromTopic ? hasOperationToken() ? await client.goal.createConversationGoal.mutate({
512697
512812
  ...goalInput,
512813
+ draft: true,
512698
512814
  operationId
512699
512815
  }) : await client.goal.create.mutate({
512700
512816
  ...goalInput,
512701
512817
  agentId: process.env.LOBEHUB_AGENT_ID,
512818
+ draft: true,
512702
512819
  conversationOperationId: operationId,
512703
512820
  conversationTopicId: process.env.LOBEHUB_TOPIC_ID
512704
512821
  }) : await client.goal.create.mutate({
@@ -512726,7 +512843,11 @@ function registerGoalCommand$1(program) {
512726
512843
  "first; every other seed task (or only --units) leaves the graph as a roster unit, its description",
512727
512844
  "kept as that unit's note, and is released wave by wave once the probes ran through without a person.",
512728
512845
  "A rejection revises the recipe and re-probes. Tasks that differ only in names, files or details are",
512729
- "one recipe; tasks whose steps differ are not, so start those without --rollout."
512846
+ "one recipe; tasks whose steps differ are not, so start those without --rollout.",
512847
+ "Inside a batch a seed may depend only on a probe, and folded seeds need distinct titles.",
512848
+ "",
512849
+ "Starting a draft that holds tasks settles the planning turn that created it: submit any plan with",
512850
+ "lh goal plan before starting, not after."
512730
512851
  ].join("\n")).option("--rollout <mode>", "Fold the seed tasks into a batch: canary or full").option("--probes <nodeIds...>", "Seed task node ids that run first (2-5 for canary)").option("--units <nodeIds...>", "Seed task node ids that become batch units (default: every other seed)").option("--recipe <text>", "The procedure every unit follows").option("--recipe-file <path>", "Read the recipe from a markdown file").option("--variant <axis=values...>", "An axis the units differ along, e.g. mechanism=replica,swr (repeatable); the gate holds until the probes cover each value").option("--operation <id>", "Defaults to LOBEHUB_OPERATION_ID").option("--json", "Output JSON").action(async (id, options) => {
512731
512852
  let rollout;
512732
512853
  if (options.rollout) {
@@ -522792,7 +522913,7 @@ const EMPTY_COMPLETION_RESULT = { completion: {
522792
522913
  } };
522793
522914
 
522794
522915
  //#endregion
522795
- //#region node_modules/.pnpm/@hono+node-server@2.1.4_hono@4.13.13/node_modules/@hono/node-server/dist/constants-BLSFu_RU.mjs
522916
+ //#region node_modules/.pnpm/@hono+node-server@2.1.5_hono@4.13.13/node_modules/@hono/node-server/dist/constants-BLSFu_RU.mjs
522796
522917
  const X_ALREADY_SENT = "x-hono-already-sent";
522797
522918
 
522798
522919
  //#endregion
@@ -522821,7 +522942,7 @@ const defineWebSocketHelper = (handler) => {
522821
522942
  };
522822
522943
 
522823
522944
  //#endregion
522824
- //#region node_modules/.pnpm/@hono+node-server@2.1.4_hono@4.13.13/node_modules/@hono/node-server/dist/index.mjs
522945
+ //#region node_modules/.pnpm/@hono+node-server@2.1.5_hono@4.13.13/node_modules/@hono/node-server/dist/index.mjs
522825
522946
  var RequestError = class extends Error {
522826
522947
  constructor(message, options) {
522827
522948
  super(message, options);
@@ -523643,7 +523764,7 @@ const makeCloseHandler = (req, incoming, outgoing, needsBodyCleanup) => () => {
523643
523764
  if (incoming.errored) {
523644
523765
  recordBodyBufferedBeforeDisconnect(incoming);
523645
523766
  req[abortRequest](incoming.errored.toString());
523646
- } else if (!outgoing.writableFinished) {
523767
+ } else if (!outgoing.writableFinished || incoming instanceof Http2ServerRequest && incoming.aborted) {
523647
523768
  recordBodyBufferedBeforeDisconnect(incoming);
523648
523769
  req[abortRequest]("Client connection prematurely closed.");
523649
523770
  }
@@ -535089,6 +535210,345 @@ async function uploadReportEvidence(client, params) {
535089
535210
  };
535090
535211
  }
535091
535212
 
535213
+ //#endregion
535214
+ //#region src/commands/acceptanceLanguage.ts
535215
+ const HAN = {
535216
+ languageName: "Chinese",
535217
+ pattern: /\p{Script=Han}/u
535218
+ };
535219
+ const CYRILLIC = {
535220
+ languageName: "Cyrillic script",
535221
+ pattern: /\p{Script=Cyrillic}/u
535222
+ };
535223
+ const ARABIC = {
535224
+ languageName: "Arabic script",
535225
+ pattern: /\p{Script=Arabic}/u
535226
+ };
535227
+ /** Keyed by the primary language subtag of `responseLanguage` (`zh-CN` → `zh`). */
535228
+ const SCRIPT_RULES = {
535229
+ ar: {
535230
+ ...ARABIC,
535231
+ languageName: "Arabic"
535232
+ },
535233
+ be: CYRILLIC,
535234
+ bg: {
535235
+ ...CYRILLIC,
535236
+ languageName: "Bulgarian"
535237
+ },
535238
+ el: {
535239
+ languageName: "Greek",
535240
+ pattern: /\p{Script=Greek}/u
535241
+ },
535242
+ fa: {
535243
+ ...ARABIC,
535244
+ languageName: "Persian"
535245
+ },
535246
+ he: {
535247
+ languageName: "Hebrew",
535248
+ pattern: /\p{Script=Hebrew}/u
535249
+ },
535250
+ hi: {
535251
+ languageName: "Hindi",
535252
+ pattern: /\p{Script=Devanagari}/u
535253
+ },
535254
+ ja: {
535255
+ languageName: "Japanese",
535256
+ pattern: /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/u
535257
+ },
535258
+ kk: CYRILLIC,
535259
+ ko: {
535260
+ languageName: "Korean",
535261
+ pattern: /\p{Script=Hangul}/u
535262
+ },
535263
+ mn: CYRILLIC,
535264
+ ru: {
535265
+ ...CYRILLIC,
535266
+ languageName: "Russian"
535267
+ },
535268
+ sr: CYRILLIC,
535269
+ th: {
535270
+ languageName: "Thai",
535271
+ pattern: /\p{Script=Thai}/u
535272
+ },
535273
+ uk: {
535274
+ ...CYRILLIC,
535275
+ languageName: "Ukrainian"
535276
+ },
535277
+ zh: HAN
535278
+ };
535279
+ const responseLanguageSettingsUrl = () => {
535280
+ const url = new URL("/settings/appearance", resolveServerUrl());
535281
+ url.username = "";
535282
+ url.password = "";
535283
+ return url.toString();
535284
+ };
535285
+ function reportLanguageFor(responseLanguage) {
535286
+ const locale = responseLanguage?.trim();
535287
+ if (!locale) return { status: "unset" };
535288
+ const rule = SCRIPT_RULES[locale.split(/[-_]/)[0].toLowerCase()];
535289
+ return rule ? {
535290
+ locale,
535291
+ rule,
535292
+ status: "enforced"
535293
+ } : {
535294
+ locale,
535295
+ status: "unchecked"
535296
+ };
535297
+ }
535298
+ async function resolveReportLanguage(client) {
535299
+ const value = (await client.user.getUserState.query())?.settings?.general?.responseLanguage;
535300
+ return reportLanguageFor(typeof value === "string" ? value : void 0);
535301
+ }
535302
+ /**
535303
+ * Code, links and paths are legitimately English inside any report; strip them
535304
+ * so "修复了 `useFoo` 的问题" passes and a bare `npm test` field is judged on
535305
+ * nothing rather than on the identifier.
535306
+ */
535307
+ const stripNonProse = (text) => text.replaceAll(/(`{3,}|~{3,})[\s\S]*?\1/g, " ").replaceAll(/`[^`\n]*`/g, " ").replaceAll(/!?\[[^\]]*\]\([^)]*\)/g, (match) => match.replace(/\([^)]*\)$/, "")).replaceAll(/https?:\/\/[^\s<>"'`,。!?、;:()【】「」《》]+/g, " ");
535308
+ /**
535309
+ * A field fails when it contains letters but none in the target script — the
535310
+ * whole field is in another language. Mixed text with identifiers passes.
535311
+ */
535312
+ function findLanguageViolations(fields, rule) {
535313
+ return fields.flatMap((field) => {
535314
+ if (typeof field.text !== "string") return [];
535315
+ const prose = stripNonProse(field.text);
535316
+ if (!/\p{L}/u.test(prose) || rule.pattern.test(prose)) return [];
535317
+ return [{
535318
+ path: field.path,
535319
+ text: field.text
535320
+ }];
535321
+ });
535322
+ }
535323
+ /** Prose in a check definition: its outcome, preconditions and step wording. */
535324
+ function checkDefinitionFields(at, definition) {
535325
+ const value = definition ?? {};
535326
+ const preconditions = Array.isArray(value.preconditions) ? value.preconditions : [];
535327
+ const steps = Array.isArray(value.steps) ? value.steps : [];
535328
+ return [
535329
+ {
535330
+ path: `${at}.expected`,
535331
+ text: value.expected
535332
+ },
535333
+ ...preconditions.map((text, index) => ({
535334
+ path: `${at}.preconditions[${index}]`,
535335
+ text
535336
+ })),
535337
+ ...steps.flatMap((step) => [{
535338
+ path: `${at}.steps[${String(step?.id)}].instruction`,
535339
+ text: step?.instruction
535340
+ }, {
535341
+ path: `${at}.steps[${String(step?.id)}].expected`,
535342
+ text: step?.expected
535343
+ }])
535344
+ ];
535345
+ }
535346
+ /**
535347
+ * Video step/claim captions and the before/after caption band. Shared by
535348
+ * `ingest` and `evidence upload --metadata`, which persist the same shape.
535349
+ */
535350
+ function evidenceCaptionFields(at, metadata) {
535351
+ const value = metadata ?? {};
535352
+ return [...(Array.isArray(value.chapters) ? value.chapters : []).flatMap((chapter, i) => [{
535353
+ path: `${at}.chapters[${i}].label`,
535354
+ text: chapter?.label
535355
+ }, {
535356
+ path: `${at}.chapters[${i}].note`,
535357
+ text: chapter?.note
535358
+ }]), {
535359
+ path: `${at}.comparison.label`,
535360
+ text: value.comparison?.label
535361
+ }];
535362
+ }
535363
+ /** Every `ingest` field that renders as prose on the acceptance page. */
535364
+ function ingestLanguageFields(input) {
535365
+ const fields = [
535366
+ {
535367
+ path: "title",
535368
+ text: input.title
535369
+ },
535370
+ {
535371
+ path: "focus",
535372
+ text: input.goal
535373
+ },
535374
+ {
535375
+ path: "requirement",
535376
+ text: input.requirement
535377
+ },
535378
+ {
535379
+ path: "summary.conclusion",
535380
+ text: input.conclusion
535381
+ },
535382
+ {
535383
+ path: "report.md",
535384
+ text: input.report
535385
+ },
535386
+ {
535387
+ path: "proposal.md",
535388
+ text: input.proposal
535389
+ }
535390
+ ];
535391
+ for (const item of input.plan ?? []) {
535392
+ const config = item.verifierConfig ?? {};
535393
+ const evidenceSpecs = Array.isArray(config.requiredEvidence) ? config.requiredEvidence : [];
535394
+ fields.push({
535395
+ path: `plan[${item.id}].title`,
535396
+ text: item.title
535397
+ }, {
535398
+ path: `plan[${item.id}].category`,
535399
+ text: item.category
535400
+ }, {
535401
+ path: `plan[${item.id}].method`,
535402
+ text: config.method
535403
+ }, {
535404
+ path: `plan[${item.id}].expected`,
535405
+ text: config.expected
535406
+ }, {
535407
+ path: `plan[${item.id}].description`,
535408
+ text: item.description
535409
+ }, ...checkDefinitionFields(`plan[${item.id}].definition`, item.definition), ...evidenceSpecs.map((spec, index) => ({
535410
+ path: `plan[${item.id}].requiredEvidence[${index}].hint`,
535411
+ text: spec?.hint
535412
+ })));
535413
+ }
535414
+ for (const { case: c, checkItemId } of input.cases) {
535415
+ fields.push({
535416
+ path: `cases[${checkItemId}].name`,
535417
+ text: c.name ?? c.case ?? c.title
535418
+ }, {
535419
+ path: `cases[${checkItemId}].observation`,
535420
+ text: c.keyObservation ?? c.observation ?? c.note
535421
+ }, {
535422
+ path: `cases[${checkItemId}].suggestion`,
535423
+ text: c.suggestion
535424
+ });
535425
+ (Array.isArray(c.evidence) ? c.evidence : [c.evidence]).forEach((entry, index) => {
535426
+ if (!entry || typeof entry !== "object") return;
535427
+ const at = `cases[${checkItemId}].evidence[${index}]`;
535428
+ fields.push({
535429
+ path: `${at}.description`,
535430
+ text: entry.description ?? entry.desc
535431
+ }, ...evidenceCaptionFields(at, entry));
535432
+ });
535433
+ fields.push(...visualizationFields(`cases[${checkItemId}]`, c));
535434
+ }
535435
+ return fields;
535436
+ }
535437
+ /**
535438
+ * Visualization prose: titles, context and axis/series labels. Categories and
535439
+ * table cells are data (model names, file names, ids) and legitimately Latin,
535440
+ * so they are not checked — a false rejection has no bypass.
535441
+ */
535442
+ function visualizationFields(at, c) {
535443
+ const datasets = Array.isArray(c.datasets) ? c.datasets : [];
535444
+ const views = Array.isArray(c.visualizations) ? c.visualizations : [];
535445
+ return [...datasets.flatMap((dataset, d) => (Array.isArray(dataset?.fields) ? dataset.fields : []).map((field, f) => ({
535446
+ path: `${at}.datasets[${d}].fields[${f}].label`,
535447
+ text: field?.label
535448
+ }))), ...views.flatMap((view, v) => {
535449
+ const series = Array.isArray(view?.encoding?.series) ? view.encoding.series : [];
535450
+ return [
535451
+ {
535452
+ path: `${at}.visualizations[${v}].title`,
535453
+ text: view?.title
535454
+ },
535455
+ {
535456
+ path: `${at}.visualizations[${v}].context`,
535457
+ text: view?.context
535458
+ },
535459
+ ...[
535460
+ "xLabel",
535461
+ "yLabel",
535462
+ "valueLabel"
535463
+ ].map((key) => ({
535464
+ path: `${at}.visualizations[${v}].encoding.${key}`,
535465
+ text: view?.encoding?.[key]
535466
+ })),
535467
+ ...series.map((entry, i) => ({
535468
+ path: `${at}.visualizations[${v}].encoding.series[${i}].label`,
535469
+ text: entry?.label
535470
+ }))
535471
+ ];
535472
+ })];
535473
+ }
535474
+ const preview = (text) => {
535475
+ const line = text.replaceAll(/\s+/g, " ").trim();
535476
+ return line.length > 80 ? `${line.slice(0, 77)}...` : line;
535477
+ };
535478
+ var ReportLanguageError = class extends Error {
535479
+ constructor(locale, violations, languageName) {
535480
+ super([
535481
+ `Report language check failed: your account's response language (settings.general.responseLanguage) is "${locale}", so every human-readable field must be written in ${languageName}.`,
535482
+ `These ${violations.length} field(s) contain no ${languageName} text:`,
535483
+ ...violations.map((v) => ` - ${v.path}: "${preview(v.text)}"`),
535484
+ `Rewrite them in ${languageName} and retry. If "${locale}" is not the language you read, change Response Language at ${responseLanguageSettingsUrl()} first.`
535485
+ ].join("\n"));
535486
+ this.locale = locale;
535487
+ this.violations = violations;
535488
+ this.name = "ReportLanguageError";
535489
+ }
535490
+ };
535491
+ const unsetLanguageWarning = () => `Report language not checked: your account has no response language set (Response Language is "Follow System" at ${responseLanguageSettingsUrl()}). Set it so acceptance text is checked against the language you read.`;
535492
+ /**
535493
+ * Throws `ReportLanguageError` before any remote write when a field is in the
535494
+ * wrong script. There is intentionally no bypass flag: an agent that hits the
535495
+ * error would just add it.
535496
+ */
535497
+ async function assertReportLanguage(client, fields, language) {
535498
+ const resolved = language ?? await resolveReportLanguage(client);
535499
+ if (resolved.status === "unset") {
535500
+ log$13.warn(unsetLanguageWarning());
535501
+ return resolved;
535502
+ }
535503
+ if (resolved.status !== "enforced") return resolved;
535504
+ const violations = findLanguageViolations(fields, resolved.rule);
535505
+ if (violations.length > 0) throw new ReportLanguageError(resolved.locale, violations, resolved.rule.languageName);
535506
+ return resolved;
535507
+ }
535508
+ /** JSON-safe view of the resolved language for `--json` output. */
535509
+ const languageSummary = (language) => ({
535510
+ enforced: language.status === "enforced",
535511
+ locale: language.status === "unset" ? null : language.locale,
535512
+ ...language.status === "enforced" ? { languageName: language.rule.languageName } : {},
535513
+ status: language.status
535514
+ });
535515
+ /**
535516
+ * Prose in a flow definition: its title, every inline check's wording, and
535517
+ * edge triggers/conditions, which render as graph labels.
535518
+ */
535519
+ function flowLanguageFields(definition) {
535520
+ const flow = definition ?? {};
535521
+ const fields = [{
535522
+ path: "definition.title",
535523
+ text: flow.title
535524
+ }];
535525
+ const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
535526
+ for (const node of nodes) {
535527
+ const check = node?.check;
535528
+ if (!check || typeof check !== "object") continue;
535529
+ const at = `nodes[${String(check.id ?? node.id)}].check`;
535530
+ fields.push({
535531
+ path: `${at}.title`,
535532
+ text: check.title
535533
+ }, {
535534
+ path: `${at}.description`,
535535
+ text: check.description
535536
+ }, ...checkDefinitionFields(`${at}.definition`, check.definition));
535537
+ }
535538
+ const edges = Array.isArray(flow.edges) ? flow.edges : [];
535539
+ for (const edge of edges) {
535540
+ const value = edge ?? {};
535541
+ fields.push({
535542
+ path: `edges[${String(value.id)}].trigger`,
535543
+ text: value.trigger
535544
+ }, {
535545
+ path: `edges[${String(value.id)}].condition`,
535546
+ text: value.condition
535547
+ });
535548
+ }
535549
+ return fields;
535550
+ }
535551
+
535092
535552
  //#endregion
535093
535553
  //#region src/commands/acceptanceRun.ts
535094
535554
  const listMaterializedFiles = (directory) => {
@@ -535173,7 +535633,15 @@ function printWiring(links) {
535173
535633
  }
535174
535634
  }
535175
535635
  async function runCreateAction(options) {
535176
- const created = await (await getTrpcClient()).verify.createRun.mutate({
535636
+ const client = await getTrpcClient();
535637
+ await assertReportLanguage(client, [{
535638
+ path: "--title",
535639
+ text: options.title
535640
+ }, {
535641
+ path: "--goal",
535642
+ text: options.goal
535643
+ }]);
535644
+ const created = await client.verify.createRun.mutate({
535177
535645
  goal: options.goal,
535178
535646
  operationId: options.operation,
535179
535647
  source: options.source,
@@ -535230,7 +535698,22 @@ async function runDeleteAction(runId, options) {
535230
535698
  console.log(`${import_picocolors.default.green("✓")} Deleted run ${import_picocolors.default.bold(result.id)}`);
535231
535699
  }
535232
535700
  async function resultIngestAction(options) {
535233
- const created = await (await getTrpcClient()).verify.ingestResult.mutate({
535701
+ const client = await getTrpcClient();
535702
+ await assertReportLanguage(client, [
535703
+ {
535704
+ path: "--title",
535705
+ text: options.title
535706
+ },
535707
+ {
535708
+ path: "--evidence",
535709
+ text: options.evidence
535710
+ },
535711
+ {
535712
+ path: "--suggestion",
535713
+ text: options.suggestion
535714
+ }
535715
+ ]);
535716
+ const created = await client.verify.ingestResult.mutate({
535234
535717
  checkItemId: options.check,
535235
535718
  checkItemIndex: options.index ? Number.parseInt(options.index, 10) : void 0,
535236
535719
  checkItemTitle: options.title,
@@ -535281,6 +535764,13 @@ async function submitAction(options) {
535281
535764
  process.exit(1);
535282
535765
  }
535283
535766
  const client = await getTrpcClient();
535767
+ await assertReportLanguage(client, [{
535768
+ path: "--title",
535769
+ text: options.title
535770
+ }, {
535771
+ path: "--desc",
535772
+ text: options.desc
535773
+ }]);
535284
535774
  let fileId;
535285
535775
  let inlineContent = options.content;
535286
535776
  if (options.file) {
@@ -535344,6 +535834,10 @@ async function evidenceUploadAction(options) {
535344
535834
  }
535345
535835
  const metadata = options.metadata ? JSON.parse(options.metadata) : void 0;
535346
535836
  const client = await getTrpcClient();
535837
+ await assertReportLanguage(client, [{
535838
+ path: "--desc",
535839
+ text: options.desc
535840
+ }, ...evidenceCaptionFields("--metadata", metadata)]);
535347
535841
  let fileId = options.fileId;
535348
535842
  let inlineContent = options.content;
535349
535843
  if (options.file) {
@@ -535400,7 +535894,15 @@ async function evidenceDeleteAction(evidenceId, options) {
535400
535894
  }
535401
535895
  async function reportUpsertAction(options) {
535402
535896
  const num = (s) => s === void 0 ? void 0 : Number.parseInt(s, 10);
535403
- const created = await (await getTrpcClient()).verify.upsertReport.mutate({
535897
+ const client = await getTrpcClient();
535898
+ await assertReportLanguage(client, [{
535899
+ path: "--summary",
535900
+ text: options.summary
535901
+ }, {
535902
+ path: "--content",
535903
+ text: options.content
535904
+ }]);
535905
+ const created = await client.verify.upsertReport.mutate({
535404
535906
  content: options.content,
535405
535907
  failedChecks: num(options.failed),
535406
535908
  passedChecks: num(options.passed),
@@ -535518,6 +536020,30 @@ async function ingestReportAction(reportDir, options) {
535518
536020
  const requirement = options.requirement ?? subject?.requirement;
535519
536021
  const conclusion = typeof summary.conclusion === "string" ? summary.conclusion : typeof summary.note === "string" ? summary.note : void 0;
535520
536022
  const client = await getTrpcClient();
536023
+ const language = await assertReportLanguage(client, ingestLanguageFields({
536024
+ cases,
536025
+ conclusion,
536026
+ goal,
536027
+ plan,
536028
+ proposal,
536029
+ report: content,
536030
+ requirement,
536031
+ title
536032
+ }));
536033
+ if (options.dryRun) {
536034
+ const checked = language.status === "enforced" ? `report language ${language.locale} ✓` : language.status === "unchecked" ? `report language ${language.locale} (not script-checked)` : "report language not set";
536035
+ if (options.json !== void 0) {
536036
+ outputJson({
536037
+ cases: cases.length,
536038
+ dryRun: true,
536039
+ language: languageSummary(language),
536040
+ planItems: plan?.length ?? 0
536041
+ }, typeof options.json === "string" ? options.json : void 0);
536042
+ return;
536043
+ }
536044
+ console.log(`${import_picocolors.default.green("✓ Dry run passed")} — ${cases.length} case(s), ${plan?.length ?? 0} plan item(s), ${checked}. Nothing was published.`);
536045
+ return;
536046
+ }
535521
536047
  let acceptance;
535522
536048
  let bundle;
535523
536049
  if (requestedAcceptanceId) {
@@ -535782,7 +536308,7 @@ function withReportUpsertOptions(cmd) {
535782
536308
  return cmd.requiredOption("--run <verifyRunId>", "Target session id").option("--verdict <verdict>", "passed|failed|uncertain").option("--summary <text>", "Short summary").option("--content <markdown>", "Full markdown body").option("--total <n>", "Total checks").option("--passed <n>", "Passed checks").option("--failed <n>", "Failed checks").option("--uncertain <n>", "Uncertain checks").option("--json [fields]", "Output JSON");
535783
536309
  }
535784
536310
  function withIngestReportOptions(cmd) {
535785
- return cmd.option("--source <source>", "agent | agent-testing", "agent-testing").option("--operation <id>", "Link the session to an existing Agent Run").option("--acceptance <id>", "Append this round to an existing acceptance").option("--title <title>", "Override the session title").option("--goal <goal>", "The goal/task being verified").option("--subject <type:id>", "Attach to a task/topic/document (defaults to the current topic; otherwise standalone)").option("--requirement <text>", "Acceptance requirement recorded when the aggregate is first created").option("--open", "Print the in-app URL to open the report").option("--json [fields]", "Output JSON");
536311
+ return cmd.option("--source <source>", "agent | agent-testing", "agent-testing").option("--operation <id>", "Link the session to an existing Agent Run").option("--acceptance <id>", "Append this round to an existing acceptance").option("--title <title>", "Override the session title").option("--goal <goal>", "The goal/task being verified").option("--subject <type:id>", "Attach to a task/topic/document (defaults to the current topic; otherwise standalone)").option("--requirement <text>", "Acceptance requirement recorded when the aggregate is first created").option("--open", "Print the in-app URL to open the report").option("--dry-run", "Validate the report (including its language) without publishing; works on a plan-only result.json").option("--json [fields]", "Output JSON");
535786
536312
  }
535787
536313
  /**
535788
536314
  * The run-scoped acceptance commands. A run is one immutable round of a
@@ -535896,6 +536422,7 @@ function attachAcceptanceFlowCommands(acceptance) {
535896
536422
  flow.command("publish <acceptanceId>").requiredOption("--file <path>", "JSON: definition and optional flowId").action(async (id, options) => {
535897
536423
  const client = await getTrpcClient();
535898
536424
  const input = JSON.parse(await readFile(options.file, "utf8"));
536425
+ await assertReportLanguage(client, flowLanguageFields(input.definition));
535899
536426
  outputJson(await client.acceptance.publishFlow.mutate({
535900
536427
  ...input,
535901
536428
  id
@@ -535930,6 +536457,10 @@ function attachAcceptanceFlowCommands(acceptance) {
535930
536457
  flow.command("record <acceptanceId>").requiredOption("--file <path>", "JSON: verifyRunId, checkItemId, verdict, observation").action(async (id, options) => {
535931
536458
  const client = await getTrpcClient();
535932
536459
  const input = JSON.parse(await readFile(options.file, "utf8"));
536460
+ await assertReportLanguage(client, [{
536461
+ path: "observation",
536462
+ text: input.observation
536463
+ }]);
535933
536464
  outputJson(await client.acceptance.recordFlowStep.mutate({
535934
536465
  ...input,
535935
536466
  id
@@ -535991,6 +536522,23 @@ const userGlyph = (userReview) => {
535991
536522
  function registerAcceptanceCommands(parent, options) {
535992
536523
  const acceptance = parent.command("acceptance").description(options?.deprecated ? "Deprecated alias — use `lh acceptance`" : "Delivery acceptances: the cross-round review loop (checks, feedback, decision)");
535993
536524
  attachAcceptanceFlowCommands(acceptance);
536525
+ acceptance.command("preflight").description("Check that the CLI is authenticated and print the language every acceptance text must be written in. Run it before authoring anything, and pass the language to any subagent you delegate to.").option("--json [fields]", "Output JSON").action(async (options) => {
536526
+ const language = await resolveReportLanguage(await getTrpcClient());
536527
+ const instruction = language.status === "unset" ? "No response language is set on this account, so the language is not checked. Write in the language the user converses in." : `Write every human-readable acceptance field (title, plan, case names and observations, evidence descriptions, conclusion, report.md, proposal.md) in ${language.locale}${language.status === "enforced" ? `, i.e. ${language.rule.languageName}` : ""}. Pass this language explicitly to any subagent you delegate to.`;
536528
+ if (options.json !== void 0) {
536529
+ outputJson({
536530
+ authenticated: true,
536531
+ instruction,
536532
+ language: languageSummary(language),
536533
+ settingsUrl: responseLanguageSettingsUrl()
536534
+ }, typeof options.json === "string" ? options.json : void 0);
536535
+ return;
536536
+ }
536537
+ if (language.status === "unset") log$13.warn(unsetLanguageWarning());
536538
+ console.log(`${import_picocolors.default.green("✓")} Authenticated`);
536539
+ console.log(`${import_picocolors.default.bold("report language")}: ${language.status === "unset" ? import_picocolors.default.yellow("not set") : language.locale}${language.status === "unchecked" ? import_picocolors.default.dim(" (not script-checked)") : ""}`);
536540
+ console.log(instruction);
536541
+ });
535994
536542
  acceptance.command("create").summary("Create or reuse an acceptance without creating a verification round or results").description("Create or reuse an acceptance record. This does not create checks, verification rounds, results, reports, or evidence.\n\nBefore creating, agents should load and read the acceptance skill (.agents/skills/acceptance/SKILL.md) and follow its workflow. If the skill is missing, run `lh acceptance install`, then read it.\n\nUse create for flow-first planning. If a completed local report already exists, use `lh acceptance run ingest <reportDir>` to publish it; a separate create call is unnecessary. An acceptance URL alone does not mean verification is complete: publish results and required evidence, then read back coverage before handing off a completed acceptance.").requiredOption("--requirement <text>", "Durable business goal (preserved when reusing a subject)").option("-t, --title <text>", "Display title for a standalone acceptance").option("--subject <type:id>", "Reuse or create for task/topic/document/standalone; omit for a new standalone acceptance").option("--json [fields]", "Output JSON, optionally select fields (acceptanceId, acceptanceUrl, requirement, status, subject)").action(async (options) => {
535995
536543
  const requirement = options.requirement.trim();
535996
536544
  const title = options.title?.trim();
@@ -536001,7 +536549,15 @@ function registerAcceptanceCommands(parent, options) {
536001
536549
  subjectType: "standalone"
536002
536550
  } : parseSubjectRef(options.subject);
536003
536551
  if (!subject) throw new InvalidArgumentError(`--subject must be one of ${acceptanceSubjectTypes.map((type) => `${type}:<id>`).join(" | ")}`);
536004
- const result = await (await getTrpcClient()).acceptance.ensure.mutate({
536552
+ const client = await getTrpcClient();
536553
+ await assertReportLanguage(client, [{
536554
+ path: "--requirement",
536555
+ text: requirement
536556
+ }, {
536557
+ path: "--title",
536558
+ text: title
536559
+ }]);
536560
+ const result = await client.acceptance.ensure.mutate({
536005
536561
  ...subject,
536006
536562
  requirement,
536007
536563
  title
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.63" "User Commands"
3
+ .TH LH 1 "" "@lobehub/cli 0.0.65" "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.63",
3
+ "version": "0.0.65",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "lh": "dist/index.js",
@@ -16,11 +16,36 @@
16
16
  "dist",
17
17
  "man"
18
18
  ],
19
+ "scripts": {
20
+ "build": "bun scripts/ensureWorkspaceLinks.ts && tsdown && tsdown src/daemon/maintenanceWorker.ts --no-config --no-clean --format esm --target node22",
21
+ "cli:link": "bun link",
22
+ "cli:unlink": "bun unlink",
23
+ "dev": "LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts",
24
+ "man:generate": "bun src/man/generate.ts",
25
+ "prepublishOnly": "npm run build && npm run man:generate",
26
+ "test": "bunx vitest run --config vitest.config.mts --silent='passed-only'",
27
+ "test:coverage": "bunx vitest run --config vitest.config.mts --coverage",
28
+ "test:e2e": "bunx vitest run --config vitest.e2e.config.mts --silent='passed-only'",
29
+ "type-check": "tsc --noEmit"
30
+ },
19
31
  "dependencies": {
20
32
  "@lydell/node-pty": "1.2.0-beta.15",
21
33
  "ws": "^8.21.0"
22
34
  },
23
35
  "devDependencies": {
36
+ "@lobechat/agent-gateway-client": "workspace:*",
37
+ "@lobechat/agent-tracing": "workspace:*",
38
+ "@lobechat/const": "workspace:*",
39
+ "@lobechat/device-control": "workspace:*",
40
+ "@lobechat/device-gateway-client": "workspace:*",
41
+ "@lobechat/device-identity": "workspace:*",
42
+ "@lobechat/eval-rubric": "workspace:*",
43
+ "@lobechat/heterogeneous-agents": "workspace:*",
44
+ "@lobechat/html-artifact": "workspace:*",
45
+ "@lobechat/local-file-shell": "workspace:*",
46
+ "@lobechat/tool-runtime": "workspace:*",
47
+ "@lobechat/types": "workspace:*",
48
+ "@lobechat/utils": "workspace:*",
24
49
  "@trpc/client": "^11.18.0",
25
50
  "@types/node": "^24.13.2",
26
51
  "@types/semver": "^7.7.1",
@@ -39,20 +64,7 @@
39
64
  "tsdown": "^0.21.10",
40
65
  "typescript": "^6.0.3",
41
66
  "url-join": "^5.0.0",
42
- "vitest": "5.0.0",
43
- "@lobechat/const": "1.0.0",
44
- "@lobechat/device-control": "1.0.0",
45
- "@lobechat/agent-gateway-client": "1.0.0",
46
- "@lobechat/agent-tracing": "1.0.0",
47
- "@lobechat/eval-rubric": "1.0.0",
48
- "@lobechat/device-gateway-client": "1.0.0",
49
- "@lobechat/device-identity": "1.0.0",
50
- "@lobechat/html-artifact": "1.0.0",
51
- "@lobechat/local-file-shell": "1.0.0",
52
- "@lobechat/heterogeneous-agents": "1.0.0",
53
- "@lobechat/tool-runtime": "1.0.0",
54
- "@lobechat/types": "1.0.0",
55
- "@lobechat/utils": "1.0.0"
67
+ "vitest": "5.0.0"
56
68
  },
57
69
  "engines": {
58
70
  "node": ">=22.15"
@@ -60,16 +72,5 @@
60
72
  "publishConfig": {
61
73
  "access": "public",
62
74
  "registry": "https://registry.npmjs.org"
63
- },
64
- "scripts": {
65
- "build": "bun scripts/ensureWorkspaceLinks.ts && tsdown && tsdown src/daemon/maintenanceWorker.ts --no-config --no-clean --format esm --target node22",
66
- "cli:link": "bun link",
67
- "cli:unlink": "bun unlink",
68
- "dev": "LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts",
69
- "man:generate": "bun src/man/generate.ts",
70
- "test": "bunx vitest run --config vitest.config.mts --silent='passed-only'",
71
- "test:coverage": "bunx vitest run --config vitest.config.mts --coverage",
72
- "test:e2e": "bunx vitest run --config vitest.e2e.config.mts --silent='passed-only'",
73
- "type-check": "tsc --noEmit"
74
75
  }
75
- }
76
+ }