@nanhara/hara 0.131.1 → 0.132.1

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/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ All notable changes to `@nanhara/hara`.
5
5
  > Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
6
6
  > **patch** (last) number bumps for **optimizations/fixes of existing features**.
7
7
 
8
+ ## 0.132.1 — 2026-07-22 — stable native release gates
9
+
10
+ - Native release tests now wait for observable child-process and terminal-render handshakes within explicit
11
+ upper bounds instead of assuming fixed startup delays. This removes an Intel macOS runner race that could
12
+ read fixture state before Node or Ink had started while preserving the timeout, process-tree cleanup, MCP
13
+ diagnostic-boundary, and command-history assertions.
14
+ - This patch carries the observable gateway status, scoped web proxy, full config redaction, and
15
+ user-managed organization connection features introduced in 0.132.0 into the verified standalone and
16
+ Desktop release train. Upgrade with `npm i -g @nanhara/hara@0.132.1`.
17
+
18
+ ## 0.132.0 — 2026-07-22 — observable chat gateways and scoped web proxies
19
+
20
+ - `hara gateway status --platform <name>` now reports redacted credential readability, the live
21
+ credential-scoped lease/PID, transport state, start time, last connection/poll/message, bounded error code,
22
+ and one focused action. The same read-only data is available to Desktop through
23
+ `settings.gateways.list`; no model call, chat content, credential id, token, URL, or raw transport error is
24
+ stored or returned. WeChat status distinguishes missing, malformed, and readable QR login state, while
25
+ Feishu lifecycle comes from the SDK's actual connect/reconnect/error callbacks.
26
+ - `web_fetch` and `web_search` now honor `HTTPS_PROXY`, `HTTP_PROXY`, lowercase equivalents, `NO_PROXY`,
27
+ `HARA_WEB_PROXY`, or global `~/.hara/config.json` key `proxy`. Proxy dispatch is scoped to those two web
28
+ tools: model/provider, Control enrollment, and chat gateway traffic are unchanged. `web_fetch` still
29
+ resolves and rejects internal targets before every redirect and sends the approved public IP in the
30
+ CONNECT tunnel, preserving its DNS-rebinding/SSRF boundary even when a proxy is used.
31
+ - Proxy URLs and every `*ApiKey` config value are masked by `hara config get`; invalid proxy errors do not
32
+ echo the supplied value. The proxy transport uses patched Undici 7.28.0, and Hono/node-server/fast-uri
33
+ transitive dependencies are pinned to their current security fixes.
34
+ - Desktop can now list, add, re-enroll, switch, check, and locally remove user-provided organization
35
+ connections through authenticated `settings.organizations.*` Serve methods. Enrollment rejects redirects,
36
+ embedded URL credentials, and non-HTTPS remote endpoints; one-time codes are never persisted or returned,
37
+ while scoped device tokens remain only in the private `profiles.json` store. No enterprise endpoint is
38
+ preconfigured by Hara.
39
+ - Upgrade with `npm i -g @nanhara/hara@0.132.0`.
40
+
8
41
  ## 0.131.1 — 2026-07-21 — authenticated macOS standalone assets
9
42
 
10
43
  - Darwin arm64 and Intel standalone binaries are now built on their matching native macOS runners,
package/README.md CHANGED
@@ -127,6 +127,17 @@ hara config set apiKey ...
127
127
  hara config set model ...
128
128
  ```
129
129
 
130
+ **Proxy for web tools in mainland/restricted networks** — only `web_fetch` and `web_search` use it:
131
+ ```bash
132
+ export HTTPS_PROXY=http://127.0.0.1:7890
133
+ # or persist the same endpoint privately:
134
+ hara config set proxy http://127.0.0.1:7890
135
+ ```
136
+ `HTTP_PROXY`, lowercase variants, `NO_PROXY`, and `HARA_WEB_PROXY` are supported. Model/provider calls,
137
+ organization enrollment, and chat gateways are not silently redirected. Even through a proxy, `web_fetch`
138
+ pins each DNS-approved public IP and rechecks redirects; authenticated proxy URLs are masked by
139
+ `hara config get`.
140
+
130
141
  **Vision** — hara **auto-detects** whether your main model can see images. A vision model (Claude, gpt-4o,
131
142
  qwen-vl, glm-4v…) gets pasted images **inline**. For a **text-only** model (DeepSeek, coding models), set a
132
143
  describer — the "eyes" — and hara OCRs/describes each pasted image into text first:
@@ -183,6 +194,7 @@ hara --sandbox workspace-write # confine shell writes to the project (macOS Se
183
194
  hara --cwd /path/to/project # explicitly select a workspace without changing your shell directory
184
195
  hara -c # resume the most recent session in this directory
185
196
  hara --profile work # use a named profile from ~/.hara/config.json
197
+ hara profile add work --gateway https://control.example.com --code <one-time-code> # user-added org route
186
198
  hara -m glm-5 # pick a model
187
199
  ```
188
200
 
@@ -331,6 +343,9 @@ start an external tool server.
331
343
  **Profiles and live config**: select an identity with `--profile <name>`; use `overlays` in
332
344
  `~/.hara/config.json` for named config overlays. Project `.hara/config.json` files get the safe preference
333
345
  allowlist above; project-specific routing requires `HARA_TRUST_PROJECT_CONFIG=1` before launch.
346
+ Organization connections are never prefilled: add one with `hara profile add <id> --gateway <https-url>
347
+ --code <one-time-code>` or in Desktop's AI & models settings. The registration code is exchanged once and
348
+ discarded; only the scoped, revocable device credential is retained in the private `~/.hara/profiles.json`.
334
349
  `.hara-profile` identity pins are read no-follow with size, single-inode, and hard-link checks; pin updates use
335
350
  an atomic compare-and-swap, and invalid-pin warnings never echo file contents or paths. A Git-tracked pin is
336
351
  repository-controlled and ignored by default; local untracked pins created with `hara profile pin` work
package/dist/config.js CHANGED
@@ -76,7 +76,7 @@ export function providerCatalog() {
76
76
  ...(PROVIDER_DEFAULTS[id].baseURL ? { defaultBaseURL: PROVIDER_DEFAULTS[id].baseURL } : {}),
77
77
  }));
78
78
  }
79
- export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "routeModel", "routeBaseURL", "routeApiKey", "guardian", "notify", "runTimeoutMs", "maxAgentRounds", "vimMode", "autoCompact", "fileCheckpoints", "updateCheck", "fallbackModel", "fallbackProvider", "fallbackBaseURL", "fallbackApiKey", "reasoningEffort"];
79
+ export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "routeModel", "routeBaseURL", "routeApiKey", "guardian", "notify", "runTimeoutMs", "maxAgentRounds", "vimMode", "autoCompact", "fileCheckpoints", "updateCheck", "proxy", "fallbackModel", "fallbackProvider", "fallbackBaseURL", "fallbackApiKey", "reasoningEffort"];
80
80
  export const REASONING_EFFORTS = ["off", "low", "medium", "high", "max"];
81
81
  export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
82
82
  export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
@@ -492,6 +492,7 @@ export function loadConfig(opts = {}) {
492
492
  const autoCompact = !(process.env.HARA_AUTO_COMPACT === "0" || merged.autoCompact === false || merged.autoCompact === "false"); // default ON
493
493
  const fileCheckpoints = !(process.env.HARA_CHECKPOINTS === "0" || merged.fileCheckpoints === false || merged.fileCheckpoints === "false"); // default ON
494
494
  const updateCheck = !(process.env.HARA_UPDATE_CHECK === "0" || merged.updateCheck === false || merged.updateCheck === "false"); // default ON
495
+ const proxy = typeof merged.proxy === "string" && merged.proxy.trim() ? merged.proxy.trim() : undefined;
495
496
  const fallbackModel = nonBlankEnv(process.env.HARA_FALLBACK_MODEL) ?? merged.fallbackModel;
496
497
  const requestedFallbackProvider = nonBlankEnv(process.env.HARA_FALLBACK_PROVIDER) ?? merged.fallbackProvider;
497
498
  const fallbackProvider = isProviderId(requestedFallbackProvider) ? requestedFallbackProvider : undefined;
@@ -501,7 +502,7 @@ export function loadConfig(opts = {}) {
501
502
  const reasoningEffort = reasoningRaw && ["off", "low", "medium", "high", "max"].includes(reasoningRaw)
502
503
  ? reasoningRaw
503
504
  : undefined;
504
- return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, guardian, hooks, notify, runTimeoutMs, maxAgentRounds, vimMode, autoCompact, fileCheckpoints, updateCheck, fallbackModel, fallbackProvider, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: effectiveCwd };
505
+ return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, guardian, hooks, notify, runTimeoutMs, maxAgentRounds, vimMode, autoCompact, fileCheckpoints, updateCheck, proxy, fallbackModel, fallbackProvider, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: effectiveCwd };
505
506
  }
506
507
  export function providerEnvKey(provider) {
507
508
  return (PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic).envKey;
@@ -234,6 +234,7 @@ export function feishuAdapter(appId, appSecret) {
234
234
  // visibility so a reconnect is observable instead of a mystery silence.
235
235
  const wsHealth = new FeishuWsHealthMonitor();
236
236
  let failActiveStart;
237
+ let runtimeObserver;
237
238
  const wsClient = new lark.WSClient({
238
239
  appId,
239
240
  appSecret,
@@ -243,10 +244,12 @@ export function feishuAdapter(appId, appSecret) {
243
244
  handshakeTimeoutMs: 15_000,
244
245
  onReady: () => {
245
246
  wsHealth.ready();
247
+ runtimeObserver?.connected();
246
248
  console.error("hara feishu: ✓ ws connected");
247
249
  },
248
250
  onReconnecting: () => {
249
251
  const health = wsHealth.disconnect();
252
+ runtimeObserver?.error("reconnecting");
250
253
  console.error(`hara feishu: ws disconnected #${health.total} · ${health.hourCount}/1h · ${health.dayCount}/24h · ` +
251
254
  `previous connection ${wsDuration(health.connectedForMs)} · auto-reconnecting (SDK does not expose close code/reason)`);
252
255
  if (health.alert) {
@@ -255,9 +258,11 @@ export function feishuAdapter(appId, appSecret) {
255
258
  }
256
259
  },
257
260
  onReconnected: () => {
261
+ runtimeObserver?.connected();
258
262
  console.error(`hara feishu: ✓ ws reconnected after ${wsDuration(wsHealth.reconnected())}`);
259
263
  },
260
264
  onError: (err) => {
265
+ runtimeObserver?.error("transport-terminal");
261
266
  const safe = redactKnownSecrets(String(err?.message ?? err), [appId, appSecret]).text
262
267
  .replace(/\s+/gu, " ")
263
268
  .slice(0, 300);
@@ -460,7 +465,8 @@ export function feishuAdapter(appId, appSecret) {
460
465
  });
461
466
  });
462
467
  },
463
- async start(onMessage, signal, shouldDownload) {
468
+ async start(onMessage, signal, shouldDownload, runtime) {
469
+ runtimeObserver = runtime;
464
470
  const runAbort = new AbortController();
465
471
  const relayAbort = () => {
466
472
  runAbort.abort(signal.reason instanceof Error ? signal.reason : new Error("Feishu gateway cancelled"));
@@ -615,6 +621,8 @@ export function feishuAdapter(appId, appSecret) {
615
621
  }
616
622
  finally {
617
623
  failActiveStart = undefined;
624
+ if (runtimeObserver === runtime)
625
+ runtimeObserver = undefined;
618
626
  runAbort.abort(new Error("Feishu gateway stopped"));
619
627
  signal.removeEventListener("abort", relayAbort);
620
628
  try {
@@ -18,6 +18,8 @@ import { sameOpenedFileIdentity } from "../fs-identity.js";
18
18
  const PLATFORM = /^[a-z0-9][a-z0-9_-]{0,63}$/;
19
19
  const LOCK_BYTES = 4 * 1024;
20
20
  const LOCK_WAIT_MS = 5_000;
21
+ const RUNTIME_STATUS_BYTES = 32 * 1024;
22
+ const RUNTIME_STATUS_FILE = /^status-([a-z0-9][a-z0-9_-]{0,63})\.json$/;
21
23
  const STORE_BYTES = 256 * 1024;
22
24
  const DEFAULT_TTL_MS = 60 * 60_000;
23
25
  const DEFAULT_CAPACITY = 2_048;
@@ -341,6 +343,251 @@ export function acquireGatewayInstance(platformValue, options = {}) {
341
343
  throw new Error(`gateway '${displayPlatform}' stale instance could not be reclaimed`);
342
344
  }
343
345
  }
346
+ const GATEWAY_RUNTIME_ERROR_CODES = new Set([
347
+ "network",
348
+ "platform-error",
349
+ "reconnecting",
350
+ "session-expired",
351
+ "transport-exited",
352
+ "transport-terminal",
353
+ ]);
354
+ function optionalRuntimeTimestamp(value) {
355
+ return Number.isFinite(value) && value > 0 ? Math.trunc(value) : undefined;
356
+ }
357
+ function parseGatewayRuntimeStatus(snapshot, path) {
358
+ if (!snapshot)
359
+ return null;
360
+ let value;
361
+ try {
362
+ value = JSON.parse(snapshot.text);
363
+ }
364
+ catch {
365
+ throw new Error(`invalid gateway runtime status: ${path}`);
366
+ }
367
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
368
+ throw new Error(`invalid gateway runtime status: ${path}`);
369
+ }
370
+ const record = value;
371
+ const state = record.state;
372
+ const error = record.lastErrorCode;
373
+ if (record.version !== 1
374
+ || typeof record.platform !== "string"
375
+ || checkedPlatform(record.platform) !== record.platform
376
+ || !Number.isSafeInteger(record.pid)
377
+ || record.pid <= 0
378
+ || !optionalRuntimeTimestamp(record.startedAt)
379
+ || !optionalRuntimeTimestamp(record.updatedAt)
380
+ || !["starting", "connected", "degraded", "stopped", "failed"].includes(state)
381
+ || (error !== undefined && !GATEWAY_RUNTIME_ERROR_CODES.has(error))) {
382
+ throw new Error(`invalid gateway runtime status: ${path}`);
383
+ }
384
+ for (const timestamp of [
385
+ record.lastConnectedAt,
386
+ record.lastPollAt,
387
+ record.lastMessageAt,
388
+ record.lastErrorAt,
389
+ ]) {
390
+ if (timestamp !== undefined && !optionalRuntimeTimestamp(timestamp)) {
391
+ throw new Error(`invalid gateway runtime status: ${path}`);
392
+ }
393
+ }
394
+ return record;
395
+ }
396
+ function gatewayRuntimeStatusPath(directory, runtimeScope) {
397
+ return join(directory, `status-${checkedPlatform(runtimeScope)}.json`);
398
+ }
399
+ /**
400
+ * Credential-scoped, redacted health writer for one live gateway. It stores only lifecycle timestamps and
401
+ * bounded error codes: never tokens, app ids, URLs, chat ids, message text, or raw transport errors.
402
+ */
403
+ export class GatewayRuntimeReporter {
404
+ dir;
405
+ path;
406
+ now;
407
+ record;
408
+ tail = Promise.resolve();
409
+ writeWarningShown = false;
410
+ constructor(dir, path, now, record) {
411
+ this.dir = dir;
412
+ this.path = path;
413
+ this.now = now;
414
+ this.record = record;
415
+ }
416
+ static async open(runtimeScopeValue, platformValue, options = {}) {
417
+ const runtimeScope = checkedPlatform(runtimeScopeValue);
418
+ const platform = checkedPlatform(platformValue);
419
+ const dir = stateDirectory(options.home ?? homedir());
420
+ const path = gatewayRuntimeStatusPath(dir, runtimeScope);
421
+ const previous = parseGatewayRuntimeStatus(await readPrivateStateFileSnapshot(path, RUNTIME_STATUS_BYTES), path);
422
+ if (previous && previous.platform !== platform) {
423
+ throw new Error("gateway runtime status belongs to a different platform");
424
+ }
425
+ const now = options.now ?? Date.now;
426
+ const startedAt = now();
427
+ const reporter = new GatewayRuntimeReporter(dir, path, now, {
428
+ version: 1,
429
+ platform,
430
+ pid: process.pid,
431
+ startedAt,
432
+ updatedAt: startedAt,
433
+ state: "starting",
434
+ ...(previous?.lastConnectedAt ? { lastConnectedAt: previous.lastConnectedAt } : {}),
435
+ ...(previous?.lastPollAt ? { lastPollAt: previous.lastPollAt } : {}),
436
+ ...(previous?.lastMessageAt ? { lastMessageAt: previous.lastMessageAt } : {}),
437
+ ...(previous?.lastErrorAt ? { lastErrorAt: previous.lastErrorAt } : {}),
438
+ ...(previous?.lastErrorCode ? { lastErrorCode: previous.lastErrorCode } : {}),
439
+ });
440
+ reporter.scheduleWrite();
441
+ await reporter.flush();
442
+ return reporter;
443
+ }
444
+ scheduleWrite() {
445
+ this.record.updatedAt = this.now();
446
+ const payload = JSON.stringify({ ...this.record }, null, 2) + "\n";
447
+ this.tail = this.tail
448
+ .then(async () => {
449
+ const snapshot = await readPrivateStateFileSnapshot(this.path, RUNTIME_STATUS_BYTES);
450
+ const boundary = bindHaraPrivateStateWritePath(this.path, this.dir, "write gateway runtime status");
451
+ await atomicWriteText(this.path, payload, {
452
+ expected: snapshot?.text ?? null,
453
+ expectedIdentity: snapshot
454
+ ? { dev: snapshot.dev, ino: snapshot.ino, mode: snapshot.mode, nlink: snapshot.nlink }
455
+ : undefined,
456
+ mode: 0o600,
457
+ boundary,
458
+ });
459
+ })
460
+ .catch(() => {
461
+ if (!this.writeWarningShown) {
462
+ this.writeWarningShown = true;
463
+ console.error("hara gateway: runtime status could not be persisted; gateway operation continues");
464
+ }
465
+ });
466
+ }
467
+ connected() {
468
+ const now = this.now();
469
+ this.record.state = "connected";
470
+ this.record.lastConnectedAt = now;
471
+ this.scheduleWrite();
472
+ }
473
+ poll() {
474
+ const now = this.now();
475
+ this.record.state = "connected";
476
+ this.record.lastConnectedAt = now;
477
+ this.record.lastPollAt = now;
478
+ this.scheduleWrite();
479
+ }
480
+ message() {
481
+ const now = this.now();
482
+ this.record.state = "connected";
483
+ this.record.lastConnectedAt = now;
484
+ this.record.lastMessageAt = now;
485
+ this.scheduleWrite();
486
+ }
487
+ error(code) {
488
+ if (!GATEWAY_RUNTIME_ERROR_CODES.has(code))
489
+ return;
490
+ this.record.state = code === "transport-terminal" || code === "transport-exited" ? "failed" : "degraded";
491
+ this.record.lastErrorAt = this.now();
492
+ this.record.lastErrorCode = code;
493
+ this.scheduleWrite();
494
+ }
495
+ stopped(failed = false) {
496
+ this.record.state = failed ? "failed" : "stopped";
497
+ this.scheduleWrite();
498
+ }
499
+ async flush() {
500
+ await this.tail;
501
+ }
502
+ }
503
+ /** Aggregate all credential-scoped instances of one platform without exposing their scope hashes or ids. */
504
+ export async function inspectGatewayRuntime(platformValue, runtimeScopes = [], options = {}) {
505
+ const platform = checkedPlatform(platformValue);
506
+ const dir = stateDirectory(options.home ?? homedir());
507
+ const scopes = new Set(runtimeScopes.map(checkedPlatform));
508
+ let unreadable = false;
509
+ for (const name of readdirSync(dir)) {
510
+ const match = RUNTIME_STATUS_FILE.exec(name);
511
+ if (!match)
512
+ continue;
513
+ const scope = match[1];
514
+ try {
515
+ const record = parseGatewayRuntimeStatus(await readPrivateStateFileSnapshot(join(dir, name), RUNTIME_STATUS_BYTES), join(dir, name));
516
+ if (record?.platform === platform)
517
+ scopes.add(scope);
518
+ }
519
+ catch {
520
+ if (scope === platform || scope.startsWith(`${platform}-`))
521
+ unreadable = true;
522
+ }
523
+ }
524
+ const pidAlive = options.pidAlive ?? defaultPidAlive;
525
+ const processIdentity = options.processIdentity ?? defaultProcessIdentity;
526
+ const candidates = [];
527
+ for (const scope of scopes) {
528
+ let record = null;
529
+ let lease = null;
530
+ try {
531
+ record = parseGatewayRuntimeStatus(await readPrivateStateFileSnapshot(gatewayRuntimeStatusPath(dir, scope), RUNTIME_STATUS_BYTES), gatewayRuntimeStatusPath(dir, scope));
532
+ if (record && record.platform !== platform)
533
+ continue;
534
+ lease = readLease(join(dir, `instance-${scope}.lock`), scope);
535
+ }
536
+ catch {
537
+ unreadable = true;
538
+ }
539
+ candidates.push({
540
+ scope,
541
+ record,
542
+ lease,
543
+ live: Boolean(lease && leaseOwnerAlive(lease.record, pidAlive, processIdentity)),
544
+ });
545
+ }
546
+ const live = candidates
547
+ .filter((candidate) => candidate.live && candidate.lease)
548
+ .sort((left, right) => (right.lease?.record.startedAt ?? 0) - (left.lease?.record.startedAt ?? 0));
549
+ const selected = live[0]
550
+ ?? candidates
551
+ .filter((candidate) => candidate.record)
552
+ .sort((left, right) => (right.record?.updatedAt ?? 0) - (left.record?.updatedAt ?? 0))[0];
553
+ const matchingRecord = selected?.record
554
+ && (!selected.lease || selected.record.pid === selected.lease.record.pid)
555
+ ? selected.record
556
+ : null;
557
+ const history = matchingRecord
558
+ ? {
559
+ ...(matchingRecord.lastConnectedAt ? { lastConnectedAt: matchingRecord.lastConnectedAt } : {}),
560
+ ...(matchingRecord.lastPollAt ? { lastPollAt: matchingRecord.lastPollAt } : {}),
561
+ ...(matchingRecord.lastMessageAt ? { lastMessageAt: matchingRecord.lastMessageAt } : {}),
562
+ ...(matchingRecord.lastErrorAt ? { lastErrorAt: matchingRecord.lastErrorAt } : {}),
563
+ ...(matchingRecord.lastErrorCode ? { lastErrorCode: matchingRecord.lastErrorCode } : {}),
564
+ }
565
+ : {};
566
+ if (live.length && selected?.lease) {
567
+ return {
568
+ running: true,
569
+ runningInstances: live.length,
570
+ state: matchingRecord?.state ?? "unknown",
571
+ pid: selected.lease.record.pid,
572
+ startedAt: selected.lease.record.startedAt,
573
+ ...history,
574
+ };
575
+ }
576
+ if (selected?.record) {
577
+ return {
578
+ running: false,
579
+ runningInstances: 0,
580
+ state: selected.record.state === "failed" ? "failed" : "stopped",
581
+ startedAt: selected.record.startedAt,
582
+ ...history,
583
+ };
584
+ }
585
+ return {
586
+ running: false,
587
+ runningInstances: 0,
588
+ state: unreadable ? "unreadable" : "unknown",
589
+ };
590
+ }
344
591
  function parseProcessedStore(snapshot, path) {
345
592
  if (!snapshot)
346
593
  return [];
@@ -19,7 +19,7 @@ import { join, resolve } from "node:path";
19
19
  import { chmodSync, mkdirSync, writeFileSync, readFileSync, existsSync, statSync, rmSync } from "node:fs";
20
20
  import { redactToolSubprocessOutput, terminateSubprocessTree } from "../security/subprocess-env.js";
21
21
  import { cleanupOutboundSnapshot, cleanupOutboundSnapshots, consumeOutboundSnapshots, queueOutboundSnapshot, } from "./outbound-files.js";
22
- import { acquireGatewayInstance, gatewayRuntimeScope, GatewayFlowRunStore, GatewayInboundTracker, GatewayMessageDeduper, GatewayRunOutcomeStore, } from "./runtime-state.js";
22
+ import { acquireGatewayInstance, gatewayRuntimeScope, GatewayFlowRunStore, GatewayInboundTracker, GatewayMessageDeduper, GatewayRuntimeReporter, GatewayRunOutcomeStore, inspectGatewayRuntime, } from "./runtime-state.js";
23
23
  /** Parse a leading slash-command from a chat message (pure). null if it isn't one. */
24
24
  export function parseCommand(text) {
25
25
  const m = /^\/([a-z]+)\b\s*([\s\S]*)$/i.exec(text.trim());
@@ -485,6 +485,154 @@ function runFlowAgent(prompt, _cwd, schema, signal) {
485
485
  }
486
486
  /** Re-exported so `hara gateway --platform weixin --login` can run the QR flow. */
487
487
  export { weixinLogin } from "./weixin.js";
488
+ export const GATEWAY_PLATFORMS = [
489
+ "telegram",
490
+ "weixin",
491
+ "discord",
492
+ "feishu",
493
+ "slack",
494
+ "mattermost",
495
+ "matrix",
496
+ "dingtalk",
497
+ "wecom",
498
+ "signal",
499
+ ];
500
+ const GATEWAY_LABELS = {
501
+ telegram: "Telegram",
502
+ weixin: "WeChat",
503
+ discord: "Discord",
504
+ feishu: "Feishu",
505
+ slack: "Slack",
506
+ mattermost: "Mattermost",
507
+ matrix: "Matrix",
508
+ dingtalk: "DingTalk",
509
+ wecom: "WeCom",
510
+ signal: "Signal",
511
+ };
512
+ function gatewayPlatform(value) {
513
+ const platform = canonicalGatewayPlatform(value);
514
+ if (!GATEWAY_PLATFORMS.includes(platform)) {
515
+ throw new Error(`unsupported gateway platform '${value}'`);
516
+ }
517
+ return platform;
518
+ }
519
+ function environmentConfiguration(values, runtimeScope, missingHint) {
520
+ const present = values.filter((value) => Boolean(value?.trim())).length;
521
+ return present === values.length
522
+ ? { state: "ready", runtimeScope: runtimeScope(), missingHint }
523
+ : { state: present === 0 ? "missing" : "incomplete", missingHint };
524
+ }
525
+ async function inspectGatewayConfiguration(platform) {
526
+ if (platform === "weixin") {
527
+ const { inspectWeixinCredentials } = await import("./weixin.js");
528
+ const inspected = inspectWeixinCredentials();
529
+ if (inspected.state === "ready") {
530
+ return {
531
+ state: "ready",
532
+ runtimeScope: gatewayRuntimeScope("weixin", inspected.credentials.user_id),
533
+ missingHint: "run `hara gateway --platform weixin --login`",
534
+ };
535
+ }
536
+ return {
537
+ state: inspected.state,
538
+ missingHint: inspected.state === "unreadable"
539
+ ? "repair the private WeChat login state, then log in again"
540
+ : "run `hara gateway --platform weixin --login`",
541
+ };
542
+ }
543
+ if (platform === "telegram") {
544
+ const token = process.env.HARA_TELEGRAM_TOKEN;
545
+ return environmentConfiguration([token], () => gatewayRuntimeScope("telegram", token), "set HARA_TELEGRAM_TOKEN");
546
+ }
547
+ if (platform === "discord") {
548
+ const token = process.env.HARA_DISCORD_TOKEN;
549
+ return environmentConfiguration([token], () => gatewayRuntimeScope("discord", token), "set HARA_DISCORD_TOKEN");
550
+ }
551
+ if (platform === "feishu") {
552
+ const appId = process.env.HARA_FEISHU_APP_ID;
553
+ const secret = process.env.HARA_FEISHU_APP_SECRET;
554
+ return environmentConfiguration([appId, secret], () => gatewayRuntimeScope("feishu", appId), "set HARA_FEISHU_APP_ID and HARA_FEISHU_APP_SECRET");
555
+ }
556
+ if (platform === "slack") {
557
+ const appToken = process.env.HARA_SLACK_APP_TOKEN;
558
+ const botToken = process.env.HARA_SLACK_BOT_TOKEN;
559
+ return environmentConfiguration([appToken, botToken], () => gatewayRuntimeScope("slack", appToken), "set HARA_SLACK_APP_TOKEN and HARA_SLACK_BOT_TOKEN");
560
+ }
561
+ if (platform === "mattermost") {
562
+ const url = process.env.HARA_MATTERMOST_URL;
563
+ const token = process.env.HARA_MATTERMOST_TOKEN;
564
+ return environmentConfiguration([url, token], () => gatewayRuntimeScope("mattermost", `${url}\0${token}`), "set HARA_MATTERMOST_URL and HARA_MATTERMOST_TOKEN");
565
+ }
566
+ if (platform === "matrix") {
567
+ const homeserver = process.env.HARA_MATRIX_HOMESERVER;
568
+ const token = process.env.HARA_MATRIX_TOKEN;
569
+ const userId = process.env.HARA_MATRIX_USER_ID;
570
+ return environmentConfiguration([homeserver, token, userId], () => gatewayRuntimeScope("matrix", `${homeserver}\0${userId}`), "set HARA_MATRIX_HOMESERVER, HARA_MATRIX_TOKEN, and HARA_MATRIX_USER_ID");
571
+ }
572
+ if (platform === "dingtalk") {
573
+ const clientId = process.env.HARA_DINGTALK_CLIENT_ID;
574
+ const secret = process.env.HARA_DINGTALK_CLIENT_SECRET;
575
+ return environmentConfiguration([clientId, secret], () => gatewayRuntimeScope("dingtalk", clientId), "set HARA_DINGTALK_CLIENT_ID and HARA_DINGTALK_CLIENT_SECRET");
576
+ }
577
+ if (platform === "wecom") {
578
+ const botId = process.env.HARA_WECOM_BOT_ID;
579
+ const secret = process.env.HARA_WECOM_SECRET;
580
+ return environmentConfiguration([botId, secret], () => gatewayRuntimeScope("wecom", botId), "set HARA_WECOM_BOT_ID and HARA_WECOM_SECRET");
581
+ }
582
+ const rpcUrl = process.env.HARA_SIGNAL_RPC_URL;
583
+ const number = process.env.HARA_SIGNAL_NUMBER;
584
+ return environmentConfiguration([rpcUrl, number], () => gatewayRuntimeScope("signal", `${rpcUrl}\0${number}`), "set HARA_SIGNAL_RPC_URL and HARA_SIGNAL_NUMBER");
585
+ }
586
+ function gatewayRecommendation(platform, configuration, runtime) {
587
+ const unresolvedError = runtime.lastErrorAt !== undefined
588
+ && runtime.state !== "connected"
589
+ && (runtime.lastConnectedAt === undefined || runtime.lastErrorAt >= runtime.lastConnectedAt);
590
+ if (unresolvedError && runtime.lastErrorCode === "session-expired" && platform === "weixin") {
591
+ return "re-login with `hara gateway --platform weixin --login`, then restart the gateway";
592
+ }
593
+ if (runtime.state === "degraded" || runtime.state === "failed" || runtime.state === "unreadable") {
594
+ return "inspect the redacted gateway log and restart the gateway if the error persists";
595
+ }
596
+ if (runtime.running)
597
+ return "none";
598
+ if (configuration.state !== "ready")
599
+ return configuration.missingHint;
600
+ return `run \`hara gateway --platform ${platform}\``;
601
+ }
602
+ /** Read-only, redacted gateway diagnosis shared by the CLI and Desktop serve protocol. */
603
+ export async function gatewayStatus(platformValue) {
604
+ const platform = gatewayPlatform(platformValue);
605
+ const configuration = await inspectGatewayConfiguration(platform);
606
+ const runtime = await inspectGatewayRuntime(platform, configuration.runtimeScope ? [configuration.runtimeScope] : []);
607
+ // A long-running gateway can own environment-only credentials that are intentionally unavailable to the
608
+ // Desktop/CLI status process. Report that boundary explicitly instead of contradicting a live connection
609
+ // with "missing credentials". WeChat uses a shared private state file, so unreadability there stays visible.
610
+ const exposedConfiguration = platform !== "weixin"
611
+ && runtime.running
612
+ && configuration.state !== "ready"
613
+ ? "process-only"
614
+ : configuration.state;
615
+ return {
616
+ platform,
617
+ label: GATEWAY_LABELS[platform],
618
+ configuration: exposedConfiguration,
619
+ configured: exposedConfiguration === "ready" || exposedConfiguration === "process-only" || runtime.running,
620
+ running: runtime.running,
621
+ runningInstances: runtime.runningInstances,
622
+ runtimeState: runtime.state,
623
+ ...(runtime.pid ? { pid: runtime.pid } : {}),
624
+ ...(runtime.startedAt ? { startedAt: runtime.startedAt } : {}),
625
+ ...(runtime.lastConnectedAt ? { lastConnectedAt: runtime.lastConnectedAt } : {}),
626
+ ...(runtime.lastPollAt ? { lastPollAt: runtime.lastPollAt } : {}),
627
+ ...(runtime.lastMessageAt ? { lastMessageAt: runtime.lastMessageAt } : {}),
628
+ ...(runtime.lastErrorAt ? { lastErrorAt: runtime.lastErrorAt } : {}),
629
+ ...(runtime.lastErrorCode ? { lastErrorCode: runtime.lastErrorCode } : {}),
630
+ recommendation: gatewayRecommendation(platform, configuration, runtime),
631
+ };
632
+ }
633
+ export async function listGatewayStatuses(platforms = GATEWAY_PLATFORMS) {
634
+ return Promise.all(platforms.map((platform) => gatewayStatus(platform)));
635
+ }
488
636
  async function buildAdapter(platform) {
489
637
  if (platform === "weixin") {
490
638
  const { loadWeixinCreds, weixinAdapter } = await import("./weixin.js");
@@ -694,6 +842,13 @@ export async function runGateway(opts) {
694
842
  // requested spelling only for startup/config hints; all persisted/routable identities are canonical.
695
843
  const platform = adapter.name || canonicalGatewayPlatform(requestedPlatform);
696
844
  const releaseInstance = acquireGatewayInstance(runtimeScope, { displayPlatform: platform });
845
+ let runtimeReporter;
846
+ try {
847
+ runtimeReporter = await GatewayRuntimeReporter.open(runtimeScope, platform);
848
+ }
849
+ catch {
850
+ console.error("hara gateway: runtime status is unavailable; gateway operation continues");
851
+ }
697
852
  let messageDeduper;
698
853
  let flowEffectReceipts;
699
854
  let flowRuns;
@@ -709,6 +864,9 @@ export async function runGateway(opts) {
709
864
  runOutcomes = await GatewayRunOutcomeStore.open(gatewayRuntimeScope("run-cache", runtimeScope));
710
865
  }
711
866
  catch (error) {
867
+ runtimeReporter?.error("transport-exited");
868
+ runtimeReporter?.stopped(true);
869
+ await runtimeReporter?.flush();
712
870
  releaseInstance();
713
871
  throw error;
714
872
  }
@@ -745,6 +903,7 @@ export async function runGateway(opts) {
745
903
  process.once("SIGTERM", stop);
746
904
  ac.signal.addEventListener("abort", closeQueue, { once: true });
747
905
  console.error(`hara gateway: ${adapter.name} up · cwd=${cwd} · ${allowlist.size} allowed user(s) · Ctrl-C to stop`);
906
+ let transportFailed = false;
748
907
  try {
749
908
  await pruneStaleMedia(platform).catch((error) => {
750
909
  console.error(`hara gateway: media cleanup failed — ${error instanceof Error ? error.message : String(error)}`);
@@ -753,6 +912,7 @@ export async function runGateway(opts) {
753
912
  try {
754
913
  if (ac.signal.aborted)
755
914
  return;
915
+ runtimeReporter?.message();
756
916
  let existingRunOutcome = null;
757
917
  let outcomeLoadError;
758
918
  try {
@@ -1174,7 +1334,12 @@ export async function runGateway(opts) {
1174
1334
  console.error(`hara gateway: inbound media cleanup failed — ${error instanceof Error ? error.message : String(error)}`);
1175
1335
  });
1176
1336
  }
1177
- })()), ac.signal, (m) => shouldDownloadInboundMedia(m, allowlist));
1337
+ })()), ac.signal, (m) => shouldDownloadInboundMedia(m, allowlist), runtimeReporter);
1338
+ }
1339
+ catch (error) {
1340
+ transportFailed = true;
1341
+ runtimeReporter?.error("transport-exited");
1342
+ throw error;
1178
1343
  }
1179
1344
  finally {
1180
1345
  stop();
@@ -1184,11 +1349,18 @@ export async function runGateway(opts) {
1184
1349
  ac.signal.removeEventListener("abort", closeQueue);
1185
1350
  process.off("SIGINT", stop);
1186
1351
  process.off("SIGTERM", stop);
1187
- if (handlersDrained)
1352
+ if (handlersDrained) {
1188
1353
  releaseInstance();
1354
+ runtimeReporter?.stopped(transportFailed);
1355
+ await runtimeReporter?.flush();
1356
+ }
1189
1357
  else {
1190
1358
  console.error("hara gateway: shutdown timed out with inbound work still active; instance lease retained until callbacks finish.");
1191
- void inboundHandlers.waitForIdle().then(() => releaseInstance(), (error) => console.error(`hara gateway: could not release the deferred instance lease — ${error instanceof Error ? error.message : String(error)}`));
1359
+ void inboundHandlers.waitForIdle().then(async () => {
1360
+ releaseInstance();
1361
+ runtimeReporter?.stopped(transportFailed);
1362
+ await runtimeReporter?.flush();
1363
+ }, (error) => console.error(`hara gateway: could not release the deferred instance lease — ${error instanceof Error ? error.message : String(error)}`));
1192
1364
  }
1193
1365
  }
1194
1366
  }