@nanhara/hara 0.119.2 → 0.121.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 +32 -0
- package/dist/context/mentions.js +17 -7
- package/dist/desk.js +64 -0
- package/dist/feedback.js +55 -0
- package/dist/fs-read.js +157 -0
- package/dist/fs-write.js +105 -0
- package/dist/index.js +134 -0
- package/dist/tools/builtin.js +34 -11
- package/dist/tools/edit.js +10 -2
- package/dist/tools/patch.js +45 -15
- package/dist/tools/registry.js +20 -6
- package/dist/tools/result-limit.js +37 -0
- package/dist/tui/App.js +1 -1
- package/dist/tui/InputBox.js +62 -4
- package/dist/tui/input-history.js +167 -0
- package/dist/undo.js +6 -4
- package/package.json +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,38 @@ 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.121.0 — `hara desk` · crash-safe coding/files · bounded interactive I/O
|
|
9
|
+
|
|
10
|
+
- **`hara desk` connects the CLI to the shared coordination desk.** Register an agent, post/list/
|
|
11
|
+
claim/complete/ack/cancel tasks, and persist the desk token with owner-only file permissions. The
|
|
12
|
+
client identifies itself as `hara-cli`, so mixed Codex/Claude Code/hara fleets remain legible.
|
|
13
|
+
- **Coding edits are transaction-safe.** `write_file` uses same-directory atomic replacement;
|
|
14
|
+
multi-file patches validate every destination before writing and roll back already-committed
|
|
15
|
+
files if a later commit fails. Undo snapshots are captured before mutation, failed writes do not
|
|
16
|
+
create false undo history, and symlink/non-regular destinations are rejected consistently.
|
|
17
|
+
- **Large files stay useful without flooding memory or context.** Slice reads stream only the
|
|
18
|
+
requested line window, `@file` mentions have explicit byte/line caps, and tool results are
|
|
19
|
+
bounded centrally (including plugin/MCP results) with clear truncation metadata.
|
|
20
|
+
- **Interactive input is friendlier and bounded.** The TUI composer has shell-style up/down history
|
|
21
|
+
with draft restoration, duplicate suppression, navigation reset after edits, and a fixed maximum
|
|
22
|
+
history size. The ask-mode sentinel is also source-safe on every supported Node runtime.
|
|
23
|
+
- **Faster cold start.** Unicode-width data and the write/edit tool graph are loaded only when the
|
|
24
|
+
active command needs them, reducing startup work for lightweight commands such as `--version`.
|
|
25
|
+
- **Production dependency hardening.** The Lark SDK's Axios transport is pinned to a patched 1.x
|
|
26
|
+
release instead of its vulnerable `~1.13.3` range; the production npm audit is clean at release.
|
|
27
|
+
|
|
28
|
+
## 0.120.0 — `hara feedback`: one door for humans and agents
|
|
29
|
+
|
|
30
|
+
- **`hara feedback "what happened"`** files a structured GitHub issue (repo hara-cli/hara,
|
|
31
|
+
label `feedback`) via the gh CLI, with a copy-paste fallback. Auto-collects hara version /
|
|
32
|
+
OS / Node / provider:model (never keys) and aggressively redacts credentials
|
|
33
|
+
(sk-/ghp_/AWS/Bearer/JWT/key=value families). `--session` opt-in attaches a redacted session
|
|
34
|
+
tail (issues are public); `--dry-run` prints without filing. Matching .github issue forms
|
|
35
|
+
ship in the repo — hand-filed and command-filed reports read identically.
|
|
36
|
+
- **Security stance (documented)**: hara agents exchange structured data — they never accept
|
|
37
|
+
task instructions from untrusted parties. Cross-trust-boundary agent task-passing is
|
|
38
|
+
permanently out of scope; feedback/issues are the entire agent-to-agent surface.
|
|
39
|
+
|
|
8
40
|
## 0.119.2 — TUI: update notices actually visible · CJK-correct input wrapping
|
|
9
41
|
|
|
10
42
|
- **Update notices now render INSIDE the TUI** (yellow line under the header card). They used to
|
package/dist/context/mentions.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// @file mentions — expand `@path` references in user input into appended file contents,
|
|
2
2
|
// and provide fuzzy file candidates for REPL tab-completion.
|
|
3
|
-
import {
|
|
3
|
+
import { existsSync, statSync } from "node:fs";
|
|
4
4
|
import { isAbsolute, resolve } from "node:path";
|
|
5
5
|
import { listProjectFiles, dirPrefixes, walkFiles } from "../fs-walk.js";
|
|
6
6
|
import { fuzzyRank } from "../fuzzy.js";
|
|
7
7
|
import { mediaTypeFor } from "../images.js";
|
|
8
|
+
import { readTextPrefixSync } from "../fs-read.js";
|
|
8
9
|
const MAX_FILE = 50_000;
|
|
9
10
|
// @ at start-of-string or after whitespace; capture a path with no spaces/@ (avoids emails like a@b.com)
|
|
10
11
|
const MENTION_RE = /(?:^|\s)@([^\s@]+)/g;
|
|
@@ -53,9 +54,10 @@ function expandRef(ref, cwd) {
|
|
|
53
54
|
// don't inline binary image bytes as text — paste with Ctrl+V (or drag the file in) to attach visually
|
|
54
55
|
if (mediaTypeFor(abs))
|
|
55
56
|
return `Referenced \`${ref}\` is an image — paste it with Ctrl+V to attach it visually.`;
|
|
56
|
-
|
|
57
|
-
if (
|
|
58
|
-
|
|
57
|
+
const prefix = readTextPrefixSync(abs, MAX_FILE);
|
|
58
|
+
if (prefix.binary)
|
|
59
|
+
return `Referenced \`${ref}\` appears to be binary — it was not inserted into the model context.`;
|
|
60
|
+
const txt = prefix.text + (prefix.truncated ? "\n…[truncated]" : "");
|
|
59
61
|
return `\nReferenced file \`${ref}\`:\n\`\`\`\n${txt}\n\`\`\`\n`;
|
|
60
62
|
}
|
|
61
63
|
if (st.isDirectory()) {
|
|
@@ -73,16 +75,24 @@ function expandRef(ref, cwd) {
|
|
|
73
75
|
const cache = new Map();
|
|
74
76
|
const CACHE_MS = 5000;
|
|
75
77
|
function projectEntries(cwd) {
|
|
76
|
-
const
|
|
78
|
+
const key = resolve(cwd);
|
|
79
|
+
const hit = cache.get(key);
|
|
77
80
|
const now = Date.now();
|
|
78
81
|
if (hit && now - hit.at < CACHE_MS)
|
|
79
82
|
return hit.entries;
|
|
80
|
-
const files = listProjectFiles(
|
|
83
|
+
const files = listProjectFiles(key);
|
|
81
84
|
// files + their directory prefixes (so `@src/` drills into the subtree)
|
|
82
85
|
const entries = [...dirPrefixes(files), ...files];
|
|
83
|
-
cache.set(
|
|
86
|
+
cache.set(key, { at: now, entries });
|
|
84
87
|
return entries;
|
|
85
88
|
}
|
|
89
|
+
/** Invalidate the derived @path inventory after a coding tool creates/deletes a file. */
|
|
90
|
+
export function invalidateFileCandidates(cwd) {
|
|
91
|
+
if (cwd)
|
|
92
|
+
cache.delete(resolve(cwd));
|
|
93
|
+
else
|
|
94
|
+
cache.clear();
|
|
95
|
+
}
|
|
86
96
|
/**
|
|
87
97
|
* File/dir candidates whose path matches `query`, for @ autocomplete.
|
|
88
98
|
* Recurses subdirectories (git-tracked + untracked, or a filesystem walk outside git),
|
package/dist/desk.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// `hara desk` client — how a hara agent connects to the hara-desk coordination server (the closed-
|
|
2
|
+
// source identity registry + task board). This is the OPEN-SOURCE side of the wire: a thin HTTP
|
|
3
|
+
// client + local credential storage. The desk itself (auth, board, L2 ack) lives in hara-desk.
|
|
4
|
+
//
|
|
5
|
+
// Credentials live in ~/.hara/desk.json (0600): the desk URL + this agent's token, written once at
|
|
6
|
+
// `hara desk register`. Every later call reads them back. The token is a bearer secret — never logged.
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
const credsPath = () => join(homedir(), ".hara", "desk.json");
|
|
11
|
+
export function loadCreds() {
|
|
12
|
+
try {
|
|
13
|
+
const c = JSON.parse(readFileSync(credsPath(), "utf8"));
|
|
14
|
+
return c.url && c.token ? c : null;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function saveCreds(c) {
|
|
21
|
+
const dir = join(homedir(), ".hara");
|
|
22
|
+
mkdirSync(dir, { recursive: true });
|
|
23
|
+
const p = credsPath();
|
|
24
|
+
writeFileSync(p, JSON.stringify(c, null, 2));
|
|
25
|
+
try {
|
|
26
|
+
chmodSync(p, 0o600);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
/* best-effort on non-posix */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** One HTTP call to the desk. Auth via bearer token when creds are present. Throws on non-2xx with
|
|
33
|
+
* the server's error message (so the CLI can surface a real reason, not "request failed"). */
|
|
34
|
+
export async function deskCall(url, method, path, opts = {}) {
|
|
35
|
+
const r = await fetch(url.replace(/\/$/, "") + path, {
|
|
36
|
+
method,
|
|
37
|
+
headers: {
|
|
38
|
+
"content-type": "application/json",
|
|
39
|
+
...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}),
|
|
40
|
+
},
|
|
41
|
+
...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
|
|
42
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 10000),
|
|
43
|
+
});
|
|
44
|
+
const text = await r.text();
|
|
45
|
+
let data = {};
|
|
46
|
+
try {
|
|
47
|
+
data = text ? JSON.parse(text) : {};
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
data = { raw: text };
|
|
51
|
+
}
|
|
52
|
+
if (!r.ok)
|
|
53
|
+
throw new Error(data?.error || `desk ${r.status}: ${text.slice(0, 200)}`);
|
|
54
|
+
return data;
|
|
55
|
+
}
|
|
56
|
+
/** Register this machine's agent with a desk and persist the returned credentials. `client`
|
|
57
|
+
* identifies the kind of client (defaults "hara-cli"); the desk is client-agnostic, so any agent
|
|
58
|
+
* may pass its own label. */
|
|
59
|
+
export async function registerAgent(url, enrollKey, name, owner, client = "hara-cli") {
|
|
60
|
+
const r = await deskCall(url, "POST", "/register", { body: { enrollKey, name, owner, client } });
|
|
61
|
+
const creds = { url, agentId: r.agentId, owner: r.owner, token: r.token };
|
|
62
|
+
saveCreds(creds);
|
|
63
|
+
return creds;
|
|
64
|
+
}
|
package/dist/feedback.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// `hara feedback` — the hara-hub verdict made flesh: feedback is a COMMAND, not a server.
|
|
2
|
+
// Collects environment facts, redacts secrets, and builds a structured GitHub issue body that
|
|
3
|
+
// humans and agents file through the same door (gh CLI when present, copy-paste text otherwise).
|
|
4
|
+
// The session tail is OFF by default and explicitly opt-in (--session) because issues are public.
|
|
5
|
+
import { platform, release, arch } from "node:os";
|
|
6
|
+
export function collectEnv(version, model) {
|
|
7
|
+
return {
|
|
8
|
+
version,
|
|
9
|
+
os: `${platform()} ${release()} ${arch()}`,
|
|
10
|
+
node: process.version,
|
|
11
|
+
model,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/** Strip credential-looking material from text that is about to become a PUBLIC issue.
|
|
15
|
+
* Deliberately aggressive: false positives cost a little readability, false negatives leak keys. */
|
|
16
|
+
export function redact(text) {
|
|
17
|
+
return text
|
|
18
|
+
.replace(/\bsk-[A-Za-z0-9_-]{8,}/g, "sk-***")
|
|
19
|
+
.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}/g, "gh*_***")
|
|
20
|
+
.replace(/\b(AKIA|ASIA)[A-Z0-9]{16}\b/g, "AWS-KEY-***")
|
|
21
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, "Bearer ***")
|
|
22
|
+
.replace(/\b(api[_-]?key|apikey|token|secret|password|passwd|authorization)(["']?\s*[:=]\s*["']?)[^\s"',;]{6,}/gi, "$1$2***")
|
|
23
|
+
.replace(/\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "JWT-***");
|
|
24
|
+
}
|
|
25
|
+
/** The structured issue body — same shape as .github/ISSUE_TEMPLATE/bug_report.yml so hand-filed
|
|
26
|
+
* and command-filed issues read identically to the triage side. */
|
|
27
|
+
export function buildIssueBody(description, env, sessionTail) {
|
|
28
|
+
const parts = [
|
|
29
|
+
"## What happened",
|
|
30
|
+
"",
|
|
31
|
+
redact(description.trim() || "(no description provided)"),
|
|
32
|
+
"",
|
|
33
|
+
"## Environment",
|
|
34
|
+
"",
|
|
35
|
+
"| | |",
|
|
36
|
+
"|---|---|",
|
|
37
|
+
`| hara | ${env.version} |`,
|
|
38
|
+
`| os | ${env.os} |`,
|
|
39
|
+
`| node | ${env.node} |`,
|
|
40
|
+
...(env.model ? [`| model | ${redact(env.model)} |`] : []),
|
|
41
|
+
"",
|
|
42
|
+
];
|
|
43
|
+
if (sessionTail && sessionTail.trim()) {
|
|
44
|
+
parts.push("## Session tail (redacted, shared with --session)", "", "```", redact(sessionTail).slice(0, 4000), "```", "");
|
|
45
|
+
}
|
|
46
|
+
parts.push("---", "_filed via `hara feedback`_");
|
|
47
|
+
return parts.join("\n");
|
|
48
|
+
}
|
|
49
|
+
/** Issue title from the description: first line, trimmed, capped. */
|
|
50
|
+
export function issueTitle(description) {
|
|
51
|
+
const first = (description.trim().split("\n")[0] || "feedback").trim();
|
|
52
|
+
return first.length > 70 ? first.slice(0, 67) + "…" : first;
|
|
53
|
+
}
|
|
54
|
+
export const FEEDBACK_REPO = "hara-cli/hara";
|
|
55
|
+
export const NEW_ISSUE_URL = `https://github.com/${FEEDBACK_REPO}/issues/new`;
|
package/dist/fs-read.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Bounded streaming line reader for files too large to load as one string. It stores only the requested
|
|
2
|
+
// window and a capped prefix of each line, so huge logs/JSONL and minified one-line files stay safe.
|
|
3
|
+
import { closeSync, createReadStream, fstatSync, openSync, readSync } from "node:fs";
|
|
4
|
+
export class BinaryFileError extends Error {
|
|
5
|
+
constructor(path) {
|
|
6
|
+
super(`${path} appears to be binary (NUL byte detected)`);
|
|
7
|
+
this.name = "BinaryFileError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const DEFAULT_LINE_CAP = 2000;
|
|
11
|
+
const DEFAULT_MAX_SCAN = 64 * 1024 * 1024;
|
|
12
|
+
/** Read only enough bytes to produce a bounded UTF-8 prefix (used by synchronous @file expansion). */
|
|
13
|
+
export function readTextPrefixSync(path, maxChars) {
|
|
14
|
+
const chars = Math.max(0, Math.floor(maxChars));
|
|
15
|
+
const fd = openSync(path, "r");
|
|
16
|
+
try {
|
|
17
|
+
const size = fstatSync(fd).size;
|
|
18
|
+
// Four bytes per Unicode scalar plus a small boundary cushion guarantees enough decoded input for
|
|
19
|
+
// `chars` without allocating the entire file. readSync can short-read, so fill in a loop.
|
|
20
|
+
const byteLimit = Math.min(size, chars * 4 + 4);
|
|
21
|
+
const buffer = Buffer.allocUnsafe(byteLimit);
|
|
22
|
+
let read = 0;
|
|
23
|
+
while (read < byteLimit) {
|
|
24
|
+
const n = readSync(fd, buffer, read, byteLimit - read, read);
|
|
25
|
+
if (n === 0)
|
|
26
|
+
break;
|
|
27
|
+
read += n;
|
|
28
|
+
}
|
|
29
|
+
const bytes = buffer.subarray(0, read);
|
|
30
|
+
const binary = bytes.subarray(0, Math.min(bytes.length, 4096)).includes(0);
|
|
31
|
+
const decoded = binary ? "" : bytes.toString("utf8");
|
|
32
|
+
return {
|
|
33
|
+
text: decoded.slice(0, chars),
|
|
34
|
+
truncated: size > read || decoded.length > chars,
|
|
35
|
+
binary,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
closeSync(fd);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Render a line slice without retaining the entire file. Total line count is shown only when EOF is reached. */
|
|
43
|
+
export async function streamFileSlice(path, offset = 1, limit = 300, options = {}) {
|
|
44
|
+
const start = Math.max(1, Math.floor(offset));
|
|
45
|
+
const want = Math.max(1, Math.floor(limit));
|
|
46
|
+
const requestedEnd = start + want - 1;
|
|
47
|
+
const lineCap = Math.max(1, Math.floor(options.lineCap ?? DEFAULT_LINE_CAP));
|
|
48
|
+
const maxScan = Math.max(lineCap, Math.floor(options.maxScanChars ?? DEFAULT_MAX_SCAN));
|
|
49
|
+
const rendered = [];
|
|
50
|
+
let lineNo = 1;
|
|
51
|
+
let linePrefix = "";
|
|
52
|
+
let lineChars = 0;
|
|
53
|
+
let lineEndsWithCr = false;
|
|
54
|
+
let sawData = false;
|
|
55
|
+
let endedWithNewline = false;
|
|
56
|
+
let scanned = 0;
|
|
57
|
+
let hasMore = false;
|
|
58
|
+
let scanLimited = false;
|
|
59
|
+
let stoppedEarly = false;
|
|
60
|
+
const append = (part) => {
|
|
61
|
+
if (!part.length)
|
|
62
|
+
return;
|
|
63
|
+
sawData = true;
|
|
64
|
+
endedWithNewline = false;
|
|
65
|
+
if (lineNo > requestedEnd) {
|
|
66
|
+
hasMore = true;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (lineNo >= start) {
|
|
70
|
+
lineChars += part.length;
|
|
71
|
+
if (linePrefix.length < lineCap)
|
|
72
|
+
linePrefix += part.slice(0, lineCap - linePrefix.length);
|
|
73
|
+
lineEndsWithCr = part.endsWith("\r");
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const finishLine = (partial = false) => {
|
|
77
|
+
const current = lineNo;
|
|
78
|
+
if (current >= start && current <= requestedEnd) {
|
|
79
|
+
let chars = lineChars;
|
|
80
|
+
let prefix = linePrefix;
|
|
81
|
+
if (!partial && lineEndsWithCr) {
|
|
82
|
+
chars = Math.max(0, chars - 1);
|
|
83
|
+
if (prefix.endsWith("\r"))
|
|
84
|
+
prefix = prefix.slice(0, -1);
|
|
85
|
+
}
|
|
86
|
+
const omitted = Math.max(0, chars - prefix.length);
|
|
87
|
+
const tail = partial
|
|
88
|
+
? `…[line continues; scan stopped after ${scanned} chars]`
|
|
89
|
+
: omitted
|
|
90
|
+
? `…[+${omitted} chars]`
|
|
91
|
+
: "";
|
|
92
|
+
rendered.push(`${String(current).padStart(6)}\t${prefix}${tail}`);
|
|
93
|
+
}
|
|
94
|
+
lineNo++;
|
|
95
|
+
linePrefix = "";
|
|
96
|
+
lineChars = 0;
|
|
97
|
+
lineEndsWithCr = false;
|
|
98
|
+
if (current > requestedEnd)
|
|
99
|
+
hasMore = true;
|
|
100
|
+
};
|
|
101
|
+
const stream = createReadStream(path, { encoding: "utf8", highWaterMark: 64 * 1024 });
|
|
102
|
+
try {
|
|
103
|
+
for await (const raw of stream) {
|
|
104
|
+
const chunk = String(raw);
|
|
105
|
+
if (chunk.length)
|
|
106
|
+
sawData = true;
|
|
107
|
+
if (scanned < 4096 && chunk.slice(0, 4096 - scanned).includes("\0"))
|
|
108
|
+
throw new BinaryFileError(path);
|
|
109
|
+
scanned += chunk.length;
|
|
110
|
+
let at = 0;
|
|
111
|
+
while (at < chunk.length) {
|
|
112
|
+
const newline = chunk.indexOf("\n", at);
|
|
113
|
+
if (newline < 0) {
|
|
114
|
+
append(chunk.slice(at));
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
append(chunk.slice(at, newline));
|
|
118
|
+
finishLine();
|
|
119
|
+
endedWithNewline = true;
|
|
120
|
+
at = newline + 1;
|
|
121
|
+
if (hasMore)
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
if (hasMore) {
|
|
125
|
+
stoppedEarly = true;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
if (scanned >= maxScan) {
|
|
129
|
+
scanLimited = true;
|
|
130
|
+
stoppedEarly = true;
|
|
131
|
+
if (lineNo >= start && lineNo <= requestedEnd)
|
|
132
|
+
finishLine(true);
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
stream.destroy();
|
|
139
|
+
}
|
|
140
|
+
if (!stoppedEarly) {
|
|
141
|
+
// A trailing newline creates no phantom line, matching String#split + trailing-empty removal.
|
|
142
|
+
if (!endedWithNewline || !sawData)
|
|
143
|
+
finishLine();
|
|
144
|
+
const total = lineNo - 1;
|
|
145
|
+
if (start > total)
|
|
146
|
+
return `(file has ${total} lines — offset ${start} is past the end)`;
|
|
147
|
+
const end = start + rendered.length - 1;
|
|
148
|
+
const sliced = start > 1 || end < total;
|
|
149
|
+
const head = sliced ? `(lines ${start}–${end} of ${total}${end < total ? ` — continue with offset:${end + 1}` : ""})\n` : "";
|
|
150
|
+
return head + rendered.join("\n");
|
|
151
|
+
}
|
|
152
|
+
const end = start + Math.max(0, rendered.length - 1);
|
|
153
|
+
if (scanLimited) {
|
|
154
|
+
return `(large file scan stopped after ${scanned} chars — use grep or bash byte-range tools for a narrower target)\n${rendered.join("\n")}`;
|
|
155
|
+
}
|
|
156
|
+
return `(lines ${start}–${end}; more lines follow — continue with offset:${end + 1})\n${rendered.join("\n")}`;
|
|
157
|
+
}
|
package/dist/fs-write.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Crash-safe UTF-8 writes for coding tools. Content is staged beside the destination, fsynced, then
|
|
2
|
+
// renamed into place so a killed process never leaves a half-written source file.
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { link, lstat, mkdir, open, readFile, realpath, rename, stat, unlink } from "node:fs/promises";
|
|
5
|
+
export class FileChangedError extends Error {
|
|
6
|
+
code = "HARA_FILE_CHANGED";
|
|
7
|
+
constructor(path) {
|
|
8
|
+
super(`File changed while the edit was being prepared: ${path}. Re-read it and retry the edit.`);
|
|
9
|
+
this.name = "FileChangedError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
let tempSequence = 0;
|
|
13
|
+
async function writeTarget(path) {
|
|
14
|
+
try {
|
|
15
|
+
const info = await lstat(path);
|
|
16
|
+
// Replacing a symlink would silently break it. Stage beside the real target instead so edits retain
|
|
17
|
+
// the link (common in dotfile repos and generated workspace layouts).
|
|
18
|
+
if (info.isSymbolicLink())
|
|
19
|
+
return await realpath(path);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error?.code !== "ENOENT")
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
return path;
|
|
26
|
+
}
|
|
27
|
+
async function syncDirectory(path) {
|
|
28
|
+
// Directory fsync makes the rename durable across a power loss on POSIX. Some filesystems/platforms
|
|
29
|
+
// reject opening directories, so durability degrades gracefully after the file itself was synced.
|
|
30
|
+
try {
|
|
31
|
+
const handle = await open(path, "r");
|
|
32
|
+
try {
|
|
33
|
+
await handle.sync();
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
await handle.close();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* best effort */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Atomically replace/create a UTF-8 file, optionally refusing to overwrite a newer disk version. */
|
|
44
|
+
export async function atomicWriteText(path, content, options = {}) {
|
|
45
|
+
const target = await writeTarget(path);
|
|
46
|
+
const dir = dirname(target);
|
|
47
|
+
await mkdir(dir, { recursive: true });
|
|
48
|
+
let mode = 0o666;
|
|
49
|
+
try {
|
|
50
|
+
mode = (await stat(target)).mode & 0o777;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error?.code !== "ENOENT")
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
// Keep the staging basename fixed-size: prefixing the destination's full basename would make a
|
|
57
|
+
// perfectly valid near-NAME_MAX file impossible to edit because the temporary name becomes longer.
|
|
58
|
+
const temp = join(dir, `.hara-${process.pid}-${Date.now().toString(36)}-${tempSequence++}.tmp`);
|
|
59
|
+
let staged = false;
|
|
60
|
+
try {
|
|
61
|
+
const handle = await open(temp, "wx", mode);
|
|
62
|
+
staged = true;
|
|
63
|
+
try {
|
|
64
|
+
await handle.writeFile(content, "utf8");
|
|
65
|
+
await handle.sync();
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
await handle.close();
|
|
69
|
+
}
|
|
70
|
+
if (options.expected === null) {
|
|
71
|
+
// link(2) is an atomic create-if-absent operation. A plain rename would overwrite a file that
|
|
72
|
+
// appeared after validation, defeating create's no-clobber contract.
|
|
73
|
+
try {
|
|
74
|
+
await link(temp, target);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (error?.code === "EEXIST")
|
|
78
|
+
throw new FileChangedError(path);
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
await unlink(temp);
|
|
82
|
+
staged = false;
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
if (typeof options.expected === "string") {
|
|
86
|
+
let current;
|
|
87
|
+
try {
|
|
88
|
+
current = await readFile(target, "utf8");
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
throw new FileChangedError(path);
|
|
92
|
+
}
|
|
93
|
+
if (current !== options.expected)
|
|
94
|
+
throw new FileChangedError(path);
|
|
95
|
+
}
|
|
96
|
+
await rename(temp, target);
|
|
97
|
+
staged = false;
|
|
98
|
+
}
|
|
99
|
+
await syncDirectory(dir);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
if (staged)
|
|
103
|
+
await unlink(temp).catch(() => { });
|
|
104
|
+
}
|
|
105
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1990,6 +1990,140 @@ rolesCmd.action(() => {
|
|
|
1990
1990
|
out(`${c.bold(r.id)}${r.model ? c.dim(` (${r.model})`) : ""} ${c.dim("owns: " + r.owns.join(", "))}\n ${r.description}\n`);
|
|
1991
1991
|
}
|
|
1992
1992
|
});
|
|
1993
|
+
program
|
|
1994
|
+
.command("feedback [description...]")
|
|
1995
|
+
.description("file a structured bug/feature report to GitHub (hara-cli/hara) — humans and agents use the same door")
|
|
1996
|
+
.option("--session", "append a REDACTED tail of the most recent session (the issue is PUBLIC)")
|
|
1997
|
+
.option("--dry-run", "print the issue body without filing")
|
|
1998
|
+
.action(async (parts, o) => {
|
|
1999
|
+
// the hara-hub verdict: feedback is a command, not a server — GitHub Issues is the bus
|
|
2000
|
+
const { collectEnv, buildIssueBody, issueTitle, FEEDBACK_REPO, NEW_ISSUE_URL } = await import("./feedback.js");
|
|
2001
|
+
const desc = (parts ?? []).join(" ").trim();
|
|
2002
|
+
if (!desc) {
|
|
2003
|
+
out(`Usage: hara feedback "what happened…" [--session] [--dry-run] — files a structured issue to ${FEEDBACK_REPO}\n`);
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
let tail;
|
|
2007
|
+
if (o.session) {
|
|
2008
|
+
const { listSessions, loadSession } = await import("./session/store.js");
|
|
2009
|
+
const metas = listSessions();
|
|
2010
|
+
const last = metas[0] ? loadSession(metas[0].id) : null;
|
|
2011
|
+
if (last) {
|
|
2012
|
+
tail = last.history
|
|
2013
|
+
.slice(-8)
|
|
2014
|
+
.map((m) => (m.role === "user" ? `user: ${m.content}` : m.role === "assistant" && m.text ? `assistant: ${m.text}` : ""))
|
|
2015
|
+
.filter(Boolean)
|
|
2016
|
+
.join("\n")
|
|
2017
|
+
.slice(-3000);
|
|
2018
|
+
out(c.yellow("⚠ --session attaches a redacted tail of your last session to a PUBLIC issue.\n"));
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
const { readRawConfig } = await import("./config.js");
|
|
2022
|
+
const raw = readRawConfig();
|
|
2023
|
+
const modelLabel = raw.provider && raw.model ? `${raw.provider}:${raw.model}` : undefined;
|
|
2024
|
+
const body = buildIssueBody(desc, collectEnv(pkg.version, modelLabel), tail);
|
|
2025
|
+
if (o.dryRun) {
|
|
2026
|
+
out(body + "\n");
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2029
|
+
const { execFileSync } = await import("node:child_process");
|
|
2030
|
+
try {
|
|
2031
|
+
execFileSync("gh", ["--version"], { stdio: "ignore" });
|
|
2032
|
+
const url = execFileSync("gh", ["issue", "create", "--repo", FEEDBACK_REPO, "--title", issueTitle(desc), "--body", body, "--label", "feedback"], { encoding: "utf8" });
|
|
2033
|
+
out(c.green("✓ filed: ") + url.trim() + "\n");
|
|
2034
|
+
}
|
|
2035
|
+
catch {
|
|
2036
|
+
out(`\n(gh CLI unavailable or filing failed — copy everything below into ${NEW_ISSUE_URL})\n\n# ${issueTitle(desc)}\n\n${body}\n`);
|
|
2037
|
+
}
|
|
2038
|
+
});
|
|
2039
|
+
// `hara desk` — connect to a hara-desk coordination server (identity registry + task board).
|
|
2040
|
+
// The desk is the closed-source enterprise piece; this is the open-source client side.
|
|
2041
|
+
const deskCmd = program.command("desk").description("coordinate with a hara-desk server (register · post · board · claim · complete)");
|
|
2042
|
+
deskCmd
|
|
2043
|
+
.command("register")
|
|
2044
|
+
.description("register this agent with a desk and save credentials to ~/.hara/desk.json")
|
|
2045
|
+
.requiredOption("--url <url>", "desk base URL (e.g. http://127.0.0.1:4200)")
|
|
2046
|
+
.requiredOption("--key <enrollKey>", "the desk's enroll key")
|
|
2047
|
+
.option("--name <name>", "agent name shown in the registry", "hara-cli")
|
|
2048
|
+
.option("--owner <owner>", "the human this agent belongs to", "me")
|
|
2049
|
+
.action(async (o) => {
|
|
2050
|
+
const { registerAgent } = await import("./desk.js");
|
|
2051
|
+
try {
|
|
2052
|
+
const creds = await registerAgent(o.url, o.key, o.name, o.owner);
|
|
2053
|
+
out(c.green("✓ registered ") + `${creds.agentId} (owner ${creds.owner}) → ${creds.url}\n`);
|
|
2054
|
+
}
|
|
2055
|
+
catch (e) {
|
|
2056
|
+
out(c.red(`register failed: ${e.message}\n`));
|
|
2057
|
+
}
|
|
2058
|
+
});
|
|
2059
|
+
deskCmd
|
|
2060
|
+
.command("post [title...]")
|
|
2061
|
+
.description("post a task or feedback report to the board")
|
|
2062
|
+
.option("--dispatch", "a dispatch task (someone does work) instead of a feedback report")
|
|
2063
|
+
.option("--high", "high-risk dispatch (needs an owner ack before it can complete)")
|
|
2064
|
+
.option("--body <text>", "task body / details", "")
|
|
2065
|
+
.action(async (parts, o) => {
|
|
2066
|
+
const { loadCreds, deskCall } = await import("./desk.js");
|
|
2067
|
+
const creds = loadCreds();
|
|
2068
|
+
if (!creds)
|
|
2069
|
+
return void out(c.red("not registered — run `hara desk register --url … --key …` first\n"));
|
|
2070
|
+
const title = (parts ?? []).join(" ").trim();
|
|
2071
|
+
if (!title)
|
|
2072
|
+
return void out("Usage: hara desk post <title> [--dispatch] [--high] [--body …]\n");
|
|
2073
|
+
try {
|
|
2074
|
+
const r = await deskCall(creds.url, "POST", "/tasks", { token: creds.token, body: { kind: o.dispatch ? "dispatch" : "feedback", risk: o.high ? "high" : "low", title, body: o.body } });
|
|
2075
|
+
out(c.green("✓ posted ") + `${r.task.id} (${r.task.kind}/${r.task.state})\n`);
|
|
2076
|
+
}
|
|
2077
|
+
catch (e) {
|
|
2078
|
+
out(c.red(`post failed: ${e.message}\n`));
|
|
2079
|
+
}
|
|
2080
|
+
});
|
|
2081
|
+
deskCmd
|
|
2082
|
+
.command("board")
|
|
2083
|
+
.description("list the board (default: open tasks)")
|
|
2084
|
+
.option("--state <state>", "open | claimed | done | cancelled", "open")
|
|
2085
|
+
.option("--kind <kind>", "feedback | dispatch")
|
|
2086
|
+
.action(async (o) => {
|
|
2087
|
+
const { loadCreds, deskCall } = await import("./desk.js");
|
|
2088
|
+
const creds = loadCreds();
|
|
2089
|
+
if (!creds)
|
|
2090
|
+
return void out(c.red("not registered — run `hara desk register` first\n"));
|
|
2091
|
+
try {
|
|
2092
|
+
const q = `/tasks?state=${encodeURIComponent(o.state)}${o.kind ? `&kind=${encodeURIComponent(o.kind)}` : ""}`;
|
|
2093
|
+
const r = await deskCall(creds.url, "GET", q, { token: creds.token });
|
|
2094
|
+
if (!r.tasks.length)
|
|
2095
|
+
return void out(c.dim("(board empty)\n"));
|
|
2096
|
+
for (const t of r.tasks)
|
|
2097
|
+
out(`${t.id} ${c.bold(t.kind)}${t.risk === "high" ? c.red("!") : " "} ${t.state.padEnd(8)} ${t.title}\n`);
|
|
2098
|
+
}
|
|
2099
|
+
catch (e) {
|
|
2100
|
+
out(c.red(`board failed: ${e.message}\n`));
|
|
2101
|
+
}
|
|
2102
|
+
});
|
|
2103
|
+
for (const [verb, path, ok] of [
|
|
2104
|
+
["claim", "claim", "claimed"],
|
|
2105
|
+
["complete", "complete", "completed"],
|
|
2106
|
+
["ack", "ack", "acked"],
|
|
2107
|
+
["cancel", "cancel", "cancelled"],
|
|
2108
|
+
]) {
|
|
2109
|
+
deskCmd
|
|
2110
|
+
.command(`${verb} <taskId>`)
|
|
2111
|
+
.description(`${verb} a task`)
|
|
2112
|
+
.option("--detail <text>", "note (complete)", "")
|
|
2113
|
+
.action(async (taskId, o) => {
|
|
2114
|
+
const { loadCreds, deskCall } = await import("./desk.js");
|
|
2115
|
+
const creds = loadCreds();
|
|
2116
|
+
if (!creds)
|
|
2117
|
+
return void out(c.red("not registered — run `hara desk register` first\n"));
|
|
2118
|
+
try {
|
|
2119
|
+
const r = await deskCall(creds.url, "POST", `/tasks/${taskId}/${path}`, { token: creds.token, body: verb === "complete" ? { detail: o.detail } : {} });
|
|
2120
|
+
out(c.green(`✓ ${ok} `) + `${r.task?.id ?? taskId}${r.task ? ` (${r.task.state})` : ""}\n`);
|
|
2121
|
+
}
|
|
2122
|
+
catch (e) {
|
|
2123
|
+
out(c.red(`${verb} failed: ${e.message}\n`));
|
|
2124
|
+
}
|
|
2125
|
+
});
|
|
2126
|
+
}
|
|
1993
2127
|
const skillsCmd = program.command("skills").description("manage skills (.hara/skills/<name>/SKILL.md)");
|
|
1994
2128
|
skillsCmd
|
|
1995
2129
|
.command("init")
|