@nanhara/hara 0.125.3 → 0.126.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 +59 -1
- package/README.md +10 -4
- package/SECURITY.md +7 -3
- package/dist/agent/limits.js +2 -2
- package/dist/agent/loop.js +270 -98
- package/dist/config.js +172 -4
- package/dist/gateway/flows-pending.js +14 -36
- package/dist/gateway/wecom.js +9 -1
- package/dist/index.js +372 -159
- package/dist/mcp/client.js +2 -0
- package/dist/plugins/manifest.js +317 -0
- package/dist/plugins/plugins.js +476 -139
- package/dist/providers/bounded-turn.js +6 -0
- package/dist/providers/factory.js +54 -0
- package/dist/providers/openai.js +6 -1
- package/dist/providers/registry.js +1 -0
- package/dist/providers/target.js +98 -0
- package/dist/security/guardian.js +6 -1
- package/dist/security/private-state.js +1 -1
- package/dist/security/secrets.js +19 -0
- package/dist/serve/protocol.js +7 -2
- package/dist/serve/server.js +139 -35
- package/dist/serve/sessions.js +11 -2
- package/dist/session/operation-drain.js +45 -0
- package/dist/tools/agent.js +1 -0
- package/dist/tools/all.js +1 -0
- package/dist/tools/ask_user.js +8 -3
- package/dist/tools/builtin.js +19 -1
- package/dist/tools/codebase.js +1 -0
- package/dist/tools/computer.js +1 -0
- package/dist/tools/cron.js +10 -0
- package/dist/tools/external_agent.js +1 -0
- package/dist/tools/memory.js +2 -0
- package/dist/tools/registry.js +95 -4
- package/dist/tools/result-limit.js +172 -2
- package/dist/tools/runtime.js +67 -0
- package/dist/tools/search.js +3 -0
- package/dist/tools/skill.js +1 -0
- package/dist/tools/task.js +5 -0
- package/dist/tools/todo.js +3 -2
- package/dist/tools/web.js +4 -0
- package/dist/tui/App.js +5 -1
- package/package.json +1 -1
|
@@ -33,6 +33,12 @@ export async function boundedProviderTurn(provider, args, options) {
|
|
|
33
33
|
// microtask starts. Re-check here so an already-cancelled auxiliary call never incurs a request/cost.
|
|
34
34
|
.then(() => controller.signal.aborted ? errorResult(`${label} cancelled`) : provider.turn({ ...args, signal: controller.signal }))
|
|
35
35
|
.catch((error) => errorResult(error instanceof Error ? error.message : String(error)));
|
|
36
|
+
try {
|
|
37
|
+
options.onProviderTurn?.(turn);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Observability must never weaken the provider boundary.
|
|
41
|
+
}
|
|
36
42
|
const result = await Promise.race([turn, stopped]);
|
|
37
43
|
clearTimeout(timer);
|
|
38
44
|
parent?.removeEventListener("abort", onParentAbort);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Shared provider construction for the interactive CLI, Desktop serve, gateway approval/judge calls, and
|
|
2
|
+
// connection tests. Every path must interpret auth:none/OAuth/wire-protocol targets identically.
|
|
3
|
+
import { providerIsLocal } from "../config.js";
|
|
4
|
+
import { getValidQwenAuth } from "./qwen-oauth.js";
|
|
5
|
+
import { createAnthropicProvider } from "./anthropic.js";
|
|
6
|
+
import { createOpenAIProvider } from "./openai.js";
|
|
7
|
+
import { resolvePlatform } from "./registry.js";
|
|
8
|
+
export async function createProviderForTarget(target, reasoningEffort) {
|
|
9
|
+
const { provider, apiKey, model, baseURL } = target;
|
|
10
|
+
if (provider === "qwen-oauth") {
|
|
11
|
+
const auth = await getValidQwenAuth();
|
|
12
|
+
if (!auth)
|
|
13
|
+
return null;
|
|
14
|
+
return createOpenAIProvider({
|
|
15
|
+
apiKey: auth.accessToken,
|
|
16
|
+
baseURL: auth.baseURL,
|
|
17
|
+
model,
|
|
18
|
+
label: provider,
|
|
19
|
+
reasoningEffort,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
// The OpenAI SDK requires a non-empty constructor value even when a compatible local endpoint has no
|
|
23
|
+
// authentication. This sentinel never leaves the process for cloud targets and local targets have already
|
|
24
|
+
// discarded all user credentials at target resolution.
|
|
25
|
+
const transportKey = apiKey ?? (providerIsLocal(provider) ? "hara-local-no-secret" : undefined);
|
|
26
|
+
if (!transportKey)
|
|
27
|
+
return null;
|
|
28
|
+
const wire = resolvePlatform(provider, baseURL).wireApi;
|
|
29
|
+
if (wire === "anthropic") {
|
|
30
|
+
return createAnthropicProvider({ apiKey: transportKey, model, baseURL, reasoningEffort });
|
|
31
|
+
}
|
|
32
|
+
if (wire === "responses") {
|
|
33
|
+
return {
|
|
34
|
+
id: provider,
|
|
35
|
+
model,
|
|
36
|
+
async turn() {
|
|
37
|
+
return {
|
|
38
|
+
text: "",
|
|
39
|
+
toolUses: [],
|
|
40
|
+
stop: "error",
|
|
41
|
+
errorMsg: "This endpoint uses the OpenAI Responses API, which hara doesn't speak yet. Point hara at an OpenAI-compatible chat endpoint (…/compatible-mode/v1 or …/v1) or an Anthropic-compatible endpoint (…/apps/anthropic).",
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return createOpenAIProvider({
|
|
47
|
+
apiKey: transportKey,
|
|
48
|
+
model,
|
|
49
|
+
baseURL,
|
|
50
|
+
label: provider,
|
|
51
|
+
reasoningEffort,
|
|
52
|
+
omitAuthorization: providerIsLocal(provider),
|
|
53
|
+
});
|
|
54
|
+
}
|
package/dist/providers/openai.js
CHANGED
|
@@ -80,7 +80,12 @@ export function toOpenAI(system, history) {
|
|
|
80
80
|
}
|
|
81
81
|
/** OpenAI-compatible provider (works with OpenAI, Qwen/DashScope, GLM, Kimi, …). */
|
|
82
82
|
export function createOpenAIProvider(opts) {
|
|
83
|
-
const client = new OpenAI({
|
|
83
|
+
const client = new OpenAI({
|
|
84
|
+
apiKey: opts.apiKey,
|
|
85
|
+
maxRetries: 4,
|
|
86
|
+
...(opts.baseURL ? { baseURL: opts.baseURL } : {}),
|
|
87
|
+
...(opts.omitAuthorization ? { defaultHeaders: { Authorization: null } } : {}),
|
|
88
|
+
});
|
|
84
89
|
return {
|
|
85
90
|
id: opts.label ?? "openai",
|
|
86
91
|
model: opts.model,
|
|
@@ -32,6 +32,7 @@ const BY_PROVIDER = {
|
|
|
32
32
|
glm: { wireApi: "chat", reasoning: "none", cache: "auto" }, // Zhipu native /paas/v4 — different thinking param; leave alone (its /anthropic endpoint resolves via baseURL)
|
|
33
33
|
deepseek: { wireApi: "chat", reasoning: "deepseek", cache: "auto" }, // V4: thinking:{type} + reasoning_effort(high|max)
|
|
34
34
|
ollama: { wireApi: "chat", reasoning: "ollama_think", cache: "none" }, // local; `think` toggles reasoning
|
|
35
|
+
lmstudio: { wireApi: "chat", reasoning: "ollama_think", cache: "none" }, // local OpenAI-compatible server
|
|
35
36
|
openai: { wireApi: "chat", reasoning: "reasoning_effort", cache: "auto" },
|
|
36
37
|
openrouter: { wireApi: "chat", reasoning: "none", cache: "auto" },
|
|
37
38
|
"hara-gateway": { wireApi: "chat", reasoning: "none", cache: "auto" },
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { providerDefaultBaseURL, providerDefaultModel, providerEnvKey, isProviderId, providerIsLocal, } from "../config.js";
|
|
2
|
+
import { getProfile, PERSONAL_ID, resolveActive, } from "../profile/profile.js";
|
|
3
|
+
/** Resolve the identity profile for the same cwd used to load config. */
|
|
4
|
+
export function profileForConfig(cfg) {
|
|
5
|
+
const resolution = resolveActive(cfg.cwd);
|
|
6
|
+
if (resolution.id !== PERSONAL_ID) {
|
|
7
|
+
const profile = getProfile(resolution.id);
|
|
8
|
+
if (profile)
|
|
9
|
+
return { profile, resolution };
|
|
10
|
+
}
|
|
11
|
+
return {
|
|
12
|
+
resolution: resolution.id === PERSONAL_ID
|
|
13
|
+
? resolution
|
|
14
|
+
: { id: PERSONAL_ID, source: "fallback" },
|
|
15
|
+
profile: {
|
|
16
|
+
id: PERSONAL_ID,
|
|
17
|
+
kind: "byok",
|
|
18
|
+
label: "Personal",
|
|
19
|
+
provider: cfg.provider,
|
|
20
|
+
apiKey: cfg.apiKey,
|
|
21
|
+
baseURL: cfg.baseURL,
|
|
22
|
+
defaultModel: cfg.model,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolve one BYOK/local transport target.
|
|
28
|
+
*
|
|
29
|
+
* A named Profile is an identity boundary: its vendor, credential and endpoint must not be replaced by
|
|
30
|
+
* the always-populated personal/global config. Explicit HARA_* values remain one-shot overrides. The
|
|
31
|
+
* personal profile and explicit sidecars continue to use the merged config.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveByokProviderTarget(cfg, profile, sidecarOverride, env = process.env) {
|
|
34
|
+
const personalOrOverride = profile.id === "personal" || sidecarOverride;
|
|
35
|
+
const profileProvider = profile.provider && profile.provider !== "hara-gateway" ? profile.provider : "anthropic";
|
|
36
|
+
const environmentProvider = isProviderId(env.HARA_PROVIDER) && env.HARA_PROVIDER !== "hara-gateway"
|
|
37
|
+
? env.HARA_PROVIDER
|
|
38
|
+
: undefined;
|
|
39
|
+
const provider = personalOrOverride
|
|
40
|
+
? (cfg.provider !== "hara-gateway" ? cfg.provider : profileProvider)
|
|
41
|
+
: environmentProvider ?? profileProvider;
|
|
42
|
+
const namedProviderChanged = !personalOrOverride && provider !== profileProvider;
|
|
43
|
+
const envKey = providerEnvKey(provider);
|
|
44
|
+
const providerEnvApiKey = envKey ? env[envKey] : undefined;
|
|
45
|
+
const candidateApiKey = personalOrOverride
|
|
46
|
+
? cfg.apiKey ?? profile.apiKey
|
|
47
|
+
: env.HARA_API_KEY ?? providerEnvApiKey ?? (namedProviderChanged ? undefined : profile.apiKey);
|
|
48
|
+
// Ollama/LM Studio declare auth:none. Never forward a stale cloud key (including HARA_API_KEY) to a
|
|
49
|
+
// loopback process that happens to occupy the configured port.
|
|
50
|
+
const apiKey = providerIsLocal(provider) ? undefined : candidateApiKey;
|
|
51
|
+
const baseURL = personalOrOverride
|
|
52
|
+
? cfg.baseURL ?? profile.baseURL ?? providerDefaultBaseURL(provider)
|
|
53
|
+
: env.HARA_BASE_URL
|
|
54
|
+
?? (namedProviderChanged ? undefined : profile.baseURL)
|
|
55
|
+
?? providerDefaultBaseURL(provider);
|
|
56
|
+
const profileModel = profile.model || profile.defaultModel || "";
|
|
57
|
+
const model = personalOrOverride
|
|
58
|
+
? cfg.model || env.HARA_MODEL || profileModel
|
|
59
|
+
: env.HARA_MODEL
|
|
60
|
+
|| (namedProviderChanged ? "" : profileModel)
|
|
61
|
+
|| providerDefaultModel(provider);
|
|
62
|
+
return { provider, apiKey, baseURL, model };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Apply an explicit runtime selection (session model, role model, vision/route sidecar, fallback).
|
|
66
|
+
*
|
|
67
|
+
* A provider switch starts a fresh credential/endpoint boundary; absent fields use that provider's public
|
|
68
|
+
* defaults and never inherit the previous profile's key or host.
|
|
69
|
+
*/
|
|
70
|
+
export function overrideProviderTarget(base, override) {
|
|
71
|
+
if (!override)
|
|
72
|
+
return base;
|
|
73
|
+
const owns = (key) => Object.prototype.hasOwnProperty.call(override, key);
|
|
74
|
+
const provider = override.provider ?? base.provider;
|
|
75
|
+
const providerChanged = provider !== base.provider;
|
|
76
|
+
const apiKey = providerIsLocal(provider)
|
|
77
|
+
? undefined
|
|
78
|
+
: owns("apiKey")
|
|
79
|
+
? override.apiKey
|
|
80
|
+
: providerChanged
|
|
81
|
+
? undefined
|
|
82
|
+
: base.apiKey;
|
|
83
|
+
const baseURL = owns("baseURL")
|
|
84
|
+
? override.baseURL
|
|
85
|
+
: providerChanged
|
|
86
|
+
? providerDefaultBaseURL(provider)
|
|
87
|
+
: base.baseURL;
|
|
88
|
+
const model = owns("model") && override.model
|
|
89
|
+
? override.model
|
|
90
|
+
: providerChanged
|
|
91
|
+
? providerDefaultModel(provider)
|
|
92
|
+
: base.model;
|
|
93
|
+
return { provider, apiKey, baseURL, model };
|
|
94
|
+
}
|
|
95
|
+
/** Gateway profiles own their default, while an explicit session/role model remains selectable. */
|
|
96
|
+
export function resolveGatewayModel(cfg, profile, env = process.env, requestedModel) {
|
|
97
|
+
return env.HARA_MODEL || requestedModel || profile.model || profile.defaultModel || cfg.model;
|
|
98
|
+
}
|
|
@@ -223,7 +223,12 @@ export async function guardianVeto(provider, action, history, opts = {}) {
|
|
|
223
223
|
history: [{ role: "user", content: prompt }],
|
|
224
224
|
tools: [],
|
|
225
225
|
onText: () => { },
|
|
226
|
-
}, {
|
|
226
|
+
}, {
|
|
227
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
228
|
+
signal: opts.signal,
|
|
229
|
+
label: "security guardian",
|
|
230
|
+
onProviderTurn: opts.onProviderTurn,
|
|
231
|
+
});
|
|
227
232
|
if (r.stop === "error")
|
|
228
233
|
return { decision: "allow", reason: "" }; // fail-open on model error
|
|
229
234
|
return parseVerdict(r.text);
|
|
@@ -8,7 +8,7 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
8
8
|
import { FileReadLimitError, MAX_EDIT_READ_BYTES, decodeUtf8Strict, openVerifiedRegularFileNoFollow, verifyOpenedRegularFileSync, } from "../fs-read.js";
|
|
9
9
|
import { optionalPosixOpenFlag } from "../fs-open-flags.js";
|
|
10
10
|
import { sameOpenedFileIdentity } from "../fs-identity.js";
|
|
11
|
-
const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin"]);
|
|
11
|
+
const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin", "tool-results", "plugin-receipts"]);
|
|
12
12
|
const tightenedHomes = new Set();
|
|
13
13
|
const DEFAULT_MIGRATION_CAP = 50_000;
|
|
14
14
|
function isMissing(error) {
|
package/dist/security/secrets.js
CHANGED
|
@@ -87,6 +87,25 @@ export function redactSensitiveText(text) {
|
|
|
87
87
|
}
|
|
88
88
|
return { text: out, redactions };
|
|
89
89
|
}
|
|
90
|
+
/** Remove caller-known secret values before pattern matching. Provider/server errors may echo an opaque key
|
|
91
|
+
* that has no recognizable prefix, so field-name and token-shape heuristics alone cannot uphold no-echo APIs. */
|
|
92
|
+
export function redactKnownSecrets(text, secrets) {
|
|
93
|
+
let out = text;
|
|
94
|
+
const exactHits = [];
|
|
95
|
+
for (const value of secrets) {
|
|
96
|
+
if (!value)
|
|
97
|
+
continue;
|
|
98
|
+
const variants = new Set([value, encodeURIComponent(value)]);
|
|
99
|
+
for (const variant of variants) {
|
|
100
|
+
if (!variant || !out.includes(variant))
|
|
101
|
+
continue;
|
|
102
|
+
out = out.split(variant).join("***");
|
|
103
|
+
exactHits.push("known-secret");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const patterned = redactSensitiveText(out);
|
|
107
|
+
return { text: patterned.text, redactions: [...exactHits, ...patterned.redactions] };
|
|
108
|
+
}
|
|
90
109
|
/** Structured tool/config data often carries an opaque value that has no recognizable token prefix. In that
|
|
91
110
|
* case the FIELD name is the evidence (`apiKey`, `access_token`, `FEISHU_APP_SECRET`, …). Keep this narrow
|
|
92
111
|
* enough not to erase ordinary fields such as `tokenCount` or `secretary`. */
|
package/dist/serve/protocol.js
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
// Everything here is PURE (parse + frame builders + error codes) and unit-tested.
|
|
4
4
|
//
|
|
5
5
|
// Client → server requests:
|
|
6
|
-
// initialize {token,capabilities?} → {name,version,protocol,cwd,provider,model,
|
|
6
|
+
// initialize {token,capabilities?} → {name,version,protocol,cwd,provider,model,setupState,
|
|
7
7
|
// capabilities:{methods:[…]}} (feature detection)
|
|
8
|
+
// server.shutdown {} → {accepted:true} (authenticated graceful local shutdown;
|
|
9
|
+
// BUSY while any client work/approval is active)
|
|
8
10
|
// session.list {cwd?} → {sessions:[{id,title,cwd,model,updatedAt}]}
|
|
9
11
|
// session.create {cwd?,approval?} → {sessionId,model}
|
|
10
12
|
// session.resume {sessionId} → {sessionId,model,history:[{role,text}]}
|
|
@@ -19,6 +21,9 @@
|
|
|
19
21
|
// automation.list {} → {jobs:[{id,name,mode,enabled,lastRunAt,lastStatus,…}],
|
|
20
22
|
// sessions:[{id,title,source,sourceName,updatedAt,…}]}
|
|
21
23
|
// models.list {} → {models:[…], current, effortLevels:[…]}
|
|
24
|
+
// settings.providers.list {} → redacted provider catalog + current profile state
|
|
25
|
+
// settings.providers.test {provider,model,…} → {ok,models,error?} (credential is ephemeral)
|
|
26
|
+
// settings.providers.save {provider,model,…} → redacted state (credential is never returned)
|
|
22
27
|
// automation.add {name,schedule,task,mode?,cwd?,tz?} → {id,name,schedule}
|
|
23
28
|
// automation.toggle {id,enabled} → {id,enabled}
|
|
24
29
|
// automation.delete {id} → {id,deleted}
|
|
@@ -37,7 +42,7 @@ export const ERR = {
|
|
|
37
42
|
PARAMS: -32602,
|
|
38
43
|
INTERNAL: -32603,
|
|
39
44
|
UNAUTHORIZED: -32001, // initialize first (or bad token)
|
|
40
|
-
BUSY: -32002, //
|
|
45
|
+
BUSY: -32002, // requested operation conflicts with active server/session work
|
|
41
46
|
NO_SESSION: -32003, // unknown/expired sessionId
|
|
42
47
|
LOCKED: -32004, // session held by another live hara process (single-writer lock)
|
|
43
48
|
};
|
package/dist/serve/server.js
CHANGED
|
@@ -34,6 +34,7 @@ import { parseFrame, rpcResult, rpcError, rpcNotify, ERR, PROTOCOL_VERSION } fro
|
|
|
34
34
|
import { readModelContextFileSync } from "../fs-read.js";
|
|
35
35
|
import { optionalPosixOpenFlag } from "../fs-open-flags.js";
|
|
36
36
|
import { sameOpenedFileIdentity } from "../fs-identity.js";
|
|
37
|
+
import { redactSensitiveText, redactSensitiveValue } from "../security/secrets.js";
|
|
37
38
|
import { consumePendingTaskSteering, createTaskExecution, continueTaskExecution, finishTaskExecution, newSteerInteraction, newTurnInteraction, recordTaskSteering, requestsTaskContinuation, taskExecutionContext, } from "../session/task.js";
|
|
38
39
|
const APPROVAL_TIMEOUT_MS = 300_000; // an unanswered approval denies after 5 min (never hangs a turn)
|
|
39
40
|
const COMPACT_TIMEOUT_MS = 60_000;
|
|
@@ -251,12 +252,11 @@ export async function startServe(opts, deps) {
|
|
|
251
252
|
const fresh = deps.buildProviderFor
|
|
252
253
|
? await deps.buildProviderFor(session.meta.model, session.effort, session.meta.cwd)
|
|
253
254
|
: await deps.buildSessionProvider(session.meta.cwd);
|
|
254
|
-
if (!fresh)
|
|
255
|
+
if (!fresh || fresh.model !== session.meta.model)
|
|
255
256
|
return false;
|
|
256
257
|
session.provider = fresh;
|
|
257
258
|
session.meta.provider = fresh.id;
|
|
258
|
-
|
|
259
|
-
return fresh.model === session.meta.model;
|
|
259
|
+
return true;
|
|
260
260
|
};
|
|
261
261
|
const wss = new WebSocketServer({ host: opts.host, port: opts.port, maxPayload: 10 * 1024 * 1024 });
|
|
262
262
|
await new Promise((res, rej) => {
|
|
@@ -267,8 +267,66 @@ export async function startServe(opts, deps) {
|
|
|
267
267
|
const authed = new Set();
|
|
268
268
|
const pendingApprovals = new Map();
|
|
269
269
|
const inFlightRequests = new Set();
|
|
270
|
+
// Physical provider/tool work can outlive its logical timeout. Keep a process-level ledger independent
|
|
271
|
+
// of SessionHub membership so detach/delete cannot make an updater believe the old engine is quiescent.
|
|
272
|
+
const activeOperations = new Set();
|
|
270
273
|
let closing = false;
|
|
271
274
|
let closePromise = null;
|
|
275
|
+
const trackActiveOperation = (operation) => {
|
|
276
|
+
activeOperations.add(operation);
|
|
277
|
+
const settled = () => {
|
|
278
|
+
activeOperations.delete(operation);
|
|
279
|
+
if (closing)
|
|
280
|
+
hub.releaseIdle();
|
|
281
|
+
};
|
|
282
|
+
void operation.then(settled, settled);
|
|
283
|
+
return operation;
|
|
284
|
+
};
|
|
285
|
+
const releaseSessionBusyIfIdle = (session) => {
|
|
286
|
+
if (session.abort === null &&
|
|
287
|
+
session.pendingProviderTurns === 0 &&
|
|
288
|
+
session.pendingToolRuns === 0) {
|
|
289
|
+
session.busy = false;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
const observeProviderTurn = (session, turn) => {
|
|
293
|
+
session.pendingProviderTurns += 1;
|
|
294
|
+
trackActiveOperation(turn);
|
|
295
|
+
const settled = () => {
|
|
296
|
+
session.pendingProviderTurns = Math.max(0, session.pendingProviderTurns - 1);
|
|
297
|
+
// A logical timeout/interrupt may return before a non-cooperative provider physically settles.
|
|
298
|
+
// Retain the per-session lease so a second turn cannot share that provider instance concurrently.
|
|
299
|
+
releaseSessionBusyIfIdle(session);
|
|
300
|
+
if (closing)
|
|
301
|
+
hub.releaseIdle();
|
|
302
|
+
};
|
|
303
|
+
void turn.then(settled, settled);
|
|
304
|
+
};
|
|
305
|
+
const observeToolRun = (session, toolRun) => {
|
|
306
|
+
session.pendingToolRuns += 1;
|
|
307
|
+
trackActiveOperation(toolRun);
|
|
308
|
+
const settled = () => {
|
|
309
|
+
session.pendingToolRuns = Math.max(0, session.pendingToolRuns - 1);
|
|
310
|
+
// `abort === null` means the logical turn already returned. Keep the session busy/locked until
|
|
311
|
+
// every late side-effect-capable Promise has physically stopped.
|
|
312
|
+
releaseSessionBusyIfIdle(session);
|
|
313
|
+
if (closing)
|
|
314
|
+
hub.releaseIdle();
|
|
315
|
+
};
|
|
316
|
+
void toolRun.then(settled, settled);
|
|
317
|
+
};
|
|
318
|
+
/** An RPC-requested shutdown is a cooperative handoff (for example, before a Desktop update), not a
|
|
319
|
+
* force-stop. Refuse it while ANY client still owns live work. `inFlightRequests` covers async work that
|
|
320
|
+
* has not attached a session yet (provider factories/settings/filesystem scans); the session fields cover
|
|
321
|
+
* turns, compaction, provider reconfiguration, and physically late provider/tool promises. */
|
|
322
|
+
const hasActiveClientWork = () => inFlightRequests.size > 0 ||
|
|
323
|
+
activeOperations.size > 0 ||
|
|
324
|
+
pendingApprovals.size > 0 ||
|
|
325
|
+
hub.active().some((session) => session.busy ||
|
|
326
|
+
session.configuring ||
|
|
327
|
+
session.abort !== null ||
|
|
328
|
+
session.pendingProviderTurns > 0 ||
|
|
329
|
+
session.pendingToolRuns > 0);
|
|
272
330
|
const broadcast = (method, params) => {
|
|
273
331
|
const frame = rpcNotify(method, params);
|
|
274
332
|
for (const ws of authed)
|
|
@@ -384,8 +442,8 @@ export async function startServe(opts, deps) {
|
|
|
384
442
|
if (signal.aborted)
|
|
385
443
|
finish(false);
|
|
386
444
|
else {
|
|
387
|
-
// `signal`
|
|
388
|
-
// leave the approval map and Desktop prompt stale
|
|
445
|
+
// `signal` composes the owning turn cancellation with runAgent's lifecycle cancellation. Listening
|
|
446
|
+
// only to turnAbort would leave the approval map and Desktop prompt stale after an internal stop.
|
|
389
447
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
390
448
|
broadcast("approval.request", { sessionId, approvalId, question: q });
|
|
391
449
|
}
|
|
@@ -419,7 +477,10 @@ export async function startServe(opts, deps) {
|
|
|
419
477
|
cwd: s.meta.cwd,
|
|
420
478
|
sandbox: deps.sandbox,
|
|
421
479
|
todoScope: sessionId,
|
|
422
|
-
spawn: (t, role, signal) => deps.spawnSubagent(s.provider, s.meta.cwd, s.projectContext, s.stats, t, role, signal
|
|
480
|
+
spawn: (t, role, signal) => deps.spawnSubagent(s.provider, s.meta.cwd, s.projectContext, s.stats, t, role, signal, {
|
|
481
|
+
onProviderTurn: (turn) => observeProviderTurn(s, turn),
|
|
482
|
+
onToolRun: (toolRun) => observeToolRun(s, toolRun),
|
|
483
|
+
}),
|
|
423
484
|
ui: sink,
|
|
424
485
|
},
|
|
425
486
|
approval: s.approval,
|
|
@@ -446,28 +507,8 @@ export async function startServe(opts, deps) {
|
|
|
446
507
|
},
|
|
447
508
|
stats: s.stats,
|
|
448
509
|
signal: turnAbort.signal,
|
|
449
|
-
onProviderTurn: (turn) =>
|
|
450
|
-
|
|
451
|
-
const settled = () => {
|
|
452
|
-
s.pendingProviderTurns = Math.max(0, s.pendingProviderTurns - 1);
|
|
453
|
-
if (closing)
|
|
454
|
-
hub.releaseIdle();
|
|
455
|
-
};
|
|
456
|
-
void turn.then(settled, settled);
|
|
457
|
-
},
|
|
458
|
-
onToolRun: (toolRun) => {
|
|
459
|
-
s.pendingToolRuns += 1;
|
|
460
|
-
const settled = () => {
|
|
461
|
-
s.pendingToolRuns = Math.max(0, s.pendingToolRuns - 1);
|
|
462
|
-
// `abort === null` means the logical turn already returned. Keep the session busy/locked until
|
|
463
|
-
// every late side-effect-capable Promise has physically stopped.
|
|
464
|
-
if (s.pendingToolRuns === 0 && s.abort === null)
|
|
465
|
-
s.busy = false;
|
|
466
|
-
if (closing)
|
|
467
|
-
hub.releaseIdle();
|
|
468
|
-
};
|
|
469
|
-
void toolRun.then(settled, settled);
|
|
470
|
-
},
|
|
510
|
+
onProviderTurn: (turn) => observeProviderTurn(s, turn),
|
|
511
|
+
onToolRun: (toolRun) => observeToolRun(s, toolRun),
|
|
471
512
|
guardian: turnGuardian,
|
|
472
513
|
...(deps.runLimits?.(s.meta.cwd) ?? {}),
|
|
473
514
|
});
|
|
@@ -508,7 +549,7 @@ export async function startServe(opts, deps) {
|
|
|
508
549
|
}
|
|
509
550
|
finally {
|
|
510
551
|
s.abort = null;
|
|
511
|
-
s.busy = s.pendingToolRuns > 0;
|
|
552
|
+
s.busy = s.pendingProviderTurns > 0 || s.pendingToolRuns > 0;
|
|
512
553
|
}
|
|
513
554
|
};
|
|
514
555
|
/** Context watermark for a session: how full the model's window was on the last turn. */
|
|
@@ -544,7 +585,7 @@ export async function startServe(opts, deps) {
|
|
|
544
585
|
if (controller.signal.aborted)
|
|
545
586
|
return onAbort();
|
|
546
587
|
// Promise.resolve protects this boundary even if a non-conforming provider throws synchronously.
|
|
547
|
-
|
|
588
|
+
const providerTurn = Promise.resolve().then(() => {
|
|
548
589
|
// The abort can fire after scheduling this microtask but before it runs. Gate the provider call at
|
|
549
590
|
// the actual invocation boundary so an interrupted/expired compact cannot start a late request.
|
|
550
591
|
if (controller.signal.aborted)
|
|
@@ -556,7 +597,9 @@ export async function startServe(opts, deps) {
|
|
|
556
597
|
onText: () => { },
|
|
557
598
|
signal: controller.signal,
|
|
558
599
|
});
|
|
559
|
-
})
|
|
600
|
+
});
|
|
601
|
+
observeProviderTurn(s, providerTurn);
|
|
602
|
+
void providerTurn.then((result) => finish(() => resolve(result)), (error) => finish(() => reject(error)));
|
|
560
603
|
});
|
|
561
604
|
if (controller.signal.aborted || r.stop === "error")
|
|
562
605
|
return null;
|
|
@@ -619,18 +662,46 @@ export async function startServe(opts, deps) {
|
|
|
619
662
|
// clients feature-detect up front instead of probing for -32601 per call. `p.capabilities`
|
|
620
663
|
// (client-declared) is accepted and currently unused — reserved for opt-outs/experimental gating.
|
|
621
664
|
const methods = [
|
|
665
|
+
"server.shutdown",
|
|
622
666
|
"session.list", "session.create", "session.resume", "session.send", "session.steer", "session.interrupt", "session.set-model",
|
|
623
667
|
"session.rename", "session.archive", "session.compact", "session.rewind", "session.context", "session.delete", "session.fork",
|
|
624
668
|
"approval.reply", "plugins.list", "plugins.set", "skills.list", "models.list", "files.search", "project.panels",
|
|
669
|
+
"settings.providers.list", "settings.providers.test", "settings.providers.save",
|
|
625
670
|
"automation.list", "automation.add", "automation.toggle", "automation.delete",
|
|
626
671
|
"tasks.list", "approvals.list", "approvals.resolve",
|
|
627
672
|
];
|
|
628
673
|
const runtime = runtimeInfo();
|
|
629
|
-
|
|
674
|
+
const setupState = deps.providerSettings
|
|
675
|
+
? (deps.providerSettings(opts.cwd).current.authenticated ? "ready" : "needs-credentials")
|
|
676
|
+
: "ready";
|
|
677
|
+
return reply(rpcResult(id, {
|
|
678
|
+
name: "hara",
|
|
679
|
+
version: deps.version,
|
|
680
|
+
protocol: PROTOCOL_VERSION,
|
|
681
|
+
cwd: opts.cwd,
|
|
682
|
+
provider: runtime.providerId,
|
|
683
|
+
model: runtime.model,
|
|
684
|
+
setupState,
|
|
685
|
+
capabilities: { methods },
|
|
686
|
+
}));
|
|
630
687
|
}
|
|
631
688
|
if (!authed.has(ws))
|
|
632
689
|
return reply(rpcError(id, ERR.UNAUTHORIZED, "initialize first"));
|
|
633
690
|
switch (req.method) {
|
|
691
|
+
case "server.shutdown": {
|
|
692
|
+
// The updater's stop request must never abort another client's turn or dismiss its approval.
|
|
693
|
+
// The current shutdown request is not inserted into inFlightRequests until this synchronous
|
|
694
|
+
// branch returns, so any entry observed here belongs to another request. Once accepted, close
|
|
695
|
+
// admission atomically before replying/scheduling close: no new work can race into the gap.
|
|
696
|
+
if (hasActiveClientWork()) {
|
|
697
|
+
return reply(rpcError(id, ERR.BUSY, "server has active work — retry shutdown after all sessions and approvals are idle"));
|
|
698
|
+
}
|
|
699
|
+
closing = true;
|
|
700
|
+
reply(rpcResult(id, { accepted: true }));
|
|
701
|
+
const shutdown = setTimeout(() => void close(), 0);
|
|
702
|
+
shutdown.unref();
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
634
705
|
case "session.list":
|
|
635
706
|
return reply(rpcResult(id, { sessions: hub.list(typeof p.cwd === "string" ? p.cwd : undefined).filter((m) => !m.archived || p.archived === true).map((m) => ({ id: m.id, title: m.title, cwd: m.cwd, model: m.model, updatedAt: m.updatedAt, source: m.source ?? "interactive", sourceName: m.sourceName, archived: m.archived ?? false })) }));
|
|
636
707
|
case "session.create": {
|
|
@@ -826,6 +897,37 @@ export async function startServe(opts, deps) {
|
|
|
826
897
|
const currentRuntime = runtimeInfo(targetCwd, current);
|
|
827
898
|
return reply(rpcResult(id, { models, current, effort: session?.effort ?? null, effortLevels: currentRuntime.effortLevels }));
|
|
828
899
|
}
|
|
900
|
+
case "settings.providers.list": {
|
|
901
|
+
if (!deps.providerSettings)
|
|
902
|
+
return reply(rpcError(id, ERR.METHOD, "provider settings not supported by this server"));
|
|
903
|
+
const targetCwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
|
|
904
|
+
return reply(rpcResult(id, redactSensitiveValue(deps.providerSettings(targetCwd)).value));
|
|
905
|
+
}
|
|
906
|
+
case "settings.providers.test":
|
|
907
|
+
case "settings.providers.save": {
|
|
908
|
+
const callback = req.method === "settings.providers.test" ? deps.testProviderSettings : deps.saveProviderSettings;
|
|
909
|
+
if (!callback)
|
|
910
|
+
return reply(rpcError(id, ERR.METHOD, "provider settings not supported by this server"));
|
|
911
|
+
if (typeof p.provider !== "string" ||
|
|
912
|
+
typeof p.model !== "string" ||
|
|
913
|
+
(p.baseURL !== undefined && typeof p.baseURL !== "string") ||
|
|
914
|
+
(p.apiKey !== undefined && typeof p.apiKey !== "string") ||
|
|
915
|
+
(p.clearApiKey !== undefined && typeof p.clearApiKey !== "boolean") ||
|
|
916
|
+
(p.activatePersonal !== undefined && typeof p.activatePersonal !== "boolean")) {
|
|
917
|
+
return reply(rpcError(id, ERR.PARAMS, "provider + model required; optional baseURL/apiKey/clearApiKey/activatePersonal have invalid types"));
|
|
918
|
+
}
|
|
919
|
+
const targetCwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
|
|
920
|
+
const input = {
|
|
921
|
+
provider: p.provider,
|
|
922
|
+
model: p.model,
|
|
923
|
+
...(p.baseURL !== undefined ? { baseURL: p.baseURL } : {}),
|
|
924
|
+
...(p.apiKey !== undefined ? { apiKey: p.apiKey } : {}),
|
|
925
|
+
...(p.clearApiKey !== undefined ? { clearApiKey: p.clearApiKey } : {}),
|
|
926
|
+
...(p.activatePersonal !== undefined ? { activatePersonal: p.activatePersonal } : {}),
|
|
927
|
+
};
|
|
928
|
+
const result = await callback(input, targetCwd);
|
|
929
|
+
return reply(rpcResult(id, redactSensitiveValue(result).value));
|
|
930
|
+
}
|
|
829
931
|
case "session.set-model": {
|
|
830
932
|
// per-session model / thinking-effort switch (the composer picker). Rebuilds the session's
|
|
831
933
|
// provider; takes effect on the NEXT turn. Refused mid-turn.
|
|
@@ -989,9 +1091,9 @@ export async function startServe(opts, deps) {
|
|
|
989
1091
|
return reply(rpcResult(id, { sessionId: s.meta.id, ctx: ctxOf(s), notes: s.meta.workingSet?.length ?? 0, history: historyForClient(s.history) }));
|
|
990
1092
|
}
|
|
991
1093
|
finally {
|
|
992
|
-
s.busy = false;
|
|
993
1094
|
if (s.abort === compactAbort)
|
|
994
1095
|
s.abort = null;
|
|
1096
|
+
s.busy = s.pendingProviderTurns > 0 || s.pendingToolRuns > 0;
|
|
995
1097
|
}
|
|
996
1098
|
}
|
|
997
1099
|
case "session.rewind": {
|
|
@@ -1018,7 +1120,7 @@ export async function startServe(opts, deps) {
|
|
|
1018
1120
|
}
|
|
1019
1121
|
}
|
|
1020
1122
|
catch (e) {
|
|
1021
|
-
return reply(rpcError(id, ERR.INTERNAL, String(e?.message ?? e)));
|
|
1123
|
+
return reply(rpcError(id, ERR.INTERNAL, redactSensitiveText(String(e?.message ?? e)).text));
|
|
1022
1124
|
}
|
|
1023
1125
|
})();
|
|
1024
1126
|
inFlightRequests.add(task);
|
|
@@ -1077,7 +1179,9 @@ export async function startServe(opts, deps) {
|
|
|
1077
1179
|
await removeOwnedDiscovery(discoveryDir, discoveryPath, discovery).catch(() => { });
|
|
1078
1180
|
let quiet = false;
|
|
1079
1181
|
while (Date.now() < deadline) {
|
|
1080
|
-
if (inFlightRequests.size === 0 &&
|
|
1182
|
+
if (inFlightRequests.size === 0 &&
|
|
1183
|
+
activeOperations.size === 0 &&
|
|
1184
|
+
hub.active().every((session) => !session.busy && !session.configuring && session.pendingProviderTurns === 0 && session.pendingToolRuns === 0)) {
|
|
1081
1185
|
quiet = true;
|
|
1082
1186
|
break;
|
|
1083
1187
|
}
|
package/dist/serve/sessions.js
CHANGED
|
@@ -95,7 +95,12 @@ export class SessionHub {
|
|
|
95
95
|
* when resume attached successfully but live provider validation failed before the client got a handle. */
|
|
96
96
|
detach(id) {
|
|
97
97
|
const live = this.sessions.get(id);
|
|
98
|
-
if (!live ||
|
|
98
|
+
if (!live ||
|
|
99
|
+
live.busy ||
|
|
100
|
+
live.configuring ||
|
|
101
|
+
live.abort !== null ||
|
|
102
|
+
live.pendingProviderTurns > 0 ||
|
|
103
|
+
live.pendingToolRuns > 0)
|
|
99
104
|
return false;
|
|
100
105
|
this.sessions.delete(id);
|
|
101
106
|
this.store.release(id);
|
|
@@ -213,7 +218,11 @@ export class SessionHub {
|
|
|
213
218
|
* "gone" on success, "busy" when a turn is running, "missing" when unknown/held elsewhere. */
|
|
214
219
|
delete(id) {
|
|
215
220
|
const live = this.sessions.get(id);
|
|
216
|
-
if (live?.busy ||
|
|
221
|
+
if (live?.busy ||
|
|
222
|
+
live?.configuring ||
|
|
223
|
+
(live?.abort ?? null) !== null ||
|
|
224
|
+
(live?.pendingProviderTurns ?? 0) > 0 ||
|
|
225
|
+
(live?.pendingToolRuns ?? 0) > 0)
|
|
217
226
|
return "busy";
|
|
218
227
|
const ok = this.store.delete(id);
|
|
219
228
|
if (!ok)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A dynamic physical-operation drain for session leases.
|
|
3
|
+
*
|
|
4
|
+
* A one-time `Promise.allSettled([...pending])` snapshot is insufficient: an observed outer tool may
|
|
5
|
+
* start and register a nested provider after shutdown begins. This drain checks the live Set after every
|
|
6
|
+
* settlement and releases only once it remains empty at a microtask boundary.
|
|
7
|
+
*/
|
|
8
|
+
export function createPhysicalOperationDrain(onDrained) {
|
|
9
|
+
const pending = new Set();
|
|
10
|
+
let closing = false;
|
|
11
|
+
let drained = false;
|
|
12
|
+
let drainQueued = false;
|
|
13
|
+
const requestDrain = () => {
|
|
14
|
+
if (!closing || drained || drainQueued || pending.size > 0)
|
|
15
|
+
return;
|
|
16
|
+
drainQueued = true;
|
|
17
|
+
queueMicrotask(() => {
|
|
18
|
+
drainQueued = false;
|
|
19
|
+
if (!closing || drained || pending.size > 0)
|
|
20
|
+
return;
|
|
21
|
+
drained = true;
|
|
22
|
+
onDrained();
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
const observe = (operation) => {
|
|
26
|
+
if (drained) {
|
|
27
|
+
throw new Error("cannot observe a physical operation after its session lease drained");
|
|
28
|
+
}
|
|
29
|
+
pending.add(operation);
|
|
30
|
+
const settled = () => {
|
|
31
|
+
pending.delete(operation);
|
|
32
|
+
requestDrain();
|
|
33
|
+
};
|
|
34
|
+
void operation.then(settled, settled);
|
|
35
|
+
return operation;
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
observe,
|
|
39
|
+
close: () => {
|
|
40
|
+
closing = true;
|
|
41
|
+
requestDrain();
|
|
42
|
+
},
|
|
43
|
+
pendingCount: () => pending.size,
|
|
44
|
+
};
|
|
45
|
+
}
|
package/dist/tools/agent.js
CHANGED
|
@@ -31,6 +31,7 @@ registerTool({
|
|
|
31
31
|
required: ["task"],
|
|
32
32
|
},
|
|
33
33
|
kind: "read", // parallel-safe: multiple agent() calls in a turn run concurrently
|
|
34
|
+
concurrencySafe: true,
|
|
34
35
|
async run(input, ctx) {
|
|
35
36
|
if (!ctx.spawn)
|
|
36
37
|
return "Error: sub-agents are not available in this context.";
|
package/dist/tools/all.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// never empty when runAgent plans a turn — an unregistered tool is silently unplannable, which shows up
|
|
4
4
|
// as "the model called write_file and nothing happened".
|
|
5
5
|
import "./builtin.js"; // read_file / write_file / bash / job
|
|
6
|
+
import "./runtime.js"; // tool_search / tool_result_read
|
|
6
7
|
import "./edit.js"; // edit_file
|
|
7
8
|
import "./search.js"; // grep / glob / ls
|
|
8
9
|
import "./patch.js"; // apply_patch
|