@nanhara/hara 0.53.0 → 0.62.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 +181 -0
- package/README.md +25 -7
- package/SECURITY.md +54 -0
- package/dist/agent/loop.js +5 -3
- package/dist/completions.js +49 -0
- package/dist/config.js +17 -7
- package/dist/cron/install.js +112 -0
- package/dist/cron/runner.js +109 -0
- package/dist/cron/schedule.js +147 -0
- package/dist/cron/store.js +87 -0
- package/dist/index.js +381 -11
- package/dist/mcp/server.js +56 -0
- package/dist/memory/store.js +44 -6
- package/dist/org/review-chain.js +91 -0
- package/dist/org/roles.js +11 -0
- package/dist/providers/qwen-oauth.js +9 -2
- package/dist/sandbox.js +25 -3
- package/dist/search/semindex.js +9 -2
- package/dist/session/store.js +12 -2
- package/dist/tools/computer.js +9 -4
- package/dist/tools/patch.js +31 -12
- package/dist/tools/web.js +81 -8
- package/dist/tui/App.js +2 -2
- package/dist/tui/InputBox.js +37 -3
- package/dist/tui/vim.js +115 -0
- package/package.json +6 -2
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// The tick runner for `hara cron`: find due jobs and run each as a fresh `hara` session (the fired
|
|
2
|
+
// session IS the agent — same model as openclaw/hermes). Meant to be invoked every minute by the OS
|
|
3
|
+
// scheduler (see install.ts). A lock file prevents overlapping ticks from double-firing a slow job.
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync, statSync, appendFileSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { loadJobs, recordRun, cronDir, logPath } from "./store.js";
|
|
8
|
+
import { isDue } from "./schedule.js";
|
|
9
|
+
/** Jobs that are enabled AND due at `nowMs` (pure — for the tick and for testing). */
|
|
10
|
+
export function dueJobs(jobs, nowMs) {
|
|
11
|
+
return jobs.filter((j) => j.enabled && isDue(j, nowMs));
|
|
12
|
+
}
|
|
13
|
+
/** How to invoke hara again — handles both `node dist/index.js` (argv[1] is a script) and the compiled
|
|
14
|
+
* single-binary (argv[1] is a user arg, so re-invoke the binary directly). Used by the tick + by install. */
|
|
15
|
+
export function selfArgv() {
|
|
16
|
+
const a1 = process.argv[1];
|
|
17
|
+
return a1 && /\.[cm]?js$|\.ts$/.test(a1) ? [process.execPath, a1] : [process.execPath];
|
|
18
|
+
}
|
|
19
|
+
const lockPath = () => join(cronDir(), ".tick.lock");
|
|
20
|
+
// Generous: a live-PID owner is respected this long (so a genuinely long job isn't double-fired); past it
|
|
21
|
+
// we assume PID reuse and take over. A *dead* owner is taken over within one tick regardless (see below).
|
|
22
|
+
const LOCK_STALE_MS = 6 * 60 * 60_000;
|
|
23
|
+
/** Keep a per-job log from growing forever: once over ~1MB, retain only the last ~256KB. */
|
|
24
|
+
function capLog(log) {
|
|
25
|
+
try {
|
|
26
|
+
if (existsSync(log) && statSync(log).size > 1_000_000) {
|
|
27
|
+
writeFileSync(log, "…[older log truncated]\n" + readFileSync(log, "utf8").slice(-256_000));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
/* best-effort */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Run one job's task in a fresh hara process (full-auto, no prompts), appending output to its log.
|
|
35
|
+
* Exported so `hara cron run <id>` can fire a job on demand, ignoring its schedule. */
|
|
36
|
+
export function runJobOnce(job) {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
mkdirSync(join(cronDir(), "logs"), { recursive: true });
|
|
39
|
+
const args = job.mode === "org" ? ["org", job.task] : ["-p", job.task, "--approval", "full-auto"];
|
|
40
|
+
const log = logPath(job.id);
|
|
41
|
+
capLog(log);
|
|
42
|
+
try {
|
|
43
|
+
appendFileSync(log, `\n===== ${new Date().toISOString()} · ${job.name} (${job.mode}) =====\n`);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
/* logging is best-effort */
|
|
47
|
+
}
|
|
48
|
+
const self = selfArgv();
|
|
49
|
+
const child = spawn(self[0], [...self.slice(1), ...args], { cwd: job.cwd, env: process.env });
|
|
50
|
+
const append = (d) => {
|
|
51
|
+
try {
|
|
52
|
+
appendFileSync(log, d);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
/* ignore */
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
child.stdout.on("data", append);
|
|
59
|
+
child.stderr.on("data", append);
|
|
60
|
+
child.on("error", (e) => resolve({ ok: false, error: String(e?.message ?? e) }));
|
|
61
|
+
child.on("close", (code) => resolve(code === 0 ? { ok: true } : { ok: false, error: `exited ${code}` }));
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** One scheduler tick: run every due job (sequentially), recording each outcome. Lock-guarded so an
|
|
65
|
+
* overlapping tick (launchd fires every 60s; a job may run longer) skips instead of double-firing.
|
|
66
|
+
* `run` is injectable for tests. Returns the job ids that ran. */
|
|
67
|
+
export async function runTick(nowMs, run = runJobOnce) {
|
|
68
|
+
mkdirSync(cronDir(), { recursive: true });
|
|
69
|
+
const lock = lockPath();
|
|
70
|
+
if (existsSync(lock)) {
|
|
71
|
+
let held = false;
|
|
72
|
+
try {
|
|
73
|
+
const fresh = nowMs - statSync(lock).mtimeMs < LOCK_STALE_MS;
|
|
74
|
+
const pid = Number(readFileSync(lock, "utf8").trim());
|
|
75
|
+
let alive = false;
|
|
76
|
+
try {
|
|
77
|
+
alive = pid > 0 && (process.kill(pid, 0), true); // signal 0 = liveness probe
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
alive = false; // ESRCH → the tick that wrote this lock is gone
|
|
81
|
+
}
|
|
82
|
+
held = fresh && alive; // respect a lock only if it's recent AND owned by a live process (no crash-poison)
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
held = false; // unreadable lock → proceed
|
|
86
|
+
}
|
|
87
|
+
if (held)
|
|
88
|
+
return { ran: [], skipped: "another tick is in progress" };
|
|
89
|
+
}
|
|
90
|
+
writeFileSync(lock, String(process.pid));
|
|
91
|
+
try {
|
|
92
|
+
const due = dueJobs(loadJobs(), nowMs);
|
|
93
|
+
const ran = [];
|
|
94
|
+
for (const job of due) {
|
|
95
|
+
const r = await run(job);
|
|
96
|
+
recordRun(job.id, nowMs, r.ok ? "ok" : "error", r.error);
|
|
97
|
+
ran.push(job.id);
|
|
98
|
+
}
|
|
99
|
+
return { ran };
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
try {
|
|
103
|
+
rmSync(lock);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
/* best-effort */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Schedule parsing + matching for `hara cron` — the pure, testable core (no I/O, no Date.now()).
|
|
2
|
+
// Three forms, mirroring openclaw/hermes: a 5-field cron expr, a fixed interval ("every 30m"), and a
|
|
3
|
+
// one-shot ("in 2h" or an ISO timestamp). Cron matching is hand-rolled (no dependency) at minute
|
|
4
|
+
// granularity in LOCAL time — same mental model as a real crontab.
|
|
5
|
+
const UNIT_MS = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
6
|
+
/** "45s" | "30m" | "2h" | "1d" → milliseconds, or null. */
|
|
7
|
+
export function durationToMs(s) {
|
|
8
|
+
const m = /^(\d+)\s*([smhd])$/.exec(s.trim());
|
|
9
|
+
if (!m)
|
|
10
|
+
return null;
|
|
11
|
+
return Number(m[1]) * UNIT_MS[m[2]];
|
|
12
|
+
}
|
|
13
|
+
// Parse one cron field — supports `*`, a step `/n`, a single value `a`, a range `a-b`, a list `a,b`,
|
|
14
|
+
// and a stepped range `a-b/n` — into the explicit set of matching values.
|
|
15
|
+
const isUint = (s) => /^\d+$/.test(s); // strict — `Number("")`/`Number(" ")` are 0, so reject non-digits
|
|
16
|
+
function parseField(f, min, max) {
|
|
17
|
+
const out = new Set();
|
|
18
|
+
for (const part of f.split(",")) {
|
|
19
|
+
if (part === "")
|
|
20
|
+
return null; // empty list element (e.g. a trailing/leading comma)
|
|
21
|
+
const slash = part.split("/");
|
|
22
|
+
if (slash.length > 2)
|
|
23
|
+
return null; // more than one step
|
|
24
|
+
const [rangeRaw, stepRaw] = slash;
|
|
25
|
+
if (stepRaw !== undefined && !isUint(stepRaw))
|
|
26
|
+
return null; // "5/", "5/x"
|
|
27
|
+
const step = stepRaw === undefined ? 1 : Number(stepRaw);
|
|
28
|
+
if (step < 1)
|
|
29
|
+
return null;
|
|
30
|
+
let lo;
|
|
31
|
+
let hi;
|
|
32
|
+
if (rangeRaw === "*") {
|
|
33
|
+
lo = min;
|
|
34
|
+
hi = max;
|
|
35
|
+
}
|
|
36
|
+
else if (rangeRaw.includes("-")) {
|
|
37
|
+
const ab = rangeRaw.split("-");
|
|
38
|
+
if (ab.length !== 2 || !isUint(ab[0]) || !isUint(ab[1]))
|
|
39
|
+
return null;
|
|
40
|
+
lo = Number(ab[0]);
|
|
41
|
+
hi = Number(ab[1]);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
if (!isUint(rangeRaw))
|
|
45
|
+
return null;
|
|
46
|
+
lo = Number(rangeRaw);
|
|
47
|
+
hi = stepRaw !== undefined ? max : lo; // Vixie: "N/step" means N..max step (not just {N})
|
|
48
|
+
}
|
|
49
|
+
if (lo < min || hi > max || lo > hi)
|
|
50
|
+
return null;
|
|
51
|
+
for (let v = lo; v <= hi; v += step)
|
|
52
|
+
out.add(v);
|
|
53
|
+
}
|
|
54
|
+
return out.size ? out : null;
|
|
55
|
+
}
|
|
56
|
+
/** Parse a 5-field cron expression (minute hour day-of-month month day-of-week), or null if invalid. */
|
|
57
|
+
export function parseCron(expr) {
|
|
58
|
+
const f = expr.trim().split(/\s+/);
|
|
59
|
+
if (f.length !== 5)
|
|
60
|
+
return null;
|
|
61
|
+
const m = parseField(f[0], 0, 59);
|
|
62
|
+
const h = parseField(f[1], 0, 23);
|
|
63
|
+
const dom = parseField(f[2], 1, 31);
|
|
64
|
+
const mon = parseField(f[3], 1, 12);
|
|
65
|
+
const dow = parseField(f[4], 0, 6); // 0 = Sunday
|
|
66
|
+
if (!m || !h || !dom || !mon || !dow)
|
|
67
|
+
return null;
|
|
68
|
+
return { m, h, dom, mon, dow, domStar: f[2] === "*", dowStar: f[4] === "*" };
|
|
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) {
|
|
72
|
+
const p = parseCron(expr);
|
|
73
|
+
if (!p)
|
|
74
|
+
return false;
|
|
75
|
+
if (!p.m.has(d.getMinutes()) || !p.h.has(d.getHours()) || !p.mon.has(d.getMonth() + 1))
|
|
76
|
+
return false;
|
|
77
|
+
const domOk = p.dom.has(d.getDate());
|
|
78
|
+
const dowOk = p.dow.has(d.getDay());
|
|
79
|
+
if (p.domStar && p.dowStar)
|
|
80
|
+
return true; // both unrestricted → any day
|
|
81
|
+
if (!p.domStar && !p.dowStar)
|
|
82
|
+
return domOk || dowOk; // both restricted → OR
|
|
83
|
+
return p.domStar ? dowOk : domOk; // one restricted → that one
|
|
84
|
+
}
|
|
85
|
+
/** Parse a user schedule string into a Schedule (or an error). `nowMs` anchors relative one-shots. */
|
|
86
|
+
export function parseSchedule(input, nowMs) {
|
|
87
|
+
const s = input.trim();
|
|
88
|
+
let m = /^every\s+(\d+\s*[smhd])$/i.exec(s);
|
|
89
|
+
if (m) {
|
|
90
|
+
const ms = durationToMs(m[1]);
|
|
91
|
+
if (!ms)
|
|
92
|
+
return { error: `bad interval: ${m[1]}` };
|
|
93
|
+
return { kind: "every", everyMs: ms, display: `every ${m[1].replace(/\s+/g, "")}` };
|
|
94
|
+
}
|
|
95
|
+
m = /^in\s+(\d+\s*[smhd])$/i.exec(s);
|
|
96
|
+
if (m) {
|
|
97
|
+
const ms = durationToMs(m[1]);
|
|
98
|
+
if (!ms)
|
|
99
|
+
return { error: `bad delay: ${m[1]}` };
|
|
100
|
+
return { kind: "once", runAt: nowMs + ms, display: `once, in ${m[1].replace(/\s+/g, "")}` };
|
|
101
|
+
}
|
|
102
|
+
if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(s)) {
|
|
103
|
+
const t = Date.parse(s);
|
|
104
|
+
if (Number.isNaN(t))
|
|
105
|
+
return { error: `bad timestamp: ${s}` };
|
|
106
|
+
return { kind: "once", runAt: t, display: `once, at ${s}` };
|
|
107
|
+
}
|
|
108
|
+
if (parseCron(s))
|
|
109
|
+
return { kind: "cron", expr: s };
|
|
110
|
+
return { error: `unrecognized schedule "${s}" — use a cron expr ("0 9 * * *"), "every 30m", "in 2h", or an ISO timestamp` };
|
|
111
|
+
}
|
|
112
|
+
export function describeSchedule(sched) {
|
|
113
|
+
return sched.kind === "cron" ? `cron \`${sched.expr}\`` : sched.display;
|
|
114
|
+
}
|
|
115
|
+
/** Is this job due to run at `nowMs`? Cron jobs fire once per matching minute (deduped via lastRunAt);
|
|
116
|
+
* intervals fire when `everyMs` has elapsed since the last run; one-shots fire once when their time passes. */
|
|
117
|
+
export function isDue(job, nowMs) {
|
|
118
|
+
const s = job.schedule;
|
|
119
|
+
if (s.kind === "cron") {
|
|
120
|
+
if (!cronMatches(s.expr, new Date(nowMs)))
|
|
121
|
+
return false;
|
|
122
|
+
return job.lastRunAt === undefined || Math.floor(job.lastRunAt / 60_000) < Math.floor(nowMs / 60_000);
|
|
123
|
+
}
|
|
124
|
+
// interval: fire once per grid slot of width everyMs — a tick landing slightly early still counts the
|
|
125
|
+
// slot (a plain `now >= last+everyMs` deadline loses ~half the fires of `every 1m` at 60s tick granularity).
|
|
126
|
+
if (s.kind === "every")
|
|
127
|
+
return Math.floor(nowMs / s.everyMs) > Math.floor((job.lastRunAt ?? job.createdAt) / s.everyMs);
|
|
128
|
+
return job.lastRunAt === undefined && nowMs >= s.runAt; // once
|
|
129
|
+
}
|
|
130
|
+
/** Next fire time at/after `fromMs` (for display). Cron scans minute-by-minute up to a year; null if none
|
|
131
|
+
* (e.g. a one-shot already past). */
|
|
132
|
+
export function nextRun(job, fromMs) {
|
|
133
|
+
const s = job.schedule;
|
|
134
|
+
if (s.kind === "every")
|
|
135
|
+
return (Math.floor(fromMs / s.everyMs) + 1) * s.everyMs; // next grid boundary (always > fromMs)
|
|
136
|
+
if (s.kind === "once")
|
|
137
|
+
return job.lastRunAt === undefined ? s.runAt : null;
|
|
138
|
+
const p = parseCron(s.expr);
|
|
139
|
+
if (!p)
|
|
140
|
+
return null;
|
|
141
|
+
const start = Math.floor(fromMs / 60_000) * 60_000 + 60_000; // next minute boundary
|
|
142
|
+
for (let t = start, i = 0; i < 366 * 24 * 60; t += 60_000, i++) {
|
|
143
|
+
if (cronMatches(s.expr, new Date(t)))
|
|
144
|
+
return t;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Persistent job store for `hara cron` — atomic JSON at ~/.hara/cron/jobs.json (temp + rename, like
|
|
2
|
+
// openclaw/hermes). Each job runs a fresh `hara` session when due; per-job run logs live alongside.
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
|
|
7
|
+
export function cronDir() {
|
|
8
|
+
return join(homedir(), ".hara", "cron");
|
|
9
|
+
}
|
|
10
|
+
export function jobsPath() {
|
|
11
|
+
return join(cronDir(), "jobs.json");
|
|
12
|
+
}
|
|
13
|
+
export function logPath(id) {
|
|
14
|
+
return join(cronDir(), "logs", `${id}.log`);
|
|
15
|
+
}
|
|
16
|
+
export function loadJobs() {
|
|
17
|
+
const p = jobsPath();
|
|
18
|
+
if (!existsSync(p))
|
|
19
|
+
return [];
|
|
20
|
+
try {
|
|
21
|
+
const j = JSON.parse(readFileSync(p, "utf8"));
|
|
22
|
+
return Array.isArray(j) ? j : [];
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Persist the job list with an atomic temp-write + rename (never leaves a half-written jobs.json). */
|
|
29
|
+
export function saveJobs(jobs) {
|
|
30
|
+
mkdirSync(cronDir(), { recursive: true });
|
|
31
|
+
const p = jobsPath();
|
|
32
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
33
|
+
writeFileSync(tmp, JSON.stringify(jobs, null, 2) + "\n", "utf8");
|
|
34
|
+
renameSync(tmp, p);
|
|
35
|
+
}
|
|
36
|
+
export function addJob(j) {
|
|
37
|
+
const jobs = loadJobs();
|
|
38
|
+
const job = { id: randomUUID().slice(0, 8), enabled: j.enabled ?? true, ...j };
|
|
39
|
+
jobs.push(job);
|
|
40
|
+
saveJobs(jobs);
|
|
41
|
+
return job;
|
|
42
|
+
}
|
|
43
|
+
/** Resolve an id or unique id-prefix to a single job. Exact id wins; otherwise the prefix must match
|
|
44
|
+
* EXACTLY one job — an ambiguous prefix returns "ambiguous" (never silently picks one, since callers
|
|
45
|
+
* delete/toggle). `undefined` = no match. */
|
|
46
|
+
export function resolveJob(idOrPrefix) {
|
|
47
|
+
const jobs = loadJobs();
|
|
48
|
+
const exact = jobs.find((x) => x.id === idOrPrefix);
|
|
49
|
+
if (exact)
|
|
50
|
+
return exact;
|
|
51
|
+
const pre = jobs.filter((x) => x.id.startsWith(idOrPrefix));
|
|
52
|
+
return pre.length === 1 ? pre[0] : pre.length > 1 ? "ambiguous" : undefined;
|
|
53
|
+
}
|
|
54
|
+
/** Find a job by id/unique-prefix (back-compat; ambiguous → undefined). */
|
|
55
|
+
export function findJob(idOrPrefix) {
|
|
56
|
+
const r = resolveJob(idOrPrefix);
|
|
57
|
+
return r === "ambiguous" ? undefined : r;
|
|
58
|
+
}
|
|
59
|
+
/** Delete a job by EXACT id (callers resolve the prefix first via resolveJob). */
|
|
60
|
+
export function removeJob(id) {
|
|
61
|
+
const jobs = loadJobs();
|
|
62
|
+
if (!jobs.some((x) => x.id === id))
|
|
63
|
+
return false;
|
|
64
|
+
saveJobs(jobs.filter((x) => x.id !== id));
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
/** Enable/disable a job by EXACT id. */
|
|
68
|
+
export function setEnabled(id, on) {
|
|
69
|
+
const jobs = loadJobs();
|
|
70
|
+
const job = jobs.find((x) => x.id === id);
|
|
71
|
+
if (!job)
|
|
72
|
+
return false;
|
|
73
|
+
job.enabled = on;
|
|
74
|
+
saveJobs(jobs);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
/** Record a run's outcome (and stamp lastRunAt) for the given job. */
|
|
78
|
+
export function recordRun(id, at, status, error) {
|
|
79
|
+
const jobs = loadJobs();
|
|
80
|
+
const job = jobs.find((x) => x.id === id);
|
|
81
|
+
if (!job)
|
|
82
|
+
return;
|
|
83
|
+
job.lastRunAt = at;
|
|
84
|
+
job.lastStatus = status;
|
|
85
|
+
job.lastError = error;
|
|
86
|
+
saveJobs(jobs);
|
|
87
|
+
}
|