@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
package/dist/session/store.js
CHANGED
|
@@ -22,11 +22,91 @@ export function automatedTitle(source, sourceName, at = new Date()) {
|
|
|
22
22
|
function sessionsDir() {
|
|
23
23
|
const d = join(homedir(), ".hara", "sessions");
|
|
24
24
|
mkdirSync(d, { recursive: true, mode: 0o700 });
|
|
25
|
-
|
|
25
|
+
// `mode` is ignored when the directory already exists. Tighten legacy installs too: a session holds
|
|
26
|
+
// private conversation history, so inheriting a permissive umask (typically 0755) is not acceptable.
|
|
27
|
+
chmodSync(d, 0o700);
|
|
26
28
|
return d;
|
|
27
29
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
/** Session ids become filenames. Gateway/platform ids are not always UUIDs, so allow printable filename
|
|
31
|
+
* characters broadly while rejecting separators, traversal sentinels, NULs, and unbounded names. */
|
|
32
|
+
export function validSessionId(id) {
|
|
33
|
+
return typeof id === "string" && id.length > 0 && id.length <= 220 && id !== "." && id !== ".." && !/[\\/\0]/.test(id);
|
|
34
|
+
}
|
|
35
|
+
function checkedSessionId(id) {
|
|
36
|
+
if (!validSessionId(id))
|
|
37
|
+
throw new Error("invalid session id");
|
|
38
|
+
return id;
|
|
39
|
+
}
|
|
40
|
+
const sessionFile = (id) => join(sessionsDir(), `${checkedSessionId(id)}.json`);
|
|
41
|
+
const lockFile = (id) => join(sessionsDir(), `${checkedSessionId(id)}.lock`);
|
|
42
|
+
const reclaimFile = (id) => join(sessionsDir(), `${checkedSessionId(id)}.lock.reclaim`);
|
|
43
|
+
/** Distinguish a missing session from an existing but unreadable/corrupt one without exposing its path. */
|
|
44
|
+
export function sessionFileExists(id) {
|
|
45
|
+
if (!validSessionId(id))
|
|
46
|
+
return false;
|
|
47
|
+
return existsSync(sessionFile(id));
|
|
48
|
+
}
|
|
49
|
+
// A pid alone is not ownership: pids are reused, and another writer could replace a lock between read and
|
|
50
|
+
// release. Keep the random token for every lock this module actually created and require both on release.
|
|
51
|
+
const ownedLocks = new Map();
|
|
52
|
+
function readLockRecord(path) {
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
55
|
+
if (!Number.isInteger(parsed.pid) ||
|
|
56
|
+
Number(parsed.pid) <= 0 ||
|
|
57
|
+
typeof parsed.startedAt !== "number" ||
|
|
58
|
+
(parsed.token !== undefined && (typeof parsed.token !== "string" || !parsed.token))) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return { pid: Number(parsed.pid), startedAt: parsed.startedAt, ...(parsed.token ? { token: parsed.token } : {}) };
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Create one private file without ever replacing an existing path. On a partial write, remove only the
|
|
68
|
+
* inode we just created. Callers use this for both the primary lock and the stale-lock reclaimer. */
|
|
69
|
+
function writeExclusive(path, record) {
|
|
70
|
+
let fd;
|
|
71
|
+
try {
|
|
72
|
+
fd = openSync(path, "wx", 0o600);
|
|
73
|
+
writeFileSync(fd, JSON.stringify(record), "utf8");
|
|
74
|
+
fsyncSync(fd);
|
|
75
|
+
closeSync(fd);
|
|
76
|
+
fd = undefined;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (fd !== undefined) {
|
|
80
|
+
try {
|
|
81
|
+
closeSync(fd);
|
|
82
|
+
fd = undefined;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
/* continue with best-effort removal */
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
rmSync(path, { force: true });
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
/* the original error is more useful */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
if (fd !== undefined) {
|
|
98
|
+
try {
|
|
99
|
+
closeSync(fd);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* the acquisition will fail closed */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function newLockRecord() {
|
|
108
|
+
return { pid: process.pid, startedAt: Date.now(), token: randomUUID() };
|
|
109
|
+
}
|
|
30
110
|
/** Is a process with this pid alive? `process.kill(pid, 0)` sends no signal — it just probes: throws
|
|
31
111
|
* ESRCH if dead, EPERM if alive-but-not-ours (still alive). Best-effort across platforms. */
|
|
32
112
|
function pidAlive(pid) {
|
|
@@ -38,60 +118,145 @@ function pidAlive(pid) {
|
|
|
38
118
|
return e?.code === "EPERM";
|
|
39
119
|
}
|
|
40
120
|
}
|
|
41
|
-
/** Take an
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* filesystem hiccup resolves to `ok:true` so a lock problem never blocks the user. Pair with
|
|
45
|
-
* `releaseSessionLock` on exit. */
|
|
121
|
+
/** Take an O_EXCL lock on a session so two hara processes cannot both pass a check-then-write race.
|
|
122
|
+
* Filesystem/malformed-lock errors fail CLOSED. A well-formed dead holder can be reclaimed under a second
|
|
123
|
+
* O_EXCL guard; a corrupt lock is deliberately left for explicit operator inspection/removal. */
|
|
46
124
|
export function acquireSessionLock(id) {
|
|
47
|
-
|
|
125
|
+
let f;
|
|
126
|
+
let reclaim;
|
|
48
127
|
try {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
128
|
+
f = lockFile(id);
|
|
129
|
+
reclaim = reclaimFile(id);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return { ok: false };
|
|
133
|
+
}
|
|
134
|
+
// A stale-lock recovery is changing the primary lock. New contenders wait/fail instead of creating a
|
|
135
|
+
// primary lock in the short remove→create window.
|
|
136
|
+
if (existsSync(reclaim)) {
|
|
137
|
+
const stale = readLockRecord(reclaim);
|
|
138
|
+
if (!stale?.token || !Number.isFinite(stale.startedAt) || stale.startedAt <= 0 || pidAlive(stale.pid)) {
|
|
139
|
+
return { ok: false, pid: readLockRecord(f)?.pid };
|
|
55
140
|
}
|
|
56
|
-
|
|
141
|
+
const current = readLockRecord(reclaim);
|
|
142
|
+
if (!current?.token ||
|
|
143
|
+
current.pid !== stale.pid ||
|
|
144
|
+
current.token !== stale.token ||
|
|
145
|
+
current.startedAt !== stale.startedAt ||
|
|
146
|
+
pidAlive(current.pid)) {
|
|
147
|
+
return { ok: false, pid: readLockRecord(f)?.pid };
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
rmSync(reclaim);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return { ok: false, pid: readLockRecord(f)?.pid };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const claim = newLockRecord();
|
|
157
|
+
try {
|
|
158
|
+
writeExclusive(f, claim);
|
|
159
|
+
ownedLocks.set(id, claim.token);
|
|
57
160
|
return { ok: true };
|
|
58
161
|
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
if (error?.code !== "EEXIST")
|
|
164
|
+
return { ok: false };
|
|
165
|
+
}
|
|
166
|
+
const held = readLockRecord(f);
|
|
167
|
+
if (!held)
|
|
168
|
+
return { ok: false }; // malformed/unreadable is not proof that the owner is dead
|
|
169
|
+
if (held.pid === process.pid && !!held.token && ownedLocks.get(id) === held.token)
|
|
170
|
+
return { ok: true }; // re-entrant
|
|
171
|
+
if (pidAlive(held.pid))
|
|
172
|
+
return { ok: false, pid: held.pid };
|
|
173
|
+
// Serialize stale takeover. All participants check this guard before attempting the primary O_EXCL create,
|
|
174
|
+
// so no second contender can steal the freshly-created primary lock during reclamation.
|
|
175
|
+
const reclaimClaim = newLockRecord();
|
|
176
|
+
try {
|
|
177
|
+
writeExclusive(reclaim, reclaimClaim);
|
|
178
|
+
}
|
|
59
179
|
catch {
|
|
180
|
+
return { ok: false, pid: held.pid };
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const current = readLockRecord(f);
|
|
184
|
+
if (!current)
|
|
185
|
+
return { ok: false }; // disappeared/corrupted unexpectedly: fail closed
|
|
186
|
+
if (current.pid === process.pid && !!current.token && ownedLocks.get(id) === current.token)
|
|
187
|
+
return { ok: true };
|
|
188
|
+
if (pidAlive(current.pid))
|
|
189
|
+
return { ok: false, pid: current.pid };
|
|
190
|
+
rmSync(f); // known-dead, well-formed owner; protected by the reclaim guard
|
|
191
|
+
const replacement = newLockRecord();
|
|
192
|
+
writeExclusive(f, replacement);
|
|
193
|
+
ownedLocks.set(id, replacement.token);
|
|
60
194
|
return { ok: true };
|
|
61
195
|
}
|
|
196
|
+
catch {
|
|
197
|
+
return { ok: false };
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
// Only remove our own reclaimer. A replacement would indicate outside interference and must survive.
|
|
201
|
+
const currentReclaim = readLockRecord(reclaim);
|
|
202
|
+
if (currentReclaim?.pid === process.pid && currentReclaim.token === reclaimClaim.token) {
|
|
203
|
+
try {
|
|
204
|
+
rmSync(reclaim);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
/* fail closed on the next acquisition until this guard can be inspected/removed */
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
62
211
|
}
|
|
63
212
|
/** Release a session lock we hold (only removes it if the pid matches ours — never steals another's). */
|
|
64
213
|
export function releaseSessionLock(id) {
|
|
214
|
+
const token = ownedLocks.get(id);
|
|
215
|
+
if (!token)
|
|
216
|
+
return;
|
|
65
217
|
try {
|
|
66
218
|
const f = lockFile(id);
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const held = JSON.parse(readFileSync(f, "utf8"));
|
|
70
|
-
if (held?.pid === process.pid)
|
|
219
|
+
const held = readLockRecord(f);
|
|
220
|
+
if (held?.pid === process.pid && held.token === token)
|
|
71
221
|
rmSync(f);
|
|
222
|
+
ownedLocks.delete(id);
|
|
72
223
|
}
|
|
73
224
|
catch {
|
|
74
|
-
|
|
225
|
+
// Keep the ownership token so a later cleanup attempt can still prove ownership. Never unlink blindly.
|
|
75
226
|
}
|
|
76
227
|
}
|
|
77
228
|
/** Permanently delete a session from disk (codex thread/delete — unlike archive, irreversible).
|
|
78
229
|
* Refuses when a LIVE other process holds the lock; removes the session file and any lock we may hold.
|
|
79
230
|
* Returns false when the session doesn't exist or is held elsewhere. */
|
|
80
231
|
export function deleteSession(id) {
|
|
81
|
-
|
|
232
|
+
let f;
|
|
233
|
+
try {
|
|
234
|
+
f = sessionFile(id);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
82
239
|
if (!existsSync(f))
|
|
83
240
|
return false;
|
|
241
|
+
const ownedBefore = ownedLocks.has(id);
|
|
84
242
|
const lock = acquireSessionLock(id);
|
|
85
243
|
if (!lock.ok)
|
|
86
244
|
return false;
|
|
245
|
+
let deleted = false;
|
|
87
246
|
try {
|
|
88
247
|
rmSync(f);
|
|
89
|
-
|
|
248
|
+
deleted = true;
|
|
90
249
|
return true;
|
|
91
250
|
}
|
|
92
251
|
catch {
|
|
93
252
|
return false;
|
|
94
253
|
}
|
|
254
|
+
finally {
|
|
255
|
+
// A successful delete ends the session. On failure, release only a lock acquired by this call; a live
|
|
256
|
+
// SessionHub that already owned the lock must retain it and remain protected.
|
|
257
|
+
if (deleted || !ownedBefore)
|
|
258
|
+
releaseSessionLock(id);
|
|
259
|
+
}
|
|
95
260
|
}
|
|
96
261
|
/** A full UUID per session (the stable identity). */
|
|
97
262
|
export const newSessionId = () => randomUUID();
|
|
@@ -99,10 +264,12 @@ export const newSessionId = () => randomUUID();
|
|
|
99
264
|
export const shortId = (id) => id.slice(0, 8);
|
|
100
265
|
/** Resolve a full id OR a unique prefix (e.g. the short id) to a session id, for `--resume`. */
|
|
101
266
|
export function resolveSessionId(idOrPrefix) {
|
|
267
|
+
if (!validSessionId(idOrPrefix))
|
|
268
|
+
return null;
|
|
102
269
|
if (existsSync(sessionFile(idOrPrefix)))
|
|
103
270
|
return idOrPrefix;
|
|
104
|
-
const
|
|
105
|
-
return
|
|
271
|
+
const hits = listSessions().filter((m) => m.id.startsWith(idOrPrefix));
|
|
272
|
+
return hits.length === 1 ? hits[0].id : null;
|
|
106
273
|
}
|
|
107
274
|
const STOP = new Set("the a an to of for and or with in on at my our your this that it is please can could you help me we add fix make do run create update change implement".split(" "));
|
|
108
275
|
const WORDS = "amber basalt cedar delta ember flint grove harbor indigo jade kelp larch maple onyx quartz river slate terra umber vale willow zephyr".split(" ");
|
|
@@ -156,19 +323,34 @@ export function slugify(text, max = 40) {
|
|
|
156
323
|
.slice(0, max)
|
|
157
324
|
.replace(/-+$/, "");
|
|
158
325
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const data = { meta, history };
|
|
326
|
+
/** Redact a deep in-memory copy while retaining fields that define session identity/routing. */
|
|
327
|
+
function redactedSessionCopy(data) {
|
|
162
328
|
// Redact a deep COPY: the live turn may still need a credential the user supplied, but the durable
|
|
163
329
|
// transcript never should. Tool inputs/results are included, not just user message content.
|
|
164
330
|
const safe = redactSensitiveValue(data).value;
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
safe.meta.
|
|
169
|
-
safe.meta.
|
|
170
|
-
safe.meta.
|
|
171
|
-
safe.meta.
|
|
331
|
+
// Structural routing/identity fields must remain byte-for-byte stable even if a path happens to contain
|
|
332
|
+
// credential-looking text. Free-form meta (title, workingSet, todos, sourceName) and ALL history remain
|
|
333
|
+
// deeply redacted. The live objects are not modified by the redaction walk.
|
|
334
|
+
safe.meta.id = data.meta.id;
|
|
335
|
+
safe.meta.cwd = data.meta.cwd;
|
|
336
|
+
safe.meta.provider = data.meta.provider;
|
|
337
|
+
safe.meta.model = data.meta.model;
|
|
338
|
+
safe.meta.createdAt = data.meta.createdAt;
|
|
339
|
+
safe.meta.updatedAt = data.meta.updatedAt;
|
|
340
|
+
if (data.meta.source !== undefined)
|
|
341
|
+
safe.meta.source = data.meta.source;
|
|
342
|
+
if (data.meta.effort !== undefined)
|
|
343
|
+
safe.meta.effort = data.meta.effort;
|
|
344
|
+
if (data.meta.archived !== undefined)
|
|
345
|
+
safe.meta.archived = data.meta.archived;
|
|
346
|
+
if (data.meta.gatewayOwner !== undefined)
|
|
347
|
+
safe.meta.gatewayOwner = data.meta.gatewayOwner;
|
|
348
|
+
return safe;
|
|
349
|
+
}
|
|
350
|
+
export function saveSession(meta, history) {
|
|
351
|
+
checkedSessionId(meta.id);
|
|
352
|
+
meta.updatedAt = new Date().toISOString();
|
|
353
|
+
const safe = redactedSessionCopy({ meta, history });
|
|
172
354
|
const target = sessionFile(meta.id);
|
|
173
355
|
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
174
356
|
let fd;
|
|
@@ -178,7 +360,8 @@ export function saveSession(meta, history) {
|
|
|
178
360
|
fsyncSync(fd);
|
|
179
361
|
closeSync(fd);
|
|
180
362
|
fd = undefined;
|
|
181
|
-
|
|
363
|
+
// Same directory/filesystem: readers observe the complete old JSON or complete new JSON, never a prefix.
|
|
364
|
+
renameSync(tmp, target);
|
|
182
365
|
chmodSync(target, 0o600);
|
|
183
366
|
}
|
|
184
367
|
catch (error) {
|
|
@@ -199,29 +382,97 @@ export function saveSession(meta, history) {
|
|
|
199
382
|
throw error;
|
|
200
383
|
}
|
|
201
384
|
}
|
|
202
|
-
|
|
385
|
+
function isTimestamp(value) {
|
|
386
|
+
return typeof value === "string" && value.length > 0 && Number.isFinite(Date.parse(value));
|
|
387
|
+
}
|
|
388
|
+
function isStringArray(value) {
|
|
389
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
390
|
+
}
|
|
391
|
+
function isPersistedTodo(value) {
|
|
392
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
393
|
+
return false;
|
|
394
|
+
const todo = value;
|
|
395
|
+
return (typeof todo.text === "string" &&
|
|
396
|
+
(todo.status === "pending" || todo.status === "in_progress" || todo.status === "done") &&
|
|
397
|
+
(todo.activeForm === undefined || typeof todo.activeForm === "string") &&
|
|
398
|
+
(todo.blockedBy === undefined || isStringArray(todo.blockedBy)) &&
|
|
399
|
+
(todo.owner === undefined || typeof todo.owner === "string"));
|
|
400
|
+
}
|
|
401
|
+
function isNeutralMessage(value) {
|
|
402
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
403
|
+
return false;
|
|
404
|
+
const message = value;
|
|
405
|
+
if (message.role === "user") {
|
|
406
|
+
return typeof message.content === "string" && (message.images === undefined || (Array.isArray(message.images) && message.images.every((image) => {
|
|
407
|
+
if (!image || typeof image !== "object" || Array.isArray(image))
|
|
408
|
+
return false;
|
|
409
|
+
const attachment = image;
|
|
410
|
+
return typeof attachment.path === "string" && typeof attachment.mediaType === "string";
|
|
411
|
+
})));
|
|
412
|
+
}
|
|
413
|
+
if (message.role === "assistant") {
|
|
414
|
+
return typeof message.text === "string" && Array.isArray(message.toolUses) && message.toolUses.every((use) => {
|
|
415
|
+
if (!use || typeof use !== "object" || Array.isArray(use))
|
|
416
|
+
return false;
|
|
417
|
+
const tool = use;
|
|
418
|
+
return typeof tool.id === "string" && typeof tool.name === "string" && Object.hasOwn(tool, "input");
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
if (message.role === "tool") {
|
|
422
|
+
return Array.isArray(message.results) && message.results.every((result) => {
|
|
423
|
+
if (!result || typeof result !== "object" || Array.isArray(result))
|
|
424
|
+
return false;
|
|
425
|
+
const tool = result;
|
|
426
|
+
return typeof tool.id === "string" && typeof tool.name === "string" && typeof tool.content === "string" &&
|
|
427
|
+
(tool.isError === undefined || typeof tool.isError === "boolean");
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
function isSessionMeta(value) {
|
|
433
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
434
|
+
return false;
|
|
435
|
+
const meta = value;
|
|
436
|
+
return (validSessionId(meta.id) &&
|
|
437
|
+
typeof meta.cwd === "string" &&
|
|
438
|
+
typeof meta.provider === "string" &&
|
|
439
|
+
typeof meta.model === "string" &&
|
|
440
|
+
typeof meta.title === "string" &&
|
|
441
|
+
isTimestamp(meta.createdAt) &&
|
|
442
|
+
isTimestamp(meta.updatedAt) &&
|
|
443
|
+
(meta.workingSet === undefined || isStringArray(meta.workingSet)) &&
|
|
444
|
+
(meta.todos === undefined || (Array.isArray(meta.todos) && meta.todos.every(isPersistedTodo))) &&
|
|
445
|
+
(meta.source === undefined || meta.source === "interactive" || meta.source === "gateway" || meta.source === "cron") &&
|
|
446
|
+
(meta.sourceName === undefined || typeof meta.sourceName === "string") &&
|
|
447
|
+
(meta.effort === undefined || typeof meta.effort === "string") &&
|
|
448
|
+
(meta.archived === undefined || typeof meta.archived === "boolean") &&
|
|
449
|
+
(meta.gatewayOwner === undefined || typeof meta.gatewayOwner === "string"));
|
|
450
|
+
}
|
|
451
|
+
/** True if a parsed object has the SessionData shape we can safely use. */
|
|
203
452
|
function isSessionData(d) {
|
|
204
453
|
const o = d;
|
|
205
|
-
return !!o && typeof o === "object" &&
|
|
454
|
+
return !!o && typeof o === "object" && !Array.isArray(o) && isSessionMeta(o.meta) &&
|
|
455
|
+
Array.isArray(o.history) && o.history.every(isNeutralMessage);
|
|
206
456
|
}
|
|
207
|
-
/**
|
|
208
|
-
*
|
|
457
|
+
/** Read only. Legacy plaintext is redacted in the returned in-memory copy but intentionally not migrated
|
|
458
|
+
* here: listing/resuming must not perform an unlocked write. The next explicit save atomically migrates it. */
|
|
209
459
|
function readSessionFile(p) {
|
|
210
460
|
try {
|
|
211
461
|
const d = JSON.parse(readFileSync(p, "utf8"));
|
|
212
|
-
|
|
213
|
-
return null;
|
|
214
|
-
return redactSensitiveValue(d).value;
|
|
462
|
+
return isSessionData(d) ? redactedSessionCopy(d) : null;
|
|
215
463
|
}
|
|
216
464
|
catch {
|
|
217
465
|
return null;
|
|
218
466
|
}
|
|
219
467
|
}
|
|
220
468
|
export function loadSession(id) {
|
|
469
|
+
if (!validSessionId(id))
|
|
470
|
+
return null;
|
|
221
471
|
const p = sessionFile(id);
|
|
222
472
|
if (!existsSync(p))
|
|
223
473
|
return null;
|
|
224
|
-
|
|
474
|
+
const data = readSessionFile(p);
|
|
475
|
+
return data?.meta.id === id ? data : null; // a corrupt, spoofed, or hand-edited file resumes as "no session"
|
|
225
476
|
}
|
|
226
477
|
/** Session metas, newest first; optionally filtered to a cwd. */
|
|
227
478
|
export function listSessions(cwd) {
|
|
@@ -230,12 +481,12 @@ export function listSessions(cwd) {
|
|
|
230
481
|
if (!f.endsWith(".json"))
|
|
231
482
|
continue;
|
|
232
483
|
const d = readSessionFile(join(sessionsDir(), f));
|
|
233
|
-
if (d?.meta.id && d.meta.updatedAt)
|
|
234
|
-
metas.push(d.meta); // skip
|
|
484
|
+
if (d?.meta.id && validSessionId(d.meta.id) && f === `${d.meta.id}.json` && d.meta.updatedAt)
|
|
485
|
+
metas.push(d.meta); // skip spoofed/metalless/corrupt; never mutate while listing
|
|
235
486
|
}
|
|
236
487
|
if (cwd)
|
|
237
488
|
metas = metas.filter((m) => m.cwd === cwd);
|
|
238
|
-
return metas.sort((a, b) => b.updatedAt.
|
|
489
|
+
return metas.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
|
239
490
|
}
|
|
240
491
|
export function latestForCwd(cwd) {
|
|
241
492
|
const [m] = listSessions(cwd);
|
package/dist/skills/skills.js
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
// ~/.hara/skills). Frontmatter: name, description (required) + when_to_use / allowed-tools /
|
|
3
3
|
// context inline|fork / model / paths / user-invocable / disable-model-invocation. The body is the
|
|
4
4
|
// instructions, loaded ON DEMAND (progressive disclosure) — only the frontmatter index sits in context.
|
|
5
|
-
import {
|
|
5
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { findProjectRoot } from "../context/agents-md.js";
|
|
9
9
|
import { scanMemory } from "../memory/guard.js";
|
|
10
10
|
import { pluginSkillDirs } from "../plugins/plugins.js";
|
|
11
|
+
import { readModelContextFileSync, readVerifiedRegularFileSnapshot } from "../fs-read.js";
|
|
12
|
+
import { atomicWriteText, bindAtomicWritePath } from "../fs-write.js";
|
|
13
|
+
const MAX_SKILL_BYTES = 512 * 1024;
|
|
11
14
|
export function skillsDir(cwd) {
|
|
12
15
|
return join(findProjectRoot(cwd), ".hara", "skills");
|
|
13
16
|
}
|
|
@@ -60,7 +63,7 @@ export function loadSkillIndex(cwd) {
|
|
|
60
63
|
if (!existsSync(file))
|
|
61
64
|
continue;
|
|
62
65
|
try {
|
|
63
|
-
const { fm } = parseFrontmatter(
|
|
66
|
+
const { fm } = parseFrontmatter(readModelContextFileSync(file, MAX_SKILL_BYTES));
|
|
64
67
|
const id = fm.name || entry;
|
|
65
68
|
byId.set(id, {
|
|
66
69
|
id,
|
|
@@ -86,7 +89,7 @@ export function loadSkillIndex(cwd) {
|
|
|
86
89
|
/** Read a skill's instruction body (progressive disclosure — only when the model/user opens it). */
|
|
87
90
|
export function loadSkillBody(skill) {
|
|
88
91
|
try {
|
|
89
|
-
return parseFrontmatter(
|
|
92
|
+
return parseFrontmatter(readModelContextFileSync(skill.file, MAX_SKILL_BYTES)).body;
|
|
90
93
|
}
|
|
91
94
|
catch {
|
|
92
95
|
return "";
|
|
@@ -129,13 +132,19 @@ when_to_use: after editing code, before declaring a task done
|
|
|
129
132
|
4. Summarize: what you ran, pass/fail, and anything still unverified.
|
|
130
133
|
`;
|
|
131
134
|
/** Create ~/.hara/skills/verify-change/SKILL.md as a starter example. Returns the paths written. */
|
|
132
|
-
export function scaffoldSkills(cwd) {
|
|
135
|
+
export async function scaffoldSkills(cwd) {
|
|
133
136
|
const dir = join(skillsDir(cwd), "verify-change");
|
|
134
|
-
mkdirSync(dir, { recursive: true });
|
|
135
137
|
const p = join(dir, "SKILL.md");
|
|
136
|
-
|
|
138
|
+
const boundary = bindAtomicWritePath(p, "scaffold skill");
|
|
139
|
+
try {
|
|
140
|
+
await readVerifiedRegularFileSnapshot(boundary.target, undefined, "scaffold skill");
|
|
137
141
|
return [];
|
|
138
|
-
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (error?.code !== "ENOENT")
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
await atomicWriteText(boundary.target, SCAFFOLD, { expected: null, boundary });
|
|
139
148
|
invalidateSkillsCache();
|
|
140
149
|
return [p];
|
|
141
150
|
}
|
package/dist/tools/all.js
CHANGED
|
@@ -12,6 +12,7 @@ import "./memory.js"; // memory_search/get/write/forget + skill_create
|
|
|
12
12
|
import "./skill.js"; // skill loader
|
|
13
13
|
import "./codebase.js"; // codebase_search
|
|
14
14
|
import "./todo.js"; // todo_write
|
|
15
|
+
import "./task.js"; // task (project-level persistent task pool)
|
|
15
16
|
import "./send.js"; // send_file (self-gates on HARA_GATEWAY)
|
|
16
17
|
import "./external_agent.js"; // external_agent (claude-code / codex delegation)
|
|
17
18
|
import "./ask_user.js"; // ask_user
|