@nanhara/hara 0.127.1 → 0.128.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 CHANGED
@@ -5,6 +5,31 @@ 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.128.0 — 2026-07-20 — local deliverable foundation
9
+
10
+ - Hara Serve now provides the first `artifact/1` local runtime through authenticated
11
+ `artifact.import`, `artifact.list`, `artifact.get`, and `artifact.revisions` methods. Presentations,
12
+ spreadsheets, and documents receive opaque Artifact/revision identities and a stable content receipt.
13
+ - Import is copy-on-write and leaves the selected file untouched. The private store rejects relative paths,
14
+ symbolic links, hard links, protected credential/configuration files, macro-enabled Office extensions,
15
+ type-confused Office packages, empty files, and files over 64 MiB; activation and immutable binary writes
16
+ are crash-safe, owner-only, and digest-verified.
17
+ - Local metadata never retains the selected absolute path. Corrupt entries remain hidden without hiding
18
+ healthy work, and unexpected filesystem failures cross the Desktop RPC boundary as path-free errors.
19
+ - This release intentionally stores and verifies deliverables but does not render, edit, export, or execute
20
+ them. Those actions remain gated on a matching reviewed Office capability in the next vertical slices.
21
+ - Upgrade with `npm i -g @nanhara/hara@0.128.0`.
22
+
23
+ ## 0.127.2 — 2026-07-20 — quiet scheduled monitoring
24
+
25
+ - Cron delivery now supports `--deliver-mode always|on-output|on-error`. Existing jobs retain the
26
+ `always` heartbeat; high-frequency monitors can use `on-output` to stay silent unless redacted stdout
27
+ is non-empty, while `on-error` sends only failed outcomes.
28
+ - Failure streak alarms remain independent of outcome filtering, so an `on-output` watchdog still sends
29
+ its configured `--alert-after N` alert even when routine runs and stderr-only diagnostics are quiet.
30
+ - CLI, agent tool, and Desktop Serve automation APIs validate, persist, list, and display the same policy.
31
+ - Upgrade with `npm i -g @nanhara/hara@0.127.2`.
32
+
8
33
  ## 0.127.1 — 2026-07-20 — managed access lifecycle
9
34
 
10
35
  - Managed enrollment now persists the control plane's authoritative device-token expiry. Hara warns
package/README.md CHANGED
@@ -272,9 +272,16 @@ Type-ahead Enter steers the exact live turn; `/next <message>` queues a separate
272
272
  clients use `session.steer` with `expectedTurnId` (durable before ACK) and may pass `newTask: true` to force a
273
273
  new execution. The `hara resume` launcher preserves terminal input in both npm/Node and standalone-binary
274
274
  installs.
275
+ **Local deliverables**: Hara Serve 0.128 adds the first local `artifact/1` foundation for presentations,
276
+ spreadsheets, and documents. An authenticated Desktop client can import, list, integrity-check, and inspect
277
+ version history through `artifact.import|list|get|revisions`. Import makes an owner-only immutable copy under
278
+ `~/.hara/artifacts`, never changes the selected file, and never stores its absolute source path. Relative,
279
+ linked, protected, macro-enabled, type-confused, empty, and oversized inputs fail closed. This foundation
280
+ does **not** render, edit, export, or execute imported content; those operations require a matching reviewed
281
+ Office capability.
275
282
  **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
283
  **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.
284
+ **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
285
  **Work coordination**: `todo_write` is the agent's short, session-scoped checklist; it persists with that
279
286
  session and is isolated between simultaneous sub-agents and serve sessions. `task` is the durable project pool
280
287
  for work that outlives a conversation: add/update/list/remove items with `pending|in_progress|done`, an optional
@@ -0,0 +1,530 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { closeSync, constants, fstatSync, fsyncSync, lstatSync, openSync, readSync, readdirSync, realpathSync, renameSync, } from "node:fs";
3
+ import { basename, extname, isAbsolute, join, resolve } from "node:path";
4
+ import { openVerifiedRegularFileNoFollow, verifyOpenedRegularFileSync, } from "../fs-read.js";
5
+ import { sameOpenedFileIdentity } from "../fs-identity.js";
6
+ import { optionalPosixOpenFlag } from "../fs-open-flags.js";
7
+ import { bindPrivateHaraStateFile, ensurePrivateStateSubdirectory, readPrivateStateFileSnapshotSync, writePrivateStateBytesOnceSync, writePrivateStateFileSync, } from "../security/private-state.js";
8
+ export const ARTIFACT_PROTOCOL_VERSION = "artifact/1";
9
+ export const MAX_ARTIFACT_IMPORT_BYTES = 64 * 1024 * 1024;
10
+ const MAX_ARTIFACTS = 10_000;
11
+ const MAX_REVISIONS = 10_000;
12
+ const JSON_LIMIT = 512 * 1024;
13
+ const ARTIFACT_ID = /^art_[a-f0-9]{32}$/;
14
+ const REVISION_ID = /^rev_[a-f0-9]{32}$/;
15
+ const SAFE_CONTENT_REF = /^content\.[a-z0-9]{1,12}$/;
16
+ export class ArtifactStoreError extends Error {
17
+ code;
18
+ constructor(code, message, options) {
19
+ super(message, options);
20
+ this.code = code;
21
+ this.name = "ArtifactStoreError";
22
+ }
23
+ }
24
+ const FORMATS = new Map([
25
+ [".pptx", { kind: "presentation", mediaType: "application/vnd.openxmlformats-officedocument.presentationml.presentation" }],
26
+ [".ppt", { kind: "presentation", mediaType: "application/vnd.ms-powerpoint" }],
27
+ [".odp", { kind: "presentation", mediaType: "application/vnd.oasis.opendocument.presentation" }],
28
+ [".xlsx", { kind: "spreadsheet", mediaType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }],
29
+ [".xls", { kind: "spreadsheet", mediaType: "application/vnd.ms-excel" }],
30
+ [".csv", { kind: "spreadsheet", mediaType: "text/csv" }],
31
+ [".ods", { kind: "spreadsheet", mediaType: "application/vnd.oasis.opendocument.spreadsheet" }],
32
+ [".docx", { kind: "document", mediaType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }],
33
+ [".doc", { kind: "document", mediaType: "application/msword" }],
34
+ [".md", { kind: "document", mediaType: "text/markdown" }],
35
+ [".txt", { kind: "document", mediaType: "text/plain" }],
36
+ [".rtf", { kind: "document", mediaType: "application/rtf" }],
37
+ [".odt", { kind: "document", mediaType: "application/vnd.oasis.opendocument.text" }],
38
+ ]);
39
+ const MACRO_FORMATS = new Set([".pptm", ".ppsm", ".xlsm", ".xltm", ".docm", ".dotm"]);
40
+ const ZIP_FORMATS = new Set([".pptx", ".odp", ".xlsx", ".ods", ".docx", ".odt"]);
41
+ const COMPOUND_FORMATS = new Set([".ppt", ".xls", ".doc"]);
42
+ const newOpaqueId = (prefix) => `${prefix}_${randomUUID().replaceAll("-", "")}`;
43
+ const storeError = (code, message, cause) => new ArtifactStoreError(code, message, cause === undefined ? undefined : { cause });
44
+ function verifyDirectory(identity) {
45
+ const info = lstatSync(identity.path);
46
+ if (!info.isDirectory()
47
+ || info.isSymbolicLink()
48
+ || info.dev !== identity.dev
49
+ || info.ino !== identity.ino
50
+ || realpathSync.native(identity.path) !== identity.path)
51
+ throw storeError("ARTIFACT_CORRUPT", "private artifact directory identity changed");
52
+ }
53
+ function artifactRoot(home) {
54
+ return ensurePrivateStateSubdirectory(home, [".hara", "artifacts"]);
55
+ }
56
+ function parseJsonFile(path) {
57
+ const snapshot = readPrivateStateFileSnapshotSync(path, JSON_LIMIT);
58
+ if (!snapshot)
59
+ throw storeError("ARTIFACT_CORRUPT", "artifact metadata is missing");
60
+ try {
61
+ return JSON.parse(snapshot.text);
62
+ }
63
+ catch (error) {
64
+ throw storeError("ARTIFACT_CORRUPT", "artifact metadata is not valid JSON", error);
65
+ }
66
+ }
67
+ function plainRecord(value) {
68
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
69
+ }
70
+ function exactKeys(value, required, optional) {
71
+ const allowed = new Set([...required, ...optional]);
72
+ return required.every((key) => Object.hasOwn(value, key))
73
+ && Object.keys(value).every((key) => allowed.has(key));
74
+ }
75
+ function isArtifactLockRef(value) {
76
+ if (!plainRecord(value) || !exactKeys(value, ["id", "version", "sha256"], []))
77
+ return false;
78
+ return typeof value.id === "string"
79
+ && value.id.length >= 1
80
+ && value.id.length <= 256
81
+ && typeof value.version === "string"
82
+ && value.version.length >= 1
83
+ && value.version.length <= 128
84
+ && typeof value.sha256 === "string"
85
+ && /^[a-f0-9]{64}$/.test(value.sha256);
86
+ }
87
+ function isArtifactRecord(value) {
88
+ if (!plainRecord(value) || !exactKeys(value, ["protocol", "artifactId", "kind", "title", "currentRevisionId"], ["origin", "dataResidency", "capabilityLock", "templateLock"]))
89
+ return false;
90
+ return value.protocol === ARTIFACT_PROTOCOL_VERSION
91
+ && typeof value.artifactId === "string"
92
+ && ARTIFACT_ID.test(value.artifactId)
93
+ && (value.kind === "presentation" || value.kind === "spreadsheet" || value.kind === "document")
94
+ && typeof value.title === "string"
95
+ && value.title.length >= 1
96
+ && value.title.length <= 1_024
97
+ && typeof value.currentRevisionId === "string"
98
+ && REVISION_ID.test(value.currentRevisionId)
99
+ && (value.origin === undefined || (typeof value.origin === "string" && value.origin.length <= 256))
100
+ && (value.dataResidency === undefined
101
+ || value.dataResidency === "local"
102
+ || value.dataResidency === "cn"
103
+ || value.dataResidency === "global")
104
+ && (value.capabilityLock === undefined || isArtifactLockRef(value.capabilityLock))
105
+ && (value.templateLock === undefined || isArtifactLockRef(value.templateLock));
106
+ }
107
+ function isRevision(value) {
108
+ if (!plainRecord(value) || !exactKeys(value, [
109
+ "revisionId",
110
+ "artifactId",
111
+ "baseRevisionId",
112
+ "actor",
113
+ "contentRef",
114
+ "assetRefs",
115
+ "contentDigest",
116
+ "changedPaths",
117
+ "createdAt",
118
+ ], ["parentRevisionId", "taskRunId"]))
119
+ return false;
120
+ return typeof value.revisionId === "string"
121
+ && REVISION_ID.test(value.revisionId)
122
+ && typeof value.artifactId === "string"
123
+ && ARTIFACT_ID.test(value.artifactId)
124
+ && typeof value.baseRevisionId === "string"
125
+ && REVISION_ID.test(value.baseRevisionId)
126
+ && (value.parentRevisionId === undefined
127
+ || (typeof value.parentRevisionId === "string" && REVISION_ID.test(value.parentRevisionId)))
128
+ && (value.actor === "user" || value.actor === "agent" || value.actor === "migration")
129
+ && (value.taskRunId === undefined || (typeof value.taskRunId === "string" && value.taskRunId.length > 0))
130
+ && typeof value.contentRef === "string"
131
+ && SAFE_CONTENT_REF.test(value.contentRef)
132
+ && Array.isArray(value.assetRefs)
133
+ && value.assetRefs.length <= 10_000
134
+ && value.assetRefs.every((entry) => typeof entry === "string" && entry.length > 0)
135
+ && typeof value.contentDigest === "string"
136
+ && /^[a-f0-9]{64}$/.test(value.contentDigest)
137
+ && Array.isArray(value.changedPaths)
138
+ && value.changedPaths.length <= 10_000
139
+ && value.changedPaths.every((entry) => typeof entry === "string" && entry.length > 0)
140
+ && typeof value.createdAt === "string"
141
+ && Number.isFinite(Date.parse(value.createdAt));
142
+ }
143
+ function isContentInfo(value) {
144
+ if (!plainRecord(value) || !exactKeys(value, ["contentRef", "extension", "mediaType", "byteSize", "sha256"], []))
145
+ return false;
146
+ return typeof value.contentRef === "string"
147
+ && SAFE_CONTENT_REF.test(value.contentRef)
148
+ && typeof value.extension === "string"
149
+ && /^\.[a-z0-9]{1,12}$/.test(value.extension)
150
+ && value.contentRef === `content${value.extension}`
151
+ && typeof value.mediaType === "string"
152
+ && value.mediaType.length > 0
153
+ && typeof value.byteSize === "number"
154
+ && Number.isSafeInteger(value.byteSize)
155
+ && value.byteSize >= 0
156
+ && value.byteSize <= MAX_ARTIFACT_IMPORT_BYTES
157
+ && typeof value.sha256 === "string"
158
+ && /^[a-f0-9]{64}$/.test(value.sha256);
159
+ }
160
+ function checkedArtifactId(value) {
161
+ if (!ARTIFACT_ID.test(value)) {
162
+ throw storeError("ARTIFACT_INVALID_INPUT", "artifactId is not a valid opaque Artifact id");
163
+ }
164
+ return value;
165
+ }
166
+ function checkedRevisionId(value) {
167
+ if (!REVISION_ID.test(value)) {
168
+ throw storeError("ARTIFACT_CORRUPT", "revision id is invalid");
169
+ }
170
+ return value;
171
+ }
172
+ function childDirectory(parent, name, notFoundMessage) {
173
+ verifyDirectory(parent);
174
+ const path = join(parent.path, name);
175
+ let info;
176
+ try {
177
+ info = lstatSync(path);
178
+ }
179
+ catch (error) {
180
+ if (error?.code === "ENOENT")
181
+ throw storeError("ARTIFACT_NOT_FOUND", notFoundMessage);
182
+ throw error;
183
+ }
184
+ if (!info.isDirectory()
185
+ || info.isSymbolicLink()
186
+ || realpathSync.native(path) !== path)
187
+ throw storeError("ARTIFACT_CORRUPT", "artifact directory is not a canonical private directory");
188
+ verifyDirectory(parent);
189
+ return { path, dev: info.dev, ino: info.ino };
190
+ }
191
+ function artifactDirectory(home, artifactId) {
192
+ return childDirectory(artifactRoot(home), checkedArtifactId(artifactId), `no artifact ${artifactId}`);
193
+ }
194
+ function revisionDirectory(artifact, revisionId) {
195
+ const revisions = childDirectory(artifact, "revisions", "artifact revisions are missing");
196
+ return childDirectory(revisions, checkedRevisionId(revisionId), `no revision ${revisionId}`);
197
+ }
198
+ function inspectContent(revision, content, verifyDigest) {
199
+ verifyDirectory(revision);
200
+ const path = join(revision.path, content.contentRef);
201
+ const before = lstatSync(path);
202
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) {
203
+ throw storeError("ARTIFACT_CORRUPT", "artifact content is not an immutable regular file");
204
+ }
205
+ const fd = openSync(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK") | optionalPosixOpenFlag("O_NOFOLLOW"));
206
+ try {
207
+ const opened = fstatSync(fd);
208
+ if (!opened.isFile()
209
+ || opened.nlink !== 1
210
+ || !sameOpenedFileIdentity(before, opened)
211
+ || opened.size !== content.byteSize
212
+ || (process.platform !== "win32" && (opened.mode & 0o777) !== 0o600))
213
+ throw storeError("ARTIFACT_CORRUPT", "artifact content identity or size does not match its receipt");
214
+ if (verifyDigest) {
215
+ const hash = createHash("sha256");
216
+ const chunk = Buffer.allocUnsafe(64 * 1024);
217
+ let position = 0;
218
+ for (;;) {
219
+ const read = readSync(fd, chunk, 0, chunk.length, position);
220
+ if (!read)
221
+ break;
222
+ hash.update(chunk.subarray(0, read));
223
+ position += read;
224
+ }
225
+ if (hash.digest("hex") !== content.sha256) {
226
+ throw storeError("ARTIFACT_CORRUPT", "artifact content digest does not match its receipt");
227
+ }
228
+ }
229
+ const after = fstatSync(fd);
230
+ const linked = lstatSync(path);
231
+ if (!sameOpenedFileIdentity(opened, after)
232
+ || opened.size !== after.size
233
+ || opened.mtimeMs !== after.mtimeMs
234
+ || opened.ctimeMs !== after.ctimeMs
235
+ || !sameOpenedFileIdentity(after, linked))
236
+ throw storeError("ARTIFACT_CORRUPT", "artifact content changed while it was inspected");
237
+ }
238
+ finally {
239
+ closeSync(fd);
240
+ }
241
+ verifyDirectory(revision);
242
+ }
243
+ function readArtifactDetailsFromDirectory(directory, verifyDigest) {
244
+ verifyDirectory(directory);
245
+ const artifactValue = parseJsonFile(join(directory.path, "metadata.json"));
246
+ if (!isArtifactRecord(artifactValue) || artifactValue.artifactId !== basename(directory.path)) {
247
+ throw storeError("ARTIFACT_CORRUPT", "artifact metadata does not match its directory");
248
+ }
249
+ const currentRevisionId = checkedRevisionId(artifactValue.currentRevisionId);
250
+ const revisionDir = revisionDirectory(directory, currentRevisionId);
251
+ const revisionValue = parseJsonFile(join(revisionDir.path, "revision.json"));
252
+ const contentValue = parseJsonFile(join(revisionDir.path, "content.json"));
253
+ if (!isRevision(revisionValue)
254
+ || revisionValue.revisionId !== currentRevisionId
255
+ || revisionValue.artifactId !== artifactValue.artifactId)
256
+ throw storeError("ARTIFACT_CORRUPT", "current revision metadata does not match its Artifact");
257
+ if (!isContentInfo(contentValue)
258
+ || contentValue.contentRef !== revisionValue.contentRef
259
+ || contentValue.sha256 !== revisionValue.contentDigest)
260
+ throw storeError("ARTIFACT_CORRUPT", "current revision content receipt is inconsistent");
261
+ inspectContent(revisionDir, contentValue, verifyDigest);
262
+ verifyDirectory(directory);
263
+ return { artifact: artifactValue, currentRevision: revisionValue, content: contentValue };
264
+ }
265
+ function titleFor(sourcePath, extension, requested, kind) {
266
+ const raw = requested ?? basename(sourcePath, extension);
267
+ const cleaned = raw
268
+ .normalize("NFC")
269
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
270
+ .replace(/\s+/g, " ")
271
+ .trim();
272
+ const title = cleaned || `Untitled ${kind}`;
273
+ if (title.length > 1_024) {
274
+ throw storeError("ARTIFACT_INVALID_INPUT", "artifact title must be at most 1024 characters");
275
+ }
276
+ return title;
277
+ }
278
+ async function readImportSource(sourcePath) {
279
+ let verified;
280
+ try {
281
+ verified = await openVerifiedRegularFileNoFollow(sourcePath, {
282
+ action: "import as an Artifact",
283
+ rejectHardLinks: true,
284
+ protectSensitive: true,
285
+ });
286
+ }
287
+ catch (error) {
288
+ if (error?.code === "ENOENT") {
289
+ throw storeError("ARTIFACT_SOURCE_REJECTED", "the selected source file no longer exists", error);
290
+ }
291
+ if (error?.code === "HARA_PROTECTED_CONTEXT_FILE") {
292
+ throw storeError("ARTIFACT_SOURCE_REJECTED", "protected credential/configuration files cannot be imported", error);
293
+ }
294
+ if (error?.code === "HARA_HARD_LINKED_FILE") {
295
+ throw storeError("ARTIFACT_SOURCE_REJECTED", "hard-linked source files cannot be imported safely", error);
296
+ }
297
+ if (error?.code === "HARA_NOT_REGULAR_FILE" || error?.code === "ELOOP") {
298
+ throw storeError("ARTIFACT_SOURCE_REJECTED", "the selected source must be a regular file, not a link or device", error);
299
+ }
300
+ throw error;
301
+ }
302
+ try {
303
+ const { handle, info } = verified;
304
+ if (info.size > MAX_ARTIFACT_IMPORT_BYTES) {
305
+ throw storeError("ARTIFACT_TOO_LARGE", `the selected file exceeds the ${MAX_ARTIFACT_IMPORT_BYTES / (1024 * 1024)} MiB import limit`);
306
+ }
307
+ if (info.size === 0) {
308
+ throw storeError("ARTIFACT_INVALID_INPUT", "an empty file cannot be imported as an Artifact");
309
+ }
310
+ const bytes = Buffer.allocUnsafe(info.size);
311
+ let position = 0;
312
+ while (position < bytes.length) {
313
+ const { bytesRead } = await handle.read(bytes, position, bytes.length - position, position);
314
+ if (!bytesRead) {
315
+ throw storeError("ARTIFACT_SOURCE_REJECTED", "the selected file changed while it was being imported");
316
+ }
317
+ position += bytesRead;
318
+ }
319
+ const after = await handle.stat();
320
+ verifyOpenedRegularFileSync(sourcePath, after, {
321
+ action: "import as an Artifact",
322
+ rejectHardLinks: true,
323
+ protectSensitive: true,
324
+ });
325
+ if (after.dev !== info.dev
326
+ || after.ino !== info.ino
327
+ || after.size !== info.size
328
+ || after.mtimeMs !== info.mtimeMs
329
+ || after.ctimeMs !== info.ctimeMs)
330
+ throw storeError("ARTIFACT_SOURCE_REJECTED", "the selected file changed while it was being imported");
331
+ return bytes;
332
+ }
333
+ finally {
334
+ await verified.handle.close().catch(() => { });
335
+ }
336
+ }
337
+ function verifyClaimedFormat(extension, bytes) {
338
+ const startsWith = (signature) => bytes.length >= signature.length
339
+ && signature.every((value, index) => bytes[index] === value);
340
+ if (ZIP_FORMATS.has(extension) && !startsWith([0x50, 0x4b, 0x03, 0x04])) {
341
+ throw storeError("ARTIFACT_SOURCE_REJECTED", "the selected Office file does not match its extension; save an unencrypted, macro-free copy and try again");
342
+ }
343
+ if (COMPOUND_FORMATS.has(extension)
344
+ && !startsWith([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1])) {
345
+ throw storeError("ARTIFACT_SOURCE_REJECTED", "the selected legacy Office file does not match its extension");
346
+ }
347
+ if (extension === ".rtf"
348
+ && !bytes.subarray(0, Math.min(bytes.length, 32)).toString("utf8").replace(/^\uFEFF/, "").startsWith("{\\rtf")) {
349
+ throw storeError("ARTIFACT_SOURCE_REJECTED", "the selected RTF file does not match its extension");
350
+ }
351
+ }
352
+ function syncDirectory(path) {
353
+ let fd;
354
+ try {
355
+ fd = openSync(path, constants.O_RDONLY
356
+ | optionalPosixOpenFlag("O_NONBLOCK")
357
+ | optionalPosixOpenFlag("O_NOFOLLOW")
358
+ | optionalPosixOpenFlag("O_DIRECTORY"));
359
+ fsyncSync(fd);
360
+ }
361
+ catch {
362
+ /* Directory fsync is not portable; every staged file is still fsync'd before activation. */
363
+ }
364
+ finally {
365
+ if (fd !== undefined)
366
+ closeSync(fd);
367
+ }
368
+ }
369
+ function activateStaging(root, staging, artifactId) {
370
+ verifyDirectory(root);
371
+ verifyDirectory(staging);
372
+ const destination = join(root.path, artifactId);
373
+ try {
374
+ lstatSync(destination);
375
+ throw storeError("ARTIFACT_CORRUPT", "an Artifact id collision occurred; retry the import");
376
+ }
377
+ catch (error) {
378
+ if (error?.code !== "ENOENT")
379
+ throw error;
380
+ }
381
+ renameSync(staging.path, destination);
382
+ const activated = lstatSync(destination);
383
+ if (!activated.isDirectory()
384
+ || activated.isSymbolicLink()
385
+ || activated.dev !== staging.dev
386
+ || activated.ino !== staging.ino
387
+ || realpathSync.native(destination) !== destination)
388
+ throw storeError("ARTIFACT_CORRUPT", "Artifact activation changed the staged directory identity");
389
+ verifyDirectory(root);
390
+ syncDirectory(root.path);
391
+ }
392
+ export async function importArtifact(home, input) {
393
+ if (typeof input.sourcePath !== "string" || !input.sourcePath || input.sourcePath.length > 4_096) {
394
+ throw storeError("ARTIFACT_INVALID_INPUT", "sourcePath must be a non-empty path of at most 4096 characters");
395
+ }
396
+ if (!isAbsolute(input.sourcePath)) {
397
+ throw storeError("ARTIFACT_INVALID_INPUT", "sourcePath must be an absolute path selected by the user");
398
+ }
399
+ const sourcePath = resolve(input.sourcePath);
400
+ const extension = extname(sourcePath).toLowerCase();
401
+ if (MACRO_FORMATS.has(extension)) {
402
+ throw storeError("ARTIFACT_SOURCE_REJECTED", "macro-enabled Office files are not supported; save a macro-free copy before importing");
403
+ }
404
+ const format = FORMATS.get(extension);
405
+ if (!format) {
406
+ throw storeError("ARTIFACT_INVALID_INPUT", "supported files are PPT/PPTX/ODP, XLS/XLSX/CSV/ODS, DOC/DOCX/ODT/RTF, Markdown, and text");
407
+ }
408
+ if (input.kind !== undefined && input.kind !== format.kind) {
409
+ throw storeError("ARTIFACT_INVALID_INPUT", `the selected file is ${format.kind}, not ${input.kind}`);
410
+ }
411
+ if (input.kind !== undefined
412
+ && input.kind !== "presentation"
413
+ && input.kind !== "spreadsheet"
414
+ && input.kind !== "document")
415
+ throw storeError("ARTIFACT_INVALID_INPUT", "kind must be presentation, spreadsheet, or document");
416
+ if (input.title !== undefined && typeof input.title !== "string") {
417
+ throw storeError("ARTIFACT_INVALID_INPUT", "title must be a string");
418
+ }
419
+ const bytes = await readImportSource(sourcePath);
420
+ verifyClaimedFormat(extension, bytes);
421
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
422
+ const artifactId = newOpaqueId("art");
423
+ const revisionId = newOpaqueId("rev");
424
+ const now = new Date().toISOString();
425
+ const contentRef = `content${extension}`;
426
+ const artifact = {
427
+ protocol: ARTIFACT_PROTOCOL_VERSION,
428
+ artifactId,
429
+ kind: format.kind,
430
+ title: titleFor(sourcePath, extension, input.title, format.kind),
431
+ currentRevisionId: revisionId,
432
+ origin: "local-import",
433
+ dataResidency: "local",
434
+ };
435
+ const revision = {
436
+ revisionId,
437
+ artifactId,
438
+ baseRevisionId: revisionId,
439
+ actor: "user",
440
+ contentRef,
441
+ assetRefs: [],
442
+ contentDigest: sha256,
443
+ changedPaths: ["content"],
444
+ createdAt: now,
445
+ };
446
+ const content = {
447
+ contentRef,
448
+ extension,
449
+ mediaType: format.mediaType,
450
+ byteSize: bytes.byteLength,
451
+ sha256,
452
+ };
453
+ const root = artifactRoot(home);
454
+ const stagingName = `.staging-${artifactId}-${randomUUID().replaceAll("-", "")}`;
455
+ const staging = ensurePrivateStateSubdirectory(home, [".hara", "artifacts", stagingName]);
456
+ ensurePrivateStateSubdirectory(home, [".hara", "artifacts", stagingName, "revisions", revisionId]);
457
+ writePrivateStateBytesOnceSync(bindPrivateHaraStateFile(home, ["artifacts", stagingName, "revisions", revisionId], contentRef), bytes);
458
+ writePrivateStateFileSync(bindPrivateHaraStateFile(home, ["artifacts", stagingName, "revisions", revisionId], "content.json"), `${JSON.stringify(content, null, 2)}\n`);
459
+ writePrivateStateFileSync(bindPrivateHaraStateFile(home, ["artifacts", stagingName, "revisions", revisionId], "revision.json"), `${JSON.stringify(revision, null, 2)}\n`);
460
+ writePrivateStateFileSync(bindPrivateHaraStateFile(home, ["artifacts", stagingName], "metadata.json"), `${JSON.stringify(artifact, null, 2)}\n`);
461
+ activateStaging(root, staging, artifactId);
462
+ return getArtifact(home, artifactId, false);
463
+ }
464
+ export function getArtifact(home, artifactId, verifyDigest = true) {
465
+ return readArtifactDetailsFromDirectory(artifactDirectory(home, artifactId), verifyDigest);
466
+ }
467
+ export function listArtifacts(home) {
468
+ const root = artifactRoot(home);
469
+ verifyDirectory(root);
470
+ const entries = readdirSync(root.path, { withFileTypes: true });
471
+ const candidates = entries.filter((entry) => !entry.name.startsWith("."));
472
+ const artifacts = [];
473
+ let invalid = 0;
474
+ for (const entry of candidates.slice(0, MAX_ARTIFACTS)) {
475
+ if (!entry.isDirectory() || !ARTIFACT_ID.test(entry.name)) {
476
+ invalid += 1;
477
+ continue;
478
+ }
479
+ try {
480
+ const directory = childDirectory(root, entry.name, `no artifact ${entry.name}`);
481
+ const details = readArtifactDetailsFromDirectory(directory, false);
482
+ artifacts.push({
483
+ artifactId: details.artifact.artifactId,
484
+ kind: details.artifact.kind,
485
+ title: details.artifact.title,
486
+ currentRevisionId: details.artifact.currentRevisionId,
487
+ updatedAt: details.currentRevision.createdAt,
488
+ extension: details.content.extension,
489
+ mediaType: details.content.mediaType,
490
+ byteSize: details.content.byteSize,
491
+ });
492
+ }
493
+ catch {
494
+ invalid += 1;
495
+ }
496
+ }
497
+ verifyDirectory(root);
498
+ artifacts.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.artifactId.localeCompare(b.artifactId));
499
+ return {
500
+ artifacts,
501
+ invalid,
502
+ truncated: candidates.length > MAX_ARTIFACTS,
503
+ };
504
+ }
505
+ export function listArtifactRevisions(home, artifactId) {
506
+ const artifact = artifactDirectory(home, artifactId);
507
+ const metadata = parseJsonFile(join(artifact.path, "metadata.json"));
508
+ if (!isArtifactRecord(metadata) || metadata.artifactId !== artifactId) {
509
+ throw storeError("ARTIFACT_CORRUPT", "artifact metadata does not match its directory");
510
+ }
511
+ const revisions = childDirectory(artifact, "revisions", "artifact revisions are missing");
512
+ const entries = readdirSync(revisions.path, { withFileTypes: true });
513
+ if (entries.length > MAX_REVISIONS) {
514
+ throw storeError("ARTIFACT_CORRUPT", `artifact has more than ${MAX_REVISIONS} revisions`);
515
+ }
516
+ const out = [];
517
+ for (const entry of entries) {
518
+ if (!entry.isDirectory() || !REVISION_ID.test(entry.name)) {
519
+ throw storeError("ARTIFACT_CORRUPT", "artifact contains an invalid revision entry");
520
+ }
521
+ const directory = childDirectory(revisions, entry.name, `no revision ${entry.name}`);
522
+ const value = parseJsonFile(join(directory.path, "revision.json"));
523
+ if (!isRevision(value) || value.revisionId !== entry.name || value.artifactId !== artifactId) {
524
+ throw storeError("ARTIFACT_CORRUPT", "revision metadata does not match its directory");
525
+ }
526
+ out.push(value);
527
+ }
528
+ verifyDirectory(revisions);
529
+ return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || a.revisionId.localeCompare(b.revisionId));
530
+ }
@@ -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
  });
@@ -8,7 +8,7 @@ import { basename, dirname, join, resolve } from "node:path";
8
8
  import { FileReadLimitError, MAX_EDIT_READ_BYTES, decodeUtf8Strict, openVerifiedRegularFileNoFollow, verifyOpenedRegularFileSync, } from "../fs-read.js";
9
9
  import { optionalPosixOpenFlag } from "../fs-open-flags.js";
10
10
  import { sameOpenedFileIdentity } from "../fs-identity.js";
11
- const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin", "tool-results", "plugin-receipts"]);
11
+ const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin", "tool-results", "plugin-receipts", "artifacts"]);
12
12
  const tightenedHomes = new Set();
13
13
  const DEFAULT_MIGRATION_CAP = 50_000;
14
14
  function isMissing(error) {
@@ -300,6 +300,99 @@ function syncPrivateDirectory(path) {
300
300
  /* Directory fsync is not portable; the staged file itself is always fsynced. */
301
301
  }
302
302
  }
303
+ /**
304
+ * Crash-safe immutable binary creation below a bound private-state directory.
305
+ *
306
+ * Artifact revisions use this instead of the text replacement writer: revision content is immutable,
307
+ * may be a binary Office file, and must never replace an entry that appeared concurrently. The staged
308
+ * inode is fsync'd, hard-linked into place with create-if-absent semantics, and checked again after the
309
+ * temporary name is removed.
310
+ */
311
+ export function writePrivateStateBytesOnceSync(binding, bytes) {
312
+ const { directory, path } = binding;
313
+ verifyPrivateDirectory(directory);
314
+ if (resolve(path) !== join(directory.path, checkedPrivateComponent(basename(path)))) {
315
+ throw new Error(`private Hara state file is outside '${directory.path}'`);
316
+ }
317
+ try {
318
+ lstatSync(path);
319
+ throw new Error(`private Hara state file already exists: '${path}'`);
320
+ }
321
+ catch (error) {
322
+ if (error?.code !== "ENOENT")
323
+ throw error;
324
+ }
325
+ const temp = join(directory.path, `.hara-private-${process.pid}-${randomUUID()}.tmp`);
326
+ let fd;
327
+ let staged;
328
+ try {
329
+ fd = openSync(temp, "wx", 0o600);
330
+ writeFileSync(fd, bytes);
331
+ try {
332
+ fchmodSync(fd, 0o600);
333
+ }
334
+ catch (error) {
335
+ if (process.platform !== "win32")
336
+ throw error;
337
+ }
338
+ fsyncSync(fd);
339
+ const stagedInfo = fstatSync(fd);
340
+ if (!stagedInfo.isFile() || stagedInfo.nlink !== 1) {
341
+ throw new Error("unsafe private Hara binary staging inode");
342
+ }
343
+ staged = {
344
+ dev: stagedInfo.dev,
345
+ ino: stagedInfo.ino,
346
+ mode: stagedInfo.mode & 0o777,
347
+ nlink: stagedInfo.nlink,
348
+ };
349
+ closeSync(fd);
350
+ fd = undefined;
351
+ verifyPrivateDirectory(directory);
352
+ try {
353
+ linkSync(temp, path);
354
+ }
355
+ catch (error) {
356
+ if (error?.code === "EEXIST") {
357
+ throw new Error(`private Hara state file changed before create: '${path}'`);
358
+ }
359
+ throw error;
360
+ }
361
+ const linkedStaged = { ...staged, nlink: staged.nlink + 1 };
362
+ if (!samePrivateFile(temp, linkedStaged) || !samePrivateFile(path, linkedStaged)) {
363
+ throw new Error(`private Hara binary staging identity changed during commit: '${path}'`
364
+ + `; expected=${JSON.stringify(linkedStaged)}`
365
+ + `; staging=${privateFileIdentitySummary(temp)}`
366
+ + `; target=${privateFileIdentitySummary(path)}`);
367
+ }
368
+ unlinkSync(temp);
369
+ const committed = lstatSync(path);
370
+ if (!committed.isFile()
371
+ || committed.isSymbolicLink()
372
+ || !sameOpenedFileIdentity(committed, staged)
373
+ || committed.nlink !== 1
374
+ || (process.platform !== "win32" && (committed.mode & 0o777) !== 0o600))
375
+ throw new Error(`private Hara state file changed during commit: '${path}'`);
376
+ verifyPrivateDirectory(directory);
377
+ syncPrivateDirectory(directory.path);
378
+ }
379
+ finally {
380
+ if (fd !== undefined)
381
+ try {
382
+ closeSync(fd);
383
+ }
384
+ catch { /* preserve original error */ }
385
+ if (staged) {
386
+ try {
387
+ if (samePrivateFile(temp, staged))
388
+ unlinkSync(temp);
389
+ }
390
+ catch {
391
+ /* A changed entry is retained rather than unlinking an attacker-supplied replacement. */
392
+ }
393
+ }
394
+ }
395
+ }
303
396
  /**
304
397
  * Crash-safe, no-follow, compare-and-swap replacement for one bound private state file. Existing entries
305
398
  * are move-claimed before verification so a concurrent alias/replacement is never overwritten silently.
@@ -24,9 +24,14 @@
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}
31
+ // artifact.import {sourcePath,title?,kind?} → {artifact,currentRevision,content}
32
+ // artifact.list {} → {artifacts,invalid,truncated}
33
+ // artifact.get {artifactId} → {artifact,currentRevision,content}
34
+ // artifact.revisions {artifactId} → {artifactId,revisions}
30
35
  // session.rename {sessionId,title} → {sessionId,title}
31
36
  // session.archive {sessionId,archived} → {sessionId,archived} (list hides archived unless {archived:true})
32
37
  // session.set-model {sessionId,model?,effort?} → {sessionId,model,effort} (next turn; refused mid-turn)
@@ -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";
@@ -36,12 +37,22 @@ import { readModelContextFileSync } from "../fs-read.js";
36
37
  import { optionalPosixOpenFlag } from "../fs-open-flags.js";
37
38
  import { sameOpenedFileIdentity } from "../fs-identity.js";
38
39
  import { redactSensitiveText, redactSensitiveValue } from "../security/secrets.js";
40
+ import { ArtifactStoreError, getArtifact, importArtifact, listArtifactRevisions, listArtifacts, } from "../artifacts/store.js";
39
41
  import { consumePendingTaskSteering, createTaskExecution, continueTaskExecution, finishTaskExecution, newSteerInteraction, newTurnInteraction, recordTaskSteering, requestsTaskContinuation, taskExecutionContext, } from "../session/task.js";
40
42
  const APPROVAL_TIMEOUT_MS = 300_000; // an unanswered approval denies after 5 min (never hangs a turn)
41
43
  const COMPACT_TIMEOUT_MS = 60_000;
42
44
  const SHUTDOWN_GRACE_MS = 2_000;
43
45
  const SOCKET_CLOSE_GRACE_MS = 250;
44
46
  const DISCOVERY_LOCK_WAIT_MS = 2_000;
47
+ const artifactRpcError = (id, error, action) => {
48
+ if (error instanceof ArtifactStoreError) {
49
+ const code = error.code === "ARTIFACT_CORRUPT" ? ERR.INTERNAL : ERR.PARAMS;
50
+ return rpcError(id, code, error.message);
51
+ }
52
+ return rpcError(id, ERR.INTERNAL, action === "import"
53
+ ? "Artifact import failed safely; the source file was not modified"
54
+ : `Artifact ${action} failed safely; local Artifact data was not changed`);
55
+ };
45
56
  const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
46
57
  const isPidAlive = (pid) => {
47
58
  if (!Number.isSafeInteger(pid) || pid <= 0)
@@ -348,6 +359,7 @@ export async function startServe(opts, deps) {
348
359
  };
349
360
  // Discovery file — the desktop shell reads this to find the running server (like a pid/port file).
350
361
  const discoveryDir = join(deps.discoveryHome ?? homedir(), ".hara");
362
+ const artifactHome = deps.artifactHome ?? homedir();
351
363
  const discoveryPath = join(discoveryDir, "serve.json");
352
364
  const discovery = { host: opts.host, port, token, pid: process.pid, version: deps.version, instanceId };
353
365
  if (!deps.quietDiscovery) {
@@ -730,6 +742,7 @@ export async function startServe(opts, deps) {
730
742
  "approval.reply", "plugins.list", "plugins.set", "skills.list", "models.list", "files.search", "project.panels",
731
743
  "settings.providers.list", "settings.providers.test", "settings.providers.save",
732
744
  "automation.list", "automation.add", "automation.toggle", "automation.delete",
745
+ "artifact.import", "artifact.list", "artifact.get", "artifact.revisions",
733
746
  "tasks.list", "approvals.list", "approvals.resolve",
734
747
  ];
735
748
  const runtime = runtimeInfo();
@@ -1034,7 +1047,20 @@ export async function startServe(opts, deps) {
1034
1047
  // The automation timeline's data: cron jobs with their last outcome, plus this machine's
1035
1048
  // automated sessions (source=cron/gateway) so the desktop can render results and "continue
1036
1049
  // 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) }));
1050
+ const jobs = loadJobs().map((j) => ({
1051
+ id: j.id,
1052
+ name: j.name,
1053
+ mode: j.mode,
1054
+ cwd: j.cwd,
1055
+ enabled: j.enabled,
1056
+ deliver: j.deliver,
1057
+ deliverMode: j.deliverMode ?? "always",
1058
+ alertAfter: j.alertAfter ?? 3,
1059
+ lastRunAt: j.lastRunAt,
1060
+ lastStatus: j.lastStatus,
1061
+ lastError: j.lastError,
1062
+ schedule: describeSchedule(j.schedule),
1063
+ }));
1038
1064
  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
1065
  return reply(rpcResult(id, { jobs, sessions: automated }));
1040
1066
  }
@@ -1066,6 +1092,24 @@ export async function startServe(opts, deps) {
1066
1092
  const sched = parseSchedule(p.schedule, Date.now());
1067
1093
  if ("error" in sched)
1068
1094
  return reply(rpcError(id, ERR.PARAMS, `bad schedule: ${sched.error}`));
1095
+ const deliver = typeof p.deliver === "string" && p.deliver ? p.deliver : undefined;
1096
+ if (deliver) {
1097
+ const parsed = parseDeliver(deliver);
1098
+ if ("error" in parsed)
1099
+ return reply(rpcError(id, ERR.PARAMS, parsed.error));
1100
+ }
1101
+ const deliverMode = typeof p.deliverMode === "string" && p.deliverMode
1102
+ ? p.deliverMode
1103
+ : undefined;
1104
+ if (deliverMode && !deliver)
1105
+ return reply(rpcError(id, ERR.PARAMS, "deliverMode requires deliver"));
1106
+ if (deliverMode && !["always", "on-output", "on-error"].includes(deliverMode)) {
1107
+ return reply(rpcError(id, ERR.PARAMS, "deliverMode must be always, on-output, or on-error"));
1108
+ }
1109
+ const alertAfter = p.alertAfter === undefined ? undefined : Number(p.alertAfter);
1110
+ if (alertAfter !== undefined && (!Number.isInteger(alertAfter) || alertAfter < 1 || alertAfter > 1_000)) {
1111
+ return reply(rpcError(id, ERR.PARAMS, "alertAfter must be an integer from 1 to 1000"));
1112
+ }
1069
1113
  const job = addJob({
1070
1114
  name: p.name.slice(0, 60),
1071
1115
  schedule: sched,
@@ -1073,6 +1117,9 @@ export async function startServe(opts, deps) {
1073
1117
  mode: ["print", "org", "command"].includes(p.mode) ? p.mode : "print",
1074
1118
  cwd: typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd,
1075
1119
  ...(typeof p.tz === "string" && p.tz ? { tz: p.tz } : {}),
1120
+ ...(deliver ? { deliver } : {}),
1121
+ ...(deliverMode ? { deliverMode: deliverMode } : {}),
1122
+ ...(alertAfter !== undefined ? { alertAfter } : {}),
1076
1123
  createdAt: Date.now(),
1077
1124
  });
1078
1125
  return reply(rpcResult(id, { id: job.id, name: job.name, schedule: describeSchedule(job.schedule) }));
@@ -1091,6 +1138,61 @@ export async function startServe(opts, deps) {
1091
1138
  return reply(rpcError(id, ERR.PARAMS, `no job ${p.id}`));
1092
1139
  return reply(rpcResult(id, { id: p.id, deleted: true }));
1093
1140
  }
1141
+ case "artifact.import": {
1142
+ if (typeof p.sourcePath !== "string" || !p.sourcePath) {
1143
+ return reply(rpcError(id, ERR.PARAMS, "sourcePath required"));
1144
+ }
1145
+ if (p.kind !== undefined
1146
+ && p.kind !== "presentation"
1147
+ && p.kind !== "spreadsheet"
1148
+ && p.kind !== "document")
1149
+ return reply(rpcError(id, ERR.PARAMS, "kind must be presentation, spreadsheet, or document"));
1150
+ if (p.title !== undefined && typeof p.title !== "string") {
1151
+ return reply(rpcError(id, ERR.PARAMS, "title must be a string"));
1152
+ }
1153
+ try {
1154
+ const details = await importArtifact(artifactHome, {
1155
+ sourcePath: p.sourcePath,
1156
+ ...(p.title !== undefined ? { title: p.title } : {}),
1157
+ ...(p.kind !== undefined ? { kind: p.kind } : {}),
1158
+ });
1159
+ return reply(rpcResult(id, details));
1160
+ }
1161
+ catch (error) {
1162
+ return reply(artifactRpcError(id, error, "import"));
1163
+ }
1164
+ }
1165
+ case "artifact.list": {
1166
+ try {
1167
+ return reply(rpcResult(id, listArtifacts(artifactHome)));
1168
+ }
1169
+ catch (error) {
1170
+ return reply(artifactRpcError(id, error, "list"));
1171
+ }
1172
+ }
1173
+ case "artifact.get": {
1174
+ if (typeof p.artifactId !== "string") {
1175
+ return reply(rpcError(id, ERR.PARAMS, "artifactId required"));
1176
+ }
1177
+ try {
1178
+ return reply(rpcResult(id, getArtifact(artifactHome, p.artifactId)));
1179
+ }
1180
+ catch (error) {
1181
+ return reply(artifactRpcError(id, error, "open"));
1182
+ }
1183
+ }
1184
+ case "artifact.revisions": {
1185
+ if (typeof p.artifactId !== "string") {
1186
+ return reply(rpcError(id, ERR.PARAMS, "artifactId required"));
1187
+ }
1188
+ try {
1189
+ const revisions = listArtifactRevisions(artifactHome, p.artifactId);
1190
+ return reply(rpcResult(id, { artifactId: p.artifactId, revisions }));
1191
+ }
1192
+ catch (error) {
1193
+ return reply(artifactRpcError(id, error, "list revisions"));
1194
+ }
1195
+ }
1094
1196
  case "skills.list": {
1095
1197
  const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
1096
1198
  return reply(rpcResult(id, { skills: loadSkillIndex(cwd).map((s) => ({ id: s.id, description: s.description, source: s.source })) }));
@@ -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.128.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"