@nanhara/hara 0.121.0 → 0.122.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +40 -6
  3. package/dist/agent/failover.js +1 -1
  4. package/dist/agent/loop.js +158 -21
  5. package/dist/agent/reminders.js +22 -7
  6. package/dist/agent/repeat-guard.js +26 -7
  7. package/dist/agent/structured.js +231 -0
  8. package/dist/agent/touched.js +20 -6
  9. package/dist/config.js +62 -26
  10. package/dist/cron/deliver.js +37 -3
  11. package/dist/feedback.js +5 -9
  12. package/dist/fs-read.js +106 -12
  13. package/dist/fs-write.js +242 -16
  14. package/dist/gateway/dingtalk.js +4 -1
  15. package/dist/gateway/discord.js +53 -18
  16. package/dist/gateway/feishu.js +158 -57
  17. package/dist/gateway/flows-pending.js +720 -0
  18. package/dist/gateway/flows.js +391 -16
  19. package/dist/gateway/matrix.js +80 -15
  20. package/dist/gateway/mattermost.js +44 -32
  21. package/dist/gateway/media.js +659 -0
  22. package/dist/gateway/serve.js +657 -162
  23. package/dist/gateway/sessions.js +475 -78
  24. package/dist/gateway/signal.js +27 -22
  25. package/dist/gateway/slack.js +26 -17
  26. package/dist/gateway/telegram.js +32 -18
  27. package/dist/gateway/wecom.js +32 -24
  28. package/dist/gateway/weixin.js +127 -49
  29. package/dist/hooks.js +32 -20
  30. package/dist/index.js +640 -219
  31. package/dist/org/projects.js +347 -0
  32. package/dist/org/roles.js +38 -11
  33. package/dist/security/secrets.js +150 -0
  34. package/dist/serve/server.js +772 -317
  35. package/dist/serve/sessions.js +112 -28
  36. package/dist/session/store.js +337 -44
  37. package/dist/tools/all.js +1 -0
  38. package/dist/tools/builtin.js +61 -23
  39. package/dist/tools/codebase.js +3 -1
  40. package/dist/tools/computer.js +98 -92
  41. package/dist/tools/edit.js +11 -8
  42. package/dist/tools/patch.js +230 -31
  43. package/dist/tools/search.js +482 -72
  44. package/dist/tools/task.js +453 -0
  45. package/dist/tools/todo.js +67 -16
  46. package/dist/tools/web.js +364 -64
  47. package/dist/tui/run.js +26 -23
  48. package/dist/undo.js +83 -7
  49. package/package.json +1 -1
package/dist/undo.js CHANGED
@@ -1,7 +1,11 @@
1
1
  // In-session undo stack for file changes. Each edit tool records the prior state of the files it
2
2
  // touched; `/undo` pops the last group and restores it. Process-scoped (one REPL session).
3
- import { unlink } from "node:fs/promises";
4
- import { atomicWriteText } from "./fs-write.js";
3
+ import { randomUUID } from "node:crypto";
4
+ import { dirname, join } from "node:path";
5
+ import { linkSync, lstatSync, readlinkSync, symlinkSync } from "node:fs";
6
+ import { rename, symlink, unlink } from "node:fs/promises";
7
+ import { atomicWriteText, discardClaimedPath, FileChangedError, removeCreatedDirectories } from "./fs-write.js";
8
+ import { readRegularFileSnapshotNoFollow } from "./fs-read.js";
5
9
  import { invalidateFileCandidates } from "./context/mentions.js";
6
10
  const stack = [];
7
11
  const MAX = 50;
@@ -16,27 +20,99 @@ export function recordEdit(group) {
16
20
  export function undoDepth() {
17
21
  return stack.length;
18
22
  }
23
+ async function restoreMovedFile(quarantine, target) {
24
+ const info = lstatSync(quarantine);
25
+ // link(2) may follow a symlink source on some platforms. Recreate its topology explicitly while retaining
26
+ // create-if-absent semantics; a regular inode can still use an atomic hard link.
27
+ const linkTarget = info.isSymbolicLink() ? readlinkSync(quarantine) : undefined;
28
+ if (linkTarget !== undefined)
29
+ symlinkSync(linkTarget, target);
30
+ else
31
+ linkSync(quarantine, target);
32
+ discardClaimedPath(quarantine, {
33
+ dev: info.dev,
34
+ ino: info.ino,
35
+ mode: info.mode & 0o777,
36
+ nlink: info.nlink + (linkTarget === undefined ? 1 : 0),
37
+ linkTarget,
38
+ });
39
+ }
40
+ /** Undo an update/create only if the path still names the exact inode and bytes written by that tool. */
41
+ async function undoCommitted(s) {
42
+ if (!s.committed || s.after === undefined)
43
+ throw new Error(`missing committed snapshot for ${s.path}`);
44
+ const target = s.committed.target;
45
+ const quarantine = join(dirname(target), `.hara-undo-${process.pid}-${randomUUID()}.tmp`);
46
+ await rename(target, quarantine);
47
+ try {
48
+ // O_NOFOLLOW + same-fd fstat/read rejects a path replaced with a symlink to the committed inode.
49
+ const current = await readRegularFileSnapshotNoFollow(quarantine);
50
+ if (current.dev !== s.committed.dev ||
51
+ current.ino !== s.committed.ino ||
52
+ current.mode !== s.committed.mode ||
53
+ current.nlink !== s.committed.nlink ||
54
+ current.text !== s.after) {
55
+ await restoreMovedFile(quarantine, target);
56
+ throw new FileChangedError(s.path);
57
+ }
58
+ if (s.before === null) {
59
+ discardClaimedPath(quarantine, s.committed);
60
+ await removeCreatedDirectories(s.committed.createdDirs);
61
+ return;
62
+ }
63
+ await atomicWriteText(target, s.before, { expected: null, mode: s.beforeMode });
64
+ discardClaimedPath(quarantine, s.committed);
65
+ }
66
+ catch (error) {
67
+ // If restoration failed after the owned inode was moved, put it back only when the destination is still
68
+ // absent. link(2) never overwrites a concurrent file; leave quarantine for recovery if another appeared.
69
+ try {
70
+ await restoreMovedFile(quarantine, target);
71
+ }
72
+ catch {
73
+ /* the original error remains the useful user-facing failure */
74
+ }
75
+ throw error;
76
+ }
77
+ }
19
78
  /** Restore the most recent edit group. Returns the files reverted, or an error. */
20
79
  export async function undoLast() {
21
80
  const group = stack.pop();
22
81
  if (!group)
23
82
  return { error: "nothing to undo" };
24
83
  const files = [];
84
+ const failures = [];
25
85
  for (const s of group) {
26
86
  try {
27
- if (s.before === null) {
28
- await unlink(s.absPath).catch(() => { }); // was newly created → remove
87
+ if (s.committed && s.after !== undefined) {
88
+ await undoCommitted(s);
89
+ }
90
+ else if (s.before === null) {
91
+ try {
92
+ await unlink(s.absPath); // legacy snapshot: was newly created → remove
93
+ }
94
+ catch (error) {
95
+ if (error?.code !== "ENOENT")
96
+ throw error;
97
+ }
98
+ }
99
+ else if (s.linkTarget !== undefined) {
100
+ await symlink(s.linkTarget, s.absPath); // atomic create-if-absent; never clobbers a newer path
29
101
  }
30
102
  else {
31
- await atomicWriteText(s.absPath, s.before);
103
+ await atomicWriteText(s.absPath, s.before, { expected: s.removed ? null : undefined, mode: s.beforeMode });
32
104
  }
33
105
  files.push(s.path);
34
106
  }
35
- catch {
36
- /* skip a file we can't restore */
107
+ catch (error) {
108
+ failures.push(`${s.path}: ${error?.message ?? String(error)}`);
37
109
  }
38
110
  }
39
111
  if (files.length)
40
112
  invalidateFileCandidates();
113
+ if (failures.length) {
114
+ const prefix = files.length ? `partially reverted ${files.join(", ")}; ` : "";
115
+ return { error: `${prefix}could not safely undo ${failures.join("; ")}` };
116
+ }
41
117
  return { files };
42
118
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.121.0",
3
+ "version": "0.122.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"