@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.
- package/CHANGELOG.md +85 -0
- package/README.md +40 -6
- package/dist/agent/failover.js +1 -1
- package/dist/agent/loop.js +158 -21
- 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 +20 -6
- package/dist/config.js +62 -26
- package/dist/cron/deliver.js +37 -3
- package/dist/feedback.js +5 -9
- package/dist/fs-read.js +106 -12
- package/dist/fs-write.js +242 -16
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -18
- package/dist/gateway/feishu.js +158 -57
- package/dist/gateway/flows-pending.js +720 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +80 -15
- package/dist/gateway/mattermost.js +44 -32
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/serve.js +657 -162
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +27 -22
- package/dist/gateway/slack.js +26 -17
- package/dist/gateway/telegram.js +32 -18
- package/dist/gateway/wecom.js +32 -24
- package/dist/gateway/weixin.js +127 -49
- package/dist/hooks.js +32 -20
- package/dist/index.js +640 -219
- package/dist/org/projects.js +347 -0
- package/dist/org/roles.js +38 -11
- package/dist/security/secrets.js +150 -0
- package/dist/serve/server.js +772 -317
- package/dist/serve/sessions.js +112 -28
- package/dist/session/store.js +337 -44
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +61 -23
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/edit.js +11 -8
- package/dist/tools/patch.js +230 -31
- package/dist/tools/search.js +482 -72
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +364 -64
- package/dist/tui/run.js +26 -23
- package/dist/undo.js +83 -7
- package/package.json +1 -1
package/dist/gateway/weixin.js
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
// (iLink only uses crypto for media upload/download, which v1 doesn't do).
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { chmodSync, readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
|
|
9
9
|
import { basename } from "node:path";
|
|
10
10
|
import { randomBytes, randomUUID, createHash, createCipheriv, createDecipheriv } from "node:crypto";
|
|
11
11
|
import { chunkText } from "./telegram.js";
|
|
12
|
+
import { INBOUND_MEDIA_MAX_BYTES, INBOUND_MEDIA_TIMEOUT_MS, InboundMediaBudget, cleanupTransientMedia, readResponseBytesLimited, savePrivateMediaBytes, } from "./media.js";
|
|
12
13
|
const ILINK_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
13
14
|
const CHANNEL_VERSION = "2.2.0";
|
|
14
15
|
const ILINK_APP_ID = "bot";
|
|
@@ -44,7 +45,6 @@ const WEIXIN_CDN_ALLOWLIST = new Set([
|
|
|
44
45
|
"mmbiz.qlogo.cn",
|
|
45
46
|
]);
|
|
46
47
|
const IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "bmp"]);
|
|
47
|
-
const INBOUND_MEDIA_MAX = 128 * 1024 * 1024; // 128 MiB cap, per the reference
|
|
48
48
|
const num = (v) => (typeof v === "number" ? v : 0);
|
|
49
49
|
const str = (v) => (v == null ? "" : String(v));
|
|
50
50
|
// ── pure protocol helpers (unit-tested) ──────────────────────────────────────
|
|
@@ -223,40 +223,74 @@ function saveCursor(accountId, buf) {
|
|
|
223
223
|
// Per-peer context_token: every reply must echo the latest token iLink sent for that peer.
|
|
224
224
|
class TokenStore {
|
|
225
225
|
accountId;
|
|
226
|
-
cache =
|
|
226
|
+
cache = new Map();
|
|
227
227
|
constructor(accountId) {
|
|
228
228
|
this.accountId = accountId;
|
|
229
229
|
try {
|
|
230
|
-
|
|
231
|
-
|
|
230
|
+
const exists = existsSync(this.file());
|
|
231
|
+
const parsed = exists ? JSON.parse(readFileSync(this.file(), "utf8")) : {};
|
|
232
|
+
if (exists)
|
|
233
|
+
chmodSync(this.file(), 0o600);
|
|
234
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
235
|
+
for (const [peer, token] of Object.entries(parsed).slice(-1000)) {
|
|
236
|
+
if (peer && typeof token === "string" && token)
|
|
237
|
+
this.cache.set(peer, token);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
232
240
|
}
|
|
233
241
|
catch {
|
|
234
|
-
this.cache
|
|
242
|
+
this.cache.clear();
|
|
235
243
|
}
|
|
236
244
|
}
|
|
237
245
|
file() {
|
|
238
246
|
return join(weixinDir(), `${this.accountId}.context-tokens.json`);
|
|
239
247
|
}
|
|
248
|
+
peerKey(peer) {
|
|
249
|
+
return String(peer ?? "").trim().slice(0, 256);
|
|
250
|
+
}
|
|
240
251
|
persist() {
|
|
252
|
+
const temporary = `${this.file()}.${process.pid}.${randomUUID()}.tmp`;
|
|
241
253
|
try {
|
|
242
|
-
mkdirSync(weixinDir(), { recursive: true });
|
|
243
|
-
|
|
254
|
+
mkdirSync(weixinDir(), { recursive: true, mode: 0o700 });
|
|
255
|
+
chmodSync(weixinDir(), 0o700);
|
|
256
|
+
writeFileSync(temporary, JSON.stringify(Object.fromEntries(this.cache)), { mode: 0o600, flag: "wx" });
|
|
257
|
+
renameSync(temporary, this.file());
|
|
258
|
+
chmodSync(this.file(), 0o600);
|
|
244
259
|
}
|
|
245
260
|
catch {
|
|
246
261
|
/* best-effort */
|
|
247
262
|
}
|
|
263
|
+
finally {
|
|
264
|
+
try {
|
|
265
|
+
rmSync(temporary, { force: true });
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
/* best-effort */
|
|
269
|
+
}
|
|
270
|
+
}
|
|
248
271
|
}
|
|
249
272
|
get(peer) {
|
|
250
|
-
|
|
273
|
+
const key = this.peerKey(peer);
|
|
274
|
+
const token = this.cache.get(key);
|
|
275
|
+
if (token) {
|
|
276
|
+
this.cache.delete(key);
|
|
277
|
+
this.cache.set(key, token);
|
|
278
|
+
}
|
|
279
|
+
return token;
|
|
251
280
|
}
|
|
252
281
|
set(peer, token) {
|
|
253
|
-
|
|
254
|
-
|
|
282
|
+
const key = this.peerKey(peer);
|
|
283
|
+
const boundedToken = String(token ?? "").slice(0, 4096);
|
|
284
|
+
if (key && boundedToken) {
|
|
285
|
+
this.cache.delete(key);
|
|
286
|
+
this.cache.set(key, boundedToken);
|
|
287
|
+
while (this.cache.size > 1000)
|
|
288
|
+
this.cache.delete(this.cache.keys().next().value);
|
|
255
289
|
this.persist();
|
|
256
290
|
}
|
|
257
291
|
}
|
|
258
292
|
del(peer) {
|
|
259
|
-
delete
|
|
293
|
+
this.cache.delete(this.peerKey(peer));
|
|
260
294
|
this.persist();
|
|
261
295
|
}
|
|
262
296
|
}
|
|
@@ -333,14 +367,25 @@ async function sendChunk(creds, tokenStore, peer, text) {
|
|
|
333
367
|
const clientId = `hara-weixin-${randomUUID().replace(/-/g, "")}`; // reused across the -14 retry for dedup
|
|
334
368
|
const post = (ctx) => apiPost(creds.base_url, EP.sendMessage, buildSendBody(peer, text, ctx, clientId), creds.token, API_TIMEOUT_MS).catch((e) => ({ ret: 1, errmsg: String(e?.message ?? e) }));
|
|
335
369
|
let resp = await post(tokenStore.get(peer));
|
|
370
|
+
// HARA_WX_DEBUG=1 → dump the raw send response (protocol exploration: does iLink return a message id we
|
|
371
|
+
// could use for a future recall/revoke?). Off by default; stderr only.
|
|
372
|
+
if (process.env.HARA_WX_DEBUG === "1")
|
|
373
|
+
console.error("weixin send resp:", JSON.stringify(resp).slice(0, 500));
|
|
336
374
|
let [ret, errcode, errmsg] = [num(resp.ret), num(resp.errcode), str(resp.errmsg ?? resp.msg)];
|
|
337
375
|
if (isSessionExpired(ret, errcode, errmsg)) {
|
|
338
376
|
tokenStore.del(peer); // stale → drop it and retry once tokenless (iLink accepts that, degraded)
|
|
339
377
|
resp = await post(undefined);
|
|
340
378
|
[ret, errcode, errmsg] = [num(resp.ret), num(resp.errcode), str(resp.errmsg ?? resp.msg)];
|
|
341
379
|
}
|
|
380
|
+
// Rate-limit (ret=-2, empty errmsg): iLink throttles cold/rapid proactive pushes. Back off and retry a few
|
|
381
|
+
// times instead of silently dropping the message — the reused clientId dedups so a retry can't double-send.
|
|
382
|
+
for (let attempt = 0; ret === RATE_LIMIT && errcode === 0 && attempt < 3; attempt++) {
|
|
383
|
+
await sleep(1500 * (attempt + 1));
|
|
384
|
+
resp = await post(tokenStore.get(peer));
|
|
385
|
+
[ret, errcode, errmsg] = [num(resp.ret), num(resp.errcode), str(resp.errmsg ?? resp.msg)];
|
|
386
|
+
}
|
|
342
387
|
if (ret !== 0 || errcode !== 0)
|
|
343
|
-
|
|
388
|
+
throw new Error(`weixin send failed: ret=${ret} errcode=${errcode} errmsg=${errmsg || "unknown error"}`);
|
|
344
389
|
}
|
|
345
390
|
function aes128EcbEncrypt(plaintext, key) {
|
|
346
391
|
const c = createCipheriv("aes-128-ecb", key, null); // PKCS7 auto-padding (Node default) matches iLink
|
|
@@ -397,8 +442,6 @@ export function inboundMediaRefs(itemList) {
|
|
|
397
442
|
return out;
|
|
398
443
|
}
|
|
399
444
|
const cdnDownloadUrl = (param) => `${WEIXIN_CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(param)}`;
|
|
400
|
-
const mediaDir = () => join(weixinDir(), "media");
|
|
401
|
-
const sanitizeName = (name) => name.replace(/[^\w.\- ]+/g, "_").slice(-80) || "file.bin";
|
|
402
445
|
function assertCdnHost(url) {
|
|
403
446
|
let u;
|
|
404
447
|
try {
|
|
@@ -413,7 +456,10 @@ function assertCdnHost(url) {
|
|
|
413
456
|
throw new Error(`host ${u.hostname} not in WeChat CDN allowlist (SSRF guard)`);
|
|
414
457
|
}
|
|
415
458
|
/** Download (+ AES-decrypt if keyed) an inbound media item to a local file. Returns its path + mime, or null. */
|
|
416
|
-
export async function downloadInboundMedia(ref
|
|
459
|
+
export async function downloadInboundMedia(ref, options = {
|
|
460
|
+
maxBytes: INBOUND_MEDIA_MAX_BYTES,
|
|
461
|
+
signal: AbortSignal.timeout(INBOUND_MEDIA_TIMEOUT_MS),
|
|
462
|
+
}) {
|
|
417
463
|
try {
|
|
418
464
|
let url;
|
|
419
465
|
if (ref.encryptQueryParam)
|
|
@@ -424,22 +470,20 @@ export async function downloadInboundMedia(ref) {
|
|
|
424
470
|
}
|
|
425
471
|
else
|
|
426
472
|
return null;
|
|
427
|
-
const res = await fetch(url, { signal:
|
|
473
|
+
const res = await fetch(url, { signal: options.signal });
|
|
428
474
|
if (!res.ok)
|
|
429
475
|
throw new Error(`media download HTTP ${res.status}`);
|
|
430
|
-
|
|
431
|
-
if (ab.byteLength > INBOUND_MEDIA_MAX)
|
|
432
|
-
throw new Error("media exceeds size cap");
|
|
433
|
-
let buf = Buffer.from(ab);
|
|
476
|
+
let buf = await readResponseBytesLimited(res, options.maxBytes, options.signal);
|
|
434
477
|
if (ref.aesKeyB64)
|
|
435
478
|
buf = aes128EcbDecrypt(buf, parseAesKey(ref.aesKeyB64));
|
|
436
479
|
if (!buf.length)
|
|
437
480
|
return null;
|
|
438
|
-
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
481
|
+
const filenameHint = ref.kind === "image" ? "image.jpg" : ref.kind === "voice" ? "audio.silk" : ref.fileName || "document.bin";
|
|
482
|
+
const path = await savePrivateMediaBytes(buf, {
|
|
483
|
+
platform: "weixin",
|
|
484
|
+
filenameHint,
|
|
485
|
+
...options,
|
|
486
|
+
});
|
|
443
487
|
return { path, mime: ref.kind === "image" ? "image/jpeg" : ref.kind === "voice" ? "audio/silk" : "application/octet-stream" };
|
|
444
488
|
}
|
|
445
489
|
catch (e) {
|
|
@@ -510,34 +554,67 @@ export function weixinAdapter(creds) {
|
|
|
510
554
|
const tokenStore = new TokenStore(creds.account_id);
|
|
511
555
|
// Reuse parseWeixinMessage for text/voice-transcription, then download any image/file/voice media and append
|
|
512
556
|
// a `[kind: localpath]` reference so hara can read the file. Handles media-only messages (no text) too.
|
|
513
|
-
const buildInbound = async (msg) => {
|
|
557
|
+
const buildInbound = async (msg, signal, shouldDownload) => {
|
|
514
558
|
const parsed = parseWeixinMessage(msg, creds.account_id);
|
|
515
559
|
const from = str(msg?.from_user_id).trim();
|
|
516
560
|
if (!from || from === creds.account_id)
|
|
517
561
|
return null;
|
|
518
|
-
|
|
562
|
+
const chat = guessChatType(msg, creds.account_id);
|
|
563
|
+
const refs = inboundMediaRefs(msg?.item_list);
|
|
564
|
+
let text = parsed?.inbound.text ?? extractText(msg?.item_list);
|
|
565
|
+
const base = {
|
|
566
|
+
chatId: chat.id,
|
|
567
|
+
userId: from,
|
|
568
|
+
userName: from,
|
|
569
|
+
text: text || (refs.some((ref) => ref.kind === "image") ? "[图片]" : refs.length ? "[附件]" : ""),
|
|
570
|
+
chatType: chat.kind === "dm" ? "p2p" : "group",
|
|
571
|
+
};
|
|
572
|
+
if (!base.text)
|
|
519
573
|
return null;
|
|
520
|
-
|
|
574
|
+
const downloadAllowed = base.chatType === "p2p" && shouldDownload?.(base) === true;
|
|
575
|
+
// Persist context only for an authenticated DM, before any download. Rejected senders cannot grow state.
|
|
576
|
+
if (downloadAllowed)
|
|
577
|
+
tokenStore.set(from, parsed?.contextToken || str(msg?.context_token).trim());
|
|
521
578
|
const images = [];
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
579
|
+
const transientFiles = [];
|
|
580
|
+
let handedOff = false;
|
|
581
|
+
try {
|
|
582
|
+
if (downloadAllowed) {
|
|
583
|
+
const budget = new InboundMediaBudget("weixin", signal);
|
|
584
|
+
for (const ref of refs) {
|
|
585
|
+
let downloaded = null;
|
|
586
|
+
const path = await budget.download(async (options) => {
|
|
587
|
+
downloaded = await downloadInboundMedia(ref, options);
|
|
588
|
+
return downloaded?.path ?? null;
|
|
589
|
+
});
|
|
590
|
+
if (!path || !downloaded)
|
|
591
|
+
continue;
|
|
592
|
+
transientFiles.push(path);
|
|
593
|
+
if (ref.kind === "image") {
|
|
594
|
+
// images go through as real attachments (seen/described downstream); leave only a light marker in text
|
|
595
|
+
images.push(path);
|
|
596
|
+
if (text !== "[图片]")
|
|
597
|
+
text += `${text ? "\n" : ""}[图片]`;
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
const label = ref.kind === "voice" ? "语音" : `文件 ${ref.fileName ?? ""}`.trim();
|
|
601
|
+
text = text === "[附件]" ? `[${label}: ${path}]` : `${text}${text ? "\n" : ""}[${label}: ${path}]`;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
534
604
|
}
|
|
605
|
+
const inbound = {
|
|
606
|
+
...base,
|
|
607
|
+
text: text.trim() || base.text,
|
|
608
|
+
images: images.length ? images : undefined,
|
|
609
|
+
transientFiles: transientFiles.length ? transientFiles : undefined,
|
|
610
|
+
};
|
|
611
|
+
handedOff = true;
|
|
612
|
+
return inbound;
|
|
613
|
+
}
|
|
614
|
+
finally {
|
|
615
|
+
if (!handedOff && transientFiles.length)
|
|
616
|
+
await cleanupTransientMedia("weixin", transientFiles);
|
|
535
617
|
}
|
|
536
|
-
text = text.trim();
|
|
537
|
-
if (!text && !images.length)
|
|
538
|
-
return null;
|
|
539
|
-
tokenStore.set(from, parsed?.contextToken || str(msg?.context_token).trim());
|
|
540
|
-
return { chatId: from, userId: from, userName: from, text: text || "[图片]", images: images.length ? images : undefined };
|
|
541
618
|
};
|
|
542
619
|
return {
|
|
543
620
|
name: "weixin",
|
|
@@ -547,9 +624,10 @@ export function weixinAdapter(creds) {
|
|
|
547
624
|
await sendChunk(creds, tokenStore, peer, part);
|
|
548
625
|
},
|
|
549
626
|
async sendFile(chatId, filePath) {
|
|
550
|
-
await sendMediaFile(creds, tokenStore, String(chatId), filePath)
|
|
627
|
+
if (!(await sendMediaFile(creds, tokenStore, String(chatId), filePath)))
|
|
628
|
+
throw new Error(`weixin file delivery failed: ${filePath}`);
|
|
551
629
|
},
|
|
552
|
-
async start(onMessage, signal) {
|
|
630
|
+
async start(onMessage, signal, shouldDownload) {
|
|
553
631
|
let buf = loadCursor(creds.account_id);
|
|
554
632
|
let pollMs = LONG_POLL_TIMEOUT_MS;
|
|
555
633
|
while (!signal.aborted) {
|
|
@@ -578,7 +656,7 @@ export function weixinAdapter(creds) {
|
|
|
578
656
|
buf = str(resp.get_updates_buf) || buf;
|
|
579
657
|
saveCursor(creds.account_id, buf);
|
|
580
658
|
for (const msg of Array.isArray(resp.msgs) ? resp.msgs : []) {
|
|
581
|
-
const inbound = await buildInbound(msg);
|
|
659
|
+
const inbound = await buildInbound(msg, signal, shouldDownload);
|
|
582
660
|
if (inbound)
|
|
583
661
|
await onMessage(inbound).catch(() => { });
|
|
584
662
|
}
|
package/dist/hooks.js
CHANGED
|
@@ -1,24 +1,28 @@
|
|
|
1
1
|
// Lifecycle hooks — run user/plugin shell commands around tool calls (codex/Claude-Code parity).
|
|
2
|
-
// PreToolUse runs BEFORE a tool:
|
|
2
|
+
// PreToolUse runs BEFORE a tool: non-zero, timeout, signal, or launch failure BLOCKS the call.
|
|
3
3
|
// PostToolUse runs AFTER: observe-only (format, log, notify). Configured in config.json `hooks` + contributed
|
|
4
4
|
// by plugins. The command receives {tool, payload} as JSON on stdin + HARA_TOOL_NAME in the env.
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { resolve } from "node:path";
|
|
6
7
|
import { loadConfig } from "./config.js";
|
|
7
8
|
import { pluginHooks } from "./plugins/plugins.js";
|
|
8
|
-
|
|
9
|
+
const cache = new Map();
|
|
9
10
|
export function resetHooksCache() {
|
|
10
|
-
cache
|
|
11
|
+
cache.clear();
|
|
11
12
|
}
|
|
12
|
-
function merged() {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
function merged(cwd) {
|
|
14
|
+
const key = resolve(cwd);
|
|
15
|
+
const cached = cache.get(key);
|
|
16
|
+
if (cached)
|
|
17
|
+
return cached;
|
|
18
|
+
const cfg = loadConfig({ cwd: key }).hooks ?? {};
|
|
16
19
|
const plg = pluginHooks();
|
|
17
|
-
|
|
20
|
+
const value = {
|
|
18
21
|
PreToolUse: [...(cfg.PreToolUse ?? []), ...(plg.PreToolUse ?? [])],
|
|
19
22
|
PostToolUse: [...(cfg.PostToolUse ?? []), ...(plg.PostToolUse ?? [])],
|
|
20
23
|
};
|
|
21
|
-
|
|
24
|
+
cache.set(key, value);
|
|
25
|
+
return value;
|
|
22
26
|
}
|
|
23
27
|
const matches = (m, name) => {
|
|
24
28
|
if (!m || m === "*")
|
|
@@ -31,14 +35,14 @@ const matches = (m, name) => {
|
|
|
31
35
|
}
|
|
32
36
|
};
|
|
33
37
|
/** True if any hook is configured (lets the loop skip the work entirely in the common case). */
|
|
34
|
-
export function hasHooks() {
|
|
35
|
-
const h = merged();
|
|
38
|
+
export function hasHooks(cwd = process.cwd()) {
|
|
39
|
+
const h = merged(cwd);
|
|
36
40
|
return !!(h.PreToolUse?.length || h.PostToolUse?.length);
|
|
37
41
|
}
|
|
38
|
-
/** Run hooks for an event matching `toolName`.
|
|
39
|
-
* PostToolUse
|
|
40
|
-
export function runHooks(event, toolName, payload, cwd) {
|
|
41
|
-
for (const h of merged()[event] ?? []) {
|
|
42
|
+
/** Run hooks for an event matching `toolName`. Any abnormal PreToolUse termination fails closed;
|
|
43
|
+
* PostToolUse remains observe-only. Sync (hooks are short, opt-in); 30s timeout each. */
|
|
44
|
+
export function runHooks(event, toolName, payload, cwd, timeoutMs = 30_000) {
|
|
45
|
+
for (const h of merged(cwd)[event] ?? []) {
|
|
42
46
|
if (!matches(h.matcher, toolName))
|
|
43
47
|
continue;
|
|
44
48
|
let r;
|
|
@@ -48,16 +52,24 @@ export function runHooks(event, toolName, payload, cwd) {
|
|
|
48
52
|
cwd,
|
|
49
53
|
input: JSON.stringify({ tool: toolName, payload }),
|
|
50
54
|
encoding: "utf8",
|
|
51
|
-
timeout:
|
|
55
|
+
timeout: timeoutMs,
|
|
52
56
|
env: { ...process.env, HARA_TOOL_NAME: toolName },
|
|
53
57
|
});
|
|
54
58
|
}
|
|
55
|
-
catch {
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (event === "PreToolUse") {
|
|
61
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
62
|
+
return { block: true, message: `⛔ blocked because a PreToolUse hook could not start${detail ? `: ${detail}` : ""}` };
|
|
63
|
+
}
|
|
56
64
|
continue;
|
|
57
65
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
66
|
+
// A hook is allowed to ignore stdin. On Linux, a command that exits successfully before Node finishes
|
|
67
|
+
// writing `input` can report EPIPE in r.error *alongside status=0*. The wait status is authoritative in
|
|
68
|
+
// that case; true launch/write failures still have status=null, while timeouts/signals remain blocked.
|
|
69
|
+
if (event === "PreToolUse" && (r.status !== 0 || !!r.signal)) {
|
|
70
|
+
const output = (String(r.stdout ?? "") + String(r.stderr ?? "")).trim();
|
|
71
|
+
const failure = r.error?.message || (r.signal ? `terminated by ${r.signal}` : `exit ${r.status ?? "unknown"}`);
|
|
72
|
+
return { block: true, message: `⛔ blocked by a PreToolUse hook${output ? `: ${output}` : ` (${failure})`}` };
|
|
61
73
|
}
|
|
62
74
|
}
|
|
63
75
|
return { block: false, message: "" };
|