@nanhara/hara 0.127.0 → 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 +19 -0
- package/README.md +1 -1
- package/dist/cron/runner.js +7 -2
- package/dist/cron/store.js +13 -3
- package/dist/index.js +35 -9
- package/dist/org-fleet/enroll.js +38 -1
- package/dist/profile/profile.js +1 -0
- 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,25 @@ 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
|
+
|
|
18
|
+
## 0.127.1 — 2026-07-20 — managed access lifecycle
|
|
19
|
+
|
|
20
|
+
- Managed enrollment now persists the control plane's authoritative device-token expiry. Hara warns
|
|
21
|
+
during the final 24 hours, reports the boundary in `whoami`/`enroll --status`, and fails closed with
|
|
22
|
+
a focused re-enrollment command once access expires or lifecycle data is corrupt.
|
|
23
|
+
- Serve provider settings expose only expiry metadata—not the token—so Hara Desktop can distinguish
|
|
24
|
+
an authenticated managed route from an expired one.
|
|
25
|
+
- Upgrade with `npm i -g @nanhara/hara@0.127.1`.
|
|
26
|
+
|
|
8
27
|
## 0.127.0 — 2026-07-20 — structured context and typed task lifecycle
|
|
9
28
|
|
|
10
29
|
- **Prompt assembly is now an explicit, ordered runtime layer.** Cache-stable policy, tool, project, and
|
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
|
@@ -22,7 +22,7 @@ import { notifyDone } from "./notify.js";
|
|
|
22
22
|
import { startMcpServer, mcpServeToolNames } from "./mcp/server.js";
|
|
23
23
|
import { completionScript } from "./completions.js";
|
|
24
24
|
import { renderSessionMarkdown } from "./export.js";
|
|
25
|
-
import { clearEnrollment, enrollDevice, heartbeat, syncOrgRoles } from "./org-fleet/enroll.js";
|
|
25
|
+
import { clearEnrollment, enrollDevice, heartbeat, syncOrgRoles, deviceTokenExpired, deviceTokenExpiryWarning, } from "./org-fleet/enroll.js";
|
|
26
26
|
import { loadActiveProfile, listProfiles, useProfile, addProfile, upsertProfile, removeProfile, setModel as setProfileModel, resetModel as resetProfileModel, getProfile, effectiveModel, routingLabel, routeHost, activeId, resolveActive, setFlagOverride, writePin, removePin, pinFilePath, DEFAULT_ORG_ID, PERSONAL_ID, } from "./profile/profile.js";
|
|
27
27
|
import { loadPermissionRules, scaffoldPermissions, globalPermissionsPath, projectPermissionsPath } from "./security/permissions.js";
|
|
28
28
|
import { routingProvider } from "./agent/route.js";
|
|
@@ -134,7 +134,7 @@ async function buildProvider(cfg, targetOverride) {
|
|
|
134
134
|
// requests" — gateway (deviceToken at the gateway) vs BYOK (user's key direct to the provider).
|
|
135
135
|
const { profile: ap } = profileForConfig(cfg);
|
|
136
136
|
if (ap.kind === "gateway") {
|
|
137
|
-
if (!ap.gatewayUrl || !ap.deviceToken)
|
|
137
|
+
if (!ap.gatewayUrl || !ap.deviceToken || deviceTokenExpired(ap.tokenExpiresAt))
|
|
138
138
|
return null;
|
|
139
139
|
const baseURL = ap.baseURL || `${ap.gatewayUrl.replace(/\/$/, "")}/v1`;
|
|
140
140
|
const model = resolveGatewayModel(cfg, ap, process.env, targetOverride?.model);
|
|
@@ -193,8 +193,12 @@ async function buildGuardian(cfg, primary) {
|
|
|
193
193
|
}
|
|
194
194
|
function authHint(cfg) {
|
|
195
195
|
const { profile: ap } = profileForConfig(cfg);
|
|
196
|
-
if (ap.kind === "gateway")
|
|
196
|
+
if (ap.kind === "gateway") {
|
|
197
|
+
if (deviceTokenExpired(ap.tokenExpiresAt)) {
|
|
198
|
+
return `Active profile '${ap.id}' has expired organization access — re-enroll with \`hara profile add ${ap.id} --gateway ${ap.gatewayUrl || "<url>"} --code <code>\`.`;
|
|
199
|
+
}
|
|
197
200
|
return `Active profile '${ap.id}' is a gateway profile but is missing deviceToken — re-enroll with \`hara profile add ${ap.id} --gateway <url> --code <code>\`.`;
|
|
201
|
+
}
|
|
198
202
|
const target = resolveByokProviderTarget(cfg, ap, false);
|
|
199
203
|
const provider = target.provider;
|
|
200
204
|
if (provider === "qwen-oauth")
|
|
@@ -215,6 +219,7 @@ function providerSettingsSnapshot(targetCwd) {
|
|
|
215
219
|
const catalog = providerCatalog();
|
|
216
220
|
if (profile.kind === "gateway") {
|
|
217
221
|
const entry = catalog.find((candidate) => candidate.id === "hara-gateway");
|
|
222
|
+
const tokenExpired = deviceTokenExpired(profile.tokenExpiresAt);
|
|
218
223
|
return {
|
|
219
224
|
current: {
|
|
220
225
|
provider: "hara-gateway",
|
|
@@ -223,11 +228,12 @@ function providerSettingsSnapshot(targetCwd) {
|
|
|
223
228
|
location: entry.location,
|
|
224
229
|
auth: entry.auth,
|
|
225
230
|
keyConfigured: !!profile.deviceToken,
|
|
226
|
-
authenticated: !!profile.gatewayUrl && !!profile.deviceToken,
|
|
231
|
+
authenticated: !!profile.gatewayUrl && !!profile.deviceToken && !tokenExpired,
|
|
227
232
|
profileId: profile.id,
|
|
228
233
|
profileKind: profile.kind,
|
|
229
234
|
profileSource: resolution.source,
|
|
230
235
|
editable: false,
|
|
236
|
+
...(profile.tokenExpiresAt ? { tokenExpiresAt: profile.tokenExpiresAt, tokenExpired } : {}),
|
|
231
237
|
},
|
|
232
238
|
providers: catalog,
|
|
233
239
|
};
|
|
@@ -1664,6 +1670,11 @@ function printWhoami() {
|
|
|
1664
1670
|
out(c.dim(` device: ${p.deviceId.length > 8 ? "…" + p.deviceId.slice(-8) : p.deviceId}\n`));
|
|
1665
1671
|
if (p.availableModels?.length)
|
|
1666
1672
|
out(c.dim(` available: ${p.availableModels.join(", ")}\n`));
|
|
1673
|
+
if (p.tokenExpiresAt)
|
|
1674
|
+
out(c.dim(` expires: ${p.tokenExpiresAt}\n`));
|
|
1675
|
+
const warning = deviceTokenExpiryWarning(p.tokenExpiresAt);
|
|
1676
|
+
if (warning)
|
|
1677
|
+
out(c.yellow(` ⚠ ${warning}\n`));
|
|
1667
1678
|
}
|
|
1668
1679
|
else {
|
|
1669
1680
|
out(c.dim(` provider: ${p.provider}\n`));
|
|
@@ -1795,6 +1806,7 @@ profileCmd
|
|
|
1795
1806
|
defaultModel: e.model || "",
|
|
1796
1807
|
availableModels: e.model ? [e.model] : [],
|
|
1797
1808
|
enrolledAt: e.enrolledAt,
|
|
1809
|
+
tokenExpiresAt: e.expiresAt,
|
|
1798
1810
|
};
|
|
1799
1811
|
upsertProfile(p); // upsert: re-enrolling the same id rotates the token
|
|
1800
1812
|
const r = useProfile(id);
|
|
@@ -1982,8 +1994,8 @@ program
|
|
|
1982
1994
|
.option("--clear", "switch active profile back to personal (does NOT delete the gateway profile)")
|
|
1983
1995
|
.action(async (gatewayUrl, opts) => {
|
|
1984
1996
|
if (opts.status) {
|
|
1985
|
-
|
|
1986
|
-
return
|
|
1997
|
+
printWhoami();
|
|
1998
|
+
return;
|
|
1987
1999
|
}
|
|
1988
2000
|
if (opts.clear) {
|
|
1989
2001
|
// Behavior change: don't *delete* the gateway profile (keeps the token around for re-use);
|
|
@@ -2010,6 +2022,7 @@ program
|
|
|
2010
2022
|
defaultModel: e.model || "",
|
|
2011
2023
|
availableModels: e.model ? [e.model] : [],
|
|
2012
2024
|
enrolledAt: e.enrolledAt,
|
|
2025
|
+
tokenExpiresAt: e.expiresAt,
|
|
2013
2026
|
};
|
|
2014
2027
|
upsertProfile(p);
|
|
2015
2028
|
useProfile(DEFAULT_ORG_ID);
|
|
@@ -2441,7 +2454,8 @@ function renderCronJobs() {
|
|
|
2441
2454
|
: j.lastStatus === "error"
|
|
2442
2455
|
? c.red(`err${elapsed}`)
|
|
2443
2456
|
: c.dim("—");
|
|
2444
|
-
|
|
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)}`;
|
|
2445
2459
|
});
|
|
2446
2460
|
return head + "\n" + lines.join("\n") + "\n";
|
|
2447
2461
|
}
|
|
@@ -2453,7 +2467,8 @@ cronCmd
|
|
|
2453
2467
|
.option("--org", "run via `hara org` (role routing + review) instead of a plain `hara -p` prompt")
|
|
2454
2468
|
.option("--command", "run the task as a plain SHELL COMMAND — deterministic, no agent, no tokens")
|
|
2455
2469
|
.option("--tz <zone>", 'IANA timezone for cron exprs (e.g. "Asia/Shanghai"); default = local time')
|
|
2456
|
-
.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")
|
|
2457
2472
|
.option("--alert-after <n>", "send a 🚨 after N consecutive failures (1..1000; default 3)")
|
|
2458
2473
|
.action((schedule, taskParts, opts) => {
|
|
2459
2474
|
const task = taskParts.join(" ");
|
|
@@ -2471,6 +2486,11 @@ cronCmd
|
|
|
2471
2486
|
if ("error" in d)
|
|
2472
2487
|
return void out(c.red(d.error + "\n"));
|
|
2473
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
|
+
}
|
|
2474
2494
|
const alertAfter = opts.alertAfter === undefined ? undefined : Number(opts.alertAfter);
|
|
2475
2495
|
if (alertAfter !== undefined && (!Number.isInteger(alertAfter) || alertAfter < 1 || alertAfter > 1_000)) {
|
|
2476
2496
|
return void out(c.red("--alert-after must be an integer from 1 to 1000\n"));
|
|
@@ -2486,6 +2506,7 @@ cronCmd
|
|
|
2486
2506
|
cwd: process.cwd(),
|
|
2487
2507
|
...(opts.tz ? { tz: opts.tz } : {}),
|
|
2488
2508
|
...(opts.deliver ? { deliver: opts.deliver } : {}),
|
|
2509
|
+
...(opts.deliverMode ? { deliverMode: opts.deliverMode } : {}),
|
|
2489
2510
|
...(alertAfter !== undefined ? { alertAfter } : {}),
|
|
2490
2511
|
createdAt: Date.now(),
|
|
2491
2512
|
});
|
|
@@ -2493,7 +2514,7 @@ cronCmd
|
|
|
2493
2514
|
catch (error) {
|
|
2494
2515
|
return void out(c.red(`${error instanceof Error ? error.message : String(error)}\n`));
|
|
2495
2516
|
}
|
|
2496
|
-
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`));
|
|
2497
2518
|
if (!isInstalled())
|
|
2498
2519
|
out(c.yellow("⚠ scheduler not installed yet — run `hara cron install` so jobs actually fire.\n"));
|
|
2499
2520
|
});
|
|
@@ -3093,6 +3114,11 @@ program.action(async (opts) => {
|
|
|
3093
3114
|
// to suppress everywhere.
|
|
3094
3115
|
if (!opts.print && process.env.HARA_QUIET !== "1") {
|
|
3095
3116
|
out(c.dim(activeProfileLine(__activeP)) + "\n");
|
|
3117
|
+
const expiryWarning = __activeP.kind === "gateway"
|
|
3118
|
+
? deviceTokenExpiryWarning(__activeP.tokenExpiresAt)
|
|
3119
|
+
: null;
|
|
3120
|
+
if (expiryWarning)
|
|
3121
|
+
out(c.yellow(`⚠ ${expiryWarning}\n`));
|
|
3096
3122
|
}
|
|
3097
3123
|
if (__activeP.kind === "gateway" || cfg.provider === "hara-gateway") {
|
|
3098
3124
|
void heartbeat(); // fleet visibility — fire-and-forget, never blocks startup
|
package/dist/org-fleet/enroll.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// plane fleet visibility. Token + endpoint live in ~/.hara/org.json (0600).
|
|
6
6
|
//
|
|
7
7
|
// Protocol (what `hara-control` implements on the other end):
|
|
8
|
-
// POST {gateway}/v1/enroll {code, device:{name,os,hara_version}} -> {device_token, device_id, model, base_url?}
|
|
8
|
+
// POST {gateway}/v1/enroll {code, device:{name,os,hara_version}} -> {device_token, device_id, model, base_url?, expires_at?}
|
|
9
9
|
// POST {gateway}/v1/heartbeat Bearer <device_token> {device_id, name, os, hara_version} -> 200/204
|
|
10
10
|
// GET {gateway}/v1/roles Bearer <device_token> -> {version, org_policy, roles:[…]} (B3 digital-employee push-down)
|
|
11
11
|
// POST {gateway}/v1/chat/completions (OpenAI-compatible; the normal agent traffic, Bearer <device_token>)
|
|
@@ -45,6 +45,7 @@ export function loadEnrollment() {
|
|
|
45
45
|
model: ap.defaultModel || "",
|
|
46
46
|
baseURL: ap.baseURL,
|
|
47
47
|
enrolledAt: ap.enrolledAt || new Date().toISOString(),
|
|
48
|
+
expiresAt: ap.tokenExpiresAt,
|
|
48
49
|
};
|
|
49
50
|
}
|
|
50
51
|
}
|
|
@@ -70,6 +71,14 @@ export function parseEnrollResponse(gatewayUrl, j, now) {
|
|
|
70
71
|
const deviceToken = (j.device_token ?? j.deviceToken);
|
|
71
72
|
if (!deviceToken)
|
|
72
73
|
throw new Error("enroll response missing device_token");
|
|
74
|
+
const rawExpiresAt = j.expires_at ?? j.expiresAt;
|
|
75
|
+
let expiresAt;
|
|
76
|
+
if (rawExpiresAt !== undefined && rawExpiresAt !== null) {
|
|
77
|
+
if (typeof rawExpiresAt !== "string" || !Number.isFinite(Date.parse(rawExpiresAt))) {
|
|
78
|
+
throw new Error("enroll response contains an invalid expires_at");
|
|
79
|
+
}
|
|
80
|
+
expiresAt = new Date(rawExpiresAt).toISOString();
|
|
81
|
+
}
|
|
73
82
|
return {
|
|
74
83
|
gatewayUrl: gatewayUrl.replace(/\/$/, ""),
|
|
75
84
|
deviceToken,
|
|
@@ -77,8 +86,36 @@ export function parseEnrollResponse(gatewayUrl, j, now) {
|
|
|
77
86
|
model: String(j.model ?? ""),
|
|
78
87
|
baseURL: (j.base_url ?? j.baseURL),
|
|
79
88
|
enrolledAt: now,
|
|
89
|
+
expiresAt,
|
|
80
90
|
};
|
|
81
91
|
}
|
|
92
|
+
/** Legacy control planes did not advertise token expiry, so absence remains compatible. New control
|
|
93
|
+
* planes provide it and the CLI can fail early with an actionable re-enrollment message. */
|
|
94
|
+
export function deviceTokenExpired(expiresAt, now = new Date()) {
|
|
95
|
+
if (!expiresAt)
|
|
96
|
+
return false;
|
|
97
|
+
const expiryMs = Date.parse(expiresAt);
|
|
98
|
+
// A present-but-corrupt lifecycle boundary must not silently become a legacy non-expiring token.
|
|
99
|
+
return !Number.isFinite(expiryMs) || expiryMs <= now.getTime();
|
|
100
|
+
}
|
|
101
|
+
/** Only warn near the boundary; healthy week-long tokens should not add startup noise. */
|
|
102
|
+
export function deviceTokenExpiryWarning(expiresAt, now = new Date()) {
|
|
103
|
+
if (!expiresAt)
|
|
104
|
+
return null;
|
|
105
|
+
const expiryMs = Date.parse(expiresAt);
|
|
106
|
+
if (!Number.isFinite(expiryMs))
|
|
107
|
+
return "organization access expiry is unreadable; re-enroll this profile";
|
|
108
|
+
const remainingMs = expiryMs - now.getTime();
|
|
109
|
+
if (remainingMs <= 0)
|
|
110
|
+
return "organization access expired; re-enroll this profile before running a task";
|
|
111
|
+
if (remainingMs > 24 * 60 * 60_000)
|
|
112
|
+
return null;
|
|
113
|
+
const remainingMinutes = Math.max(1, Math.ceil(remainingMs / 60_000));
|
|
114
|
+
const remaining = remainingMinutes < 60
|
|
115
|
+
? `${remainingMinutes}m`
|
|
116
|
+
: `${Math.ceil(remainingMinutes / 60)}h`;
|
|
117
|
+
return `organization access expires in ${remaining}; ask your admin for a new enrollment code`;
|
|
118
|
+
}
|
|
82
119
|
/** Exchange a one-time code for a device token at the gateway, persist it, and return the Enrollment. */
|
|
83
120
|
export async function enrollDevice(gatewayUrl, code, signal) {
|
|
84
121
|
const base = gatewayUrl.replace(/\/$/, "");
|
package/dist/profile/profile.js
CHANGED
|
@@ -98,6 +98,7 @@ function readDefaultOrgFromOrgJson(e) {
|
|
|
98
98
|
defaultModel,
|
|
99
99
|
availableModels: defaultModel ? [defaultModel] : [],
|
|
100
100
|
enrolledAt: e.enrolledAt || new Date().toISOString(),
|
|
101
|
+
tokenExpiresAt: typeof e.expiresAt === "string" ? e.expiresAt : undefined,
|
|
101
102
|
};
|
|
102
103
|
}
|
|
103
104
|
/** First-time migration. Idempotent — running again is a no-op (profiles.json already present). */
|
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();
|