@nanhara/hara 0.121.1 → 0.122.1
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 +120 -0
- package/README.md +57 -10
- package/SECURITY.md +48 -9
- package/dist/agent/loop.js +169 -31
- package/dist/agent/reminders.js +22 -7
- package/dist/agent/repeat-guard.js +26 -7
- package/dist/agent/structured.js +231 -0
- package/dist/agent/touched.js +24 -6
- package/dist/checkpoints.js +103 -17
- package/dist/cli.js +16 -0
- package/dist/config.js +173 -34
- package/dist/context/agents-md.js +44 -9
- package/dist/context/mentions.js +10 -4
- package/dist/context/subdir-hints.js +40 -7
- package/dist/cron/deliver.js +37 -3
- package/dist/cron/runner.js +372 -37
- package/dist/cron/store.js +11 -3
- package/dist/exec/jobs.js +88 -20
- package/dist/feedback.js +3 -2
- package/dist/fs-read.js +421 -12
- package/dist/fs-walk.js +8 -2
- package/dist/fs-write.js +433 -21
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -20
- package/dist/gateway/feishu.js +157 -58
- package/dist/gateway/flows-pending.js +727 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +81 -18
- package/dist/gateway/mattermost.js +44 -34
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/outbound-files.js +379 -0
- package/dist/gateway/serve.js +712 -169
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +31 -28
- package/dist/gateway/slack.js +28 -21
- package/dist/gateway/telegram.js +33 -21
- package/dist/gateway/tmux-routes.js +11 -3
- package/dist/gateway/wecom.js +38 -31
- package/dist/gateway/weixin.js +147 -59
- package/dist/hooks.js +41 -23
- package/dist/index.js +763 -273
- package/dist/mcp/client.js +164 -12
- package/dist/memory/store.js +68 -22
- package/dist/org/planner.js +36 -10
- package/dist/org/projects.js +347 -0
- package/dist/org/review-chain.js +360 -24
- package/dist/org/roles.js +42 -13
- package/dist/profile/profile.js +152 -27
- package/dist/recall.js +4 -2
- package/dist/runtime.js +37 -0
- package/dist/sandbox.js +142 -33
- package/dist/search/semindex.js +182 -53
- package/dist/search/zvec-store.js +121 -42
- package/dist/security/permissions.js +326 -19
- package/dist/security/private-state.js +299 -0
- package/dist/security/project-trust.js +6 -0
- package/dist/security/secrets.js +84 -9
- package/dist/security/sensitive-files.js +723 -0
- package/dist/security/subprocess-env.js +210 -0
- package/dist/serve/server.js +774 -318
- package/dist/serve/sessions.js +113 -33
- package/dist/session/store.js +298 -47
- package/dist/skills/skills.js +16 -7
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +77 -49
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/cron.js +6 -0
- package/dist/tools/edit.js +22 -9
- package/dist/tools/external_agent.js +110 -16
- package/dist/tools/memory.js +38 -8
- package/dist/tools/patch.js +253 -34
- package/dist/tools/search.js +543 -73
- package/dist/tools/send.js +11 -5
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +168 -54
- package/dist/undo.js +83 -7
- package/package.json +11 -10
- package/runtime-bootstrap.cjs +72 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// Global agent index — ~/.hara/projects.json registers canonical project homes. The registry is private,
|
|
2
|
+
// atomically replaced, and guarded across processes because gateway/desktop/CLI may update it concurrently.
|
|
3
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
4
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { loadRoles, loadGlobalRoles } from "./roles.js";
|
|
8
|
+
const PROJECT_NAME = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
|
|
9
|
+
const sleepCell = new Int32Array(new SharedArrayBuffer(4));
|
|
10
|
+
const LOCK_ATTEMPTS = 500;
|
|
11
|
+
const LOCK_WAIT_MS = 10;
|
|
12
|
+
function haraDir() {
|
|
13
|
+
const dir = join(homedir(), ".hara");
|
|
14
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
15
|
+
try {
|
|
16
|
+
chmodSync(dir, 0o700);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
/* best effort on non-POSIX filesystems */
|
|
20
|
+
}
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
|
23
|
+
const projectsFile = () => join(haraDir(), "projects.json");
|
|
24
|
+
/** Handles are case-insensitive and form the left side of `project:agent`; canonical storage is lowercase. */
|
|
25
|
+
export function canonicalProjectName(value) {
|
|
26
|
+
if (typeof value !== "string")
|
|
27
|
+
return null;
|
|
28
|
+
const name = value.trim().toLowerCase();
|
|
29
|
+
return PROJECT_NAME.test(name) ? name : null;
|
|
30
|
+
}
|
|
31
|
+
/** Existing paths resolve symlinks so one project cannot be registered twice through aliases. */
|
|
32
|
+
export function canonicalProjectPath(value, requireDirectory = false) {
|
|
33
|
+
if (typeof value !== "string" || !value.trim() || !isAbsolute(value))
|
|
34
|
+
return null;
|
|
35
|
+
const absolute = resolve(value);
|
|
36
|
+
try {
|
|
37
|
+
if (requireDirectory && !statSync(absolute).isDirectory())
|
|
38
|
+
return null;
|
|
39
|
+
return realpathSync.native(absolute);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return requireDirectory ? null : absolute;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function readLock(path) {
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
48
|
+
return Number.isInteger(parsed?.pid) && parsed.pid > 0 && typeof parsed?.token === "string" && parsed.token
|
|
49
|
+
? { pid: parsed.pid, token: parsed.token }
|
|
50
|
+
: null;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function pidAlive(pid) {
|
|
57
|
+
try {
|
|
58
|
+
process.kill(pid, 0);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
return error?.code === "EPERM";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function writeExclusive(path, record) {
|
|
66
|
+
let fd;
|
|
67
|
+
try {
|
|
68
|
+
fd = openSync(path, "wx", 0o600);
|
|
69
|
+
writeFileSync(fd, JSON.stringify(record), "utf8");
|
|
70
|
+
fsyncSync(fd);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (fd !== undefined) {
|
|
74
|
+
try {
|
|
75
|
+
closeSync(fd);
|
|
76
|
+
fd = undefined;
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
rmSync(path, { force: true });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
if (fd !== undefined)
|
|
86
|
+
closeSync(fd);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function withProjectsLock(fn) {
|
|
90
|
+
const file = projectsFile();
|
|
91
|
+
const lock = `${file}.lock`;
|
|
92
|
+
const reclaim = `${lock}.reclaim`;
|
|
93
|
+
let claim;
|
|
94
|
+
for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt++) {
|
|
95
|
+
if (existsSync(reclaim)) {
|
|
96
|
+
const stale = readLock(reclaim);
|
|
97
|
+
if (stale && !pidAlive(stale.pid)) {
|
|
98
|
+
const current = readLock(reclaim);
|
|
99
|
+
if (current?.pid === stale.pid && current.token === stale.token && !pidAlive(current.pid)) {
|
|
100
|
+
rmSync(reclaim, { force: true });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
Atomics.wait(sleepCell, 0, 0, LOCK_WAIT_MS);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const candidate = { pid: process.pid, token: randomUUID() };
|
|
108
|
+
try {
|
|
109
|
+
writeExclusive(lock, candidate);
|
|
110
|
+
claim = candidate;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error?.code !== "EEXIST")
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
const held = readLock(lock);
|
|
118
|
+
if (held && !pidAlive(held.pid)) {
|
|
119
|
+
const guard = { pid: process.pid, token: randomUUID() };
|
|
120
|
+
try {
|
|
121
|
+
writeExclusive(reclaim, guard);
|
|
122
|
+
const current = readLock(lock);
|
|
123
|
+
if (current?.pid === held.pid && current.token === held.token && !pidAlive(current.pid))
|
|
124
|
+
rmSync(lock);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Another writer is reclaiming, or the evidence is malformed. Never fail open.
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
const currentGuard = readLock(reclaim);
|
|
131
|
+
if (currentGuard?.pid === process.pid && currentGuard.token === guard.token)
|
|
132
|
+
rmSync(reclaim, { force: true });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
Atomics.wait(sleepCell, 0, 0, LOCK_WAIT_MS);
|
|
136
|
+
}
|
|
137
|
+
if (!claim)
|
|
138
|
+
throw new Error("projects registry is busy; retry the operation");
|
|
139
|
+
try {
|
|
140
|
+
return fn();
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
const current = readLock(lock);
|
|
144
|
+
if (current?.pid === process.pid && current.token === claim.token)
|
|
145
|
+
rmSync(lock, { force: true });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function parseProjects(raw, strict) {
|
|
149
|
+
const parsed = JSON.parse(raw);
|
|
150
|
+
const source = Array.isArray(parsed) ? parsed : parsed?.projects;
|
|
151
|
+
if (!Array.isArray(source))
|
|
152
|
+
throw new Error("projects registry must contain a projects array");
|
|
153
|
+
const out = [];
|
|
154
|
+
const names = new Set();
|
|
155
|
+
const paths = new Set();
|
|
156
|
+
for (const value of source) {
|
|
157
|
+
const name = canonicalProjectName(value?.name);
|
|
158
|
+
const path = canonicalProjectPath(value?.path);
|
|
159
|
+
if (!name || !path || names.has(name) || paths.has(path)) {
|
|
160
|
+
if (strict)
|
|
161
|
+
throw new Error("projects registry contains invalid or duplicate entries");
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
names.add(name);
|
|
165
|
+
paths.add(path);
|
|
166
|
+
out.push({ name, path });
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
function loadProjectsUnlocked(strict) {
|
|
171
|
+
const file = projectsFile();
|
|
172
|
+
if (!existsSync(file))
|
|
173
|
+
return [];
|
|
174
|
+
try {
|
|
175
|
+
return parseProjects(readFileSync(file, "utf8"), strict);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (strict)
|
|
179
|
+
throw error;
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
export function loadProjects() {
|
|
184
|
+
return loadProjectsUnlocked(false);
|
|
185
|
+
}
|
|
186
|
+
function atomicSave(list) {
|
|
187
|
+
const file = projectsFile();
|
|
188
|
+
const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
189
|
+
let fd;
|
|
190
|
+
try {
|
|
191
|
+
fd = openSync(tmp, "wx", 0o600);
|
|
192
|
+
writeFileSync(fd, JSON.stringify({ projects: list }, null, 2) + "\n", "utf8");
|
|
193
|
+
fsyncSync(fd);
|
|
194
|
+
closeSync(fd);
|
|
195
|
+
fd = undefined;
|
|
196
|
+
renameSync(tmp, file);
|
|
197
|
+
try {
|
|
198
|
+
chmodSync(file, 0o600);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
/* best effort on non-POSIX filesystems */
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
if (fd !== undefined)
|
|
206
|
+
closeSync(fd);
|
|
207
|
+
rmSync(tmp, { force: true });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function normalizeProjectList(list) {
|
|
211
|
+
if (!Array.isArray(list))
|
|
212
|
+
throw new Error("projects must be an array");
|
|
213
|
+
const normalized = list.map((value) => {
|
|
214
|
+
const name = canonicalProjectName(value?.name);
|
|
215
|
+
const path = canonicalProjectPath(value?.path);
|
|
216
|
+
if (!name || !path)
|
|
217
|
+
throw new Error("project entries require a valid name and absolute path");
|
|
218
|
+
return { name, path };
|
|
219
|
+
});
|
|
220
|
+
if (new Set(normalized.map((p) => p.name)).size !== normalized.length || new Set(normalized.map((p) => p.path)).size !== normalized.length) {
|
|
221
|
+
throw new Error("project names and paths must be unique");
|
|
222
|
+
}
|
|
223
|
+
return normalized;
|
|
224
|
+
}
|
|
225
|
+
export function saveProjects(list) {
|
|
226
|
+
withProjectsLock(() => atomicSave(normalizeProjectList(list)));
|
|
227
|
+
}
|
|
228
|
+
export function addProject(nameInput, pathInput) {
|
|
229
|
+
const name = canonicalProjectName(nameInput);
|
|
230
|
+
if (!name)
|
|
231
|
+
return "invalid project name: use 1-64 lowercase letters, numbers, '.', '_' or '-'";
|
|
232
|
+
const path = canonicalProjectPath(pathInput, true);
|
|
233
|
+
if (!path)
|
|
234
|
+
return `path does not exist or is not a directory: ${pathInput}`;
|
|
235
|
+
try {
|
|
236
|
+
return withProjectsLock(() => {
|
|
237
|
+
const list = loadProjectsUnlocked(true);
|
|
238
|
+
const alias = list.find((p) => p.path === path && p.name !== name);
|
|
239
|
+
if (alias)
|
|
240
|
+
return `path is already registered as '${alias.name}': ${path}`;
|
|
241
|
+
const next = list.filter((p) => p.name !== name);
|
|
242
|
+
next.push({ name, path });
|
|
243
|
+
atomicSave(next);
|
|
244
|
+
return null;
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
return `projects registry: ${error instanceof Error ? error.message : String(error)}`;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
export function removeProject(nameInput) {
|
|
252
|
+
const name = canonicalProjectName(nameInput);
|
|
253
|
+
if (!name)
|
|
254
|
+
return false;
|
|
255
|
+
return withProjectsLock(() => {
|
|
256
|
+
const list = loadProjectsUnlocked(true);
|
|
257
|
+
const next = list.filter((p) => p.name !== name);
|
|
258
|
+
if (next.length === list.length)
|
|
259
|
+
return false;
|
|
260
|
+
atomicSave(next);
|
|
261
|
+
return true;
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
function sameList(a, b) {
|
|
265
|
+
if (a === undefined || b === undefined)
|
|
266
|
+
return a === b;
|
|
267
|
+
if (a.length !== b.length)
|
|
268
|
+
return false;
|
|
269
|
+
const left = [...a].sort();
|
|
270
|
+
const right = [...b].sort();
|
|
271
|
+
return left.every((value, i) => value === right[i]);
|
|
272
|
+
}
|
|
273
|
+
/** Project roles with the same prompt can still intentionally override model/routing/tool policy. */
|
|
274
|
+
function sameRoleDefinition(a, b) {
|
|
275
|
+
return (a.id === b.id &&
|
|
276
|
+
a.description === b.description &&
|
|
277
|
+
a.system === b.system &&
|
|
278
|
+
a.model === b.model &&
|
|
279
|
+
sameList(a.owns, b.owns) &&
|
|
280
|
+
sameList(a.rejects, b.rejects) &&
|
|
281
|
+
sameList(a.allowTools, b.allowTools) &&
|
|
282
|
+
sameList(a.denyTools, b.denyTools) &&
|
|
283
|
+
a.readOnly === b.readOnly);
|
|
284
|
+
}
|
|
285
|
+
/** Build the global index: inherited globals are listed once; any project override gets its qualified home. */
|
|
286
|
+
export function buildAgentsIndex() {
|
|
287
|
+
const globals = loadGlobalRoles();
|
|
288
|
+
const globalById = new Map(globals.map((role) => [role.id, role]));
|
|
289
|
+
const out = globals.map((role) => ({ name: role.id, description: role.description, home: "" }));
|
|
290
|
+
for (const project of loadProjects()) {
|
|
291
|
+
let roles = [];
|
|
292
|
+
try {
|
|
293
|
+
roles = loadRoles(project.path);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
for (const role of roles) {
|
|
299
|
+
const global = globalById.get(role.id);
|
|
300
|
+
if (global && sameRoleDefinition(global, role))
|
|
301
|
+
continue;
|
|
302
|
+
out.push({ name: role.id, description: role.description, home: project.path, project: project.name });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
/** Resolve `global:agent`, `project:agent`, or a bare name.
|
|
308
|
+
*
|
|
309
|
+
* Bare names normally prefer the global definition, then a unique project match. Callers that already
|
|
310
|
+
* have a working directory (the chat gateway, for example) may pass it as `preferredHome`; a matching
|
|
311
|
+
* project override then wins before the global fallback. This makes `/agent reviewer` do the local thing
|
|
312
|
+
* while keeping registry-wide, context-free lookups deterministic. */
|
|
313
|
+
export function resolveAgent(refInput, preferredHome) {
|
|
314
|
+
if (typeof refInput !== "string")
|
|
315
|
+
return null;
|
|
316
|
+
const ref = refInput.trim();
|
|
317
|
+
const index = buildAgentsIndex();
|
|
318
|
+
const separator = ref.indexOf(":");
|
|
319
|
+
if (separator > 0) {
|
|
320
|
+
const namespace = ref.slice(0, separator).trim().toLowerCase();
|
|
321
|
+
const name = ref.slice(separator + 1).trim();
|
|
322
|
+
if (namespace === "global") {
|
|
323
|
+
if (!name || name.includes(":"))
|
|
324
|
+
return null;
|
|
325
|
+
return index.find((entry) => !entry.project && entry.name === name) ?? null;
|
|
326
|
+
}
|
|
327
|
+
const project = canonicalProjectName(namespace);
|
|
328
|
+
if (!project || !name || name.includes(":"))
|
|
329
|
+
return null;
|
|
330
|
+
return index.find((entry) => entry.project === project && entry.name === name) ?? null;
|
|
331
|
+
}
|
|
332
|
+
const hits = index.filter((entry) => entry.name === ref);
|
|
333
|
+
if (!hits.length)
|
|
334
|
+
return null;
|
|
335
|
+
if (hits.length === 1)
|
|
336
|
+
return hits[0];
|
|
337
|
+
if (preferredHome) {
|
|
338
|
+
const preferred = canonicalProjectPath(preferredHome);
|
|
339
|
+
const local = preferred ? hits.find((entry) => !!entry.project && entry.home === preferred) : undefined;
|
|
340
|
+
if (local)
|
|
341
|
+
return local;
|
|
342
|
+
}
|
|
343
|
+
const global = hits.find((entry) => !entry.project);
|
|
344
|
+
if (global)
|
|
345
|
+
return global;
|
|
346
|
+
return { ambiguous: hits };
|
|
347
|
+
}
|