@nanhara/hara 0.107.0 → 0.108.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 +31 -0
- package/README.md +4 -1
- package/dist/cron/deliver.js +59 -0
- package/dist/cron/runner.js +42 -5
- package/dist/cron/schedule.js +48 -7
- package/dist/cron/store.js +10 -0
- package/dist/index.js +29 -3
- package/dist/tools/cron.js +107 -0
- package/dist/tui/InputBox.js +11 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,37 @@ 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.1 — pasting no longer sends the message
|
|
9
|
+
|
|
10
|
+
- **A pasted newline is content, not "send".** Pasting multi-line text used to fire the message at
|
|
11
|
+
the first newline (any 1–2 line paste under 600 chars auto-submitted) — the classic "I pasted and
|
|
12
|
+
it sent before I could edit" bug. Now ANY paste containing a newline folds to a `[Paste #N +L lines]`
|
|
13
|
+
token and waits; only a real **Enter** sends it (the token expands to the full text on submit). This
|
|
14
|
+
is codex's paste-burst rule — Enter inside a paste is a newline, not a submit. A lone newline typed
|
|
15
|
+
at the prompt still sends, as before.
|
|
16
|
+
- (Slow paste *recognition* over a remote/SSH terminal is chunk-delivery latency — network-bound, not
|
|
17
|
+
something the client amplifies; local pastes arrive as one chunk and are instant.)
|
|
18
|
+
|
|
19
|
+
## 0.108.0 — cron grows up: chat-native scheduling, delivery, a deterministic lane
|
|
20
|
+
|
|
21
|
+
Distilled from a three-way study of openclaw (the production-grade scheduler running our company),
|
|
22
|
+
hermes (the best creation UX), and hara's own minimal cron:
|
|
23
|
+
|
|
24
|
+
- **`cronjob` model tool** (hermes parity): "every morning at 9, check X and send me a summary" in
|
|
25
|
+
chat just works — one action-style tool (add/list/remove/enable/disable/run), approval-gated like
|
|
26
|
+
any exec. **Recursion guard**: sessions spawned BY a cron job can't schedule more jobs.
|
|
27
|
+
- **`--command` deterministic lane**: run the task as a plain shell command — no agent, no tokens,
|
|
28
|
+
exact exit codes. Fixed scripts stop burning an LLM round just to type `python script.py`.
|
|
29
|
+
- **Result delivery** (openclaw/hermes parity): `--deliver telegram:<chatId> | feishu:<chatId> |
|
|
30
|
+
webhook:<url>` pushes each run's outcome (+ output tail) to a channel — no gateway process needed,
|
|
31
|
+
adapters fire one-shot from the same env vars.
|
|
32
|
+
- **Failure alerts**: 3 consecutive failures (per-job `alertAfter`) → one 🚨 on the deliver channel,
|
|
33
|
+
6h cooldown, streak resets on success.
|
|
34
|
+
- **Per-job timezone**: `--tz Asia/Shanghai` pins cron expressions to a wall clock (IANA, validated
|
|
35
|
+
at add time) instead of whatever the machine happens to be set to.
|
|
36
|
+
|
|
37
|
+
Still the lightest of the three: no daemon — the OS (launchd/crontab) ticks `hara cron`.
|
|
38
|
+
|
|
8
39
|
## 0.107.0 — interjection triage: the model is the scheduler, the todo list is the queue
|
|
9
40
|
|
|
10
41
|
- **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
|
-
|
|
11
|
+
[](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
|
+
}
|
package/dist/cron/runner.js
CHANGED
|
@@ -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
|
|
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 };
|
package/dist/cron/schedule.js
CHANGED
|
@@ -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
|
-
/**
|
|
71
|
-
|
|
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
|
-
|
|
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(
|
|
78
|
-
const dowOk = p.dow.has(
|
|
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;
|
package/dist/cron/store.js
CHANGED
|
@@ -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
|
-
|
|
1746
|
-
|
|
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/dist/tui/InputBox.js
CHANGED
|
@@ -404,15 +404,19 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
404
404
|
return;
|
|
405
405
|
}
|
|
406
406
|
if (input && !key.ctrl && !key.meta) {
|
|
407
|
-
// A
|
|
408
|
-
//
|
|
409
|
-
if (
|
|
410
|
-
|
|
407
|
+
// A lone newline delivered through `input` (some terminals send Enter this way instead of
|
|
408
|
+
// setting key.return) = the user pressed Enter → submit.
|
|
409
|
+
if (/^[\r\n]+$/.test(input)) {
|
|
410
|
+
submit(value);
|
|
411
411
|
return;
|
|
412
412
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
413
|
+
// A PASTE — any multi-char chunk that CONTAINS a newline, or a large single chunk — folds to a
|
|
414
|
+
// `[Paste #N +L lines]` token and NEVER submits. A pasted newline is content, not "send" (codex's
|
|
415
|
+
// paste-burst rule: Enter inside a paste is a newline, not submit). This fixes "paste is sent
|
|
416
|
+
// immediately": the old code submitted at the first newline in a pasted chunk, so any 1–2 line
|
|
417
|
+
// paste under 600 chars fired the message. Now only a real Enter (above / key.return) sends.
|
|
418
|
+
if (/[\r\n]/.test(input) || input.length >= 600) {
|
|
419
|
+
addPaste(input);
|
|
416
420
|
return;
|
|
417
421
|
}
|
|
418
422
|
// a dragged-in / pasted image file path attaches instead of inserting literal text
|