@nanhara/hara 0.125.1 → 0.126.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 +76 -0
- package/README.md +22 -8
- package/SECURITY.md +6 -4
- package/dist/agent/limits.js +2 -2
- package/dist/agent/loop.js +413 -77
- 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 +439 -227
- package/dist/mcp/client.js +73 -6
- package/dist/providers/bounded-turn.js +6 -0
- package/dist/providers/factory.js +54 -0
- package/dist/providers/models.js +39 -6
- package/dist/providers/openai.js +6 -1
- package/dist/providers/reasoning.js +12 -0
- 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 +157 -40
- package/dist/serve/sessions.js +11 -2
- package/dist/session/operation-drain.js +45 -0
- package/dist/session/task.js +78 -0
- package/dist/statusbar.js +15 -2
- 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 +22 -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 +49 -17
- package/dist/tui/model-picker.js +11 -8
- package/package.json +1 -1
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;
|
|
@@ -239,11 +240,11 @@ export async function startServe(opts, deps) {
|
|
|
239
240
|
const token = opts.token ?? randomBytes(16).toString("hex");
|
|
240
241
|
const instanceId = randomUUID();
|
|
241
242
|
const hub = new SessionHub(deps.store ?? realStore);
|
|
242
|
-
const runtimeInfo = (cwd) => {
|
|
243
|
-
const live = deps.runtimeInfo?.(cwd);
|
|
243
|
+
const runtimeInfo = (cwd, model) => {
|
|
244
|
+
const live = deps.runtimeInfo?.(cwd, model);
|
|
244
245
|
return {
|
|
245
246
|
providerId: live?.providerId ?? deps.providerId,
|
|
246
|
-
model: live?.model ?? deps.model,
|
|
247
|
+
model: live?.model ?? model ?? deps.model,
|
|
247
248
|
effortLevels: live?.effortLevels ?? deps.effortLevels ?? [],
|
|
248
249
|
};
|
|
249
250
|
};
|
|
@@ -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,
|
|
@@ -429,34 +490,25 @@ export async function startServe(opts, deps) {
|
|
|
429
490
|
memory: memoryDigest(s.meta.cwd),
|
|
430
491
|
continuationSession: s.continuationSession,
|
|
431
492
|
executionContext,
|
|
493
|
+
taskIntake: {
|
|
494
|
+
task: s.task,
|
|
495
|
+
current: () => s.task,
|
|
496
|
+
onUpdate: (next) => {
|
|
497
|
+
s.task = next;
|
|
498
|
+
},
|
|
499
|
+
onCheckpoint: (next) => {
|
|
500
|
+
s.task = next;
|
|
501
|
+
hub.save(s);
|
|
502
|
+
},
|
|
503
|
+
},
|
|
432
504
|
pendingInput: async () => {
|
|
433
505
|
materializePendingSteering(s); // helper updates the shared live history after its write-ahead save
|
|
434
506
|
return [];
|
|
435
507
|
},
|
|
436
508
|
stats: s.stats,
|
|
437
509
|
signal: turnAbort.signal,
|
|
438
|
-
onProviderTurn: (turn) =>
|
|
439
|
-
|
|
440
|
-
const settled = () => {
|
|
441
|
-
s.pendingProviderTurns = Math.max(0, s.pendingProviderTurns - 1);
|
|
442
|
-
if (closing)
|
|
443
|
-
hub.releaseIdle();
|
|
444
|
-
};
|
|
445
|
-
void turn.then(settled, settled);
|
|
446
|
-
},
|
|
447
|
-
onToolRun: (toolRun) => {
|
|
448
|
-
s.pendingToolRuns += 1;
|
|
449
|
-
const settled = () => {
|
|
450
|
-
s.pendingToolRuns = Math.max(0, s.pendingToolRuns - 1);
|
|
451
|
-
// `abort === null` means the logical turn already returned. Keep the session busy/locked until
|
|
452
|
-
// every late side-effect-capable Promise has physically stopped.
|
|
453
|
-
if (s.pendingToolRuns === 0 && s.abort === null)
|
|
454
|
-
s.busy = false;
|
|
455
|
-
if (closing)
|
|
456
|
-
hub.releaseIdle();
|
|
457
|
-
};
|
|
458
|
-
void toolRun.then(settled, settled);
|
|
459
|
-
},
|
|
510
|
+
onProviderTurn: (turn) => observeProviderTurn(s, turn),
|
|
511
|
+
onToolRun: (toolRun) => observeToolRun(s, toolRun),
|
|
460
512
|
guardian: turnGuardian,
|
|
461
513
|
...(deps.runLimits?.(s.meta.cwd) ?? {}),
|
|
462
514
|
});
|
|
@@ -497,7 +549,7 @@ export async function startServe(opts, deps) {
|
|
|
497
549
|
}
|
|
498
550
|
finally {
|
|
499
551
|
s.abort = null;
|
|
500
|
-
s.busy = s.pendingToolRuns > 0;
|
|
552
|
+
s.busy = s.pendingProviderTurns > 0 || s.pendingToolRuns > 0;
|
|
501
553
|
}
|
|
502
554
|
};
|
|
503
555
|
/** Context watermark for a session: how full the model's window was on the last turn. */
|
|
@@ -533,7 +585,7 @@ export async function startServe(opts, deps) {
|
|
|
533
585
|
if (controller.signal.aborted)
|
|
534
586
|
return onAbort();
|
|
535
587
|
// Promise.resolve protects this boundary even if a non-conforming provider throws synchronously.
|
|
536
|
-
|
|
588
|
+
const providerTurn = Promise.resolve().then(() => {
|
|
537
589
|
// The abort can fire after scheduling this microtask but before it runs. Gate the provider call at
|
|
538
590
|
// the actual invocation boundary so an interrupted/expired compact cannot start a late request.
|
|
539
591
|
if (controller.signal.aborted)
|
|
@@ -545,7 +597,9 @@ export async function startServe(opts, deps) {
|
|
|
545
597
|
onText: () => { },
|
|
546
598
|
signal: controller.signal,
|
|
547
599
|
});
|
|
548
|
-
})
|
|
600
|
+
});
|
|
601
|
+
observeProviderTurn(s, providerTurn);
|
|
602
|
+
void providerTurn.then((result) => finish(() => resolve(result)), (error) => finish(() => reject(error)));
|
|
549
603
|
});
|
|
550
604
|
if (controller.signal.aborted || r.stop === "error")
|
|
551
605
|
return null;
|
|
@@ -608,18 +662,46 @@ export async function startServe(opts, deps) {
|
|
|
608
662
|
// clients feature-detect up front instead of probing for -32601 per call. `p.capabilities`
|
|
609
663
|
// (client-declared) is accepted and currently unused — reserved for opt-outs/experimental gating.
|
|
610
664
|
const methods = [
|
|
665
|
+
"server.shutdown",
|
|
611
666
|
"session.list", "session.create", "session.resume", "session.send", "session.steer", "session.interrupt", "session.set-model",
|
|
612
667
|
"session.rename", "session.archive", "session.compact", "session.rewind", "session.context", "session.delete", "session.fork",
|
|
613
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",
|
|
614
670
|
"automation.list", "automation.add", "automation.toggle", "automation.delete",
|
|
615
671
|
"tasks.list", "approvals.list", "approvals.resolve",
|
|
616
672
|
];
|
|
617
673
|
const runtime = runtimeInfo();
|
|
618
|
-
|
|
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
|
+
}));
|
|
619
687
|
}
|
|
620
688
|
if (!authed.has(ws))
|
|
621
689
|
return reply(rpcError(id, ERR.UNAUTHORIZED, "initialize first"));
|
|
622
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
|
+
}
|
|
623
705
|
case "session.list":
|
|
624
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 })) }));
|
|
625
707
|
case "session.create": {
|
|
@@ -810,8 +892,41 @@ export async function startServe(opts, deps) {
|
|
|
810
892
|
const session = typeof p.sessionId === "string" ? hub.get(p.sessionId) : undefined;
|
|
811
893
|
const targetCwd = typeof p.cwd === "string" && p.cwd ? p.cwd : (session?.meta.cwd ?? opts.cwd);
|
|
812
894
|
const models = deps.listModels ? await deps.listModels(targetCwd).catch(() => []) : [];
|
|
813
|
-
const
|
|
814
|
-
|
|
895
|
+
const defaultRuntime = runtimeInfo(targetCwd);
|
|
896
|
+
const current = session?.meta.model ?? defaultRuntime.model;
|
|
897
|
+
const currentRuntime = runtimeInfo(targetCwd, current);
|
|
898
|
+
return reply(rpcResult(id, { models, current, effort: session?.effort ?? null, effortLevels: currentRuntime.effortLevels }));
|
|
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));
|
|
815
930
|
}
|
|
816
931
|
case "session.set-model": {
|
|
817
932
|
// per-session model / thinking-effort switch (the composer picker). Rebuilds the session's
|
|
@@ -976,9 +1091,9 @@ export async function startServe(opts, deps) {
|
|
|
976
1091
|
return reply(rpcResult(id, { sessionId: s.meta.id, ctx: ctxOf(s), notes: s.meta.workingSet?.length ?? 0, history: historyForClient(s.history) }));
|
|
977
1092
|
}
|
|
978
1093
|
finally {
|
|
979
|
-
s.busy = false;
|
|
980
1094
|
if (s.abort === compactAbort)
|
|
981
1095
|
s.abort = null;
|
|
1096
|
+
s.busy = s.pendingProviderTurns > 0 || s.pendingToolRuns > 0;
|
|
982
1097
|
}
|
|
983
1098
|
}
|
|
984
1099
|
case "session.rewind": {
|
|
@@ -1005,7 +1120,7 @@ export async function startServe(opts, deps) {
|
|
|
1005
1120
|
}
|
|
1006
1121
|
}
|
|
1007
1122
|
catch (e) {
|
|
1008
|
-
return reply(rpcError(id, ERR.INTERNAL, String(e?.message ?? e)));
|
|
1123
|
+
return reply(rpcError(id, ERR.INTERNAL, redactSensitiveText(String(e?.message ?? e)).text));
|
|
1009
1124
|
}
|
|
1010
1125
|
})();
|
|
1011
1126
|
inFlightRequests.add(task);
|
|
@@ -1064,7 +1179,9 @@ export async function startServe(opts, deps) {
|
|
|
1064
1179
|
await removeOwnedDiscovery(discoveryDir, discoveryPath, discovery).catch(() => { });
|
|
1065
1180
|
let quiet = false;
|
|
1066
1181
|
while (Date.now() < deadline) {
|
|
1067
|
-
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)) {
|
|
1068
1185
|
quiet = true;
|
|
1069
1186
|
break;
|
|
1070
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/session/task.js
CHANGED
|
@@ -3,6 +3,9 @@ export const TASK_SCHEMA_VERSION = 1;
|
|
|
3
3
|
export const MAX_TASK_OBJECTIVE_CHARS = 4096;
|
|
4
4
|
export const MAX_TASK_STEERING_CHARS = 24_000;
|
|
5
5
|
export const MAX_TASK_STEERING_ENTRIES = 24;
|
|
6
|
+
export const MAX_TASK_BRIEF_GOAL_CHARS = 2_000;
|
|
7
|
+
export const MAX_TASK_BRIEF_LIST_ENTRIES = 12;
|
|
8
|
+
export const MAX_TASK_BRIEF_ITEM_CHARS = 800;
|
|
6
9
|
function iso(at = new Date()) {
|
|
7
10
|
return typeof at === "string" ? at : at.toISOString();
|
|
8
11
|
}
|
|
@@ -16,12 +19,42 @@ function validId(value) {
|
|
|
16
19
|
function validTimestamp(value) {
|
|
17
20
|
return typeof value === "string" && value.length > 0 && Number.isFinite(Date.parse(value));
|
|
18
21
|
}
|
|
22
|
+
function boundedList(value, fallback) {
|
|
23
|
+
if (!Array.isArray(value))
|
|
24
|
+
return [fallback];
|
|
25
|
+
const out = value
|
|
26
|
+
.filter((item) => typeof item === "string")
|
|
27
|
+
.map((item) => boundedText(item, MAX_TASK_BRIEF_ITEM_CHARS))
|
|
28
|
+
.filter(Boolean)
|
|
29
|
+
.slice(0, MAX_TASK_BRIEF_LIST_ENTRIES);
|
|
30
|
+
return out.length ? out : [fallback];
|
|
31
|
+
}
|
|
32
|
+
function validBriefList(value) {
|
|
33
|
+
return Array.isArray(value) &&
|
|
34
|
+
value.length > 0 &&
|
|
35
|
+
value.length <= MAX_TASK_BRIEF_LIST_ENTRIES &&
|
|
36
|
+
value.every((item) => typeof item === "string" && item.length > 0 && item.length <= MAX_TASK_BRIEF_ITEM_CHARS);
|
|
37
|
+
}
|
|
19
38
|
export function newTurnInteraction() {
|
|
20
39
|
return { kind: "turn", turnId: randomUUID() };
|
|
21
40
|
}
|
|
22
41
|
export function newSteerInteraction(expectedTurnId) {
|
|
23
42
|
return { kind: "steer", expectedTurnId, turnId: randomUUID() };
|
|
24
43
|
}
|
|
44
|
+
/** Resolve a UI-delivery hint against authoritative task state. `steer` is never itself proof that an
|
|
45
|
+
* executable task is running: controls also occupy the composer briefly, and a real turn may finish between
|
|
46
|
+
* enqueue and dequeue. Preserve the submitted turn id but promote late input to a new turn. Only an explicit
|
|
47
|
+
* continuation path may opt into reopening a paused/completed task; stale live-turn ids remain hard errors
|
|
48
|
+
* in `continueTaskExecution`. */
|
|
49
|
+
export function routeTaskInteraction(task, interaction, options = {}) {
|
|
50
|
+
const steerable = !!task && (task.status === "running" || options.allowInactive === true);
|
|
51
|
+
if (interaction.kind !== "steer" || steerable)
|
|
52
|
+
return { interaction, recoveredMissingTask: false };
|
|
53
|
+
return {
|
|
54
|
+
interaction: { kind: "turn", turnId: interaction.turnId },
|
|
55
|
+
recoveredMissingTask: true,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
25
58
|
export function createTaskExecution(objective, turnId, at = new Date()) {
|
|
26
59
|
const now = iso(at);
|
|
27
60
|
return {
|
|
@@ -35,6 +68,35 @@ export function createTaskExecution(objective, turnId, at = new Date()) {
|
|
|
35
68
|
startedAt: now,
|
|
36
69
|
};
|
|
37
70
|
}
|
|
71
|
+
/** Attach or revise the explicit understanding checkpoint. Revision is intentional: steering may add a
|
|
72
|
+
* constraint or convert an investigation into an approved change, while the original request remains intact. */
|
|
73
|
+
export function applyTaskBrief(task, input, at = new Date()) {
|
|
74
|
+
if (!task)
|
|
75
|
+
return { ok: false, reason: "there is no task to brief" };
|
|
76
|
+
if (task.status !== "running")
|
|
77
|
+
return { ok: false, reason: `task ${task.id} is ${task.status}, not running` };
|
|
78
|
+
const intent = input.intent;
|
|
79
|
+
if (intent !== "answer" && intent !== "investigate" && intent !== "change") {
|
|
80
|
+
return { ok: false, reason: "intent must be answer, investigate, or change" };
|
|
81
|
+
}
|
|
82
|
+
if (typeof input.goal !== "string" || !input.goal.trim()) {
|
|
83
|
+
return { ok: false, reason: "goal must be a non-empty string" };
|
|
84
|
+
}
|
|
85
|
+
const now = iso(at);
|
|
86
|
+
const brief = {
|
|
87
|
+
intent,
|
|
88
|
+
goal: boundedText(input.goal, MAX_TASK_BRIEF_GOAL_CHARS),
|
|
89
|
+
constraints: boundedList(input.constraints, "preserve unrelated user work and stated boundaries"),
|
|
90
|
+
acceptance: boundedList(input.acceptance, intent === "change" ? "the requested change is verified" : "the answer is supported by relevant evidence"),
|
|
91
|
+
steps: boundedList(input.steps, intent === "change" ? "inspect, change, and verify" : "inspect and report"),
|
|
92
|
+
createdAt: now,
|
|
93
|
+
};
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
brief,
|
|
97
|
+
task: { ...task, brief, updatedAt: now },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
38
100
|
export function continueTaskExecution(task, interaction, at = new Date()) {
|
|
39
101
|
if (!task)
|
|
40
102
|
return { ok: false, reason: "there is no task to steer" };
|
|
@@ -171,6 +233,9 @@ export function taskExecutionContext(task, interaction, todos = []) {
|
|
|
171
233
|
steeringNote,
|
|
172
234
|
"Conversation messages provide evidence and refinements, but the task objective above remains authoritative until an explicit new task starts.",
|
|
173
235
|
];
|
|
236
|
+
// The accepted brief is deliberately absent here. `taskExecutionContext` is the stable per-interaction
|
|
237
|
+
// identity/recovery snapshot, while runAgent composes the current brief dynamically on every model round.
|
|
238
|
+
// Duplicating it here would leave the pre-run version in the prompt after a mid-run task_intake revision.
|
|
174
239
|
if (todos.length) {
|
|
175
240
|
lines.push("## Persisted execution checkpoint", ...todos.slice(0, 24).map((todo) => {
|
|
176
241
|
const mark = todo.status === "done" ? "done" : todo.status === "in_progress" ? "in progress" : "pending";
|
|
@@ -189,6 +254,7 @@ export function formatTaskExecution(task) {
|
|
|
189
254
|
`task ${task.id.slice(0, 8)} · ${task.status}`,
|
|
190
255
|
`turn ${task.turnId.slice(0, 8)} · outcome ${task.lastOutcome ?? "running"}`,
|
|
191
256
|
`objective: ${task.objective}`,
|
|
257
|
+
`brief: ${task.brief ? `${task.brief.intent} · ${task.brief.goal}` : "(not accepted yet)"}`,
|
|
192
258
|
`steering: ${task.steering?.length ?? 0}`,
|
|
193
259
|
].join("\n");
|
|
194
260
|
}
|
|
@@ -205,6 +271,18 @@ export function isTaskExecution(value) {
|
|
|
205
271
|
(task.endedAt !== undefined && !validTimestamp(task.endedAt)) ||
|
|
206
272
|
(task.lastOutcome !== undefined && task.lastOutcome !== "completed" && task.lastOutcome !== "error" && task.lastOutcome !== "empty" && task.lastOutcome !== "halted" && task.lastOutcome !== "interrupted"))
|
|
207
273
|
return false;
|
|
274
|
+
if (task.brief !== undefined) {
|
|
275
|
+
if (!task.brief || typeof task.brief !== "object" || Array.isArray(task.brief))
|
|
276
|
+
return false;
|
|
277
|
+
const brief = task.brief;
|
|
278
|
+
if ((brief.intent !== "answer" && brief.intent !== "investigate" && brief.intent !== "change") ||
|
|
279
|
+
typeof brief.goal !== "string" || brief.goal.length === 0 || brief.goal.length > MAX_TASK_BRIEF_GOAL_CHARS ||
|
|
280
|
+
!validBriefList(brief.constraints) ||
|
|
281
|
+
!validBriefList(brief.acceptance) ||
|
|
282
|
+
!validBriefList(brief.steps) ||
|
|
283
|
+
!validTimestamp(brief.createdAt))
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
208
286
|
if (task.steering === undefined)
|
|
209
287
|
return true;
|
|
210
288
|
if (!Array.isArray(task.steering) || task.steering.length > MAX_TASK_STEERING_ENTRIES)
|
package/dist/statusbar.js
CHANGED
|
@@ -13,10 +13,23 @@ const fmtTok = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
|
|
|
13
13
|
const truncate = (s, max) => (s.length <= max ? s : s.slice(0, Math.max(0, max - 1)) + "…");
|
|
14
14
|
const rule = (n) => c.dim("─".repeat(Math.max(0, n)));
|
|
15
15
|
export function contextWindow(model) {
|
|
16
|
-
|
|
16
|
+
// Provider prefixes are common (`qwen/qwen3.7-plus`). Match the actual model id and prefer exact
|
|
17
|
+
// documented Coding Plan families over broad words such as "coder"/"max": those previously labeled
|
|
18
|
+
// qwen3-coder-next and qwen3-max as 1M, causing the context guard to overfeed their 262k windows.
|
|
19
|
+
const m = model.toLowerCase().split("/").at(-1) ?? model.toLowerCase();
|
|
17
20
|
if (/haiku/.test(m))
|
|
18
21
|
return 200_000;
|
|
19
|
-
if (
|
|
22
|
+
if (/^qwen3\.[567]-plus(?:-|$)/.test(m) || /^qwen3-coder-plus(?:-|$)/.test(m))
|
|
23
|
+
return 1_000_000;
|
|
24
|
+
if (/^(?:qwen3-max-2026-01-23|qwen3-coder-next)(?:-|$)/.test(m) || /^kimi-k2\.5(?:-|$)/.test(m))
|
|
25
|
+
return 262_144;
|
|
26
|
+
if (/^glm-(?:5|4\.7)(?:-|$)/.test(m))
|
|
27
|
+
return 202_752;
|
|
28
|
+
if (/^minimax-m2\.5(?:-|$)/.test(m))
|
|
29
|
+
return 196_608;
|
|
30
|
+
if (/qwen3\.6[-:]27b/.test(m))
|
|
31
|
+
return 262_144;
|
|
32
|
+
if (/(opus|sonnet|fable)|claude-4|1m/.test(m))
|
|
20
33
|
return 1_000_000;
|
|
21
34
|
return 200_000;
|
|
22
35
|
}
|
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
|
package/dist/tools/ask_user.js
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
// the TUI. In headless / non-TTY / `-p` / gateway runs there is no interactive user (ctx.ask is absent) — the
|
|
7
7
|
// tool returns a clear "proceed with your best judgment" string instead of hanging. kind:"read" so it never
|
|
8
8
|
// itself triggers the approval gate (the interaction IS the prompt).
|
|
9
|
-
import { registerTool } from "./registry.js";
|
|
9
|
+
import { getTool, registerTool } from "./registry.js";
|
|
10
10
|
/** Returned when nobody can answer (headless / non-TTY / -p / gateway / sub-agent). Phrased so the model
|
|
11
11
|
* keeps going on its own judgment rather than re-asking or stalling. */
|
|
12
12
|
export const NO_INTERACTIVE_USER = "(no interactive user available — proceed with your best judgment)";
|
|
13
|
-
|
|
13
|
+
const definition = {
|
|
14
14
|
name: "ask_user",
|
|
15
15
|
description: "Ask the user ONE structured question mid-turn and wait for their answer, then continue. " +
|
|
16
16
|
"Use this ONLY when you are genuinely blocked on a decision that ONLY the user can make — an ambiguous " +
|
|
@@ -23,6 +23,7 @@ registerTool({
|
|
|
23
23
|
"In a non-interactive run (no terminal) it returns a 'proceed with your best judgment' note instead of " +
|
|
24
24
|
"blocking, so prefer making a reasonable call over asking when context already answers the question.",
|
|
25
25
|
kind: "read", // the prompt itself is the interaction; never route it through the approval gate
|
|
26
|
+
classify: () => ({ effect: "interactive", concurrencySafe: false }),
|
|
26
27
|
input_schema: {
|
|
27
28
|
type: "object",
|
|
28
29
|
properties: {
|
|
@@ -65,4 +66,8 @@ registerTool({
|
|
|
65
66
|
return `${NO_INTERACTIVE_USER} (ask failed: ${e?.message ?? e})`;
|
|
66
67
|
}
|
|
67
68
|
},
|
|
68
|
-
}
|
|
69
|
+
};
|
|
70
|
+
registerTool(definition);
|
|
71
|
+
/** Exact registered identity used by the agent loop to grant the engine-owned prompt its pausable
|
|
72
|
+
* active-budget semantics. A plugin that later shadows the public name does not inherit that authority. */
|
|
73
|
+
export const askUserTool = getTool(definition.name);
|
package/dist/tools/builtin.js
CHANGED
|
@@ -13,6 +13,7 @@ import { BinaryFileError, readVerifiedRegularFileSnapshot, resolveVerifiedModelP
|
|
|
13
13
|
import { startJob, listJobs, tailJob, killJob } from "../exec/jobs.js";
|
|
14
14
|
import { sensitiveFileError, sensitiveShellCommandReason } from "../security/sensitive-files.js";
|
|
15
15
|
import { createToolOutputLineRedactor, redactToolSubprocessOutput } from "../security/subprocess-env.js";
|
|
16
|
+
import { isReadOnlyCommand, splitCompound } from "../security/permissions.js";
|
|
16
17
|
import { hostsInCommand, isNetworkGitOp, hostFromConnectError, isConnectFailure, markHostUnreachable, isHostUnreachable, unreachableHostsSnapshot, } from "./net-reachability.js";
|
|
17
18
|
const MAX = 100_000;
|
|
18
19
|
/** Package installs are network-bound and routinely exceed the ordinary foreground cap. */
|
|
@@ -123,6 +124,7 @@ registerTool({
|
|
|
123
124
|
required: ["path"],
|
|
124
125
|
},
|
|
125
126
|
kind: "read",
|
|
127
|
+
concurrencySafe: true,
|
|
126
128
|
async run(input, ctx) {
|
|
127
129
|
const p = abs(input.path, ctx.cwd);
|
|
128
130
|
const denied = sensitiveFileError(p, "read");
|
|
@@ -211,8 +213,21 @@ registerTool({
|
|
|
211
213
|
required: ["command"],
|
|
212
214
|
},
|
|
213
215
|
kind: "exec",
|
|
216
|
+
classify(input) {
|
|
217
|
+
const command = typeof input?.command === "string" ? input.command : "";
|
|
218
|
+
const parts = command ? splitCompound(command) : null;
|
|
219
|
+
const readOnly = !Boolean(input?.background)
|
|
220
|
+
&& !!parts?.length
|
|
221
|
+
&& parts.every(isReadOnlyCommand);
|
|
222
|
+
return readOnly
|
|
223
|
+
? { effect: "read", concurrencySafe: true }
|
|
224
|
+
: { effect: "exec", concurrencySafe: false };
|
|
225
|
+
},
|
|
214
226
|
requiresProjectWorkspace: true,
|
|
215
227
|
async run(input, ctx) {
|
|
228
|
+
if (input.background !== undefined && typeof input.background !== "boolean") {
|
|
229
|
+
return "Error: `background` must be a boolean (true or false), not a string or another truthy value.";
|
|
230
|
+
}
|
|
216
231
|
const protectedReason = sensitiveShellCommandReason(String(input.command ?? ""), ctx.cwd);
|
|
217
232
|
if (protectedReason) {
|
|
218
233
|
return (`Blocked: shell command crosses Hara's protected secret boundary (${protectedReason}). ` +
|
|
@@ -307,7 +322,13 @@ registerTool({
|
|
|
307
322
|
},
|
|
308
323
|
required: ["action"],
|
|
309
324
|
},
|
|
310
|
-
kind: "
|
|
325
|
+
kind: "exec", // conservative default; list/tail are downgraded per input, kill remains approval-gated
|
|
326
|
+
classify(input) {
|
|
327
|
+
const action = String(input?.action ?? "");
|
|
328
|
+
return action === "list" || action === "tail"
|
|
329
|
+
? { effect: "read", concurrencySafe: true }
|
|
330
|
+
: { effect: "exec", concurrencySafe: false, destructive: action === "kill" };
|
|
331
|
+
},
|
|
311
332
|
async run(input) {
|
|
312
333
|
const action = String(input.action);
|
|
313
334
|
if (action === "list") {
|