@nanhara/hara 0.128.0 → 0.130.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,46 @@ 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.130.0 — 2026-07-20 — ordered task-state delivery
9
+
10
+ - Typed `event.task_state` notifications now carry a server-stream identity and a positive,
11
+ monotonically increasing sequence. Desktop and other authenticated Serve clients can reject
12
+ duplicated or stale lifecycle transitions instead of letting a late event overwrite the current
13
+ task, approval, checkpoint, or completion state.
14
+ - Ordering spans every session and every resume within one `hara serve` process. A server restart
15
+ creates a new stream identity, so clients can accept the fresh sequence without confusing it with
16
+ an older connection.
17
+ - Conversation streaming remains independent from execution state, and task event payloads retain
18
+ the same redacted ambient-data boundary. Existing protocol-v1 clients remain compatible because
19
+ the ordering fields are additive.
20
+ - Upgrade with `npm i -g @nanhara/hara@0.130.0`.
21
+
22
+ ## 0.129.0 — 2026-07-20 — durable revisions and workspace recovery
23
+
24
+ - Authenticated Serve clients can commit a user-selected file as a new immutable Artifact revision with
25
+ `artifact.commit`. Every write carries `baseRevisionId`; a stale editor receives the dedicated protocol
26
+ conflict code and cannot replace the current revision.
27
+ - `artifact.revert` restores an earlier ancestor by creating another immutable revision rather than moving
28
+ the pointer backward or mutating history. The imported original and every prior revision remain unchanged.
29
+ - Revision pointer updates now use a private-state compare-and-swap guard that also closes a cross-process
30
+ race. The authenticated host assigns the user actor, so a Desktop client cannot forge an Agent/migration
31
+ audit identity.
32
+ - This slice does not yet export files or claim Office fidelity. ExportReceipt, validation reports, worker
33
+ identity, format-specific editing, and Panel v2 remain separate release gates.
34
+ - Interactive launches from the home directory now offer an explicit, one-step switch to a recent or
35
+ registered project before provider and MCP startup. Hara never enumerates the home directory and never
36
+ changes workspace scope without confirmation.
37
+ - Repeated protected-home failures are now grouped by semantic root cause across `ls`, `grep`, `glob`, and
38
+ other filesystem tools. A run stops after the bounded retry threshold with a focused recovery instruction
39
+ instead of trying different tools against the same boundary indefinitely.
40
+ - Git plugin installation now rejects credential-bearing source URLs, bounds clone time, and safely
41
+ distinguishes missing Git, network, authentication/access, and private/not-found repository failures
42
+ without echoing URLs, tokens, or Git stderr.
43
+ - Feishu WebSocket gateways now report connected, disconnected, and reconnected lifecycle events with
44
+ one-hour and 24-hour counters. Repeated disconnects raise rate-limited alerts, while terminal SDK recovery
45
+ failures stop the adapter after a safe drain so a process supervisor can restart it.
46
+ - Upgrade with `npm i -g @nanhara/hara@0.129.0`.
47
+
8
48
  ## 0.128.0 — 2026-07-20 — local deliverable foundation
9
49
 
10
50
  - Hara Serve now provides the first `artifact/1` local runtime through authenticated
package/README.md CHANGED
@@ -274,11 +274,13 @@ new execution. The `hara resume` launcher preserves terminal input in both npm/N
274
274
  installs.
275
275
  **Local deliverables**: Hara Serve 0.128 adds the first local `artifact/1` foundation for presentations,
276
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.
277
+ version history through `artifact.import|list|get|revisions`. The next revision transaction slice also
278
+ advertises `artifact.commit|revert`: every edit supplies the version it started from, a stale edit fails with
279
+ an explicit conflict, and reverting creates a new immutable revision instead of rewriting history. Import and
280
+ commit make owner-only copies under `~/.hara/artifacts`, never change the selected source file, and never store
281
+ its absolute path. Relative, linked, protected, macro-enabled, type-confused, empty, and oversized inputs fail
282
+ closed. The runtime does **not** yet render, format-edit, export, or execute imported content; those operations
283
+ require matching reviewed Office capabilities and validation/export receipts.
282
284
  **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).
283
285
  **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.
284
286
  **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.
@@ -9,7 +9,7 @@ import { runHooks } from "../hooks.js";
9
9
  import { mapLimit, maxParallel } from "../concurrency.js";
10
10
  import { decideCommand, isReadOnlyCommand, loadPermissionRules, splitCompound, } from "../security/permissions.js";
11
11
  import { classifyRisk, guardianVeto, guardianEnabled, newBreaker, recordBlock } from "../security/guardian.js";
12
- import { keyOf, looksFailed, recordCall } from "./repeat-guard.js";
12
+ import { failureIdentity, looksFailed, recordCall } from "./repeat-guard.js";
13
13
  import { agentMaxRounds, agentRunTimeoutMs, formatAgentDuration } from "./limits.js";
14
14
  import { subdirHint } from "../context/subdir-hints.js";
15
15
  import { classifyError, failoverAction, errorHint } from "./failover.js";
@@ -405,7 +405,7 @@ function hardStop(opts, life, kind, detail) {
405
405
  ? `⏸ agent run paused: active-execution deadline ${formatAgentDuration(life.timeoutMs)} reached after ${life.rounds} round(s). Waiting for your answers did not consume this budget. No further model or tool calls will start in this turn. Session-backed work keeps its task and checklist checkpoint; type \`/continue\` to resume in a fresh bounded turn. Only for intentionally long single turns, use \`hara config set runTimeoutMs 45m\` (maximum 2h).`
406
406
  : kind === "max_rounds"
407
407
  ? `⛔ agent run stopped: ${life.maxRounds}-round safety limit reached after ${formatAgentDuration(elapsedMs)}. This usually means the model is looping. Increase it with \`hara config set maxAgentRounds <n>\` (maximum 256) only if the extra rounds are intentional.`
408
- : `⛔ agent run stopped: the same failing ${detail?.tool ?? "tool"} call repeated ${detail?.count ?? REPEATED_FAILURE_LIMIT} times. Change the approach or fix the reported cause before retrying.`;
408
+ : `⛔ agent run stopped: the same failing ${detail?.label ?? "tool call"} repeated ${detail?.count ?? REPEATED_FAILURE_LIMIT} times. Change the approach or fix the reported cause before retrying.`;
409
409
  const event = { kind, message, elapsedMs, rounds: life.rounds, timeoutMs: life.timeoutMs, maxRounds: life.maxRounds };
410
410
  if (!life.limitAnnounced) {
411
411
  life.limitAnnounced = true;
@@ -944,16 +944,16 @@ async function runAgentInner(history, opts, life) {
944
944
  let repeatHalt = null;
945
945
  const noteCall = (name, input, content, isError = false) => {
946
946
  const note = recordCall(name, input, content, isError, ctx.todoScope);
947
- const key = keyOf(name, input);
947
+ const identity = failureIdentity(name, input, content, isError);
948
948
  if (isError || looksFailed(content, name)) {
949
- const count = (life.failedCalls.get(key) ?? 0) + 1;
949
+ const count = (life.failedCalls.get(identity.key) ?? 0) + 1;
950
950
  // This is a *consecutive no-progress* streak, not a lifetime counter for the call. A different
951
951
  // failure is a changed attempt; keep only that new streak instead of letting an old failure
952
952
  // silently accumulate across intervening work.
953
953
  life.failedCalls.clear();
954
- life.failedCalls.set(key, count);
954
+ life.failedCalls.set(identity.key, count);
955
955
  if (count >= REPEATED_FAILURE_LIMIT && !repeatHalt)
956
- repeatHalt = { tool: name, count };
956
+ repeatHalt = { label: identity.label, count };
957
957
  }
958
958
  else {
959
959
  // Any successful action is progress (in particular edit/exec calls that may have fixed the
@@ -8,6 +8,7 @@
8
8
  // sessions in one process, so streaks are keyed by the same run scope as todo/reminder state.
9
9
  const DEFAULT_SCOPE = "default";
10
10
  const seenByScope = new Map();
11
+ const HOME_WORKSPACE_BOUNDARY_KEY = "root-cause:home-workspace-boundary";
11
12
  function scopedSeen(scope) {
12
13
  const key = scope?.trim() || DEFAULT_SCOPE;
13
14
  const seen = seenByScope.get(key) ?? new Map();
@@ -68,23 +69,52 @@ export function looksFailed(content, name) {
68
69
  }
69
70
  return false;
70
71
  }
72
+ /** Several different filesystem tools can hit the same protected Home-workspace boundary. Treating each
73
+ * tool name/argument tuple as a fresh idea lets a model spin through grep → glob → ls even though the
74
+ * root cause cannot change inside that run. Only Hara's own stable boundary diagnostics are coalesced. */
75
+ function isHomeWorkspaceBoundaryFailure(content) {
76
+ return (/will not recursively scan the home directory\b/i.test(content)
77
+ || /will not enumerate or recursively scan directories while Hara is rooted at the home directory\b/i.test(content)
78
+ || /workspace that is the home directory or contains it\b/i.test(content));
79
+ }
80
+ /** Stable identity used by both the warning note and the run-level hard breaker. */
81
+ export function failureIdentity(name, input, content, isError = false) {
82
+ const failed = isError || looksFailed(content, name);
83
+ if (failed && isHomeWorkspaceBoundaryFailure(content)) {
84
+ return {
85
+ key: HOME_WORKSPACE_BOUNDARY_KEY,
86
+ label: "Home workspace boundary",
87
+ semantic: true,
88
+ };
89
+ }
90
+ return {
91
+ key: keyOf(name, input),
92
+ label: `${name} call`,
93
+ semantic: false,
94
+ };
95
+ }
71
96
  /** Record a completed call; returns a warning to APPEND to the tool result when the same call has now
72
97
  * failed >=2x in a row (empty string otherwise). Pure aside from the session-scoped map. */
73
98
  export function recordCall(name, input, content, isError = false, scope) {
74
- const k = keyOf(name, input);
75
99
  const failed = isError || looksFailed(content, name);
100
+ const identity = failureIdentity(name, input, content, isError);
76
101
  const seen = scopedSeen(scope);
77
102
  if (!failed) {
78
103
  seen.clear(); // any success is progress; a later failure starts a fresh no-progress streak
79
104
  return "";
80
105
  }
81
- const s = seen.get(k) ?? { fails: 0 };
106
+ const s = seen.get(identity.key) ?? { fails: 0 };
82
107
  s.fails++;
83
108
  // "In a row" is literal: a different failed call is a changed attempt and breaks the old streak.
84
109
  seen.clear();
85
- seen.set(k, s);
110
+ seen.set(identity.key, s);
86
111
  if (s.fails < 2)
87
112
  return "";
113
+ if (identity.semantic) {
114
+ return (`\n\n⟳ hara: the same ${identity.label} has now blocked ${s.fails} consecutive tool calls — ` +
115
+ "another filesystem/search tool cannot bypass it. Ask the user to switch with `/cd <project>` " +
116
+ "or stop this run; do not probe another directory tool from Home.");
117
+ }
88
118
  return (`\n\n⟳ hara: this exact ${name} call has now FAILED ${s.fails}× with identical arguments — ` +
89
119
  `repeating it unchanged will fail again. Read the error above, change something (arguments / approach / tool), ` +
90
120
  `or step back and re-plan; if you're out of ideas, ask the user and say what you tried.`);
@@ -4,7 +4,7 @@ import { basename, extname, isAbsolute, join, resolve } from "node:path";
4
4
  import { openVerifiedRegularFileNoFollow, verifyOpenedRegularFileSync, } from "../fs-read.js";
5
5
  import { sameOpenedFileIdentity } from "../fs-identity.js";
6
6
  import { optionalPosixOpenFlag } from "../fs-open-flags.js";
7
- import { bindPrivateHaraStateFile, ensurePrivateStateSubdirectory, readPrivateStateFileSnapshotSync, writePrivateStateBytesOnceSync, writePrivateStateFileSync, } from "../security/private-state.js";
7
+ import { bindPrivateHaraStateFile, ensurePrivateStateSubdirectory, PrivateStateConflictError, readPrivateStateFileSnapshotSync, writePrivateStateBytesOnceSync, writePrivateStateFileSync, } from "../security/private-state.js";
8
8
  export const ARTIFACT_PROTOCOL_VERSION = "artifact/1";
9
9
  export const MAX_ARTIFACT_IMPORT_BYTES = 64 * 1024 * 1024;
10
10
  const MAX_ARTIFACTS = 10_000;
@@ -169,6 +169,51 @@ function checkedRevisionId(value) {
169
169
  }
170
170
  return value;
171
171
  }
172
+ function checkedInputRevisionId(value, field) {
173
+ if (typeof value !== "string" || !REVISION_ID.test(value)) {
174
+ throw storeError("ARTIFACT_INVALID_INPUT", `${field} is not a valid opaque Revision id`);
175
+ }
176
+ return value;
177
+ }
178
+ function checkedActor(value) {
179
+ if (value === undefined)
180
+ return "user";
181
+ if (value !== "user" && value !== "agent" && value !== "migration") {
182
+ throw storeError("ARTIFACT_INVALID_INPUT", "actor must be user, agent, or migration");
183
+ }
184
+ return value;
185
+ }
186
+ function checkedTaskRunId(value) {
187
+ if (value === undefined)
188
+ return undefined;
189
+ if (typeof value !== "string"
190
+ || value.length < 1
191
+ || value.length > 256
192
+ || /[\u0000-\u001f\u007f]/.test(value))
193
+ throw storeError("ARTIFACT_INVALID_INPUT", "taskRunId must be a safe string of at most 256 characters");
194
+ return value;
195
+ }
196
+ function normalizeArtifactPath(value) {
197
+ if (typeof value !== "string" || value.length < 1 || value.length > 1_024 || value.includes("\0")) {
198
+ throw storeError("ARTIFACT_INVALID_INPUT", "changedPaths entries must be non-empty relative paths");
199
+ }
200
+ if (value.startsWith("/") || value.startsWith("\\") || /^[A-Za-z]:/.test(value) || value.includes("\\")) {
201
+ throw storeError("ARTIFACT_INVALID_INPUT", "changedPaths entries must use relative forward-slash paths");
202
+ }
203
+ const segments = value.split("/");
204
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
205
+ throw storeError("ARTIFACT_INVALID_INPUT", "changedPaths contains an unsafe path segment");
206
+ }
207
+ return segments.join("/");
208
+ }
209
+ function checkedChangedPaths(value) {
210
+ if (value === undefined)
211
+ return ["content"];
212
+ if (!Array.isArray(value) || value.length < 1 || value.length > 10_000) {
213
+ throw storeError("ARTIFACT_INVALID_INPUT", "changedPaths must contain 1 to 10000 relative paths");
214
+ }
215
+ return [...new Set(value.map(normalizeArtifactPath))];
216
+ }
172
217
  function childDirectory(parent, name, notFoundMessage) {
173
218
  verifyDirectory(parent);
174
219
  const path = join(parent.path, name);
@@ -191,6 +236,24 @@ function childDirectory(parent, name, notFoundMessage) {
191
236
  function artifactDirectory(home, artifactId) {
192
237
  return childDirectory(artifactRoot(home), checkedArtifactId(artifactId), `no artifact ${artifactId}`);
193
238
  }
239
+ function readArtifactMetadata(directory) {
240
+ verifyDirectory(directory);
241
+ const snapshot = readPrivateStateFileSnapshotSync(join(directory.path, "metadata.json"), JSON_LIMIT);
242
+ if (!snapshot)
243
+ throw storeError("ARTIFACT_CORRUPT", "artifact metadata is missing");
244
+ let value;
245
+ try {
246
+ value = JSON.parse(snapshot.text);
247
+ }
248
+ catch (error) {
249
+ throw storeError("ARTIFACT_CORRUPT", "artifact metadata is not valid JSON", error);
250
+ }
251
+ if (!isArtifactRecord(value) || value.artifactId !== basename(directory.path)) {
252
+ throw storeError("ARTIFACT_CORRUPT", "artifact metadata does not match its directory");
253
+ }
254
+ verifyDirectory(directory);
255
+ return { artifact: value, text: snapshot.text };
256
+ }
194
257
  function revisionDirectory(artifact, revisionId) {
195
258
  const revisions = childDirectory(artifact, "revisions", "artifact revisions are missing");
196
259
  return childDirectory(revisions, checkedRevisionId(revisionId), `no revision ${revisionId}`);
@@ -240,25 +303,67 @@ function inspectContent(revision, content, verifyDigest) {
240
303
  }
241
304
  verifyDirectory(revision);
242
305
  }
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");
306
+ function readContentBytes(revision, content) {
307
+ verifyDirectory(revision);
308
+ const path = join(revision.path, content.contentRef);
309
+ const before = lstatSync(path);
310
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) {
311
+ throw storeError("ARTIFACT_CORRUPT", "artifact content is not an immutable regular file");
248
312
  }
249
- const currentRevisionId = checkedRevisionId(artifactValue.currentRevisionId);
250
- const revisionDir = revisionDirectory(directory, currentRevisionId);
313
+ const fd = openSync(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK") | optionalPosixOpenFlag("O_NOFOLLOW"));
314
+ try {
315
+ const opened = fstatSync(fd);
316
+ if (!opened.isFile()
317
+ || opened.nlink !== 1
318
+ || !sameOpenedFileIdentity(before, opened)
319
+ || opened.size !== content.byteSize
320
+ || (process.platform !== "win32" && (opened.mode & 0o777) !== 0o600))
321
+ throw storeError("ARTIFACT_CORRUPT", "artifact content identity or size does not match its receipt");
322
+ const bytes = Buffer.allocUnsafe(content.byteSize);
323
+ let position = 0;
324
+ while (position < bytes.length) {
325
+ const read = readSync(fd, bytes, position, bytes.length - position, position);
326
+ if (!read)
327
+ throw storeError("ARTIFACT_CORRUPT", "artifact content ended before its recorded size");
328
+ position += read;
329
+ }
330
+ if (createHash("sha256").update(bytes).digest("hex") !== content.sha256) {
331
+ throw storeError("ARTIFACT_CORRUPT", "artifact content digest does not match its receipt");
332
+ }
333
+ const after = fstatSync(fd);
334
+ const linked = lstatSync(path);
335
+ if (!sameOpenedFileIdentity(opened, after)
336
+ || opened.size !== after.size
337
+ || opened.mtimeMs !== after.mtimeMs
338
+ || opened.ctimeMs !== after.ctimeMs
339
+ || !sameOpenedFileIdentity(after, linked))
340
+ throw storeError("ARTIFACT_CORRUPT", "artifact content changed while it was read");
341
+ verifyDirectory(revision);
342
+ return bytes;
343
+ }
344
+ finally {
345
+ closeSync(fd);
346
+ }
347
+ }
348
+ function readRevisionDetails(artifact, revisionId, verifyDigest) {
349
+ const revisionDir = revisionDirectory(artifact, revisionId);
251
350
  const revisionValue = parseJsonFile(join(revisionDir.path, "revision.json"));
252
351
  const contentValue = parseJsonFile(join(revisionDir.path, "content.json"));
253
352
  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");
353
+ || revisionValue.revisionId !== revisionId
354
+ || revisionValue.artifactId !== basename(artifact.path))
355
+ throw storeError("ARTIFACT_CORRUPT", "revision metadata does not match its Artifact");
257
356
  if (!isContentInfo(contentValue)
258
357
  || contentValue.contentRef !== revisionValue.contentRef
259
358
  || contentValue.sha256 !== revisionValue.contentDigest)
260
- throw storeError("ARTIFACT_CORRUPT", "current revision content receipt is inconsistent");
359
+ throw storeError("ARTIFACT_CORRUPT", "revision content receipt is inconsistent");
261
360
  inspectContent(revisionDir, contentValue, verifyDigest);
361
+ return { revision: revisionValue, content: contentValue };
362
+ }
363
+ function readArtifactDetailsFromDirectory(directory, verifyDigest) {
364
+ const { artifact: artifactValue } = readArtifactMetadata(directory);
365
+ const currentRevisionId = checkedRevisionId(artifactValue.currentRevisionId);
366
+ const { revision: revisionValue, content: contentValue } = readRevisionDetails(directory, currentRevisionId, verifyDigest);
262
367
  verifyDirectory(directory);
263
368
  return { artifact: artifactValue, currentRevision: revisionValue, content: contentValue };
264
369
  }
@@ -275,6 +380,29 @@ function titleFor(sourcePath, extension, requested, kind) {
275
380
  }
276
381
  return title;
277
382
  }
383
+ function sourceFormat(sourcePathInput, claimedKind) {
384
+ if (typeof sourcePathInput !== "string"
385
+ || !sourcePathInput
386
+ || sourcePathInput.length > 4_096) {
387
+ throw storeError("ARTIFACT_INVALID_INPUT", "sourcePath must be a non-empty path of at most 4096 characters");
388
+ }
389
+ if (!isAbsolute(sourcePathInput)) {
390
+ throw storeError("ARTIFACT_INVALID_INPUT", "sourcePath must be an absolute path selected by the user");
391
+ }
392
+ const sourcePath = resolve(sourcePathInput);
393
+ const extension = extname(sourcePath).toLowerCase();
394
+ if (MACRO_FORMATS.has(extension)) {
395
+ throw storeError("ARTIFACT_SOURCE_REJECTED", "macro-enabled Office files are not supported; save a macro-free copy before using it as an Artifact revision");
396
+ }
397
+ const format = FORMATS.get(extension);
398
+ if (!format) {
399
+ throw storeError("ARTIFACT_INVALID_INPUT", "supported files are PPT/PPTX/ODP, XLS/XLSX/CSV/ODS, DOC/DOCX/ODT/RTF, Markdown, and text");
400
+ }
401
+ if (claimedKind !== undefined && claimedKind !== format.kind) {
402
+ throw storeError("ARTIFACT_INVALID_INPUT", `the selected file is ${format.kind}, not ${claimedKind}`);
403
+ }
404
+ return { sourcePath, extension, format };
405
+ }
278
406
  async function readImportSource(sourcePath) {
279
407
  let verified;
280
408
  try {
@@ -390,24 +518,6 @@ function activateStaging(root, staging, artifactId) {
390
518
  syncDirectory(root.path);
391
519
  }
392
520
  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
521
  if (input.kind !== undefined
412
522
  && input.kind !== "presentation"
413
523
  && input.kind !== "spreadsheet"
@@ -416,6 +526,7 @@ export async function importArtifact(home, input) {
416
526
  if (input.title !== undefined && typeof input.title !== "string") {
417
527
  throw storeError("ARTIFACT_INVALID_INPUT", "title must be a string");
418
528
  }
529
+ const { sourcePath, extension, format } = sourceFormat(input.sourcePath, input.kind);
419
530
  const bytes = await readImportSource(sourcePath);
420
531
  verifyClaimedFormat(extension, bytes);
421
532
  const sha256 = createHash("sha256").update(bytes).digest("hex");
@@ -461,6 +572,157 @@ export async function importArtifact(home, input) {
461
572
  activateStaging(root, staging, artifactId);
462
573
  return getArtifact(home, artifactId, false);
463
574
  }
575
+ function activateRevisionStaging(artifact, staging, revisionId) {
576
+ const revisions = childDirectory(artifact, "revisions", "artifact revisions are missing");
577
+ verifyDirectory(staging);
578
+ const destination = join(revisions.path, revisionId);
579
+ try {
580
+ lstatSync(destination);
581
+ throw storeError("ARTIFACT_CORRUPT", "a Revision id collision occurred; retry the commit");
582
+ }
583
+ catch (error) {
584
+ if (error?.code !== "ENOENT")
585
+ throw error;
586
+ }
587
+ renameSync(staging.path, destination);
588
+ const activated = lstatSync(destination);
589
+ if (!activated.isDirectory()
590
+ || activated.isSymbolicLink()
591
+ || activated.dev !== staging.dev
592
+ || activated.ino !== staging.ino
593
+ || realpathSync.native(destination) !== destination)
594
+ throw storeError("ARTIFACT_CORRUPT", "Revision activation changed the staged directory identity");
595
+ verifyDirectory(revisions);
596
+ syncDirectory(revisions.path);
597
+ }
598
+ function commitPreparedRevision(home, input) {
599
+ const artifact = artifactDirectory(home, input.artifactId);
600
+ const metadata = readArtifactMetadata(artifact);
601
+ if (metadata.artifact.currentRevisionId !== input.baseRevisionId) {
602
+ throw storeError("ARTIFACT_CONFLICT", "the Artifact changed after this edit started; reopen the latest version and apply the change again");
603
+ }
604
+ readRevisionDetails(artifact, metadata.artifact.currentRevisionId, true);
605
+ const format = FORMATS.get(input.extension);
606
+ if (!format || format.kind !== metadata.artifact.kind || format.mediaType !== input.mediaType) {
607
+ throw storeError("ARTIFACT_INVALID_INPUT", "the committed file kind does not match the Artifact");
608
+ }
609
+ if (input.bytes.byteLength < 1 || input.bytes.byteLength > MAX_ARTIFACT_IMPORT_BYTES) {
610
+ throw storeError("ARTIFACT_INVALID_INPUT", "revision content size is outside the supported range");
611
+ }
612
+ verifyClaimedFormat(input.extension, input.bytes);
613
+ const revisions = childDirectory(artifact, "revisions", "artifact revisions are missing");
614
+ if (readdirSync(revisions.path).length >= MAX_REVISIONS) {
615
+ throw storeError("ARTIFACT_TOO_LARGE", `artifact has reached the ${MAX_REVISIONS} revision limit`);
616
+ }
617
+ const revisionId = newOpaqueId("rev");
618
+ const sha256 = createHash("sha256").update(input.bytes).digest("hex");
619
+ const contentRef = `content${input.extension}`;
620
+ const revision = {
621
+ revisionId,
622
+ artifactId: metadata.artifact.artifactId,
623
+ parentRevisionId: input.baseRevisionId,
624
+ baseRevisionId: input.baseRevisionId,
625
+ actor: input.actor,
626
+ ...(input.taskRunId ? { taskRunId: input.taskRunId } : {}),
627
+ contentRef,
628
+ assetRefs: [],
629
+ contentDigest: sha256,
630
+ changedPaths: input.changedPaths,
631
+ createdAt: new Date().toISOString(),
632
+ };
633
+ const content = {
634
+ contentRef,
635
+ extension: input.extension,
636
+ mediaType: input.mediaType,
637
+ byteSize: input.bytes.byteLength,
638
+ sha256,
639
+ };
640
+ const stagingName = `.staging-${revisionId}-${randomUUID().replaceAll("-", "")}`;
641
+ const staging = ensurePrivateStateSubdirectory(home, [".hara", "artifacts", metadata.artifact.artifactId, stagingName]);
642
+ writePrivateStateBytesOnceSync(bindPrivateHaraStateFile(home, ["artifacts", metadata.artifact.artifactId, stagingName], contentRef), input.bytes);
643
+ writePrivateStateFileSync(bindPrivateHaraStateFile(home, ["artifacts", metadata.artifact.artifactId, stagingName], "content.json"), `${JSON.stringify(content, null, 2)}\n`);
644
+ writePrivateStateFileSync(bindPrivateHaraStateFile(home, ["artifacts", metadata.artifact.artifactId, stagingName], "revision.json"), `${JSON.stringify(revision, null, 2)}\n`);
645
+ activateRevisionStaging(artifact, staging, revisionId);
646
+ const updated = {
647
+ ...metadata.artifact,
648
+ currentRevisionId: revisionId,
649
+ };
650
+ try {
651
+ writePrivateStateFileSync(bindPrivateHaraStateFile(home, ["artifacts", metadata.artifact.artifactId], "metadata.json"), `${JSON.stringify(updated, null, 2)}\n`, { expectedText: metadata.text });
652
+ }
653
+ catch (error) {
654
+ if (error instanceof PrivateStateConflictError) {
655
+ throw storeError("ARTIFACT_CONFLICT", "the Artifact changed while this revision was committing; reopen the latest version", error);
656
+ }
657
+ throw error;
658
+ }
659
+ return getArtifact(home, metadata.artifact.artifactId);
660
+ }
661
+ export async function commitArtifact(home, input) {
662
+ const artifactId = checkedArtifactId(input.artifactId);
663
+ const baseRevisionId = checkedInputRevisionId(input.baseRevisionId, "baseRevisionId");
664
+ const actor = checkedActor(input.actor);
665
+ const taskRunId = checkedTaskRunId(input.taskRunId);
666
+ const changedPaths = checkedChangedPaths(input.changedPaths);
667
+ const before = getArtifact(home, artifactId);
668
+ if (before.artifact.currentRevisionId !== baseRevisionId) {
669
+ throw storeError("ARTIFACT_CONFLICT", "the Artifact changed after this edit started; reopen the latest version and apply the change again");
670
+ }
671
+ const { sourcePath, extension, format } = sourceFormat(input.sourcePath, before.artifact.kind);
672
+ const bytes = await readImportSource(sourcePath);
673
+ verifyClaimedFormat(extension, bytes);
674
+ return commitPreparedRevision(home, {
675
+ artifactId,
676
+ baseRevisionId,
677
+ extension,
678
+ mediaType: format.mediaType,
679
+ bytes,
680
+ actor,
681
+ ...(taskRunId ? { taskRunId } : {}),
682
+ changedPaths,
683
+ });
684
+ }
685
+ export function revertArtifact(home, input) {
686
+ const artifactId = checkedArtifactId(input.artifactId);
687
+ const baseRevisionId = checkedInputRevisionId(input.baseRevisionId, "baseRevisionId");
688
+ const targetRevisionId = checkedInputRevisionId(input.targetRevisionId, "targetRevisionId");
689
+ const actor = checkedActor(input.actor);
690
+ const taskRunId = checkedTaskRunId(input.taskRunId);
691
+ if (baseRevisionId === targetRevisionId) {
692
+ throw storeError("ARTIFACT_INVALID_INPUT", "targetRevisionId is already the current revision");
693
+ }
694
+ const artifact = artifactDirectory(home, artifactId);
695
+ const current = readArtifactMetadata(artifact).artifact;
696
+ if (current.currentRevisionId !== baseRevisionId) {
697
+ throw storeError("ARTIFACT_CONFLICT", "the Artifact changed after this revert started; reopen the latest version");
698
+ }
699
+ const history = listArtifactRevisions(home, artifactId);
700
+ const byId = new Map(history.map((revision) => [revision.revisionId, revision]));
701
+ let cursor = byId.get(baseRevisionId);
702
+ let targetIsAncestor = false;
703
+ for (let count = 0; cursor && count <= history.length; count++) {
704
+ if (cursor.revisionId === targetRevisionId) {
705
+ targetIsAncestor = true;
706
+ break;
707
+ }
708
+ cursor = cursor.parentRevisionId ? byId.get(cursor.parentRevisionId) : undefined;
709
+ }
710
+ if (!targetIsAncestor) {
711
+ throw storeError("ARTIFACT_INVALID_INPUT", "targetRevisionId is not an ancestor of the current revision");
712
+ }
713
+ const target = readRevisionDetails(artifact, targetRevisionId, true);
714
+ const bytes = readContentBytes(revisionDirectory(artifact, targetRevisionId), target.content);
715
+ return commitPreparedRevision(home, {
716
+ artifactId,
717
+ baseRevisionId,
718
+ extension: target.content.extension,
719
+ mediaType: target.content.mediaType,
720
+ bytes,
721
+ actor,
722
+ ...(taskRunId ? { taskRunId } : {}),
723
+ changedPaths: ["content"],
724
+ });
725
+ }
464
726
  export function getArtifact(home, artifactId, verifyDigest = true) {
465
727
  return readArtifactDetailsFromDirectory(artifactDirectory(home, artifactId), verifyDigest);
466
728
  }
@@ -56,6 +56,29 @@ export function homeWorkspaceGuidance(cwd) {
56
56
  "Ask the user to switch with `/cd /path/to/project`, run `cd /path/to/project`, or launch with " +
57
57
  "`hara --cwd /path/to/project` for project work. Only explicitly named single-file reads remain available.");
58
58
  }
59
+ /** Pick the first existing project directory from an already-authorized candidate list. This deliberately
60
+ * does not enumerate Home: callers supply recent-session or registered-project paths, and the user still
61
+ * confirms the handoff before Hara changes process.cwd(). */
62
+ export function suggestedProjectWorkspace(candidates, home = effectiveHomeDir()) {
63
+ const seen = new Set();
64
+ for (const candidate of candidates) {
65
+ if (typeof candidate !== "string" || !candidate.trim())
66
+ continue;
67
+ try {
68
+ const target = realpathSync.native(resolve(candidate));
69
+ if (seen.has(target))
70
+ continue;
71
+ seen.add(target);
72
+ if (!statSync(target).isDirectory() || isUnsafeProjectWorkspace(target, home))
73
+ continue;
74
+ return target;
75
+ }
76
+ catch {
77
+ // Stale session/project registrations are ordinary; skip them without weakening the boundary.
78
+ }
79
+ }
80
+ return undefined;
81
+ }
59
82
  /** Resolve an explicit interactive workspace handoff without accepting Home/ancestor scopes. */
60
83
  export function resolveWorkspaceSwitch(input, currentCwd, home = effectiveHomeDir()) {
61
84
  let requested = input.trim();