@nanhara/hara 0.127.0 → 0.127.2

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,25 @@ 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.127.2 — 2026-07-20 — quiet scheduled monitoring
9
+
10
+ - Cron delivery now supports `--deliver-mode always|on-output|on-error`. Existing jobs retain the
11
+ `always` heartbeat; high-frequency monitors can use `on-output` to stay silent unless redacted stdout
12
+ is non-empty, while `on-error` sends only failed outcomes.
13
+ - Failure streak alarms remain independent of outcome filtering, so an `on-output` watchdog still sends
14
+ its configured `--alert-after N` alert even when routine runs and stderr-only diagnostics are quiet.
15
+ - CLI, agent tool, and Desktop Serve automation APIs validate, persist, list, and display the same policy.
16
+ - Upgrade with `npm i -g @nanhara/hara@0.127.2`.
17
+
18
+ ## 0.127.1 — 2026-07-20 — managed access lifecycle
19
+
20
+ - Managed enrollment now persists the control plane's authoritative device-token expiry. Hara warns
21
+ during the final 24 hours, reports the boundary in `whoami`/`enroll --status`, and fails closed with
22
+ a focused re-enrollment command once access expires or lifecycle data is corrupt.
23
+ - Serve provider settings expose only expiry metadata—not the token—so Hara Desktop can distinguish
24
+ an authenticated managed route from an expired one.
25
+ - Upgrade with `npm i -g @nanhara/hara@0.127.1`.
26
+
8
27
  ## 0.127.0 — 2026-07-20 — structured context and typed task lifecycle
9
28
 
10
29
  - **Prompt assembly is now an explicit, ordered runtime layer.** Cache-stable policy, tool, project, and
package/README.md CHANGED
@@ -274,7 +274,7 @@ new execution. The `hara resume` launcher preserves terminal input in both npm/N
274
274
  installs.
275
275
  **MCP**: add an `mcpServers` map to global config (a reviewed project config additionally needs `HARA_TRUST_PROJECT_CONFIG=1` at launch). Hara starts with every configured server stopped and exposes only `mcp_connect`; when the current task first needs one server, the agent requests permission to connect that server and its tools appear on the next round as `mcp__<server>__<tool>`. Unrelated servers remain stopped. Configured MCP servers, like `external_agent`, are trusted host extensions outside Hara's protected-file boundary. Every interactive external-tool call requires confirmation (even in `full-auto`), and non-interactive runs disable them by default; reviewed automation can explicitly opt in before launch with `HARA_ALLOW_TRUSTED_EXTENSIONS=1`. hara can also **be** an MCP server — `hara mcp` exposes its read/search tools (esp. **`codebase_search`**) over stdio so other clients (Claude Desktop, Cursor, another hara) can use them; read-only by default (`HARA_MCP_TOOLS` to override).
276
276
  **Vim mode**: `hara config set vimMode true` makes the prompt modal — Esc → normal, `i/a/A/I` insert, `h l 0 $ w b e` motions, `x D C dd cw p` edits. Off by default.
277
- **Scheduled tasks**: `hara cron add "0 9 * * 1-5" "<task>"` (or `"every 30m"`, `"in 2h"`) runs a task on a schedule — each run is a fresh hara session. `hara cron install` wires a per-minute tick into launchd/crontab (no daemon); `--org` routes through the role org. Manage with `hara cron list/run/enable/disable/remove/logs`. Every job has a 30-minute deadline and the whole sequential tick has a non-renewable 60-minute watchdog: a job timeout kills its process tree, records `timed out` + duration/error, then continues with the next due job; a tick timeout stops the remainder and releases the global lock. Add `--deliver feishu:<chatId>` (or Telegram/webhook) for outcomes and `--alert-after N` for the consecutive-failure 🚨 threshold (default 3). Delivery intent is durable before transport, uses a stable idempotency key, and retries with bounded backoff on later ticks until confirmed. A failed channel cannot grow `jobs.json` forever: each job keeps at most 64 pending effects, reserves outcome/alert room before launch, and disables itself with a visible backlog error when full; restore delivery, let the queue drain, then re-enable it. Tune milliseconds with `HARA_CRON_JOB_TIMEOUT_MS` (hard max 24h) and `HARA_CRON_TICK_TIMEOUT_MS` (hard max 5h); scheduled jobs are also capped by the tick. After upgrading from a version whose tick is already stuck, terminate that specific legacy `hara cron tick` process tree once (or reboot); the next scheduler minute marks over-age state interrupted/disabled and recovers the lock without replaying a possibly orphaned task.
277
+ **Scheduled tasks**: `hara cron add "0 9 * * 1-5" "<task>"` (or `"every 30m"`, `"in 2h"`) runs a task on a schedule — each run is a fresh hara session. `hara cron install` wires a per-minute tick into launchd/crontab (no daemon); `--org` routes through the role org. Manage with `hara cron list/run/enable/disable/remove/logs`. Every job has a 30-minute deadline and the whole sequential tick has a non-renewable 60-minute watchdog: a job timeout kills its process tree, records `timed out` + duration/error, then continues with the next due job; a tick timeout stops the remainder and releases the global lock. Add `--deliver feishu:<chatId>` (or Telegram/WeChat/webhook) for outcomes. `--deliver-mode always` preserves the existing every-run heartbeat, `on-output` sends only when redacted stdout is non-empty, and `on-error` sends only failed runs; `--alert-after N` remains independent and still raises the consecutive-failure 🚨 threshold alarm (default 3). Delivery intent is durable before transport, uses a stable idempotency key, and retries with bounded backoff on later ticks until confirmed. A failed channel cannot grow `jobs.json` forever: each job keeps at most 64 pending effects, reserves outcome/alert room before launch, and disables itself with a visible backlog error when full; restore delivery, let the queue drain, then re-enable it. Tune milliseconds with `HARA_CRON_JOB_TIMEOUT_MS` (hard max 24h) and `HARA_CRON_TICK_TIMEOUT_MS` (hard max 5h); scheduled jobs are also capped by the tick. After upgrading from a version whose tick is already stuck, terminate that specific legacy `hara cron tick` process tree once (or reboot); the next scheduler minute marks over-age state interrupted/disabled and recovers the lock without replaying a possibly orphaned task.
278
278
  **Work coordination**: `todo_write` is the agent's short, session-scoped checklist; it persists with that
279
279
  session and is isolated between simultaneous sub-agents and serve sessions. `task` is the durable project pool
280
280
  for work that outlives a conversation: add/update/list/remove items with `pending|in_progress|done`, an optional
@@ -367,7 +367,8 @@ export function runJobOnce(job, options = {}) {
367
367
  resolve({ ok: false, error: `failed to start: ${error instanceof Error ? error.message : String(error)}`, output: "" });
368
368
  return;
369
369
  }
370
- let tail = ""; // last few KB, for chat delivery (the full stream goes to the log file)
370
+ let tail = ""; // combined stdout/stderr tail for diagnostics and ordinary delivery
371
+ let stdoutTail = ""; // separate so on-output never turns stderr noise into a notification
371
372
  let logBytes = (() => { try {
372
373
  return statSync(log).size;
373
374
  }
@@ -405,7 +406,10 @@ export function runJobOnce(job, options = {}) {
405
406
  };
406
407
  // stdout and stderr are separate byte streams; sharing a line buffer can splice unrelated chunks and
407
408
  // either corrupt output or create an unsafe synthetic token. Keep an independent redactor per stream.
408
- const stdout = createToolOutputLineRedactor(appendSafe);
409
+ const stdout = createToolOutputLineRedactor((safe) => {
410
+ stdoutTail = (stdoutTail + safe).slice(-4_000);
411
+ appendSafe(safe);
412
+ });
409
413
  const stderr = createToolOutputLineRedactor(appendSafe);
410
414
  const flush = () => { stdout.flush(); stderr.flush(); };
411
415
  const settle = (result) => {
@@ -424,6 +428,7 @@ export function runJobOnce(job, options = {}) {
424
428
  resolve({
425
429
  ...result,
426
430
  error: result.error ? redactToolSubprocessOutput(result.error) : undefined,
431
+ stdout: stdoutTail,
427
432
  output: tail,
428
433
  });
429
434
  };
@@ -364,6 +364,9 @@ function validCronJob(value) {
364
364
  && (job.pendingDueAt === undefined || (job.schedule.kind === "cron" && validFinite(job.pendingDueAt)))
365
365
  && (job.tz === undefined || (typeof job.tz === "string" && validTz(job.tz)))
366
366
  && (job.deliver === undefined || (typeof job.deliver === "string" && job.deliver.length > 0 && job.deliver.length <= 4_096))
367
+ && (job.deliverMode === undefined
368
+ || (job.deliver !== undefined
369
+ && (job.deliverMode === "always" || job.deliverMode === "on-output" || job.deliverMode === "on-error")))
367
370
  && (job.alertAfter === undefined || (Number.isInteger(job.alertAfter) && job.alertAfter >= 1 && job.alertAfter <= 1_000))
368
371
  && (job.lastRunAt === undefined || validFinite(job.lastRunAt))
369
372
  && (job.runningSince === undefined || validFinite(job.runningSince))
@@ -621,11 +624,18 @@ function enqueueOutcomeForJob(job, result, nowMs, attemptKey) {
621
624
  return [];
622
625
  const queued = [];
623
626
  const snippet = notificationText(result.output, 1_500).trim();
627
+ const stdout = notificationText(result.stdout, 1_500).trim();
624
628
  const safeError = notificationText(result.error || "failed", 1_000);
625
629
  const head = result.ok ? `⏰ ${job.name} ✓` : `⏰ ${job.name} ✗ ${safeError}`;
626
- const outcomeId = enqueueNotification(job, "outcome", snippet ? `${head}\n${snippet}` : head, nowMs, attemptKey);
627
- if (outcomeId)
628
- queued.push(outcomeId);
630
+ const mode = job.deliverMode ?? "always";
631
+ const shouldDeliverOutcome = mode === "always"
632
+ || (mode === "on-output" && stdout.length > 0)
633
+ || (mode === "on-error" && !result.ok);
634
+ if (shouldDeliverOutcome) {
635
+ const outcomeId = enqueueNotification(job, "outcome", snippet ? `${head}\n${snippet}` : head, nowMs, attemptKey);
636
+ if (outcomeId)
637
+ queued.push(outcomeId);
638
+ }
629
639
  if (!result.ok) {
630
640
  const count = job.consecutiveErrors ?? 0;
631
641
  const threshold = job.alertAfter ?? 3;
package/dist/index.js CHANGED
@@ -22,7 +22,7 @@ import { notifyDone } from "./notify.js";
22
22
  import { startMcpServer, mcpServeToolNames } from "./mcp/server.js";
23
23
  import { completionScript } from "./completions.js";
24
24
  import { renderSessionMarkdown } from "./export.js";
25
- import { clearEnrollment, enrollDevice, heartbeat, syncOrgRoles } from "./org-fleet/enroll.js";
25
+ import { clearEnrollment, enrollDevice, heartbeat, syncOrgRoles, deviceTokenExpired, deviceTokenExpiryWarning, } from "./org-fleet/enroll.js";
26
26
  import { loadActiveProfile, listProfiles, useProfile, addProfile, upsertProfile, removeProfile, setModel as setProfileModel, resetModel as resetProfileModel, getProfile, effectiveModel, routingLabel, routeHost, activeId, resolveActive, setFlagOverride, writePin, removePin, pinFilePath, DEFAULT_ORG_ID, PERSONAL_ID, } from "./profile/profile.js";
27
27
  import { loadPermissionRules, scaffoldPermissions, globalPermissionsPath, projectPermissionsPath } from "./security/permissions.js";
28
28
  import { routingProvider } from "./agent/route.js";
@@ -134,7 +134,7 @@ async function buildProvider(cfg, targetOverride) {
134
134
  // requests" — gateway (deviceToken at the gateway) vs BYOK (user's key direct to the provider).
135
135
  const { profile: ap } = profileForConfig(cfg);
136
136
  if (ap.kind === "gateway") {
137
- if (!ap.gatewayUrl || !ap.deviceToken)
137
+ if (!ap.gatewayUrl || !ap.deviceToken || deviceTokenExpired(ap.tokenExpiresAt))
138
138
  return null;
139
139
  const baseURL = ap.baseURL || `${ap.gatewayUrl.replace(/\/$/, "")}/v1`;
140
140
  const model = resolveGatewayModel(cfg, ap, process.env, targetOverride?.model);
@@ -193,8 +193,12 @@ async function buildGuardian(cfg, primary) {
193
193
  }
194
194
  function authHint(cfg) {
195
195
  const { profile: ap } = profileForConfig(cfg);
196
- if (ap.kind === "gateway")
196
+ if (ap.kind === "gateway") {
197
+ if (deviceTokenExpired(ap.tokenExpiresAt)) {
198
+ return `Active profile '${ap.id}' has expired organization access — re-enroll with \`hara profile add ${ap.id} --gateway ${ap.gatewayUrl || "<url>"} --code <code>\`.`;
199
+ }
197
200
  return `Active profile '${ap.id}' is a gateway profile but is missing deviceToken — re-enroll with \`hara profile add ${ap.id} --gateway <url> --code <code>\`.`;
201
+ }
198
202
  const target = resolveByokProviderTarget(cfg, ap, false);
199
203
  const provider = target.provider;
200
204
  if (provider === "qwen-oauth")
@@ -215,6 +219,7 @@ function providerSettingsSnapshot(targetCwd) {
215
219
  const catalog = providerCatalog();
216
220
  if (profile.kind === "gateway") {
217
221
  const entry = catalog.find((candidate) => candidate.id === "hara-gateway");
222
+ const tokenExpired = deviceTokenExpired(profile.tokenExpiresAt);
218
223
  return {
219
224
  current: {
220
225
  provider: "hara-gateway",
@@ -223,11 +228,12 @@ function providerSettingsSnapshot(targetCwd) {
223
228
  location: entry.location,
224
229
  auth: entry.auth,
225
230
  keyConfigured: !!profile.deviceToken,
226
- authenticated: !!profile.gatewayUrl && !!profile.deviceToken,
231
+ authenticated: !!profile.gatewayUrl && !!profile.deviceToken && !tokenExpired,
227
232
  profileId: profile.id,
228
233
  profileKind: profile.kind,
229
234
  profileSource: resolution.source,
230
235
  editable: false,
236
+ ...(profile.tokenExpiresAt ? { tokenExpiresAt: profile.tokenExpiresAt, tokenExpired } : {}),
231
237
  },
232
238
  providers: catalog,
233
239
  };
@@ -1664,6 +1670,11 @@ function printWhoami() {
1664
1670
  out(c.dim(` device: ${p.deviceId.length > 8 ? "…" + p.deviceId.slice(-8) : p.deviceId}\n`));
1665
1671
  if (p.availableModels?.length)
1666
1672
  out(c.dim(` available: ${p.availableModels.join(", ")}\n`));
1673
+ if (p.tokenExpiresAt)
1674
+ out(c.dim(` expires: ${p.tokenExpiresAt}\n`));
1675
+ const warning = deviceTokenExpiryWarning(p.tokenExpiresAt);
1676
+ if (warning)
1677
+ out(c.yellow(` ⚠ ${warning}\n`));
1667
1678
  }
1668
1679
  else {
1669
1680
  out(c.dim(` provider: ${p.provider}\n`));
@@ -1795,6 +1806,7 @@ profileCmd
1795
1806
  defaultModel: e.model || "",
1796
1807
  availableModels: e.model ? [e.model] : [],
1797
1808
  enrolledAt: e.enrolledAt,
1809
+ tokenExpiresAt: e.expiresAt,
1798
1810
  };
1799
1811
  upsertProfile(p); // upsert: re-enrolling the same id rotates the token
1800
1812
  const r = useProfile(id);
@@ -1982,8 +1994,8 @@ program
1982
1994
  .option("--clear", "switch active profile back to personal (does NOT delete the gateway profile)")
1983
1995
  .action(async (gatewayUrl, opts) => {
1984
1996
  if (opts.status) {
1985
- const p = loadActiveProfile();
1986
- return void out(p.kind === "gateway" ? c.green("enrolled") + c.dim(` · ${p.gatewayUrl} · device ${p.deviceId || "?"} · model ${effectiveModel(p) || "(gateway default)"} · since ${p.enrolledAt || "?"}\n`) : c.dim("Not enrolled — `hara enroll <gateway-url> --code <code>`.\n"));
1997
+ printWhoami();
1998
+ return;
1987
1999
  }
1988
2000
  if (opts.clear) {
1989
2001
  // Behavior change: don't *delete* the gateway profile (keeps the token around for re-use);
@@ -2010,6 +2022,7 @@ program
2010
2022
  defaultModel: e.model || "",
2011
2023
  availableModels: e.model ? [e.model] : [],
2012
2024
  enrolledAt: e.enrolledAt,
2025
+ tokenExpiresAt: e.expiresAt,
2013
2026
  };
2014
2027
  upsertProfile(p);
2015
2028
  useProfile(DEFAULT_ORG_ID);
@@ -2441,7 +2454,8 @@ function renderCronJobs() {
2441
2454
  : j.lastStatus === "error"
2442
2455
  ? c.red(`err${elapsed}`)
2443
2456
  : c.dim("—");
2444
- return `${c.bold(j.id)} ${describeSchedule(j.schedule)} ${c.dim(`· ${j.mode} · next ${nextLabel} · last ${status}`)}${j.enabled ? "" : c.dim(" [disabled]")}\n ${c.dim(j.name)}`;
2457
+ const delivery = j.deliver ? ` · deliver ${j.deliver} (${j.deliverMode ?? "always"})` : "";
2458
+ return `${c.bold(j.id)} ${describeSchedule(j.schedule)} ${c.dim(`· ${j.mode}${delivery} · next ${nextLabel} · last ${status}`)}${j.enabled ? "" : c.dim(" [disabled]")}\n ${c.dim(j.name)}`;
2445
2459
  });
2446
2460
  return head + "\n" + lines.join("\n") + "\n";
2447
2461
  }
@@ -2453,7 +2467,8 @@ cronCmd
2453
2467
  .option("--org", "run via `hara org` (role routing + review) instead of a plain `hara -p` prompt")
2454
2468
  .option("--command", "run the task as a plain SHELL COMMAND — deterministic, no agent, no tokens")
2455
2469
  .option("--tz <zone>", 'IANA timezone for cron exprs (e.g. "Asia/Shanghai"); default = local time')
2456
- .option("--deliver <spec>", "push each run's result: telegram:<chatId> | feishu:<chatId> | webhook:<url>")
2470
+ .option("--deliver <spec>", "push selected results: telegram:<chatId> | feishu:<chatId> | weixin:<peerId> | webhook:<url>")
2471
+ .option("--deliver-mode <mode>", "notification policy: always (default) | on-output | on-error")
2457
2472
  .option("--alert-after <n>", "send a 🚨 after N consecutive failures (1..1000; default 3)")
2458
2473
  .action((schedule, taskParts, opts) => {
2459
2474
  const task = taskParts.join(" ");
@@ -2471,6 +2486,11 @@ cronCmd
2471
2486
  if ("error" in d)
2472
2487
  return void out(c.red(d.error + "\n"));
2473
2488
  }
2489
+ if (opts.deliverMode && !opts.deliver)
2490
+ return void out(c.red("--deliver-mode requires --deliver\n"));
2491
+ if (opts.deliverMode && !["always", "on-output", "on-error"].includes(opts.deliverMode)) {
2492
+ return void out(c.red("--deliver-mode must be always, on-output, or on-error\n"));
2493
+ }
2474
2494
  const alertAfter = opts.alertAfter === undefined ? undefined : Number(opts.alertAfter);
2475
2495
  if (alertAfter !== undefined && (!Number.isInteger(alertAfter) || alertAfter < 1 || alertAfter > 1_000)) {
2476
2496
  return void out(c.red("--alert-after must be an integer from 1 to 1000\n"));
@@ -2486,6 +2506,7 @@ cronCmd
2486
2506
  cwd: process.cwd(),
2487
2507
  ...(opts.tz ? { tz: opts.tz } : {}),
2488
2508
  ...(opts.deliver ? { deliver: opts.deliver } : {}),
2509
+ ...(opts.deliverMode ? { deliverMode: opts.deliverMode } : {}),
2489
2510
  ...(alertAfter !== undefined ? { alertAfter } : {}),
2490
2511
  createdAt: Date.now(),
2491
2512
  });
@@ -2493,7 +2514,7 @@ cronCmd
2493
2514
  catch (error) {
2494
2515
  return void out(c.red(`${error instanceof Error ? error.message : String(error)}\n`));
2495
2516
  }
2496
- out(c.green(`✓ scheduled ${job.id}`) + c.dim(` · ${describeSchedule(sched)}${opts.tz ? ` @ ${opts.tz}` : ""} · ${job.mode}${opts.deliver ? ` · → ${opts.deliver}` : ""}${alertAfter !== undefined ? ` · alert ≥${alertAfter}` : ""} · cwd ${job.cwd}\n`));
2517
+ out(c.green(`✓ scheduled ${job.id}`) + c.dim(` · ${describeSchedule(sched)}${opts.tz ? ` @ ${opts.tz}` : ""} · ${job.mode}${opts.deliver ? ` · → ${opts.deliver} (${opts.deliverMode ?? "always"})` : ""}${alertAfter !== undefined ? ` · alert ≥${alertAfter}` : ""} · cwd ${job.cwd}\n`));
2497
2518
  if (!isInstalled())
2498
2519
  out(c.yellow("⚠ scheduler not installed yet — run `hara cron install` so jobs actually fire.\n"));
2499
2520
  });
@@ -3093,6 +3114,11 @@ program.action(async (opts) => {
3093
3114
  // to suppress everywhere.
3094
3115
  if (!opts.print && process.env.HARA_QUIET !== "1") {
3095
3116
  out(c.dim(activeProfileLine(__activeP)) + "\n");
3117
+ const expiryWarning = __activeP.kind === "gateway"
3118
+ ? deviceTokenExpiryWarning(__activeP.tokenExpiresAt)
3119
+ : null;
3120
+ if (expiryWarning)
3121
+ out(c.yellow(`⚠ ${expiryWarning}\n`));
3096
3122
  }
3097
3123
  if (__activeP.kind === "gateway" || cfg.provider === "hara-gateway") {
3098
3124
  void heartbeat(); // fleet visibility — fire-and-forget, never blocks startup
@@ -5,7 +5,7 @@
5
5
  // plane fleet visibility. Token + endpoint live in ~/.hara/org.json (0600).
6
6
  //
7
7
  // Protocol (what `hara-control` implements on the other end):
8
- // POST {gateway}/v1/enroll {code, device:{name,os,hara_version}} -> {device_token, device_id, model, base_url?}
8
+ // POST {gateway}/v1/enroll {code, device:{name,os,hara_version}} -> {device_token, device_id, model, base_url?, expires_at?}
9
9
  // POST {gateway}/v1/heartbeat Bearer <device_token> {device_id, name, os, hara_version} -> 200/204
10
10
  // GET {gateway}/v1/roles Bearer <device_token> -> {version, org_policy, roles:[…]} (B3 digital-employee push-down)
11
11
  // POST {gateway}/v1/chat/completions (OpenAI-compatible; the normal agent traffic, Bearer <device_token>)
@@ -45,6 +45,7 @@ export function loadEnrollment() {
45
45
  model: ap.defaultModel || "",
46
46
  baseURL: ap.baseURL,
47
47
  enrolledAt: ap.enrolledAt || new Date().toISOString(),
48
+ expiresAt: ap.tokenExpiresAt,
48
49
  };
49
50
  }
50
51
  }
@@ -70,6 +71,14 @@ export function parseEnrollResponse(gatewayUrl, j, now) {
70
71
  const deviceToken = (j.device_token ?? j.deviceToken);
71
72
  if (!deviceToken)
72
73
  throw new Error("enroll response missing device_token");
74
+ const rawExpiresAt = j.expires_at ?? j.expiresAt;
75
+ let expiresAt;
76
+ if (rawExpiresAt !== undefined && rawExpiresAt !== null) {
77
+ if (typeof rawExpiresAt !== "string" || !Number.isFinite(Date.parse(rawExpiresAt))) {
78
+ throw new Error("enroll response contains an invalid expires_at");
79
+ }
80
+ expiresAt = new Date(rawExpiresAt).toISOString();
81
+ }
73
82
  return {
74
83
  gatewayUrl: gatewayUrl.replace(/\/$/, ""),
75
84
  deviceToken,
@@ -77,8 +86,36 @@ export function parseEnrollResponse(gatewayUrl, j, now) {
77
86
  model: String(j.model ?? ""),
78
87
  baseURL: (j.base_url ?? j.baseURL),
79
88
  enrolledAt: now,
89
+ expiresAt,
80
90
  };
81
91
  }
92
+ /** Legacy control planes did not advertise token expiry, so absence remains compatible. New control
93
+ * planes provide it and the CLI can fail early with an actionable re-enrollment message. */
94
+ export function deviceTokenExpired(expiresAt, now = new Date()) {
95
+ if (!expiresAt)
96
+ return false;
97
+ const expiryMs = Date.parse(expiresAt);
98
+ // A present-but-corrupt lifecycle boundary must not silently become a legacy non-expiring token.
99
+ return !Number.isFinite(expiryMs) || expiryMs <= now.getTime();
100
+ }
101
+ /** Only warn near the boundary; healthy week-long tokens should not add startup noise. */
102
+ export function deviceTokenExpiryWarning(expiresAt, now = new Date()) {
103
+ if (!expiresAt)
104
+ return null;
105
+ const expiryMs = Date.parse(expiresAt);
106
+ if (!Number.isFinite(expiryMs))
107
+ return "organization access expiry is unreadable; re-enroll this profile";
108
+ const remainingMs = expiryMs - now.getTime();
109
+ if (remainingMs <= 0)
110
+ return "organization access expired; re-enroll this profile before running a task";
111
+ if (remainingMs > 24 * 60 * 60_000)
112
+ return null;
113
+ const remainingMinutes = Math.max(1, Math.ceil(remainingMs / 60_000));
114
+ const remaining = remainingMinutes < 60
115
+ ? `${remainingMinutes}m`
116
+ : `${Math.ceil(remainingMinutes / 60)}h`;
117
+ return `organization access expires in ${remaining}; ask your admin for a new enrollment code`;
118
+ }
82
119
  /** Exchange a one-time code for a device token at the gateway, persist it, and return the Enrollment. */
83
120
  export async function enrollDevice(gatewayUrl, code, signal) {
84
121
  const base = gatewayUrl.replace(/\/$/, "");
@@ -98,6 +98,7 @@ function readDefaultOrgFromOrgJson(e) {
98
98
  defaultModel,
99
99
  availableModels: defaultModel ? [defaultModel] : [],
100
100
  enrolledAt: e.enrolledAt || new Date().toISOString(),
101
+ tokenExpiresAt: typeof e.expiresAt === "string" ? e.expiresAt : undefined,
101
102
  };
102
103
  }
103
104
  /** First-time migration. Idempotent — running again is a no-op (profiles.json already present). */
@@ -24,7 +24,8 @@
24
24
  // settings.providers.list {} → redacted provider catalog + current profile state
25
25
  // settings.providers.test {provider,model,…} → {ok,models,error?} (credential is ephemeral)
26
26
  // settings.providers.save {provider,model,…} → redacted state (credential is never returned)
27
- // automation.add {name,schedule,task,mode?,cwd?,tz?} → {id,name,schedule}
27
+ // automation.add {name,schedule,task,mode?,cwd?,tz?,deliver?,deliverMode?,alertAfter?}
28
+ // → {id,name,schedule}
28
29
  // automation.toggle {id,enabled} → {id,enabled}
29
30
  // automation.delete {id} → {id,deleted}
30
31
  // session.rename {sessionId,title} → {sessionId,title}
@@ -25,6 +25,7 @@ import { listInstalled, enabledPlugins, setPluginEnabled, panelsForProject } fro
25
25
  import { loadSkillIndex, loadSkillBody } from "../skills/skills.js";
26
26
  import { loadJobs, addJob, removeJob, setEnabled } from "../cron/store.js";
27
27
  import { parseSchedule, describeSchedule } from "../cron/schedule.js";
28
+ import { parseDeliver } from "../cron/deliver.js";
28
29
  import { loadTasks } from "../tools/task.js";
29
30
  import { listPending, resolvePending } from "../gateway/flows-pending.js";
30
31
  import { disposeTodoScope, onTodosChange, restoreTodos, serializeTodos } from "../tools/todo.js";
@@ -1034,7 +1035,20 @@ export async function startServe(opts, deps) {
1034
1035
  // The automation timeline's data: cron jobs with their last outcome, plus this machine's
1035
1036
  // automated sessions (source=cron/gateway) so the desktop can render results and "continue
1036
1037
  // as conversation". Read-only.
1037
- const jobs = loadJobs().map((j) => ({ id: j.id, name: j.name, mode: j.mode, cwd: j.cwd, enabled: j.enabled, deliver: j.deliver, lastRunAt: j.lastRunAt, lastStatus: j.lastStatus, lastError: j.lastError, schedule: describeSchedule(j.schedule) }));
1038
+ const jobs = loadJobs().map((j) => ({
1039
+ id: j.id,
1040
+ name: j.name,
1041
+ mode: j.mode,
1042
+ cwd: j.cwd,
1043
+ enabled: j.enabled,
1044
+ deliver: j.deliver,
1045
+ deliverMode: j.deliverMode ?? "always",
1046
+ alertAfter: j.alertAfter ?? 3,
1047
+ lastRunAt: j.lastRunAt,
1048
+ lastStatus: j.lastStatus,
1049
+ lastError: j.lastError,
1050
+ schedule: describeSchedule(j.schedule),
1051
+ }));
1038
1052
  const automated = hub.list().filter((m) => m.source === "cron" || m.source === "gateway").map((m) => ({ id: m.id, title: m.title, cwd: m.cwd, source: m.source, sourceName: m.sourceName, updatedAt: m.updatedAt }));
1039
1053
  return reply(rpcResult(id, { jobs, sessions: automated }));
1040
1054
  }
@@ -1066,6 +1080,24 @@ export async function startServe(opts, deps) {
1066
1080
  const sched = parseSchedule(p.schedule, Date.now());
1067
1081
  if ("error" in sched)
1068
1082
  return reply(rpcError(id, ERR.PARAMS, `bad schedule: ${sched.error}`));
1083
+ const deliver = typeof p.deliver === "string" && p.deliver ? p.deliver : undefined;
1084
+ if (deliver) {
1085
+ const parsed = parseDeliver(deliver);
1086
+ if ("error" in parsed)
1087
+ return reply(rpcError(id, ERR.PARAMS, parsed.error));
1088
+ }
1089
+ const deliverMode = typeof p.deliverMode === "string" && p.deliverMode
1090
+ ? p.deliverMode
1091
+ : undefined;
1092
+ if (deliverMode && !deliver)
1093
+ return reply(rpcError(id, ERR.PARAMS, "deliverMode requires deliver"));
1094
+ if (deliverMode && !["always", "on-output", "on-error"].includes(deliverMode)) {
1095
+ return reply(rpcError(id, ERR.PARAMS, "deliverMode must be always, on-output, or on-error"));
1096
+ }
1097
+ const alertAfter = p.alertAfter === undefined ? undefined : Number(p.alertAfter);
1098
+ if (alertAfter !== undefined && (!Number.isInteger(alertAfter) || alertAfter < 1 || alertAfter > 1_000)) {
1099
+ return reply(rpcError(id, ERR.PARAMS, "alertAfter must be an integer from 1 to 1000"));
1100
+ }
1069
1101
  const job = addJob({
1070
1102
  name: p.name.slice(0, 60),
1071
1103
  schedule: sched,
@@ -1073,6 +1105,9 @@ export async function startServe(opts, deps) {
1073
1105
  mode: ["print", "org", "command"].includes(p.mode) ? p.mode : "print",
1074
1106
  cwd: typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd,
1075
1107
  ...(typeof p.tz === "string" && p.tz ? { tz: p.tz } : {}),
1108
+ ...(deliver ? { deliver } : {}),
1109
+ ...(deliverMode ? { deliverMode: deliverMode } : {}),
1110
+ ...(alertAfter !== undefined ? { alertAfter } : {}),
1076
1111
  createdAt: Date.now(),
1077
1112
  });
1078
1113
  return reply(rpcResult(id, { id: job.id, name: job.name, schedule: describeSchedule(job.schedule) }));
@@ -42,8 +42,9 @@ registerTool({
42
42
  "something on a time basis. action=add needs `schedule` (cron expr \"0 9 * * *\" · \"every 30m\" · \"in 2h\" · ISO time) " +
43
43
  "and `task`. Optional: `name`; `mode` — \"print\" (default: run task as a hara prompt), \"org\" (role-routed), or " +
44
44
  "\"command\" (run task as a plain SHELL COMMAND — deterministic, no agent, no tokens; prefer it for fixed scripts); " +
45
- "`tz` (IANA, e.g. Asia/Shanghai, for cron exprs); `deliver` (push each run's result: telegram:<chatId> | " +
46
- "feishu:<chatId> | webhook:<url>); `alertAfter` (1..1000 consecutive failures, default 3). Other actions: " +
45
+ "`tz` (IANA, e.g. Asia/Shanghai, for cron exprs); `deliver` (telegram:<chatId> | feishu:<chatId> | " +
46
+ "weixin:<peerId> | webhook:<url>); `deliverMode` always (default), on-output (stdout non-empty), or " +
47
+ "on-error (failed runs only); `alertAfter` (1..1000 consecutive failures, default 3). Other actions: " +
47
48
  "list · remove · enable · disable · run (fire now), with `id`. " +
48
49
  "Jobs fire via the OS scheduler even when hara isn't running.",
49
50
  input_schema: {
@@ -55,7 +56,8 @@ registerTool({
55
56
  name: { type: "string" },
56
57
  mode: { type: "string", enum: ["print", "org", "command"] },
57
58
  tz: { type: "string", description: "IANA timezone for cron exprs" },
58
- deliver: { type: "string", description: "telegram:<chatId> | feishu:<chatId> | webhook:<url>" },
59
+ deliver: { type: "string", description: "telegram:<chatId> | feishu:<chatId> | weixin:<peerId> | webhook:<url>" },
60
+ deliverMode: { type: "string", enum: ["always", "on-output", "on-error"] },
59
61
  alertAfter: { type: "integer", minimum: 1, maximum: 1000, description: "consecutive failures before 🚨 (default 3)" },
60
62
  id: { type: "string", description: "job id (or unique prefix) for remove/enable/disable/run" },
61
63
  },
@@ -90,7 +92,7 @@ registerTool({
90
92
  .map((j) => {
91
93
  const now = Date.now();
92
94
  const next = nextRun(j, now);
93
- return `${j.id} · ${j.enabled ? "on " : "OFF"} · ${j.name} · ${describeSchedule(j.schedule)}${j.tz ? ` @ ${j.tz}` : ""} · mode ${j.mode}${j.deliver ? ` · → ${j.deliver}` : ""} · next ${fmtNext(next, now)} · last ${status(j)}${j.consecutiveErrors ? ` (${j.consecutiveErrors}✗)` : ""}`;
95
+ return `${j.id} · ${j.enabled ? "on " : "OFF"} · ${j.name} · ${describeSchedule(j.schedule)}${j.tz ? ` @ ${j.tz}` : ""} · mode ${j.mode}${j.deliver ? ` · → ${j.deliver} (${j.deliverMode ?? "always"})` : ""} · next ${fmtNext(next, now)} · last ${status(j)}${j.consecutiveErrors ? ` (${j.consecutiveErrors}✗)` : ""}`;
94
96
  })
95
97
  .join("\n");
96
98
  }
@@ -117,6 +119,12 @@ registerTool({
117
119
  if ("error" in d)
118
120
  return `Error: ${d.error}`;
119
121
  }
122
+ const deliverMode = input.deliverMode === undefined ? undefined : String(input.deliverMode);
123
+ if (deliverMode && !deliver)
124
+ return "Error: `deliverMode` requires `deliver`.";
125
+ if (deliverMode && !["always", "on-output", "on-error"].includes(deliverMode)) {
126
+ return "Error: `deliverMode` must be always, on-output, or on-error.";
127
+ }
120
128
  const alertAfter = input.alertAfter === undefined ? undefined : Number(input.alertAfter);
121
129
  if (alertAfter !== undefined && (!Number.isInteger(alertAfter) || alertAfter < 1 || alertAfter > 1_000)) {
122
130
  return "Error: `alertAfter` must be an integer from 1 to 1000.";
@@ -137,6 +145,7 @@ registerTool({
137
145
  cwd: ctx.cwd,
138
146
  ...(tz ? { tz } : {}),
139
147
  ...(deliver ? { deliver } : {}),
148
+ ...(deliverMode ? { deliverMode: deliverMode } : {}),
140
149
  ...(alertAfter !== undefined ? { alertAfter } : {}),
141
150
  createdAt: Date.now(),
142
151
  });
@@ -145,7 +154,7 @@ registerTool({
145
154
  return `Error: ${error instanceof Error ? error.message : String(error)}`;
146
155
  }
147
156
  const warn = isInstalled() ? "" : "\n⚠ The OS scheduler isn't installed yet — tell the user to run `hara cron install` once, or jobs won't fire.";
148
- return `✓ scheduled ${job.id} · ${describeSchedule(sched)}${tz ? ` @ ${tz}` : ""} · mode ${mode}${deliver ? ` · → ${deliver}` : ""}${alertAfter !== undefined ? ` · alert ≥${alertAfter}` : ""} · next ${fmt(nextRun(job, Date.now()))}${warn}`;
157
+ return `✓ scheduled ${job.id} · ${describeSchedule(sched)}${tz ? ` @ ${tz}` : ""} · mode ${mode}${deliver ? ` · → ${deliver} (${deliverMode ?? "always"})` : ""}${alertAfter !== undefined ? ` · alert ≥${alertAfter}` : ""} · next ${fmt(nextRun(job, Date.now()))}${warn}`;
149
158
  }
150
159
  // id-based actions
151
160
  const idArg = String(input.id ?? "").trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.127.0",
3
+ "version": "0.127.2",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"