@nanhara/hara 0.107.0 → 0.108.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,26 @@ 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.108.0 — cron grows up: chat-native scheduling, delivery, a deterministic lane
9
+
10
+ Distilled from a three-way study of openclaw (the production-grade scheduler running our company),
11
+ hermes (the best creation UX), and hara's own minimal cron:
12
+
13
+ - **`cronjob` model tool** (hermes parity): "every morning at 9, check X and send me a summary" in
14
+ chat just works — one action-style tool (add/list/remove/enable/disable/run), approval-gated like
15
+ any exec. **Recursion guard**: sessions spawned BY a cron job can't schedule more jobs.
16
+ - **`--command` deterministic lane**: run the task as a plain shell command — no agent, no tokens,
17
+ exact exit codes. Fixed scripts stop burning an LLM round just to type `python script.py`.
18
+ - **Result delivery** (openclaw/hermes parity): `--deliver telegram:<chatId> | feishu:<chatId> |
19
+ webhook:<url>` pushes each run's outcome (+ output tail) to a channel — no gateway process needed,
20
+ adapters fire one-shot from the same env vars.
21
+ - **Failure alerts**: 3 consecutive failures (per-job `alertAfter`) → one 🚨 on the deliver channel,
22
+ 6h cooldown, streak resets on success.
23
+ - **Per-job timezone**: `--tz Asia/Shanghai` pins cron expressions to a wall clock (IANA, validated
24
+ at add time) instead of whatever the machine happens to be set to.
25
+
26
+ Still the lightest of the three: no daemon — the OS (launchd/crontab) ticks `hara cron`.
27
+
8
28
  ## 0.107.0 — interjection triage: the model is the scheduler, the todo list is the queue
9
29
 
10
30
  - **Mid-task messages get triaged, not blindly folded in.** Typing while hara works always reached
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  > with routing boundaries, a dispatcher, a single source-of-truth data layer, human-in-the-loop
9
9
  > approvals, and cron autonomy.
10
10
 
11
- 🚧 **v0.89** · TypeScript · local-first · Apache-2.0
11
+ [![npm](https://img.shields.io/npm/v/%40nanhara%2Fhara?label=npm&color=cb3837)](https://www.npmjs.com/package/@nanhara/hara) · TypeScript · local-first · Apache-2.0
12
12
 
13
13
  **Highlights**
14
14
  - **An org, not just an agent** — `hara org "<task>"` routes work to the role that *owns* it; `hara plan "<task>"` decomposes a task into a verified DAG of atoms (frame → atomize → sequence → execute → **verify gate**), and `hara plan --parallel` runs independent atoms concurrently.
@@ -17,6 +17,9 @@
17
17
  - **Persistent memory + self-evolution** — `memory_*` tools over global/project `MEMORY.md`; the agent recalls before acting, **proactively saves** durable facts, and grows its own playbooks (a lexical guard screens what it writes). Inspect/consolidate it with **`hara memory show`** and **`hara memory distill`** (promote recent daily logs → durable memory). Lexical-first by design — semantic search is opt-in, never required.
18
18
  - **Multi-provider, all streamed** — Anthropic (Claude) or any OpenAI-compatible endpoint (Qwen/DashScope, GLM, Kimi, OpenAI) with live Markdown + visible reasoning.
19
19
  - **Delegate to other agents** — the **`external_agent`** tool hands a self-contained task to **Claude Code** or **Codex** running headless, and returns the result — so you pick the best engine per task. Gated by approval, trust-tiered, and never exposed to read-only sub-agents.
20
+ - **Honest under a slow network** — a live "waiting for the model… Ns" status, a stall watchdog that
21
+ auto-fails-over instead of hanging, big pastes folding to a token, and a startup update notice — the
22
+ terminal never feels dead.
20
23
  - **Solid coding core** — `edit_file` / `apply_patch` (atomic multi-file) with colored diffs · `grep`/`glob`/`ls`/`codebase_search` (lexical + optional semantic search over the repo) /`web_fetch` · fuzzy `@file` · `/undo` · `/compact` · **Esc-to-interrupt** · parallel sub-agents · MCP client · macOS sandbox.
21
24
 
22
25
  Track it: https://github.com/hara-cli/hara · https://hara.run
@@ -0,0 +1,59 @@
1
+ // Cron result delivery — push a finished job's output to a chat channel (openclaw/hermes parity),
2
+ // WITHOUT needing the gateway process: adapters are constructed one-shot from the same env vars the
3
+ // gateway uses, send once, and are dropped. Spec format: "<target>:<id>" —
4
+ // telegram:<chatId> (HARA_TELEGRAM_TOKEN)
5
+ // feishu:<chatId> (HARA_FEISHU_APP_ID + HARA_FEISHU_APP_SECRET)
6
+ // webhook:<url> (plain POST {name,status,text} JSON — for anything else)
7
+ // WeChat is intentionally absent: its transport needs the long-lived gateway session, so a one-shot
8
+ // cron process can't speak it — use feishu/telegram/webhook for cron delivery.
9
+ // Adapters are imported LAZILY so the (heavy) SDKs never load unless a job actually delivers.
10
+ /** Parse a `--deliver` spec; error string on anything unsupported (listing what IS supported). */
11
+ export function parseDeliver(spec) {
12
+ const i = spec.indexOf(":");
13
+ if (i <= 0)
14
+ return { error: `bad deliver spec "${spec}" — use telegram:<chatId>, feishu:<chatId>, or webhook:<url>` };
15
+ const platform = spec.slice(0, i).toLowerCase();
16
+ const to = spec.slice(i + 1).trim();
17
+ if (!to)
18
+ return { error: `deliver spec "${spec}" is missing a target after ":"` };
19
+ if (platform === "telegram" || platform === "feishu" || platform === "webhook")
20
+ return { platform, to };
21
+ return { error: `unsupported deliver platform "${platform}" — supported: telegram, feishu, webhook (WeChat needs the live gateway)` };
22
+ }
23
+ /** Send `text` to the target. Returns null on success, or an error string (never throws — cron
24
+ * delivery is best-effort and must not kill the tick). */
25
+ export async function deliverResult(spec, text) {
26
+ const t = parseDeliver(spec);
27
+ if ("error" in t)
28
+ return t.error;
29
+ try {
30
+ if (t.platform === "webhook") {
31
+ const r = await fetch(t.to, {
32
+ method: "POST",
33
+ headers: { "content-type": "application/json" },
34
+ body: JSON.stringify({ source: "hara-cron", text }),
35
+ signal: AbortSignal.timeout(15_000),
36
+ });
37
+ return r.ok ? null : `webhook ${r.status}`;
38
+ }
39
+ if (t.platform === "telegram") {
40
+ const token = process.env.HARA_TELEGRAM_TOKEN;
41
+ if (!token)
42
+ return "HARA_TELEGRAM_TOKEN not set";
43
+ const { telegramAdapter } = await import("../gateway/telegram.js");
44
+ await telegramAdapter(token).send(t.to, text);
45
+ return null;
46
+ }
47
+ // feishu
48
+ const appId = process.env.HARA_FEISHU_APP_ID;
49
+ const appSecret = process.env.HARA_FEISHU_APP_SECRET;
50
+ if (!appId || !appSecret)
51
+ return "HARA_FEISHU_APP_ID / HARA_FEISHU_APP_SECRET not set";
52
+ const { feishuAdapter } = await import("../gateway/feishu.js");
53
+ await feishuAdapter(appId, appSecret).send(t.to, text);
54
+ return null;
55
+ }
56
+ catch (e) {
57
+ return `delivery failed: ${e instanceof Error ? e.message : String(e)}`;
58
+ }
59
+ }
@@ -2,9 +2,10 @@
2
2
  // session IS the agent — same model as openclaw/hermes). Meant to be invoked every minute by the OS
3
3
  // scheduler (see install.ts). A lock file prevents overlapping ticks from double-firing a slow job.
4
4
  import { spawn } from "node:child_process";
5
+ import { deliverResult } from "./deliver.js";
5
6
  import { existsSync, mkdirSync, writeFileSync, rmSync, statSync, appendFileSync, readFileSync } from "node:fs";
6
7
  import { join } from "node:path";
7
- import { loadJobs, recordRun, cronDir, logPath } from "./store.js";
8
+ import { loadJobs, recordRun, recordAlert, findJob, cronDir, logPath } from "./store.js";
8
9
  import { isDue } from "./schedule.js";
9
10
  /** Jobs that are enabled AND due at `nowMs` (pure — for the tick and for testing). */
10
11
  export function dueJobs(jobs, nowMs) {
@@ -39,7 +40,6 @@ function capLog(log) {
39
40
  export function runJobOnce(job) {
40
41
  return new Promise((resolve) => {
41
42
  mkdirSync(join(cronDir(), "logs"), { recursive: true });
42
- const args = job.mode === "org" ? ["org", job.task] : ["-p", job.task, "--approval", "full-auto"];
43
43
  const log = logPath(job.id);
44
44
  capLog(log);
45
45
  try {
@@ -48,9 +48,17 @@ export function runJobOnce(job) {
48
48
  catch {
49
49
  /* logging is best-effort */
50
50
  }
51
+ // mode "command" = the deterministic lane (hermes-style): run the task as a plain shell command —
52
+ // no agent, no tokens, exact. The other modes spawn a fresh hara session. Either way HARA_CRON=1
53
+ // marks the child so cron-run sessions can't create more cron jobs (recursion guard).
51
54
  const self = selfArgv();
52
- const child = spawn(self[0], [...self.slice(1), ...args], { cwd: job.cwd, env: process.env });
55
+ const [cmd, argv] = job.mode === "command"
56
+ ? ["bash", ["-lc", job.task]]
57
+ : [self[0], [...self.slice(1), ...(job.mode === "org" ? ["org", job.task] : ["-p", job.task, "--approval", "full-auto"])]];
58
+ const child = spawn(cmd, argv, { cwd: job.cwd, env: { ...process.env, HARA_CRON: "1" } });
59
+ let tail = ""; // last few KB, for chat delivery (the full stream goes to the log file)
53
60
  const append = (d) => {
61
+ tail = (tail + d.toString()).slice(-4_000);
54
62
  try {
55
63
  appendFileSync(log, d);
56
64
  }
@@ -60,10 +68,38 @@ export function runJobOnce(job) {
60
68
  };
61
69
  child.stdout.on("data", append);
62
70
  child.stderr.on("data", append);
63
- child.on("error", (e) => resolve({ ok: false, error: String(e?.message ?? e) }));
64
- child.on("close", (code) => resolve(code === 0 ? { ok: true } : { ok: false, error: `exited ${code}` }));
71
+ child.on("error", (e) => resolve({ ok: false, error: String(e?.message ?? e), output: tail }));
72
+ child.on("close", (code) => resolve(code === 0 ? { ok: true, output: tail } : { ok: false, error: `exited ${code}`, output: tail }));
65
73
  });
66
74
  }
75
+ /** After a run: push the outcome to the job's deliver channel, and — on repeated failures — a 🚨 alert
76
+ * (threshold `alertAfter` (default 3), 6h cooldown). Best-effort: a delivery error only hits the log.
77
+ * `deliver` + `nowMs` injectable for tests. */
78
+ export async function deliverOutcome(job, r, deliver = deliverResult, nowMs = Date.now()) {
79
+ if (!job.deliver)
80
+ return;
81
+ const snippet = (r.output ?? "").trim().slice(-1_500);
82
+ const head = r.ok ? `⏰ ${job.name} ✓` : `⏰ ${job.name} ✗ ${r.error ?? "failed"}`;
83
+ const err = await deliver(job.deliver, snippet ? `${head}\n${snippet}` : head);
84
+ if (err) {
85
+ try {
86
+ appendFileSync(logPath(job.id), `\n[deliver] ${err}\n`);
87
+ }
88
+ catch {
89
+ /* best-effort */
90
+ }
91
+ }
92
+ if (!r.ok) {
93
+ const fresh = findJob(job.id); // recordRun already bumped consecutiveErrors
94
+ const count = fresh?.consecutiveErrors ?? 0;
95
+ const threshold = job.alertAfter ?? 3;
96
+ const cooled = !fresh?.lastAlertAt || nowMs - fresh.lastAlertAt > 6 * 3_600_000;
97
+ if (count >= threshold && cooled) {
98
+ await deliver(job.deliver, `🚨 ${job.name} has failed ${count}× in a row — latest: ${r.error ?? "unknown"}. Log: ${logPath(job.id)}`);
99
+ recordAlert(job.id, nowMs);
100
+ }
101
+ }
102
+ }
67
103
  /** One scheduler tick: run every due job (sequentially), recording each outcome. Lock-guarded so an
68
104
  * overlapping tick (launchd fires every 60s; a job may run longer) skips instead of double-firing.
69
105
  * `run` is injectable for tests. Returns the job ids that ran. */
@@ -97,6 +133,7 @@ export async function runTick(nowMs, run = runJobOnce) {
97
133
  for (const job of due) {
98
134
  const r = await run(job);
99
135
  recordRun(job.id, nowMs, r.ok ? "ok" : "error", r.error);
136
+ await deliverOutcome(job, r);
100
137
  ran.push(job.id);
101
138
  }
102
139
  return { ran };
@@ -67,15 +67,56 @@ export function parseCron(expr) {
67
67
  return null;
68
68
  return { m, h, dom, mon, dow, domStar: f[2] === "*", dowStar: f[4] === "*" };
69
69
  }
70
- /** Does `expr` fire at the given local minute? Uses the Vixie day-of-month/day-of-week OR rule. */
71
- export function cronMatches(expr, d) {
70
+ /** Offset (ms) of IANA zone `tz` from UTC at instant `atMs`. Cached per (tz, hour) offsets only move
71
+ * at DST transitions, so hour-bucket caching keeps nextRun's minute-by-minute scan fast. */
72
+ const offsetCache = new Map();
73
+ export function zoneOffsetMs(tz, atMs) {
74
+ const key = `${tz}:${Math.floor(atMs / 3_600_000)}`;
75
+ const hit = offsetCache.get(key);
76
+ if (hit !== undefined)
77
+ return hit;
78
+ const fmt = new Intl.DateTimeFormat("en-US", {
79
+ timeZone: tz,
80
+ year: "numeric", month: "2-digit", day: "2-digit",
81
+ hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
82
+ });
83
+ const p = {};
84
+ for (const part of fmt.formatToParts(new Date(atMs)))
85
+ p[part.type] = part.value;
86
+ const asUtc = Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(p.hour) % 24, Number(p.minute), Number(p.second));
87
+ const off = asUtc - Math.floor(atMs / 1000) * 1000;
88
+ if (offsetCache.size > 10_000)
89
+ offsetCache.clear();
90
+ offsetCache.set(key, off);
91
+ return off;
92
+ }
93
+ /** Is `tz` a valid IANA timezone? (validated at job-add time so a typo fails loudly, not silently-local) */
94
+ export function validTz(tz) {
95
+ try {
96
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
97
+ return true;
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
103
+ /** Wall-clock parts of the instant — in `tz` when given (offset-shifted UTC getters), else local. */
104
+ function wallParts(d, tz) {
105
+ if (!tz)
106
+ return { min: d.getMinutes(), hour: d.getHours(), dom: d.getDate(), mon: d.getMonth() + 1, dow: d.getDay() };
107
+ const z = new Date(d.getTime() + zoneOffsetMs(tz, d.getTime()));
108
+ return { min: z.getUTCMinutes(), hour: z.getUTCHours(), dom: z.getUTCDate(), mon: z.getUTCMonth() + 1, dow: z.getUTCDay() };
109
+ }
110
+ /** Does `expr` fire at the given minute (in `tz` when set, else local)? Vixie dom/dow OR rule. */
111
+ export function cronMatches(expr, d, tz) {
72
112
  const p = parseCron(expr);
73
113
  if (!p)
74
114
  return false;
75
- if (!p.m.has(d.getMinutes()) || !p.h.has(d.getHours()) || !p.mon.has(d.getMonth() + 1))
115
+ const w = wallParts(d, tz);
116
+ if (!p.m.has(w.min) || !p.h.has(w.hour) || !p.mon.has(w.mon))
76
117
  return false;
77
- const domOk = p.dom.has(d.getDate());
78
- const dowOk = p.dow.has(d.getDay());
118
+ const domOk = p.dom.has(w.dom);
119
+ const dowOk = p.dow.has(w.dow);
79
120
  if (p.domStar && p.dowStar)
80
121
  return true; // both unrestricted → any day
81
122
  if (!p.domStar && !p.dowStar)
@@ -117,7 +158,7 @@ export function describeSchedule(sched) {
117
158
  export function isDue(job, nowMs) {
118
159
  const s = job.schedule;
119
160
  if (s.kind === "cron") {
120
- if (!cronMatches(s.expr, new Date(nowMs)))
161
+ if (!cronMatches(s.expr, new Date(nowMs), job.tz))
121
162
  return false;
122
163
  return job.lastRunAt === undefined || Math.floor(job.lastRunAt / 60_000) < Math.floor(nowMs / 60_000);
123
164
  }
@@ -140,7 +181,7 @@ export function nextRun(job, fromMs) {
140
181
  return null;
141
182
  const start = Math.floor(fromMs / 60_000) * 60_000 + 60_000; // next minute boundary
142
183
  for (let t = start, i = 0; i < 366 * 24 * 60; t += 60_000, i++) {
143
- if (cronMatches(s.expr, new Date(t)))
184
+ if (cronMatches(s.expr, new Date(t), job.tz))
144
185
  return t;
145
186
  }
146
187
  return null;
@@ -83,5 +83,15 @@ export function recordRun(id, at, status, error) {
83
83
  job.lastRunAt = at;
84
84
  job.lastStatus = status;
85
85
  job.lastError = error;
86
+ job.consecutiveErrors = status === "error" ? (job.consecutiveErrors ?? 0) + 1 : 0;
87
+ saveJobs(jobs);
88
+ }
89
+ /** Stamp the failure-alert time (cooldown gate). */
90
+ export function recordAlert(id, at) {
91
+ const jobs = loadJobs();
92
+ const job = jobs.find((x) => x.id === id);
93
+ if (!job)
94
+ return;
95
+ job.lastAlertAt = at;
86
96
  saveJobs(jobs);
87
97
  }
package/dist/index.js CHANGED
@@ -33,7 +33,8 @@ import { userTurnPreviews, rewindTo } from "./agent/rewind.js";
33
33
  import { checkpoint, listCheckpoints, restoreCheckpoint } from "./checkpoints.js";
34
34
  import { mapLimit, maxParallel } from "./concurrency.js";
35
35
  import { parseVerdict, captureChanges, reviewPrompt, fixPrompt, REVIEWER_SYSTEM, isTreeClean, stripCommitFence } from "./org/review-chain.js";
36
- import { parseSchedule, describeSchedule, nextRun } from "./cron/schedule.js";
36
+ import { parseSchedule, describeSchedule, nextRun, validTz } from "./cron/schedule.js";
37
+ import { parseDeliver } from "./cron/deliver.js";
37
38
  import { addJob, removeJob, setEnabled, resolveJob, loadJobs, recordRun, logPath } from "./cron/store.js";
38
39
  import { runTick, runJobOnce, selfArgv } from "./cron/runner.js";
39
40
  import { installScheduler, uninstallScheduler, isInstalled } from "./cron/install.js";
@@ -74,6 +75,7 @@ import "./tools/todo.js"; // register todo_write (inline task checklist)
74
75
  import "./tools/send.js"; // register send_file (self-gates on HARA_GATEWAY — pushes a file to the chat)
75
76
  import "./tools/external_agent.js"; // register external_agent (delegate to claude-code / codex headless)
76
77
  import "./tools/ask_user.js"; // register ask_user (pause mid-turn to ask the user a structured question)
78
+ import "./tools/cron.js"; // register cronjob (model-facing scheduler — "remind me every morning" just works)
77
79
  import { computerBackends } from "./tools/computer.js"; // register the computer tool + expose the backend probe
78
80
  const here = dirname(fileURLToPath(import.meta.url));
79
81
  // Version: from a build-time define in the compiled single-binary (no package.json on its virtual FS),
@@ -1737,13 +1739,37 @@ cronCmd
1737
1739
  .description('schedule a task — schedule = cron expr ("0 9 * * *"), "every 30m", "in 2h", or an ISO timestamp')
1738
1740
  .option("--name <name>", "a label for the job")
1739
1741
  .option("--org", "run via `hara org` (role routing + review) instead of a plain `hara -p` prompt")
1742
+ .option("--command", "run the task as a plain SHELL COMMAND — deterministic, no agent, no tokens")
1743
+ .option("--tz <zone>", 'IANA timezone for cron exprs (e.g. "Asia/Shanghai"); default = local time')
1744
+ .option("--deliver <spec>", "push each run's result: telegram:<chatId> | feishu:<chatId> | webhook:<url>")
1740
1745
  .action((schedule, taskParts, opts) => {
1741
1746
  const task = taskParts.join(" ");
1747
+ if (opts.org && opts.command)
1748
+ return void out(c.red("--org and --command are mutually exclusive\n"));
1742
1749
  const sched = parseSchedule(schedule, Date.now());
1743
1750
  if ("error" in sched)
1744
1751
  return void out(c.red(sched.error + "\n"));
1745
- const job = addJob({ name: opts.name || task.slice(0, 48), schedule: sched, task, mode: opts.org ? "org" : "print", cwd: process.cwd(), createdAt: Date.now() });
1746
- out(c.green(`✓ scheduled ${job.id}`) + c.dim(` · ${describeSchedule(sched)} · ${job.mode} · cwd ${job.cwd}\n`));
1752
+ if (opts.tz && !validTz(opts.tz))
1753
+ return void out(c.red(`invalid timezone "${opts.tz}" (IANA name, e.g. Asia/Shanghai)\n`));
1754
+ if (opts.tz && sched.kind !== "cron")
1755
+ return void out(c.red("--tz only applies to cron expressions\n"));
1756
+ if (opts.deliver) {
1757
+ const d = parseDeliver(opts.deliver);
1758
+ if ("error" in d)
1759
+ return void out(c.red(d.error + "\n"));
1760
+ }
1761
+ const mode = opts.command ? "command" : opts.org ? "org" : "print";
1762
+ const job = addJob({
1763
+ name: opts.name || task.slice(0, 48),
1764
+ schedule: sched,
1765
+ task,
1766
+ mode,
1767
+ cwd: process.cwd(),
1768
+ ...(opts.tz ? { tz: opts.tz } : {}),
1769
+ ...(opts.deliver ? { deliver: opts.deliver } : {}),
1770
+ createdAt: Date.now(),
1771
+ });
1772
+ out(c.green(`✓ scheduled ${job.id}`) + c.dim(` · ${describeSchedule(sched)}${opts.tz ? ` @ ${opts.tz}` : ""} · ${job.mode}${opts.deliver ? ` · → ${opts.deliver}` : ""} · cwd ${job.cwd}\n`));
1747
1773
  if (!isInstalled())
1748
1774
  out(c.yellow("⚠ scheduler not installed yet — run `hara cron install` so jobs actually fire.\n"));
1749
1775
  });
@@ -0,0 +1,107 @@
1
+ // cronjob — the model-facing scheduler tool (hermes parity): "remind me every morning at 9" in chat
2
+ // just works, no CLI knowledge needed. One action-style tool (add/list/remove/enable/disable/run)
3
+ // instead of six. kind:"exec" so creating/removing jobs rides the normal approval gate.
4
+ //
5
+ // Recursion guard (hermes's rule): a session SPAWNED BY a cron job runs with HARA_CRON=1 and is
6
+ // refused here — a scheduled task must never schedule more tasks, or one bad prompt snowballs.
7
+ import { registerTool } from "./registry.js";
8
+ import { addJob, loadJobs, resolveJob, removeJob, setEnabled } from "../cron/store.js";
9
+ import { parseSchedule, describeSchedule, nextRun, validTz } from "../cron/schedule.js";
10
+ import { runJobOnce } from "../cron/runner.js";
11
+ import { parseDeliver } from "../cron/deliver.js";
12
+ import { isInstalled } from "../cron/install.js";
13
+ const fmt = (ms) => (ms ? new Date(ms).toLocaleString() : "—");
14
+ registerTool({
15
+ name: "cronjob",
16
+ description: "Manage the user's scheduled jobs (hara cron). Use when they ask to schedule, automate, or be reminded of " +
17
+ "something on a time basis. action=add needs `schedule` (cron expr \"0 9 * * *\" · \"every 30m\" · \"in 2h\" · ISO time) " +
18
+ "and `task`. Optional: `name`; `mode` — \"print\" (default: run task as a hara prompt), \"org\" (role-routed), or " +
19
+ "\"command\" (run task as a plain SHELL COMMAND — deterministic, no agent, no tokens; prefer it for fixed scripts); " +
20
+ "`tz` (IANA, e.g. Asia/Shanghai, for cron exprs); `deliver` (push each run's result: telegram:<chatId> | " +
21
+ "feishu:<chatId> | webhook:<url>). Other actions: list · remove · enable · disable · run (fire now), with `id`. " +
22
+ "Jobs fire via the OS scheduler even when hara isn't running.",
23
+ input_schema: {
24
+ type: "object",
25
+ properties: {
26
+ action: { type: "string", enum: ["add", "list", "remove", "enable", "disable", "run"] },
27
+ schedule: { type: "string", description: "for add" },
28
+ task: { type: "string", description: "for add — the prompt / org task / shell command" },
29
+ name: { type: "string" },
30
+ mode: { type: "string", enum: ["print", "org", "command"] },
31
+ tz: { type: "string", description: "IANA timezone for cron exprs" },
32
+ deliver: { type: "string", description: "telegram:<chatId> | feishu:<chatId> | webhook:<url>" },
33
+ id: { type: "string", description: "job id (or unique prefix) for remove/enable/disable/run" },
34
+ },
35
+ required: ["action"],
36
+ },
37
+ kind: "exec", // scheduling machinery on the user's machine — approval-gated like any exec
38
+ async run(input, ctx) {
39
+ if (process.env.HARA_CRON === "1")
40
+ return "Error: cron-run sessions cannot manage cron jobs (recursion guard — a scheduled task must not schedule more tasks).";
41
+ const action = String(input.action ?? "");
42
+ if (action === "list") {
43
+ const jobs = loadJobs();
44
+ if (!jobs.length)
45
+ return "No scheduled jobs.";
46
+ return jobs
47
+ .map((j) => {
48
+ const next = nextRun(j, Date.now());
49
+ return `${j.id} · ${j.enabled ? "on " : "OFF"} · ${j.name} · ${describeSchedule(j.schedule)}${j.tz ? ` @ ${j.tz}` : ""} · mode ${j.mode}${j.deliver ? ` · → ${j.deliver}` : ""} · next ${fmt(next)} · last ${j.lastStatus ?? "—"}${j.consecutiveErrors ? ` (${j.consecutiveErrors}✗)` : ""}`;
50
+ })
51
+ .join("\n");
52
+ }
53
+ if (action === "add") {
54
+ const scheduleStr = String(input.schedule ?? "").trim();
55
+ const task = String(input.task ?? "").trim();
56
+ if (!scheduleStr || !task)
57
+ return "Error: add needs `schedule` and `task`.";
58
+ const sched = parseSchedule(scheduleStr, Date.now());
59
+ if ("error" in sched)
60
+ return `Error: ${sched.error}`;
61
+ const tz = input.tz ? String(input.tz) : undefined;
62
+ if (tz && !validTz(tz))
63
+ return `Error: invalid timezone "${tz}" (IANA name, e.g. Asia/Shanghai).`;
64
+ if (tz && sched.kind !== "cron")
65
+ return "Error: `tz` only applies to cron expressions.";
66
+ const deliver = input.deliver ? String(input.deliver) : undefined;
67
+ if (deliver) {
68
+ const d = parseDeliver(deliver);
69
+ if ("error" in d)
70
+ return `Error: ${d.error}`;
71
+ }
72
+ const mode = input.mode === "org" || input.mode === "command" ? input.mode : "print";
73
+ const job = addJob({
74
+ name: input.name ? String(input.name) : task.slice(0, 48),
75
+ schedule: sched,
76
+ task,
77
+ mode,
78
+ cwd: ctx.cwd,
79
+ ...(tz ? { tz } : {}),
80
+ ...(deliver ? { deliver } : {}),
81
+ createdAt: Date.now(),
82
+ });
83
+ 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.";
84
+ return `✓ scheduled ${job.id} · ${describeSchedule(sched)}${tz ? ` @ ${tz}` : ""} · mode ${mode}${deliver ? ` · → ${deliver}` : ""} · next ${fmt(nextRun(job, Date.now()))}${warn}`;
85
+ }
86
+ // id-based actions
87
+ const idArg = String(input.id ?? "").trim();
88
+ if (!idArg)
89
+ return `Error: ${action} needs \`id\`.`;
90
+ const j = resolveJob(idArg);
91
+ if (j === "ambiguous")
92
+ return `Error: id "${idArg}" matches multiple jobs — use more characters.`;
93
+ if (!j)
94
+ return `Error: no job matching "${idArg}".`;
95
+ if (action === "remove")
96
+ return removeJob(j.id) ? `✓ removed ${j.id} (${j.name})` : "Error: remove failed.";
97
+ if (action === "enable" || action === "disable") {
98
+ setEnabled(j.id, action === "enable");
99
+ return `✓ ${j.id} ${action}d`;
100
+ }
101
+ if (action === "run") {
102
+ const r = await runJobOnce(j);
103
+ return r.ok ? `✓ ran ${j.id} — ok\n${(r.output ?? "").trim().slice(-800)}` : `✗ ${j.id} failed: ${r.error}\n${(r.output ?? "").trim().slice(-800)}`;
104
+ }
105
+ return `Error: unknown action "${action}".`;
106
+ },
107
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.107.0",
3
+ "version": "0.108.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"