@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 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` for the consecutive-failure 🚨 threshold (default 3). Delivery intent is durable before transport, uses a stable idempotency key, and retries with bounded backoff on later ticks until confirmed. A failed channel cannot grow `jobs.json` forever: each job keeps at most 64 pending effects, reserves outcome/alert room before launch, and disables itself with a visible backlog error when full; restore delivery, let the queue drain, then re-enable it. Tune milliseconds with `HARA_CRON_JOB_TIMEOUT_MS` (hard max 24h) and `HARA_CRON_TICK_TIMEOUT_MS` (hard max 5h); scheduled jobs are also capped by the tick. After upgrading from a version whose tick is already stuck, terminate that specific legacy `hara cron tick` process tree once (or reboot); the next scheduler minute marks over-age state interrupted/disabled and recovers the lock without replaying a possibly orphaned task.
277
+ **Scheduled tasks**: `hara cron add "0 9 * * 1-5" "<task>"` (or `"every 30m"`, `"in 2h"`) runs a task on a schedule — each run is a fresh hara session. `hara cron install` wires a per-minute tick into launchd/crontab (no daemon); `--org` routes through the role org. Manage with `hara cron list/run/enable/disable/remove/logs`. Every job has a 30-minute deadline and the whole sequential tick has a non-renewable 60-minute watchdog: a job timeout kills its process tree, records `timed out` + duration/error, then continues with the next due job; a tick timeout stops the remainder and releases the global lock. Add `--deliver feishu:<chatId>` (or Telegram/WeChat/webhook) for outcomes. `--deliver-mode always` preserves the existing every-run heartbeat, `on-output` sends only when redacted stdout is non-empty, and `on-error` sends only failed runs; `--alert-after N` remains independent and still raises the consecutive-failure 🚨 threshold alarm (default 3). Delivery intent is durable before transport, uses a stable idempotency key, and retries with bounded backoff on later ticks until confirmed. A failed channel cannot grow `jobs.json` forever: each job keeps at most 64 pending effects, reserves outcome/alert room before launch, and disables itself with a visible backlog error when full; restore delivery, let the queue drain, then re-enable it. Tune milliseconds with `HARA_CRON_JOB_TIMEOUT_MS` (hard max 24h) and `HARA_CRON_TICK_TIMEOUT_MS` (hard max 5h); scheduled jobs are also capped by the tick. After upgrading from a version whose tick is already stuck, terminate that specific legacy `hara cron tick` process tree once (or reboot); the next scheduler minute marks over-age state interrupted/disabled and recovers the lock without replaying a possibly orphaned task.
278
278
  **Work coordination**: `todo_write` is the agent's short, session-scoped checklist; it persists with that
279
279
  session and is isolated between simultaneous sub-agents and serve sessions. `task` is the durable project pool
280
280
  for work that outlives a conversation: add/update/list/remove items with `pending|in_progress|done`, an optional
@@ -367,7 +367,8 @@ export function runJobOnce(job, options = {}) {
367
367
  resolve({ ok: false, error: `failed to start: ${error instanceof Error ? error.message : String(error)}`, output: "" });
368
368
  return;
369
369
  }
370
- let tail = ""; // last few KB, for chat delivery (the full stream goes to the log file)
370
+ let tail = ""; // combined stdout/stderr tail for diagnostics and ordinary delivery
371
+ let stdoutTail = ""; // separate so on-output never turns stderr noise into a notification
371
372
  let logBytes = (() => { try {
372
373
  return statSync(log).size;
373
374
  }
@@ -405,7 +406,10 @@ export function runJobOnce(job, options = {}) {
405
406
  };
406
407
  // stdout and stderr are separate byte streams; sharing a line buffer can splice unrelated chunks and
407
408
  // either corrupt output or create an unsafe synthetic token. Keep an independent redactor per stream.
408
- const stdout = createToolOutputLineRedactor(appendSafe);
409
+ const stdout = createToolOutputLineRedactor((safe) => {
410
+ stdoutTail = (stdoutTail + safe).slice(-4_000);
411
+ appendSafe(safe);
412
+ });
409
413
  const stderr = createToolOutputLineRedactor(appendSafe);
410
414
  const flush = () => { stdout.flush(); stderr.flush(); };
411
415
  const settle = (result) => {
@@ -424,6 +428,7 @@ export function runJobOnce(job, options = {}) {
424
428
  resolve({
425
429
  ...result,
426
430
  error: result.error ? redactToolSubprocessOutput(result.error) : undefined,
431
+ stdout: stdoutTail,
427
432
  output: tail,
428
433
  });
429
434
  };
@@ -364,6 +364,9 @@ function validCronJob(value) {
364
364
  && (job.pendingDueAt === undefined || (job.schedule.kind === "cron" && validFinite(job.pendingDueAt)))
365
365
  && (job.tz === undefined || (typeof job.tz === "string" && validTz(job.tz)))
366
366
  && (job.deliver === undefined || (typeof job.deliver === "string" && job.deliver.length > 0 && job.deliver.length <= 4_096))
367
+ && (job.deliverMode === undefined
368
+ || (job.deliver !== undefined
369
+ && (job.deliverMode === "always" || job.deliverMode === "on-output" || job.deliverMode === "on-error")))
367
370
  && (job.alertAfter === undefined || (Number.isInteger(job.alertAfter) && job.alertAfter >= 1 && job.alertAfter <= 1_000))
368
371
  && (job.lastRunAt === undefined || validFinite(job.lastRunAt))
369
372
  && (job.runningSince === undefined || validFinite(job.runningSince))
@@ -621,11 +624,18 @@ function enqueueOutcomeForJob(job, result, nowMs, attemptKey) {
621
624
  return [];
622
625
  const queued = [];
623
626
  const snippet = notificationText(result.output, 1_500).trim();
627
+ const stdout = notificationText(result.stdout, 1_500).trim();
624
628
  const safeError = notificationText(result.error || "failed", 1_000);
625
629
  const head = result.ok ? `⏰ ${job.name} ✓` : `⏰ ${job.name} ✗ ${safeError}`;
626
- const outcomeId = enqueueNotification(job, "outcome", snippet ? `${head}\n${snippet}` : head, nowMs, attemptKey);
627
- if (outcomeId)
628
- queued.push(outcomeId);
630
+ const mode = job.deliverMode ?? "always";
631
+ const shouldDeliverOutcome = mode === "always"
632
+ || (mode === "on-output" && stdout.length > 0)
633
+ || (mode === "on-error" && !result.ok);
634
+ if (shouldDeliverOutcome) {
635
+ const outcomeId = enqueueNotification(job, "outcome", snippet ? `${head}\n${snippet}` : head, nowMs, attemptKey);
636
+ if (outcomeId)
637
+ queued.push(outcomeId);
638
+ }
629
639
  if (!result.ok) {
630
640
  const count = job.consecutiveErrors ?? 0;
631
641
  const threshold = job.alertAfter ?? 3;
package/dist/index.js CHANGED
@@ -2454,7 +2454,8 @@ function renderCronJobs() {
2454
2454
  : j.lastStatus === "error"
2455
2455
  ? c.red(`err${elapsed}`)
2456
2456
  : c.dim("—");
2457
- return `${c.bold(j.id)} ${describeSchedule(j.schedule)} ${c.dim(`· ${j.mode} · next ${nextLabel} · last ${status}`)}${j.enabled ? "" : c.dim(" [disabled]")}\n ${c.dim(j.name)}`;
2457
+ const delivery = j.deliver ? ` · deliver ${j.deliver} (${j.deliverMode ?? "always"})` : "";
2458
+ return `${c.bold(j.id)} ${describeSchedule(j.schedule)} ${c.dim(`· ${j.mode}${delivery} · next ${nextLabel} · last ${status}`)}${j.enabled ? "" : c.dim(" [disabled]")}\n ${c.dim(j.name)}`;
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 each run's result: telegram:<chatId> | feishu:<chatId> | webhook:<url>")
2470
+ .option("--deliver <spec>", "push selected results: telegram:<chatId> | feishu:<chatId> | weixin:<peerId> | webhook:<url>")
2471
+ .option("--deliver-mode <mode>", "notification policy: always (default) | on-output | on-error")
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
  });
@@ -24,7 +24,8 @@
24
24
  // settings.providers.list {} → redacted provider catalog + current profile state
25
25
  // settings.providers.test {provider,model,…} → {ok,models,error?} (credential is ephemeral)
26
26
  // settings.providers.save {provider,model,…} → redacted state (credential is never returned)
27
- // automation.add {name,schedule,task,mode?,cwd?,tz?} → {id,name,schedule}
27
+ // automation.add {name,schedule,task,mode?,cwd?,tz?,deliver?,deliverMode?,alertAfter?}
28
+ // → {id,name,schedule}
28
29
  // automation.toggle {id,enabled} → {id,enabled}
29
30
  // automation.delete {id} → {id,deleted}
30
31
  // session.rename {sessionId,title} → {sessionId,title}
@@ -25,6 +25,7 @@ import { listInstalled, enabledPlugins, setPluginEnabled, panelsForProject } fro
25
25
  import { loadSkillIndex, loadSkillBody } from "../skills/skills.js";
26
26
  import { loadJobs, addJob, removeJob, setEnabled } from "../cron/store.js";
27
27
  import { parseSchedule, describeSchedule } from "../cron/schedule.js";
28
+ import { parseDeliver } from "../cron/deliver.js";
28
29
  import { loadTasks } from "../tools/task.js";
29
30
  import { listPending, resolvePending } from "../gateway/flows-pending.js";
30
31
  import { disposeTodoScope, onTodosChange, restoreTodos, serializeTodos } from "../tools/todo.js";
@@ -1034,7 +1035,20 @@ export async function startServe(opts, deps) {
1034
1035
  // The automation timeline's data: cron jobs with their last outcome, plus this machine's
1035
1036
  // automated sessions (source=cron/gateway) so the desktop can render results and "continue
1036
1037
  // as conversation". Read-only.
1037
- const jobs = loadJobs().map((j) => ({ id: j.id, name: j.name, mode: j.mode, cwd: j.cwd, enabled: j.enabled, deliver: j.deliver, lastRunAt: j.lastRunAt, lastStatus: j.lastStatus, lastError: j.lastError, schedule: describeSchedule(j.schedule) }));
1038
+ const jobs = loadJobs().map((j) => ({
1039
+ id: j.id,
1040
+ name: j.name,
1041
+ mode: j.mode,
1042
+ cwd: j.cwd,
1043
+ enabled: j.enabled,
1044
+ deliver: j.deliver,
1045
+ deliverMode: j.deliverMode ?? "always",
1046
+ alertAfter: j.alertAfter ?? 3,
1047
+ lastRunAt: j.lastRunAt,
1048
+ lastStatus: j.lastStatus,
1049
+ lastError: j.lastError,
1050
+ schedule: describeSchedule(j.schedule),
1051
+ }));
1038
1052
  const automated = hub.list().filter((m) => m.source === "cron" || m.source === "gateway").map((m) => ({ id: m.id, title: m.title, cwd: m.cwd, source: m.source, sourceName: m.sourceName, updatedAt: m.updatedAt }));
1039
1053
  return reply(rpcResult(id, { jobs, sessions: automated }));
1040
1054
  }
@@ -1066,6 +1080,24 @@ export async function startServe(opts, deps) {
1066
1080
  const sched = parseSchedule(p.schedule, Date.now());
1067
1081
  if ("error" in sched)
1068
1082
  return reply(rpcError(id, ERR.PARAMS, `bad schedule: ${sched.error}`));
1083
+ const deliver = typeof p.deliver === "string" && p.deliver ? p.deliver : undefined;
1084
+ if (deliver) {
1085
+ const parsed = parseDeliver(deliver);
1086
+ if ("error" in parsed)
1087
+ return reply(rpcError(id, ERR.PARAMS, parsed.error));
1088
+ }
1089
+ const deliverMode = typeof p.deliverMode === "string" && p.deliverMode
1090
+ ? p.deliverMode
1091
+ : undefined;
1092
+ if (deliverMode && !deliver)
1093
+ return reply(rpcError(id, ERR.PARAMS, "deliverMode requires deliver"));
1094
+ if (deliverMode && !["always", "on-output", "on-error"].includes(deliverMode)) {
1095
+ return reply(rpcError(id, ERR.PARAMS, "deliverMode must be always, on-output, or on-error"));
1096
+ }
1097
+ const alertAfter = p.alertAfter === undefined ? undefined : Number(p.alertAfter);
1098
+ if (alertAfter !== undefined && (!Number.isInteger(alertAfter) || alertAfter < 1 || alertAfter > 1_000)) {
1099
+ return reply(rpcError(id, ERR.PARAMS, "alertAfter must be an integer from 1 to 1000"));
1100
+ }
1069
1101
  const job = addJob({
1070
1102
  name: p.name.slice(0, 60),
1071
1103
  schedule: sched,
@@ -1073,6 +1105,9 @@ export async function startServe(opts, deps) {
1073
1105
  mode: ["print", "org", "command"].includes(p.mode) ? p.mode : "print",
1074
1106
  cwd: typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd,
1075
1107
  ...(typeof p.tz === "string" && p.tz ? { tz: p.tz } : {}),
1108
+ ...(deliver ? { deliver } : {}),
1109
+ ...(deliverMode ? { deliverMode: deliverMode } : {}),
1110
+ ...(alertAfter !== undefined ? { alertAfter } : {}),
1076
1111
  createdAt: Date.now(),
1077
1112
  });
1078
1113
  return reply(rpcResult(id, { id: job.id, name: job.name, schedule: describeSchedule(job.schedule) }));
@@ -42,8 +42,9 @@ registerTool({
42
42
  "something on a time basis. action=add needs `schedule` (cron expr \"0 9 * * *\" · \"every 30m\" · \"in 2h\" · ISO time) " +
43
43
  "and `task`. Optional: `name`; `mode` — \"print\" (default: run task as a hara prompt), \"org\" (role-routed), or " +
44
44
  "\"command\" (run task as a plain SHELL COMMAND — deterministic, no agent, no tokens; prefer it for fixed scripts); " +
45
- "`tz` (IANA, e.g. Asia/Shanghai, for cron exprs); `deliver` (push each run's result: telegram:<chatId> | " +
46
- "feishu:<chatId> | webhook:<url>); `alertAfter` (1..1000 consecutive failures, default 3). Other actions: " +
45
+ "`tz` (IANA, e.g. Asia/Shanghai, for cron exprs); `deliver` (telegram:<chatId> | feishu:<chatId> | " +
46
+ "weixin:<peerId> | webhook:<url>); `deliverMode` always (default), on-output (stdout non-empty), or " +
47
+ "on-error (failed runs only); `alertAfter` (1..1000 consecutive failures, default 3). Other actions: " +
47
48
  "list · remove · enable · disable · run (fire now), with `id`. " +
48
49
  "Jobs fire via the OS scheduler even when hara isn't running.",
49
50
  input_schema: {
@@ -55,7 +56,8 @@ registerTool({
55
56
  name: { type: "string" },
56
57
  mode: { type: "string", enum: ["print", "org", "command"] },
57
58
  tz: { type: "string", description: "IANA timezone for cron exprs" },
58
- deliver: { type: "string", description: "telegram:<chatId> | feishu:<chatId> | webhook:<url>" },
59
+ deliver: { type: "string", description: "telegram:<chatId> | feishu:<chatId> | weixin:<peerId> | webhook:<url>" },
60
+ deliverMode: { type: "string", enum: ["always", "on-output", "on-error"] },
59
61
  alertAfter: { type: "integer", minimum: 1, maximum: 1000, description: "consecutive failures before 🚨 (default 3)" },
60
62
  id: { type: "string", description: "job id (or unique prefix) for remove/enable/disable/run" },
61
63
  },
@@ -90,7 +92,7 @@ registerTool({
90
92
  .map((j) => {
91
93
  const now = Date.now();
92
94
  const next = nextRun(j, now);
93
- return `${j.id} · ${j.enabled ? "on " : "OFF"} · ${j.name} · ${describeSchedule(j.schedule)}${j.tz ? ` @ ${j.tz}` : ""} · mode ${j.mode}${j.deliver ? ` · → ${j.deliver}` : ""} · next ${fmtNext(next, now)} · last ${status(j)}${j.consecutiveErrors ? ` (${j.consecutiveErrors}✗)` : ""}`;
95
+ return `${j.id} · ${j.enabled ? "on " : "OFF"} · ${j.name} · ${describeSchedule(j.schedule)}${j.tz ? ` @ ${j.tz}` : ""} · mode ${j.mode}${j.deliver ? ` · → ${j.deliver} (${j.deliverMode ?? "always"})` : ""} · next ${fmtNext(next, now)} · last ${status(j)}${j.consecutiveErrors ? ` (${j.consecutiveErrors}✗)` : ""}`;
94
96
  })
95
97
  .join("\n");
96
98
  }
@@ -117,6 +119,12 @@ registerTool({
117
119
  if ("error" in d)
118
120
  return `Error: ${d.error}`;
119
121
  }
122
+ const deliverMode = input.deliverMode === undefined ? undefined : String(input.deliverMode);
123
+ if (deliverMode && !deliver)
124
+ return "Error: `deliverMode` requires `deliver`.";
125
+ if (deliverMode && !["always", "on-output", "on-error"].includes(deliverMode)) {
126
+ return "Error: `deliverMode` must be always, on-output, or on-error.";
127
+ }
120
128
  const alertAfter = input.alertAfter === undefined ? undefined : Number(input.alertAfter);
121
129
  if (alertAfter !== undefined && (!Number.isInteger(alertAfter) || alertAfter < 1 || alertAfter > 1_000)) {
122
130
  return "Error: `alertAfter` must be an integer from 1 to 1000.";
@@ -137,6 +145,7 @@ registerTool({
137
145
  cwd: ctx.cwd,
138
146
  ...(tz ? { tz } : {}),
139
147
  ...(deliver ? { deliver } : {}),
148
+ ...(deliverMode ? { deliverMode: deliverMode } : {}),
140
149
  ...(alertAfter !== undefined ? { alertAfter } : {}),
141
150
  createdAt: Date.now(),
142
151
  });
@@ -145,7 +154,7 @@ registerTool({
145
154
  return `Error: ${error instanceof Error ? error.message : String(error)}`;
146
155
  }
147
156
  const warn = isInstalled() ? "" : "\n⚠ The OS scheduler isn't installed yet — tell the user to run `hara cron install` once, or jobs won't fire.";
148
- return `✓ scheduled ${job.id} · ${describeSchedule(sched)}${tz ? ` @ ${tz}` : ""} · mode ${mode}${deliver ? ` · → ${deliver}` : ""}${alertAfter !== undefined ? ` · alert ≥${alertAfter}` : ""} · next ${fmt(nextRun(job, Date.now()))}${warn}`;
157
+ return `✓ scheduled ${job.id} · ${describeSchedule(sched)}${tz ? ` @ ${tz}` : ""} · mode ${mode}${deliver ? ` · → ${deliver} (${deliverMode ?? "always"})` : ""}${alertAfter !== undefined ? ` · alert ≥${alertAfter}` : ""} · next ${fmt(nextRun(job, Date.now()))}${warn}`;
149
158
  }
150
159
  // id-based actions
151
160
  const idArg = String(input.id ?? "").trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.127.1",
3
+ "version": "0.127.2",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"