@nanhara/hara 0.122.2 → 0.122.4
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 +108 -0
- package/README.md +15 -2
- package/dist/agent/limits.js +51 -0
- package/dist/agent/loop.js +367 -112
- package/dist/agent/repeat-guard.js +49 -12
- package/dist/checkpoints.js +9 -0
- package/dist/cli.js +2 -1
- package/dist/config.js +10 -2
- package/dist/context/agents-md.js +21 -2
- package/dist/context/mentions.js +84 -1
- package/dist/context/workspace-scope.js +49 -0
- package/dist/cron/deliver.js +61 -38
- package/dist/cron/runner.js +423 -65
- package/dist/cron/schedule.js +23 -7
- package/dist/cron/store.js +709 -43
- package/dist/fs-walk.js +279 -7
- package/dist/fs-write.js +9 -0
- package/dist/gateway/feishu.js +351 -64
- package/dist/gateway/flows-pending.js +31 -17
- package/dist/gateway/flows.js +306 -31
- package/dist/gateway/outbound-files.js +20 -6
- package/dist/gateway/runtime-state.js +1485 -0
- package/dist/gateway/serve.js +550 -242
- package/dist/gateway/sessions.js +3 -3
- package/dist/gateway/telegram.js +279 -29
- package/dist/gateway/tts.js +182 -38
- package/dist/hooks.js +22 -22
- package/dist/index.js +466 -158
- package/dist/memory/store.js +8 -5
- package/dist/org/planner.js +11 -6
- package/dist/org/projects.js +3 -3
- package/dist/process-identity.js +52 -0
- package/dist/providers/bounded-turn.js +42 -0
- package/dist/recall.js +69 -1
- package/dist/runtime.js +24 -0
- package/dist/sandbox.js +54 -4
- package/dist/search/embed.js +13 -2
- package/dist/search/hybrid.js +6 -3
- package/dist/search/semindex.js +87 -5
- package/dist/security/guardian.js +3 -15
- package/dist/security/permissions.js +11 -0
- package/dist/serve/server.js +70 -42
- package/dist/serve/sessions.js +4 -3
- package/dist/sync-sleep.js +46 -0
- package/dist/tools/agent.js +1 -1
- package/dist/tools/ask_user.js +5 -1
- package/dist/tools/builtin.js +5 -2
- package/dist/tools/codebase.js +67 -18
- package/dist/tools/computer.js +149 -68
- package/dist/tools/cron.js +72 -18
- package/dist/tools/edit.js +3 -2
- package/dist/tools/external_agent.js +66 -12
- package/dist/tools/memory.js +25 -7
- package/dist/tools/patch.js +11 -2
- package/dist/tools/registry.js +16 -1
- package/dist/tools/search.js +68 -9
- package/dist/tools/send.js +1 -1
- package/dist/tools/skill.js +1 -1
- package/dist/tools/task.js +3 -3
- package/dist/tools/web.js +43 -13
- package/dist/tui/App.js +93 -25
- package/dist/vision.js +5 -6
- package/package.json +2 -2
- package/runtime-bootstrap.cjs +22 -0
package/dist/search/semindex.js
CHANGED
|
@@ -7,10 +7,11 @@ import { createHash } from "node:crypto";
|
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { basename, join, isAbsolute, resolve } from "node:path";
|
|
9
9
|
import { findProjectRoot } from "../context/agents-md.js";
|
|
10
|
-
import { listProjectFiles, walkFiles } from "../fs-walk.js";
|
|
10
|
+
import { listProjectFiles, listProjectFilesAsync, walkFiles, walkFilesAsync, } from "../fs-walk.js";
|
|
11
11
|
import { zvecBuild, zvecQueryIds, zvecRemove } from "./zvec-store.js";
|
|
12
12
|
import { isSensitiveFilePath } from "../security/sensitive-files.js";
|
|
13
13
|
import { readModelContextFileSync } from "../fs-read.js";
|
|
14
|
+
import { homeWorkspaceActionError, isHomeWorkspace } from "../context/workspace-scope.js";
|
|
14
15
|
import { ensurePrivateStateSubdirectory, readPrivateStateFileSnapshot, removePrivateStateFile, } from "../security/private-state.js";
|
|
15
16
|
import { atomicWriteText, bindHaraPrivateStateWritePath, } from "../fs-write.js";
|
|
16
17
|
// Same code/text extensions codebase_search ranks lexically — keep the two walks in sync.
|
|
@@ -51,6 +52,8 @@ async function ensurePrivateIndexState(name, cwd) {
|
|
|
51
52
|
}
|
|
52
53
|
function existingPrivateIndexPath(name, cwd) {
|
|
53
54
|
try {
|
|
55
|
+
if (name === "repo" && isHomeWorkspace(cwd))
|
|
56
|
+
return null;
|
|
54
57
|
const requested = indexPath(name, cwd);
|
|
55
58
|
const base = realpathSync.native(indexBase(name, cwd));
|
|
56
59
|
const hara = join(base, ".hara");
|
|
@@ -126,7 +129,11 @@ async function rotateLegacyIndex(name, cwd, state) {
|
|
|
126
129
|
/** Build/refresh the index. **Incremental**: files whose content hash is unchanged keep their existing
|
|
127
130
|
* vectors (no re-embed); only new/changed files are embedded, and deleted files drop out. A changed
|
|
128
131
|
* embedding model forces a full rebuild (old vectors aren't comparable). Returns counts. */
|
|
129
|
-
export async function buildIndex(name, chunks, embed, cwd, model = "embed") {
|
|
132
|
+
export async function buildIndex(name, chunks, embed, cwd, model = "embed", signal) {
|
|
133
|
+
if (signal?.aborted)
|
|
134
|
+
throw new Error("semantic index build interrupted");
|
|
135
|
+
if (name === "repo" && isHomeWorkspace(cwd))
|
|
136
|
+
throw new Error(homeWorkspaceActionError("build a repository index"));
|
|
130
137
|
const state = await ensurePrivateIndexState(name, cwd);
|
|
131
138
|
const p = state.path;
|
|
132
139
|
// Load the previous index → reuse vectors for unchanged files.
|
|
@@ -169,6 +176,8 @@ export async function buildIndex(name, chunks, embed, cwd, model = "embed") {
|
|
|
169
176
|
const toEmbed = [];
|
|
170
177
|
let reused = 0;
|
|
171
178
|
for (const [file, fchunks] of byFile) {
|
|
179
|
+
if (signal?.aborted)
|
|
180
|
+
throw new Error("semantic index build interrupted");
|
|
172
181
|
const currentHash = contentHash(fchunks);
|
|
173
182
|
const prev = prevByFile.get(file);
|
|
174
183
|
if (sameModel
|
|
@@ -183,11 +192,17 @@ export async function buildIndex(name, chunks, embed, cwd, model = "embed") {
|
|
|
183
192
|
}
|
|
184
193
|
const B = 64;
|
|
185
194
|
for (let i = 0; i < toEmbed.length; i += B) {
|
|
195
|
+
if (signal?.aborted)
|
|
196
|
+
throw new Error("semantic index build interrupted");
|
|
186
197
|
const batch = toEmbed.slice(i, i + B);
|
|
187
|
-
const vecs = await embed(batch.map((c) => c.text));
|
|
198
|
+
const vecs = await embed(batch.map((c) => c.text), signal);
|
|
199
|
+
if (signal?.aborted)
|
|
200
|
+
throw new Error("semantic index build interrupted");
|
|
188
201
|
batch.forEach((c, j) => vecs[j] && items.push({ ...c, vec: vecs[j] }));
|
|
189
202
|
}
|
|
190
203
|
// The index is derived + rebuildable (and may embed file contents) — never let it be committed.
|
|
204
|
+
if (signal?.aborted)
|
|
205
|
+
throw new Error("semantic index build interrupted");
|
|
191
206
|
if (!state.ignoreSnapshot || state.ignoreSnapshot.text !== "*\n") {
|
|
192
207
|
await atomicWriteText(state.ignoreBoundary.target, "*\n", {
|
|
193
208
|
expected: state.ignoreSnapshot?.text ?? null,
|
|
@@ -239,6 +254,8 @@ export function collectDirChunks(dir, source) {
|
|
|
239
254
|
}
|
|
240
255
|
/** Walk the repo (respecting .gitignore) and chunk every code/text file — the corpus `hara index` embeds. */
|
|
241
256
|
export function collectRepoChunks(root) {
|
|
257
|
+
if (isHomeWorkspace(root))
|
|
258
|
+
throw new Error(homeWorkspaceActionError("scan the home directory for a repository index"));
|
|
242
259
|
const chunks = [];
|
|
243
260
|
for (const rel of listProjectFiles(root)) {
|
|
244
261
|
if (!CODE_RE.test(rel) || looksSecret(rel))
|
|
@@ -255,8 +272,65 @@ export function collectRepoChunks(root) {
|
|
|
255
272
|
}
|
|
256
273
|
return chunks;
|
|
257
274
|
}
|
|
275
|
+
async function chunksFromInventory(root, files, source, absolutePaths, startedAt, timeoutMs, signal) {
|
|
276
|
+
const chunks = [];
|
|
277
|
+
let fileIndex = 0;
|
|
278
|
+
for (const rel of files) {
|
|
279
|
+
if (signal?.aborted)
|
|
280
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("index collection cancelled");
|
|
281
|
+
if (Date.now() - startedAt >= timeoutMs)
|
|
282
|
+
return { chunks, truncated: true, reason: "time_limit" };
|
|
283
|
+
if (fileIndex++ > 0 && fileIndex % 32 === 0) {
|
|
284
|
+
await new Promise((resolveTurn) => setImmediate(resolveTurn));
|
|
285
|
+
if (signal?.aborted)
|
|
286
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("index collection cancelled");
|
|
287
|
+
}
|
|
288
|
+
if (!CODE_RE.test(rel) || looksSecret(rel))
|
|
289
|
+
continue;
|
|
290
|
+
const abs = join(root, rel);
|
|
291
|
+
let text;
|
|
292
|
+
try {
|
|
293
|
+
text = readModelContextFileSync(abs, 200_000);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
chunks.push(...chunkText(text, absolutePaths ? abs : rel, source, statMtime(abs)));
|
|
299
|
+
}
|
|
300
|
+
return { chunks, truncated: false };
|
|
301
|
+
}
|
|
302
|
+
/** Interruptible knowledge-directory collection for the explicit index command and background callers. */
|
|
303
|
+
export async function collectDirChunksAsync(dir, source, options = {}) {
|
|
304
|
+
if (!existsSync(dir))
|
|
305
|
+
return { chunks: [], truncated: false };
|
|
306
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(0, Math.floor(options.timeoutMs)) : 30_000;
|
|
307
|
+
const startedAt = Date.now();
|
|
308
|
+
const inventory = await walkFilesAsync(dir, { ...options, timeoutMs });
|
|
309
|
+
const collected = await chunksFromInventory(dir, inventory.files, source, true, startedAt, timeoutMs, options.signal);
|
|
310
|
+
return collected.truncated ? collected : {
|
|
311
|
+
chunks: collected.chunks,
|
|
312
|
+
truncated: inventory.truncated,
|
|
313
|
+
reason: inventory.reason,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
/** Interruptible repository collection. Git discovery and file reads share one total wall budget. */
|
|
317
|
+
export async function collectRepoChunksAsync(root, options = {}) {
|
|
318
|
+
if (isHomeWorkspace(root))
|
|
319
|
+
throw new Error(homeWorkspaceActionError("scan the home directory for a repository index"));
|
|
320
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(0, Math.floor(options.timeoutMs)) : 30_000;
|
|
321
|
+
const startedAt = Date.now();
|
|
322
|
+
const inventory = await listProjectFilesAsync(root, { ...options, timeoutMs });
|
|
323
|
+
const collected = await chunksFromInventory(root, inventory.files, "repo", false, startedAt, timeoutMs, options.signal);
|
|
324
|
+
return collected.truncated ? collected : {
|
|
325
|
+
chunks: collected.chunks,
|
|
326
|
+
truncated: inventory.truncated,
|
|
327
|
+
reason: inventory.reason,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
258
330
|
/** Cosine-rank the index against the query embedding. Returns top-k hits (empty if no index). */
|
|
259
|
-
export async function queryIndex(name, query, embed, cwd, k = 6) {
|
|
331
|
+
export async function queryIndex(name, query, embed, cwd, k = 6, signal) {
|
|
332
|
+
if (signal?.aborted)
|
|
333
|
+
throw new Error("semantic query interrupted");
|
|
260
334
|
if (!existingPrivateIndexPath(name, cwd))
|
|
261
335
|
return [];
|
|
262
336
|
let state;
|
|
@@ -291,7 +365,9 @@ export async function queryIndex(name, query, embed, cwd, k = 6) {
|
|
|
291
365
|
});
|
|
292
366
|
if (!safeItems.length)
|
|
293
367
|
return [];
|
|
294
|
-
const [qv] = await embed([query]);
|
|
368
|
+
const [qv] = await embed([query], signal);
|
|
369
|
+
if (signal?.aborted)
|
|
370
|
+
throw new Error("semantic query interrupted");
|
|
295
371
|
if (!qv)
|
|
296
372
|
return [];
|
|
297
373
|
// A format marker proves which writer created the cache, not that its source still has the same bytes.
|
|
@@ -321,6 +397,8 @@ export async function queryIndex(name, query, embed, cwd, k = 6) {
|
|
|
321
397
|
.sort((a, b) => b.score - a.score);
|
|
322
398
|
const hits = [];
|
|
323
399
|
for (const { item, score } of ranked) {
|
|
400
|
+
if (signal?.aborted)
|
|
401
|
+
break;
|
|
324
402
|
if (!isFresh(item))
|
|
325
403
|
continue;
|
|
326
404
|
hits.push({ file: item.file, source: item.source, text: item.text, score });
|
|
@@ -332,7 +410,11 @@ export async function queryIndex(name, query, embed, cwd, k = 6) {
|
|
|
332
410
|
// Prefer the zvec ANN index for candidate retrieval; re-rank candidates by EXACT cosine from the JSON
|
|
333
411
|
// store (identical score semantics to the brute-force path). Fall back to full brute-force if zvec is
|
|
334
412
|
// unavailable / has no index / errors.
|
|
413
|
+
if (signal?.aborted)
|
|
414
|
+
throw new Error("semantic query interrupted");
|
|
335
415
|
const ids = await zvecQueryIds(name, qv, cwd, k);
|
|
416
|
+
if (signal?.aborted)
|
|
417
|
+
throw new Error("semantic query interrupted");
|
|
336
418
|
if (ids?.length) {
|
|
337
419
|
const byId = new Map(safeItems.map((it) => [it.id, it]));
|
|
338
420
|
const candidates = ids
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// trips a hard stop that requires explicit user confirmation to continue — a harder stop than the soft
|
|
18
18
|
// stuck-guard nudge, and one that aborts safely (never hangs) when there's no interactive user.
|
|
19
19
|
import { resolve, isAbsolute } from "node:path";
|
|
20
|
+
import { boundedProviderTurn } from "../providers/bounded-turn.js";
|
|
20
21
|
import { canonicalize, splitCompound } from "./permissions.js";
|
|
21
22
|
// ── Deterministic risk classifier ────────────────────────────────────────────────────────────────────
|
|
22
23
|
// The classifier is intentionally NARROW: false positives here cost latency + an LLM call + potential
|
|
@@ -208,23 +209,13 @@ export async function guardianVeto(provider, action, history, opts = {}) {
|
|
|
208
209
|
`HIGH-RISK ACTION (flagged: ${action.classifierReason}):\n` +
|
|
209
210
|
`tool: ${action.tool}\n${action.detail}\n\n` +
|
|
210
211
|
`Allow or block this action? Reply with only the JSON verdict.`;
|
|
211
|
-
const ac = new AbortController();
|
|
212
|
-
const timer = setTimeout(() => ac.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
213
|
-
// Chain the caller's interrupt signal so Esc also aborts the guardian call.
|
|
214
|
-
if (opts.signal) {
|
|
215
|
-
if (opts.signal.aborted)
|
|
216
|
-
ac.abort();
|
|
217
|
-
else
|
|
218
|
-
opts.signal.addEventListener("abort", () => ac.abort(), { once: true });
|
|
219
|
-
}
|
|
220
212
|
try {
|
|
221
|
-
const r = await provider
|
|
213
|
+
const r = await boundedProviderTurn(provider, {
|
|
222
214
|
system: GUARDIAN_SYSTEM,
|
|
223
215
|
history: [{ role: "user", content: prompt }],
|
|
224
216
|
tools: [],
|
|
225
217
|
onText: () => { },
|
|
226
|
-
|
|
227
|
-
});
|
|
218
|
+
}, { timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, signal: opts.signal, label: "security guardian" });
|
|
228
219
|
if (r.stop === "error")
|
|
229
220
|
return { decision: "allow", reason: "" }; // fail-open on model error
|
|
230
221
|
return parseVerdict(r.text);
|
|
@@ -232,9 +223,6 @@ export async function guardianVeto(provider, action, history, opts = {}) {
|
|
|
232
223
|
catch {
|
|
233
224
|
return { decision: "allow", reason: "" }; // fail-open on timeout/abort/throw
|
|
234
225
|
}
|
|
235
|
-
finally {
|
|
236
|
-
clearTimeout(timer);
|
|
237
|
-
}
|
|
238
226
|
}
|
|
239
227
|
export function newBreaker() {
|
|
240
228
|
return { blocks: 0, tripped: false };
|
|
@@ -15,6 +15,7 @@ import { closeSync, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdir
|
|
|
15
15
|
import { psArgumentsExposeEnvironment } from "./sensitive-files.js";
|
|
16
16
|
import { projectRepositoryTrustedAtStartup } from "./project-trust.js";
|
|
17
17
|
import { readVerifiedRegularFileSnapshotSync } from "../fs-read.js";
|
|
18
|
+
import { homeWorkspaceActionError, isHomeWorkspace } from "../context/workspace-scope.js";
|
|
18
19
|
const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
|
|
19
20
|
const MAX_PROJECT_PERMISSIONS_BYTES = 64 * 1024;
|
|
20
21
|
const projectPermissionWarnings = new Set();
|
|
@@ -279,6 +280,10 @@ function readProjectRules(cwd) {
|
|
|
279
280
|
dir = resolve(cwd);
|
|
280
281
|
}
|
|
281
282
|
for (;;) {
|
|
283
|
+
// ~/.hara/permissions.json is global policy, not a project policy file. Stopping before Home also
|
|
284
|
+
// prevents an unmarked child workspace from inheriting a repository marker above the user's home.
|
|
285
|
+
if (isHomeWorkspace(dir))
|
|
286
|
+
break;
|
|
282
287
|
const hara = join(dir, ".hara");
|
|
283
288
|
const file = join(hara, "permissions.json");
|
|
284
289
|
let parentInfo;
|
|
@@ -350,6 +355,8 @@ export function globalPermissionsPath() {
|
|
|
350
355
|
export function projectPermissionsPath(cwd) {
|
|
351
356
|
let dir = resolve(cwd);
|
|
352
357
|
for (;;) {
|
|
358
|
+
if (isHomeWorkspace(dir))
|
|
359
|
+
break;
|
|
353
360
|
const p = join(dir, ".hara", "permissions.json");
|
|
354
361
|
if (existsSync(p))
|
|
355
362
|
return p;
|
|
@@ -426,6 +433,10 @@ function existingScaffoldTarget(path) {
|
|
|
426
433
|
/** Create project permissions without following `.hara` or replacing any existing directory entry. */
|
|
427
434
|
function scaffoldProjectPermissions(cwd) {
|
|
428
435
|
const project = realpathSync.native(resolve(cwd));
|
|
436
|
+
// Project scaffolding at ~/ would target the same ~/.hara/permissions.json used by global policy and
|
|
437
|
+
// silently promote repository starter rules to every Hara session. Reject before creating `.hara`.
|
|
438
|
+
if (isHomeWorkspace(project))
|
|
439
|
+
throw new Error(homeWorkspaceActionError("create project permissions"));
|
|
429
440
|
const projectIdentity = verifiedDirectory(project);
|
|
430
441
|
const parent = join(project, ".hara");
|
|
431
442
|
try {
|
package/dist/serve/server.js
CHANGED
|
@@ -16,14 +16,14 @@ import { analyzeContext } from "../agent/context-report.js";
|
|
|
16
16
|
import { clearTouched, recentTouched } from "../agent/touched.js";
|
|
17
17
|
import { resetRepeatGuard } from "../agent/repeat-guard.js";
|
|
18
18
|
import { contextWindow, ctxPctFor } from "../statusbar.js";
|
|
19
|
-
import {
|
|
19
|
+
import { listProjectFilesAsync } from "../fs-walk.js";
|
|
20
20
|
import { fuzzyRank } from "../fuzzy.js";
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
21
|
+
import { loadAgentContext } from "../context/agents-md.js";
|
|
22
|
+
import { expandMentionsAsync } from "../context/mentions.js";
|
|
23
23
|
import { memoryDigest } from "../memory/store.js";
|
|
24
24
|
import { listInstalled, enabledPlugins, setPluginEnabled, panelsForProject } from "../plugins/plugins.js";
|
|
25
25
|
import { loadSkillIndex, loadSkillBody } from "../skills/skills.js";
|
|
26
|
-
import { loadJobs,
|
|
26
|
+
import { loadJobs, addJob, removeJob, setEnabled } from "../cron/store.js";
|
|
27
27
|
import { parseSchedule, describeSchedule } from "../cron/schedule.js";
|
|
28
28
|
import { loadTasks } from "../tools/task.js";
|
|
29
29
|
import { listPending, resolvePending } from "../gateway/flows-pending.js";
|
|
@@ -315,7 +315,7 @@ export async function startServe(opts, deps) {
|
|
|
315
315
|
diff: (t) => broadcast("event.diff", { sessionId, text: t }),
|
|
316
316
|
notice: (t) => broadcast("event.notice", { sessionId, text: t }),
|
|
317
317
|
};
|
|
318
|
-
const confirm = (q) => new Promise((resolve) => {
|
|
318
|
+
const confirm = (q, signal = turnAbort.signal) => new Promise((resolve) => {
|
|
319
319
|
const approvalId = randomUUID();
|
|
320
320
|
let settled = false;
|
|
321
321
|
let timer;
|
|
@@ -325,16 +325,18 @@ export async function startServe(opts, deps) {
|
|
|
325
325
|
settled = true;
|
|
326
326
|
clearTimeout(timer);
|
|
327
327
|
pendingApprovals.delete(approvalId);
|
|
328
|
-
|
|
328
|
+
signal.removeEventListener("abort", onAbort);
|
|
329
329
|
resolve(v);
|
|
330
330
|
};
|
|
331
331
|
const onAbort = () => finish(false);
|
|
332
332
|
timer = setTimeout(() => finish(false), APPROVAL_TIMEOUT_MS); // unanswered → deny, turn continues
|
|
333
333
|
pendingApprovals.set(approvalId, finish);
|
|
334
|
-
if (
|
|
334
|
+
if (signal.aborted)
|
|
335
335
|
finish(false);
|
|
336
336
|
else {
|
|
337
|
-
|
|
337
|
+
// `signal` is runAgent's combined Esc + total-deadline signal. Listening only to turnAbort would
|
|
338
|
+
// leave the approval map and Desktop prompt stale when the internal lifecycle deadline fires.
|
|
339
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
338
340
|
broadcast("approval.request", { sessionId, approvalId, question: q });
|
|
339
341
|
}
|
|
340
342
|
});
|
|
@@ -358,14 +360,14 @@ export async function startServe(opts, deps) {
|
|
|
358
360
|
}
|
|
359
361
|
// @file mentions expand to file contents, same as the CLI (`@src/foo.ts` in the composer works).
|
|
360
362
|
// Pasted images ride along as NeutralMsg.images — a vision-capable model sees them inline.
|
|
361
|
-
s.history.push({ role: "user", content:
|
|
363
|
+
s.history.push({ role: "user", content: await expandMentionsAsync(content, s.meta.cwd, { signal: turnAbort.signal }), ...(images && images.length ? { images } : {}) });
|
|
362
364
|
const outcome = await runAgent(s.history, {
|
|
363
365
|
provider: s.provider,
|
|
364
366
|
ctx: {
|
|
365
367
|
cwd: s.meta.cwd,
|
|
366
368
|
sandbox: deps.sandbox,
|
|
367
369
|
todoScope: sessionId,
|
|
368
|
-
spawn: (t, role) => deps.spawnSubagent(s.provider, s.meta.cwd, s.projectContext, s.stats, t, role),
|
|
370
|
+
spawn: (t, role, signal) => deps.spawnSubagent(s.provider, s.meta.cwd, s.projectContext, s.stats, t, role, signal),
|
|
369
371
|
ui: sink,
|
|
370
372
|
},
|
|
371
373
|
approval: s.approval,
|
|
@@ -384,7 +386,21 @@ export async function startServe(opts, deps) {
|
|
|
384
386
|
};
|
|
385
387
|
void turn.then(settled, settled);
|
|
386
388
|
},
|
|
389
|
+
onToolRun: (toolRun) => {
|
|
390
|
+
s.pendingToolRuns += 1;
|
|
391
|
+
const settled = () => {
|
|
392
|
+
s.pendingToolRuns = Math.max(0, s.pendingToolRuns - 1);
|
|
393
|
+
// `abort === null` means the logical turn already returned. Keep the session busy/locked until
|
|
394
|
+
// every late side-effect-capable Promise has physically stopped.
|
|
395
|
+
if (s.pendingToolRuns === 0 && s.abort === null)
|
|
396
|
+
s.busy = false;
|
|
397
|
+
if (closing)
|
|
398
|
+
hub.releaseIdle();
|
|
399
|
+
};
|
|
400
|
+
void toolRun.then(settled, settled);
|
|
401
|
+
},
|
|
387
402
|
guardian: turnGuardian,
|
|
403
|
+
...(deps.runLimits?.(s.meta.cwd) ?? {}),
|
|
388
404
|
});
|
|
389
405
|
s.meta.todos = serializeTodos(sessionId);
|
|
390
406
|
hub.save(s);
|
|
@@ -393,11 +409,11 @@ export async function startServe(opts, deps) {
|
|
|
393
409
|
// clients render a meter without an extra round-trip.
|
|
394
410
|
const ctx = ctxOf(s);
|
|
395
411
|
if (outcome.status !== "completed") {
|
|
396
|
-
const failure = outcome.status === "
|
|
397
|
-
?
|
|
398
|
-
: outcome.status === "
|
|
399
|
-
? "
|
|
400
|
-
: "agent turn
|
|
412
|
+
const failure = outcome.error ?? (outcome.status === "empty"
|
|
413
|
+
? "the model returned an empty response after retrying"
|
|
414
|
+
: outcome.status === "halted"
|
|
415
|
+
? "agent turn halted by a safety control"
|
|
416
|
+
: "agent turn failed");
|
|
401
417
|
broadcast("event.turn_end", { sessionId, reply: "", error: failure, status: outcome.status, usage, ctx });
|
|
402
418
|
throw new Error(failure);
|
|
403
419
|
}
|
|
@@ -408,8 +424,8 @@ export async function startServe(opts, deps) {
|
|
|
408
424
|
return { reply, usage, ctx };
|
|
409
425
|
}
|
|
410
426
|
finally {
|
|
411
|
-
s.busy = false;
|
|
412
427
|
s.abort = null;
|
|
428
|
+
s.busy = s.pendingToolRuns > 0;
|
|
413
429
|
}
|
|
414
430
|
};
|
|
415
431
|
/** Context watermark for a session: how full the model's window was on the last turn. */
|
|
@@ -444,27 +460,33 @@ export async function startServe(opts, deps) {
|
|
|
444
460
|
if (controller.signal.aborted)
|
|
445
461
|
return onAbort();
|
|
446
462
|
// Promise.resolve protects this boundary even if a non-conforming provider throws synchronously.
|
|
447
|
-
void Promise.resolve().then(() =>
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
463
|
+
void Promise.resolve().then(() => {
|
|
464
|
+
// The abort can fire after scheduling this microtask but before it runs. Gate the provider call at
|
|
465
|
+
// the actual invocation boundary so an interrupted/expired compact cannot start a late request.
|
|
466
|
+
if (controller.signal.aborted)
|
|
467
|
+
throw new Error(timedOut ? "compaction timed out" : "compaction interrupted");
|
|
468
|
+
return s.provider.turn({
|
|
469
|
+
system: COMPACT_SYSTEM,
|
|
470
|
+
history: [...s.history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
|
|
471
|
+
tools: [],
|
|
472
|
+
onText: () => { },
|
|
473
|
+
signal: controller.signal,
|
|
474
|
+
});
|
|
475
|
+
}).then((result) => finish(() => resolve(result)), (error) => finish(() => reject(error)));
|
|
454
476
|
});
|
|
455
|
-
if (r.stop === "error")
|
|
477
|
+
if (controller.signal.aborted || r.stop === "error")
|
|
456
478
|
return null;
|
|
457
479
|
const summary = r.text.trim();
|
|
458
480
|
if (!summary)
|
|
459
481
|
return null;
|
|
460
|
-
|
|
461
|
-
s.history.length = 0;
|
|
462
|
-
s.history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
|
|
482
|
+
const workingSet = workingSetFromSummary(summary);
|
|
463
483
|
const touched = recentTouched(20, s.meta.id).filter((file) => {
|
|
464
484
|
const rel = relative(s.meta.cwd, file);
|
|
465
485
|
return !!rel && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
466
486
|
}).slice(0, 5);
|
|
467
487
|
const restore = buildFileRestore(touched, (f) => {
|
|
488
|
+
if (controller.signal.aborted)
|
|
489
|
+
return null;
|
|
468
490
|
try {
|
|
469
491
|
return readModelContextFileSync(f, 32 * 1024);
|
|
470
492
|
}
|
|
@@ -472,6 +494,11 @@ export async function startServe(opts, deps) {
|
|
|
472
494
|
return null;
|
|
473
495
|
}
|
|
474
496
|
});
|
|
497
|
+
if (controller.signal.aborted)
|
|
498
|
+
return null;
|
|
499
|
+
s.meta.workingSet = workingSet;
|
|
500
|
+
s.history.length = 0;
|
|
501
|
+
s.history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
|
|
475
502
|
if (restore)
|
|
476
503
|
s.history.push({ role: "user", content: restore });
|
|
477
504
|
s.stats.input += r.usage?.input ?? 0;
|
|
@@ -530,7 +557,7 @@ export async function startServe(opts, deps) {
|
|
|
530
557
|
if (!provider)
|
|
531
558
|
return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated — check the active profile and ~/.hara/config.json"));
|
|
532
559
|
const approval = ["suggest", "auto-edit", "full-auto"].includes(p.approval) ? p.approval : deps.approval;
|
|
533
|
-
const s = hub.create({ cwd, provider, providerId: provider.id, model: provider.model, approval, projectContext:
|
|
560
|
+
const s = hub.create({ cwd, provider, providerId: provider.id, model: provider.model, approval, projectContext: loadAgentContext(cwd) || undefined });
|
|
534
561
|
return reply(rpcResult(id, { sessionId: s.meta.id, model: s.meta.model }));
|
|
535
562
|
}
|
|
536
563
|
case "session.resume": {
|
|
@@ -566,7 +593,7 @@ export async function startServe(opts, deps) {
|
|
|
566
593
|
hub.detach(r.session.meta.id);
|
|
567
594
|
return reply(rpcError(id, ERR.INTERNAL, `provider not authenticated for pinned model '${r.session.meta.model}'`));
|
|
568
595
|
}
|
|
569
|
-
r.session.projectContext =
|
|
596
|
+
r.session.projectContext = loadAgentContext(r.session.meta.cwd) || undefined;
|
|
570
597
|
return reply(rpcResult(id, { sessionId: r.session.meta.id, model: r.session.meta.model, history: historyForClient(r.session.history) }));
|
|
571
598
|
}
|
|
572
599
|
case "session.send": {
|
|
@@ -660,7 +687,7 @@ export async function startServe(opts, deps) {
|
|
|
660
687
|
hub.delete(r.session.meta.id);
|
|
661
688
|
return reply(rpcError(id, ERR.INTERNAL, `provider not authenticated for pinned model '${r.session.meta.model}'`));
|
|
662
689
|
}
|
|
663
|
-
r.session.projectContext =
|
|
690
|
+
r.session.projectContext = loadAgentContext(r.session.meta.cwd) || undefined;
|
|
664
691
|
return reply(rpcResult(id, { sessionId: r.session.meta.id, title: r.session.meta.title, model: r.session.meta.model, history: historyForClient(r.session.history) }));
|
|
665
692
|
}
|
|
666
693
|
case "session.delete": {
|
|
@@ -770,21 +797,15 @@ export async function startServe(opts, deps) {
|
|
|
770
797
|
case "automation.toggle": {
|
|
771
798
|
if (typeof p.id !== "string" || typeof p.enabled !== "boolean")
|
|
772
799
|
return reply(rpcError(id, ERR.PARAMS, "id + enabled required"));
|
|
773
|
-
|
|
774
|
-
const job = jobs.find((j) => j.id === p.id);
|
|
775
|
-
if (!job)
|
|
800
|
+
if (!setEnabled(p.id, p.enabled))
|
|
776
801
|
return reply(rpcError(id, ERR.PARAMS, `no job ${p.id}`));
|
|
777
|
-
|
|
778
|
-
saveJobs(jobs);
|
|
779
|
-
return reply(rpcResult(id, { id: job.id, enabled: job.enabled }));
|
|
802
|
+
return reply(rpcResult(id, { id: p.id, enabled: p.enabled }));
|
|
780
803
|
}
|
|
781
804
|
case "automation.delete": {
|
|
782
805
|
if (typeof p.id !== "string")
|
|
783
806
|
return reply(rpcError(id, ERR.PARAMS, "id required"));
|
|
784
|
-
|
|
785
|
-
if (!jobs.some((j) => j.id === p.id))
|
|
807
|
+
if (!removeJob(p.id))
|
|
786
808
|
return reply(rpcError(id, ERR.PARAMS, `no job ${p.id}`));
|
|
787
|
-
saveJobs(jobs.filter((j) => j.id !== p.id));
|
|
788
809
|
return reply(rpcResult(id, { id: p.id, deleted: true }));
|
|
789
810
|
}
|
|
790
811
|
case "skills.list": {
|
|
@@ -805,10 +826,17 @@ export async function startServe(opts, deps) {
|
|
|
805
826
|
const sess = typeof p.sessionId === "string" ? hub.get(p.sessionId) : undefined;
|
|
806
827
|
const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : (sess?.meta.cwd ?? opts.cwd);
|
|
807
828
|
const limit = Math.min(Math.max(Math.trunc(Number(p.limit) || 20), 1), 50);
|
|
808
|
-
const
|
|
829
|
+
const inventory = await listProjectFilesAsync(cwd, {
|
|
830
|
+
maxFiles: 8_000,
|
|
831
|
+
maxDirectories: 20_000,
|
|
832
|
+
maxEntries: 100_000,
|
|
833
|
+
timeoutMs: 1_000,
|
|
834
|
+
yieldEvery: 64,
|
|
835
|
+
});
|
|
836
|
+
const all = inventory.files;
|
|
809
837
|
const query = typeof p.query === "string" ? p.query : "";
|
|
810
838
|
const files = query ? fuzzyRank(query, all, (f) => f).slice(0, limit).map((r) => r.item) : all.slice(0, limit);
|
|
811
|
-
return reply(rpcResult(id, { files, cwd }));
|
|
839
|
+
return reply(rpcResult(id, { files, cwd, truncated: inventory.truncated, reason: inventory.reason }));
|
|
812
840
|
}
|
|
813
841
|
case "session.context": {
|
|
814
842
|
// context-spend breakdown + watermark on demand (codex thread/tokenUsage + /context).
|
|
@@ -934,7 +962,7 @@ export async function startServe(opts, deps) {
|
|
|
934
962
|
await removeOwnedDiscovery(discoveryDir, discoveryPath, discovery).catch(() => { });
|
|
935
963
|
let quiet = false;
|
|
936
964
|
while (Date.now() < deadline) {
|
|
937
|
-
if (inFlightRequests.size === 0 && hub.active().every((session) => !session.busy && !session.configuring && session.pendingProviderTurns === 0)) {
|
|
965
|
+
if (inFlightRequests.size === 0 && hub.active().every((session) => !session.busy && !session.configuring && session.pendingProviderTurns === 0 && session.pendingToolRuns === 0)) {
|
|
938
966
|
quiet = true;
|
|
939
967
|
break;
|
|
940
968
|
}
|
package/dist/serve/sessions.js
CHANGED
|
@@ -47,7 +47,7 @@ export class SessionHub {
|
|
|
47
47
|
const lock = this.store.acquire(meta.id); // fresh UUID, but filesystem errors must still fail closed
|
|
48
48
|
if (!lock.ok)
|
|
49
49
|
throw new Error(`could not acquire session lock for ${meta.id}${lock.pid ? ` (held by pid ${lock.pid})` : ""}`);
|
|
50
|
-
const s = { meta, history: [], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, configuring: false, pendingProviderTurns: 0, abort: null };
|
|
50
|
+
const s = { meta, history: [], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, configuring: false, pendingProviderTurns: 0, pendingToolRuns: 0, abort: null };
|
|
51
51
|
try {
|
|
52
52
|
this.sessions.set(meta.id, s);
|
|
53
53
|
this.store.save(meta, []); // an empty newly-created thread must survive restart and appear in lists
|
|
@@ -76,7 +76,7 @@ export class SessionHub {
|
|
|
76
76
|
return { missing: true };
|
|
77
77
|
// Credential/provider routing is live, while the model remains the session's explicit pin.
|
|
78
78
|
prior.meta.provider = o.provider.id;
|
|
79
|
-
const s = { meta: prior.meta, history: [...prior.history], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, configuring: false, pendingProviderTurns: 0, abort: null, effort: prior.meta.effort };
|
|
79
|
+
const s = { meta: prior.meta, history: [...prior.history], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, configuring: false, pendingProviderTurns: 0, pendingToolRuns: 0, abort: null, effort: prior.meta.effort };
|
|
80
80
|
this.sessions.set(id, s);
|
|
81
81
|
keepLock = true; // live session owns it until delete/releaseAll
|
|
82
82
|
return { session: s };
|
|
@@ -180,6 +180,7 @@ export class SessionHub {
|
|
|
180
180
|
busy: false,
|
|
181
181
|
configuring: false,
|
|
182
182
|
pendingProviderTurns: 0,
|
|
183
|
+
pendingToolRuns: 0,
|
|
183
184
|
abort: null,
|
|
184
185
|
effort: src.meta.effort,
|
|
185
186
|
};
|
|
@@ -221,7 +222,7 @@ export class SessionHub {
|
|
|
221
222
|
/** Release only idle sessions; logical work and abandoned-but-physical provider turns retain their locks. */
|
|
222
223
|
releaseIdle() {
|
|
223
224
|
for (const [id, session] of this.sessions) {
|
|
224
|
-
if (session.busy || session.configuring || session.pendingProviderTurns > 0)
|
|
225
|
+
if (session.busy || session.configuring || session.pendingProviderTurns > 0 || session.pendingToolRuns > 0)
|
|
225
226
|
continue;
|
|
226
227
|
this.store.release(id);
|
|
227
228
|
this.sessions.delete(id);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Cross-runtime synchronous short wait for the tiny retry delays used by file locks.
|
|
2
|
+
//
|
|
3
|
+
// Bun standalone binaries do not guarantee that SharedArrayBuffer exists, so never construct one at
|
|
4
|
+
// module evaluation time. Prefer Bun's native primitive, use Atomics only in Node, and retain a bounded
|
|
5
|
+
// last resort so a missing/disabled runtime primitive cannot make lock acquisition crash or spin forever.
|
|
6
|
+
const MAX_SHORT_WAIT_MS = 100;
|
|
7
|
+
const MAX_FALLBACK_SPINS = 5_000_000;
|
|
8
|
+
let nodeWaitCell;
|
|
9
|
+
function boundedDelay(milliseconds) {
|
|
10
|
+
if (!Number.isFinite(milliseconds) || milliseconds <= 0)
|
|
11
|
+
return 0;
|
|
12
|
+
return Math.min(MAX_SHORT_WAIT_MS, Math.ceil(milliseconds));
|
|
13
|
+
}
|
|
14
|
+
/** Block for a small, bounded retry delay without assuming SharedArrayBuffer is available. */
|
|
15
|
+
export function sleepSync(milliseconds) {
|
|
16
|
+
const delay = boundedDelay(milliseconds);
|
|
17
|
+
if (delay === 0)
|
|
18
|
+
return;
|
|
19
|
+
const runtime = globalThis;
|
|
20
|
+
if (typeof runtime.Bun?.sleepSync === "function") {
|
|
21
|
+
try {
|
|
22
|
+
runtime.Bun.sleepSync(delay);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// A partially implemented Bun runtime still gets the bounded fallback below.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// Atomics.wait is a dependable synchronous sleep on Node's main thread. Do not use this branch in
|
|
30
|
+
// Bun: a compiled Bun executable may expose part of the Node compatibility surface without SAB.
|
|
31
|
+
if (!runtime.Bun && typeof runtime.SharedArrayBuffer === "function" && typeof runtime.Atomics?.wait === "function") {
|
|
32
|
+
try {
|
|
33
|
+
nodeWaitCell ??= new Int32Array(new runtime.SharedArrayBuffer(4));
|
|
34
|
+
runtime.Atomics.wait(nodeWaitCell, 0, 0, delay);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
nodeWaitCell = undefined;
|
|
39
|
+
// Some embedders disable blocking Atomics even when the globals exist. Fall through safely.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const deadline = Date.now() + delay;
|
|
43
|
+
for (let spins = 0; spins < MAX_FALLBACK_SPINS && Date.now() < deadline; spins++) {
|
|
44
|
+
// Intentionally empty: both time and iteration ceilings bound this compatibility fallback.
|
|
45
|
+
}
|
|
46
|
+
}
|
package/dist/tools/agent.js
CHANGED
|
@@ -34,6 +34,6 @@ registerTool({
|
|
|
34
34
|
return "Error: sub-agents are not available in this context.";
|
|
35
35
|
if (typeof input.task !== "string" || !input.task.trim())
|
|
36
36
|
return "Error: agent needs a `task`.";
|
|
37
|
-
return await ctx.spawn(input.task, input.role ? String(input.role) : undefined);
|
|
37
|
+
return await ctx.spawn(input.task, input.role ? String(input.role) : undefined, ctx.signal);
|
|
38
38
|
},
|
|
39
39
|
});
|
package/dist/tools/ask_user.js
CHANGED
|
@@ -52,11 +52,15 @@ registerTool({
|
|
|
52
52
|
// Compose a compact prompt: [header] (context) question — the channel renders it.
|
|
53
53
|
const prompt = [header ? `[${header}] ` : "", context ? `${context}\n` : "", question].join("");
|
|
54
54
|
try {
|
|
55
|
-
const answer = await ctx.ask(prompt, options && options.length ? options : undefined);
|
|
55
|
+
const answer = await ctx.ask(prompt, options && options.length ? options : undefined, ctx.signal);
|
|
56
56
|
const text = typeof answer === "string" ? answer.trim() : "";
|
|
57
57
|
return text || "(the user gave an empty answer)";
|
|
58
58
|
}
|
|
59
59
|
catch (e) {
|
|
60
|
+
// Cancellation is authoritative. Let the agent loop close the open tool round as interrupted/deadline;
|
|
61
|
+
// converting it into an ordinary "no user" result would let the model continue after Esc.
|
|
62
|
+
if (ctx.signal?.aborted)
|
|
63
|
+
throw e;
|
|
60
64
|
// If the interactive prompt fails for any reason, degrade gracefully rather than crash the turn.
|
|
61
65
|
return `${NO_INTERACTIVE_USER} (ask failed: ${e?.message ?? e})`;
|
|
62
66
|
}
|