@nanhara/hara 0.127.1 → 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 +10 -0
- package/README.md +1 -1
- package/dist/cron/runner.js +7 -2
- package/dist/cron/store.js +13 -3
- package/dist/index.js +11 -3
- package/dist/serve/protocol.js +2 -1
- package/dist/serve/server.js +36 -1
- package/dist/tools/cron.js +14 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,16 @@ 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
|
+
|
|
8
18
|
## 0.127.1 — 2026-07-20 — managed access lifecycle
|
|
9
19
|
|
|
10
20
|
- Managed enrollment now persists the control plane's authoritative device-token expiry. Hara warns
|
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`
|
|
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
|
package/dist/cron/runner.js
CHANGED
|
@@ -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 = ""; //
|
|
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(
|
|
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
|
};
|
package/dist/cron/store.js
CHANGED
|
@@ -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
|
|
627
|
-
|
|
628
|
-
|
|
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
|
@@ -2454,7 +2454,8 @@ function renderCronJobs() {
|
|
|
2454
2454
|
: j.lastStatus === "error"
|
|
2455
2455
|
? c.red(`err${elapsed}`)
|
|
2456
2456
|
: c.dim("—");
|
|
2457
|
-
|
|
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)}`;
|
|
2458
2459
|
});
|
|
2459
2460
|
return head + "\n" + lines.join("\n") + "\n";
|
|
2460
2461
|
}
|
|
@@ -2466,7 +2467,8 @@ cronCmd
|
|
|
2466
2467
|
.option("--org", "run via `hara org` (role routing + review) instead of a plain `hara -p` prompt")
|
|
2467
2468
|
.option("--command", "run the task as a plain SHELL COMMAND — deterministic, no agent, no tokens")
|
|
2468
2469
|
.option("--tz <zone>", 'IANA timezone for cron exprs (e.g. "Asia/Shanghai"); default = local time')
|
|
2469
|
-
.option("--deliver <spec>", "push
|
|
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")
|
|
2470
2472
|
.option("--alert-after <n>", "send a 🚨 after N consecutive failures (1..1000; default 3)")
|
|
2471
2473
|
.action((schedule, taskParts, opts) => {
|
|
2472
2474
|
const task = taskParts.join(" ");
|
|
@@ -2484,6 +2486,11 @@ cronCmd
|
|
|
2484
2486
|
if ("error" in d)
|
|
2485
2487
|
return void out(c.red(d.error + "\n"));
|
|
2486
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
|
+
}
|
|
2487
2494
|
const alertAfter = opts.alertAfter === undefined ? undefined : Number(opts.alertAfter);
|
|
2488
2495
|
if (alertAfter !== undefined && (!Number.isInteger(alertAfter) || alertAfter < 1 || alertAfter > 1_000)) {
|
|
2489
2496
|
return void out(c.red("--alert-after must be an integer from 1 to 1000\n"));
|
|
@@ -2499,6 +2506,7 @@ cronCmd
|
|
|
2499
2506
|
cwd: process.cwd(),
|
|
2500
2507
|
...(opts.tz ? { tz: opts.tz } : {}),
|
|
2501
2508
|
...(opts.deliver ? { deliver: opts.deliver } : {}),
|
|
2509
|
+
...(opts.deliverMode ? { deliverMode: opts.deliverMode } : {}),
|
|
2502
2510
|
...(alertAfter !== undefined ? { alertAfter } : {}),
|
|
2503
2511
|
createdAt: Date.now(),
|
|
2504
2512
|
});
|
|
@@ -2506,7 +2514,7 @@ cronCmd
|
|
|
2506
2514
|
catch (error) {
|
|
2507
2515
|
return void out(c.red(`${error instanceof Error ? error.message : String(error)}\n`));
|
|
2508
2516
|
}
|
|
2509
|
-
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`));
|
|
2510
2518
|
if (!isInstalled())
|
|
2511
2519
|
out(c.yellow("⚠ scheduler not installed yet — run `hara cron install` so jobs actually fire.\n"));
|
|
2512
2520
|
});
|
package/dist/serve/protocol.js
CHANGED
|
@@ -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?}
|
|
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}
|
package/dist/serve/server.js
CHANGED
|
@@ -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) => ({
|
|
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) }));
|
package/dist/tools/cron.js
CHANGED
|
@@ -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` (
|
|
46
|
-
"
|
|
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();
|