@nanhara/hara 0.100.0 → 0.101.0

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,15 @@ 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.101.0 — startup update check
9
+
10
+ - **`hara` tells you when it's out of date.** On launch (interactive TTY only), a one-line notice —
11
+ `⬆ Update available 0.100.0 → 0.101.0 · npm i -g @nanhara/hara` — driven by a daily background
12
+ probe with a 3s timeout that NEVER delays startup (the notice always comes from the previous
13
+ probe's cache, npm update-notifier style). npmjs first, npmmirror fallback for CN networks;
14
+ offline machines fail silent and back off to daily retries. Disable with
15
+ `hara config set updateCheck false` or `HARA_UPDATE_CHECK=0`.
16
+
8
17
  ## 0.100.0 — the agent keeps its own attention: system-reminders + anti-drift compaction
9
18
 
10
19
  Distilled from a source-level study of Claude Code v1.0.33's agent internals (the Ie1/WD5 reminder
package/dist/config.js CHANGED
@@ -30,7 +30,7 @@ const PROVIDER_DEFAULTS = {
30
30
  },
31
31
  "hara-gateway": { model: "", envKey: "HARA_GATEWAY_TOKEN" }, // B-end: enrolled device → token in ~/.hara/org.json, routed by the gateway
32
32
  };
33
- 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", "vimMode", "autoCompact", "fileCheckpoints", "fallbackModel", "fallbackBaseURL", "fallbackApiKey", "reasoningEffort"];
33
+ 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", "vimMode", "autoCompact", "fileCheckpoints", "updateCheck", "fallbackModel", "fallbackBaseURL", "fallbackApiKey", "reasoningEffort"];
34
34
  export const REASONING_EFFORTS = ["off", "low", "medium", "high"];
35
35
  export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
36
36
  export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
@@ -158,6 +158,7 @@ export function loadConfig(opts = {}) {
158
158
  const vimMode = process.env.HARA_VIM === "1" || merged.vimMode === true || merged.vimMode === "true";
159
159
  const autoCompact = !(process.env.HARA_AUTO_COMPACT === "0" || merged.autoCompact === false || merged.autoCompact === "false"); // default ON
160
160
  const fileCheckpoints = !(process.env.HARA_CHECKPOINTS === "0" || merged.fileCheckpoints === false || merged.fileCheckpoints === "false"); // default ON
161
+ const updateCheck = !(process.env.HARA_UPDATE_CHECK === "0" || merged.updateCheck === false || merged.updateCheck === "false"); // default ON
161
162
  const fallbackModel = process.env.HARA_FALLBACK_MODEL ?? merged.fallbackModel;
162
163
  const fallbackBaseURL = process.env.HARA_FALLBACK_BASE_URL ?? merged.fallbackBaseURL;
163
164
  const fallbackApiKey = process.env.HARA_FALLBACK_API_KEY ?? merged.fallbackApiKey;
@@ -165,7 +166,7 @@ export function loadConfig(opts = {}) {
165
166
  const reasoningEffort = reasoningRaw && ["off", "low", "medium", "high"].includes(reasoningRaw)
166
167
  ? reasoningRaw
167
168
  : undefined;
168
- 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, vimMode, autoCompact, fileCheckpoints, fallbackModel, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: process.cwd() };
169
+ 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, vimMode, autoCompact, fileCheckpoints, updateCheck, fallbackModel, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: process.cwd() };
169
170
  }
170
171
  export function providerEnvKey(provider) {
171
172
  return (PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic).envKey;
package/dist/index.js CHANGED
@@ -25,6 +25,7 @@ import { loadActiveProfile, listProfiles, useProfile, addProfile, upsertProfile,
25
25
  import { loadPermissionRules, scaffoldPermissions, globalPermissionsPath, projectPermissionsPath } from "./security/permissions.js";
26
26
  import { routingProvider } from "./agent/route.js";
27
27
  import { shouldAutoCompact, COMPACT_SYSTEM } from "./agent/compact.js";
28
+ import { checkForUpdate } from "./update-check.js";
28
29
  import { formatContextReport } from "./agent/context-report.js";
29
30
  import { userTurnPreviews, rewindTo } from "./agent/rewind.js";
30
31
  import { checkpoint, listCheckpoints, restoreCheckpoint } from "./checkpoints.js";
@@ -2187,6 +2188,13 @@ program.action(async (opts) => {
2187
2188
  // interactive REPL — ink TUI by default on a real terminal; HARA_TUI=0 forces the classic readline path
2188
2189
  const useTui = stdin.isTTY && stdout.isTTY && process.env.HARA_TUI !== "0";
2189
2190
  out(c.bold(`hara ${pkg.version}`) + c.dim(` · ${cfg.provider}:${cfg.model} · ${approval}${sandbox !== "off" ? ` · sandbox:${sandbox}` : ""} · ${cwd}\n`));
2191
+ // Startup update notice — cache-driven (a previous session's background probe), so it costs zero
2192
+ // latency; today's probe (if due) fires in the background for the NEXT launch. TTY sessions only.
2193
+ if (cfg.updateCheck && stdout.isTTY) {
2194
+ const upd = checkForUpdate(pkg.version);
2195
+ if (upd)
2196
+ out(c.yellow(`⬆ ${upd}`) + "\n");
2197
+ }
2190
2198
  const rl = createInterface({
2191
2199
  input: stdin,
2192
2200
  output: stdout,
@@ -0,0 +1,83 @@
1
+ // Startup update check (npm's update-notifier pattern): NEVER blocks or delays startup. The notice
2
+ // shown at launch comes from a CACHE written by a previous session's background probe; if the cache
3
+ // is stale (> a day) a fresh probe fires in the background with a hard timeout — its result shows on
4
+ // the NEXT launch. Offline / blocked registries fail silently. Interactive TTY sessions only (the
5
+ // caller gates); disable with `hara config set updateCheck false` or HARA_UPDATE_CHECK=0.
6
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
7
+ import { join, dirname } from "node:path";
8
+ import { homedir } from "node:os";
9
+ const cacheFile = () => join(homedir(), ".hara", "update-check.json");
10
+ /** Probe at most once a day — the check is a courtesy, not a heartbeat. */
11
+ export const CHECK_EVERY_MS = 24 * 60 * 60 * 1000;
12
+ /** npmjs first; the npmmirror fallback keeps the check working on CN networks where npmjs stalls. */
13
+ const REGISTRIES = ["https://registry.npmjs.org/@nanhara/hara/latest", "https://registry.npmmirror.com/@nanhara/hara/latest"];
14
+ /** Strict-ish semver compare on the numeric triple: is `latest` newer than `current`?
15
+ * Anything unparsable (tags, garbage, empty) → false — never nag on bad data. */
16
+ export function isNewer(latest, current) {
17
+ const parse = (v) => {
18
+ const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
19
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
20
+ };
21
+ const a = parse(latest);
22
+ const b = parse(current);
23
+ if (!a || !b)
24
+ return false;
25
+ for (let i = 0; i < 3; i++) {
26
+ if (a[i] !== b[i])
27
+ return a[i] > b[i];
28
+ }
29
+ return false;
30
+ }
31
+ export function readCache(file = cacheFile()) {
32
+ try {
33
+ const j = JSON.parse(readFileSync(file, "utf8"));
34
+ return typeof j?.checkedAt === "number" && typeof j?.latest === "string" ? j : null;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ export function writeCache(c, file = cacheFile()) {
41
+ try {
42
+ mkdirSync(dirname(file), { recursive: true });
43
+ writeFileSync(file, JSON.stringify(c));
44
+ }
45
+ catch {
46
+ /* a failed cache write must never surface */
47
+ }
48
+ }
49
+ /** The startup notice, decided purely from cache state (testable without I/O). */
50
+ export function updateNotice(current, cache) {
51
+ if (!cache || !isNewer(cache.latest, current))
52
+ return null;
53
+ return `Update available ${current} → ${cache.latest} · npm i -g @nanhara/hara`;
54
+ }
55
+ /** Background probe: first registry that answers wins; 3s hard timeout each; silent on total failure
56
+ * (checkedAt still stamps so an offline machine backs off to daily retries, not every launch). */
57
+ export async function refreshLatest(file = cacheFile(), fetchFn = fetch) {
58
+ for (const url of REGISTRIES) {
59
+ try {
60
+ const r = await fetchFn(url, { signal: AbortSignal.timeout(3000) });
61
+ if (!r.ok)
62
+ continue;
63
+ const j = (await r.json());
64
+ if (j?.version) {
65
+ writeCache({ checkedAt: Date.now(), latest: j.version }, file);
66
+ return;
67
+ }
68
+ }
69
+ catch {
70
+ /* offline / blocked / slow → try the next registry, then give up quietly */
71
+ }
72
+ }
73
+ const prev = readCache(file);
74
+ writeCache({ checkedAt: Date.now(), latest: prev?.latest ?? "" }, file);
75
+ }
76
+ /** Startup entry: return the notice to print (or null), and — when the cache is stale — fire the
77
+ * daily background probe (fire-and-forget; the caller never awaits it). */
78
+ export function checkForUpdate(current, file = cacheFile(), now = Date.now()) {
79
+ const cache = readCache(file);
80
+ if (!cache || now - cache.checkedAt > CHECK_EVERY_MS)
81
+ void refreshLatest(file).catch(() => { });
82
+ return updateNotice(current, cache);
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.100.0",
3
+ "version": "0.101.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"