agentus 0.1.0 → 0.3.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/README.md +19 -2
- package/README.zh-CN.md +396 -0
- package/docs/dev/01-dev-loop.md +186 -0
- package/docs/dev/02-verify.md +237 -0
- package/docs/dev/03-release.md +218 -0
- package/docs/dev/04-recover.md +199 -0
- package/docs/dev/05-agent-driven.md +114 -0
- package/docs/dev/README.md +65 -0
- package/docs/installation.md +366 -0
- package/docs/installation.zh-CN.md +350 -0
- package/package.json +5 -4
- package/packages/server/mock/agent.mjs +35 -0
- package/packages/server/src/acp/registry.ts +51 -2
- package/packages/server/src/acp/session-manager.ts +200 -39
- package/packages/server/src/fs.ts +216 -2
- package/packages/server/src/index.ts +172 -15
- package/packages/server/src/notify/center.ts +12 -1
- package/packages/server/src/pty-helper.py +135 -0
- package/packages/server/src/settings.ts +64 -1
- package/packages/server/src/store/store.ts +35 -3
- package/packages/server/src/term.ts +69 -28
- package/packages/shared/src/index.ts +11 -1
- package/packages/web/dist/assets/index-C5XlgEcj.js +283 -0
- package/packages/web/dist/assets/index-D667PX2c.css +32 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-B52l55_4.css +0 -1
- package/packages/web/dist/assets/index-BRgC7oYt.js +0 -246
|
@@ -14,13 +14,13 @@ import { exportFilename, renderJson, renderMarkdown, type ExportSessionHeader }
|
|
|
14
14
|
import { SessionManager, SessionUnavailable } from "./acp/session-manager.js";
|
|
15
15
|
import { describeAcpError } from "./acp/errors.js";
|
|
16
16
|
import { BACKENDS } from "./acp/backends.js";
|
|
17
|
-
import { classifyError, checkedHealth, coerceRow, inspectRow, planFor, seedRows, startupCheck } from "./acp/registry.js";
|
|
18
|
-
import { FsError, listDirs, readTextFile } from "./fs.js";
|
|
17
|
+
import { classifyError, checkedHealth, coerceRow, inspectRow, planFor, rowWarnings, seedRows, startupCheck } from "./acp/registry.js";
|
|
18
|
+
import { ChangedOnDiskError, FsError, listDirs, readTextFile, statFile, writeTextFile } from "./fs.js";
|
|
19
19
|
import { terms } from "./term.js";
|
|
20
20
|
import { VoiceError, listVoiceModels, setHotwordSource, synthesize, transcribe, voiceCapabilities } from "./voice.js";
|
|
21
21
|
import { hotwordsFor, vocabularyOf } from "./hotwords.js";
|
|
22
22
|
import { openDashscopeStream } from "./dashscope.js";
|
|
23
|
-
import { initSettings, publicSettings, saveCall, savePrefs, saveSettings, saveTheme } from "./settings.js";
|
|
23
|
+
import { agentSettings, initSettings, publicSettings, saveAgent, saveCall, savePrefs, saveSettings, saveTheme } from "./settings.js";
|
|
24
24
|
import * as auth from "./auth.js";
|
|
25
25
|
import { NotifyCenter } from "./notify/center.js";
|
|
26
26
|
import type { BackendId, ClientCommand, PermissionDecision, PromptAttachment, ServerEvent } from "../../shared/src/index.js";
|
|
@@ -169,9 +169,19 @@ const mgr = new SessionManager(store, emit);
|
|
|
169
169
|
|
|
170
170
|
/** The session list every client stays current with: the rows PLUS what is already waiting for
|
|
171
171
|
* an answer. Pending approvals are STATE, not just an event — a page that connects or refreshes
|
|
172
|
-
* after the request was raised still has to be able to show it.
|
|
172
|
+
* after the request was raised still has to be able to show it.
|
|
173
|
+
*
|
|
174
|
+
* Three buckets, because the rail has three answers to 「这个会话在哪」 and one field cannot carry
|
|
175
|
+
* them: `sessions` = live (a process is attached), `cold` = on disk with no process and NOT
|
|
176
|
+
* archived (they belong in 工作空间 too — a crashed slot must not look archived), `archived` = the
|
|
177
|
+
* operator put it away. Each row also carries `cold`/`archivedAt`, so a client never has to infer
|
|
178
|
+
* state from which list a row arrived in (that inference is exactly what merged 已归档 with 死了). */
|
|
173
179
|
const sessionsEvent = (): ServerEvent => ({
|
|
174
|
-
t: "sessions",
|
|
180
|
+
t: "sessions",
|
|
181
|
+
sessions: mgr.list(),
|
|
182
|
+
cold: mgr.cold(),
|
|
183
|
+
archived: mgr.archived(),
|
|
184
|
+
pending: mgr.pendingPermissions(),
|
|
175
185
|
});
|
|
176
186
|
|
|
177
187
|
// ---- notify channel (docs/android-notify-contract.md) ----------------------------
|
|
@@ -234,6 +244,43 @@ initSettings(DATA_DIR, store);
|
|
|
234
244
|
// so the lookup is injected rather than imported.
|
|
235
245
|
setHotwordSource((sessionId) => hotwordsFor(store, sessionId).map((h) => h.word));
|
|
236
246
|
|
|
247
|
+
// ---- idle reclaim (AionUi parity: 设置 → 系统 → 「Agent 空闲超时(分钟)」) ---------------------
|
|
248
|
+
// The operator's standing answer to 「一个 ready 了好几个小时的槽位还挂着 agent 进程」. It STOPS the
|
|
249
|
+
// process and nothing else: the row stays in 工作空间 wearing `reaped`, one click brings it back, and
|
|
250
|
+
// its transcript is untouched — reaping is not archiving (that is the operator's own decision now).
|
|
251
|
+
//
|
|
252
|
+
// The threshold is read on every tick, so changing it in 设置 applies immediately (no restart — a
|
|
253
|
+
// restart would kill exactly the slots this feature exists to keep alive cheaply). Two env knobs for
|
|
254
|
+
// QA: AGENTUS_IDLE_KILL_MIN accepts a FRACTION of a minute, which is how the reclaim path is
|
|
255
|
+
// exercised in seconds instead of in five minutes (AionUi made its own timeouts env-configurable for
|
|
256
|
+
// the same reason), and AGENTUS_IDLE_SWEEP_MS moves the tick.
|
|
257
|
+
{
|
|
258
|
+
// ABSENT must mean "follow 设置", not 0 — `Number("")` is 0, which would silently disable the
|
|
259
|
+
// reaper on every instance that never sets the env (measured: the startup line said 「0 min」 on an
|
|
260
|
+
// instance whose setting was 5). Only a real, non-empty value is an override.
|
|
261
|
+
const envNum = (raw: string | undefined): number | null => {
|
|
262
|
+
const s = String(raw ?? "").trim();
|
|
263
|
+
if (!s) return null;
|
|
264
|
+
const n = Number(s);
|
|
265
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
266
|
+
};
|
|
267
|
+
const envIdle = envNum(process.env.AGENTUS_IDLE_KILL_MIN);
|
|
268
|
+
const envIdleMs = envIdle != null ? envIdle * 60_000 : null;
|
|
269
|
+
const envSweep = envNum(process.env.AGENTUS_IDLE_SWEEP_MS);
|
|
270
|
+
const sweepMs = envSweep != null && envSweep >= 1000 ? envSweep : 30_000;
|
|
271
|
+
const source = envIdleMs != null ? "AGENTUS_IDLE_KILL_MIN" : "settings.agent.idleKillMin";
|
|
272
|
+
console.log(
|
|
273
|
+
`[agentus] idle reclaim: ${
|
|
274
|
+
envIdleMs != null ? envIdle : agentSettings().idleKillMin
|
|
275
|
+
} min (${source}), sweep ${Math.round(sweepMs / 1000)}s, 0 = off`,
|
|
276
|
+
);
|
|
277
|
+
mgr.startIdleReaper({
|
|
278
|
+
idleMs: () => envIdleMs ?? agentSettings().idleKillMin * 60_000,
|
|
279
|
+
watched: (id) => notify.operatorWatching(id),
|
|
280
|
+
intervalMs: sweepMs,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
237
284
|
/** The tail of the session being dictated into, for DashScope's context enhancement
|
|
238
285
|
* ("根据上下文"): user/assistant turns only, capped at 5 by the API. */
|
|
239
286
|
function dictationContext(sessionId?: string): { role: "user" | "assistant"; text: string }[] {
|
|
@@ -620,7 +667,10 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
620
667
|
// settings page shows it next to the row: it is the one declaration an operator may
|
|
621
668
|
// want to flip when an agent gains (or loses) native plan support.
|
|
622
669
|
nativePlanSource: row.nativePlanSource,
|
|
623
|
-
|
|
670
|
+
// Which permission mode a NEW session from this row starts in (null = the agent's own
|
|
671
|
+
// default); the settings page edits it next to the other row declarations.
|
|
672
|
+
defaultMode: row.defaultMode,
|
|
673
|
+
warnings: rowWarnings(row),
|
|
624
674
|
health: row.health, handshake: row.handshake,
|
|
625
675
|
};
|
|
626
676
|
}),
|
|
@@ -633,7 +683,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
633
683
|
if (store.getBackend(row.id)) return send(res, 409, { error: `backend ${row.id} already exists` });
|
|
634
684
|
store.upsertBackend(row);
|
|
635
685
|
const plan = planFor(row);
|
|
636
|
-
return send(res, 201, { ok: true, id: row.id, warnings:
|
|
686
|
+
return send(res, 201, { ok: true, id: row.id, warnings: rowWarnings(row) });
|
|
637
687
|
}
|
|
638
688
|
const beMatch = url.pathname.match(/^\/api\/backends\/([\w.-]+)(\/.*)?$/);
|
|
639
689
|
if (beMatch) {
|
|
@@ -643,7 +693,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
643
693
|
if (!existing) return send(res, 404, { error: `unknown backend: ${beId}` });
|
|
644
694
|
if (req.method === "GET" && beSub === "") {
|
|
645
695
|
const plan = planFor(existing);
|
|
646
|
-
return send(res, 200, { ...existing, home: plan.home, warnings:
|
|
696
|
+
return send(res, 200, { ...existing, home: plan.home, warnings: rowWarnings(existing) });
|
|
647
697
|
}
|
|
648
698
|
if ((req.method === "PATCH" || req.method === "PUT") && beSub === "") {
|
|
649
699
|
const body = (await readJson(req)) as Record<string, unknown>;
|
|
@@ -651,7 +701,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
651
701
|
if (!row) return send(res, 400, { error });
|
|
652
702
|
store.upsertBackend(row);
|
|
653
703
|
const plan = planFor(row);
|
|
654
|
-
return send(res, 200, { ok: true, id: row.id, warnings:
|
|
704
|
+
return send(res, 200, { ok: true, id: row.id, warnings: rowWarnings(row) });
|
|
655
705
|
}
|
|
656
706
|
if (req.method === "DELETE" && beSub === "") {
|
|
657
707
|
// A row that OPEN sessions still name must stay: deleting it would leave them pointing
|
|
@@ -700,7 +750,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
700
750
|
if (url.pathname === "/api/sessions" && req.method === "GET") {
|
|
701
751
|
// `pending` rides along because it is STATE, not an event: a page that loads or refreshes
|
|
702
752
|
// after the request was raised never saw the event, and would otherwise show nothing.
|
|
703
|
-
return send(res, 200, { live: mgr.list(), archived: mgr.archived(), pending: mgr.pendingPermissions() });
|
|
753
|
+
return send(res, 200, { live: mgr.list(), cold: mgr.cold(), archived: mgr.archived(), pending: mgr.pendingPermissions() });
|
|
704
754
|
}
|
|
705
755
|
// The session's plan object — what the cockpit's card renders. STATE too, and the one piece of
|
|
706
756
|
// it that cannot be replayed: after the agent process died there are no frames left to read,
|
|
@@ -740,6 +790,79 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
740
790
|
throw e;
|
|
741
791
|
}
|
|
742
792
|
}
|
|
793
|
+
// What IS this file? The panel asks before reading: an image or a PDF should not be pulled
|
|
794
|
+
// through the text endpoint to learn that it is not text, and `kind` decides which renderer
|
|
795
|
+
// and whether an editor is offered. Cheap: a stat plus a hash, no bytes decoded.
|
|
796
|
+
if (url.pathname === "/api/fs/stat" && req.method === "GET") {
|
|
797
|
+
try {
|
|
798
|
+
return send(res, 200, statFile(url.searchParams.get("path")));
|
|
799
|
+
} catch (e) {
|
|
800
|
+
if (e instanceof FsError) {
|
|
801
|
+
return send(res, e.code === "not_found" ? 404 : 400, { error: e.message, code: e.code });
|
|
802
|
+
}
|
|
803
|
+
throw e;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
// The file's RAW BYTES, for the renderers that need the real thing: an <img> in a reply,
|
|
807
|
+
// a PDF in the preview panel, an audio/video tag. Before this existed there was no way to
|
|
808
|
+
// show a local image at all (the read endpoint decodes utf-8 text and calls binary files
|
|
809
|
+
// "binary file"), so every `` an agent wrote was a broken image.
|
|
810
|
+
//
|
|
811
|
+
// Same trust model as the endpoints above: any path the server process can read, because
|
|
812
|
+
// the operator can already point an agent at any directory and browse it via /api/fs/dirs.
|
|
813
|
+
// Behind the same auth gate. `inline` on purpose — a preview must not turn into a download.
|
|
814
|
+
if (url.pathname === "/api/fs/raw" && req.method === "GET") {
|
|
815
|
+
try {
|
|
816
|
+
const req0 = url.searchParams.get("path");
|
|
817
|
+
if (!req0) return send(res, 400, { error: "path is required", code: "bad_path" });
|
|
818
|
+
const info = statFile(req0);
|
|
819
|
+
res.writeHead(200, {
|
|
820
|
+
"content-type": info.contentType,
|
|
821
|
+
"content-length": String(info.size),
|
|
822
|
+
"cache-control": "no-store",
|
|
823
|
+
"content-disposition": "inline",
|
|
824
|
+
});
|
|
825
|
+
const stream = fs.createReadStream(info.path);
|
|
826
|
+
stream.on("error", () => res.destroy());
|
|
827
|
+
// The operator may close the panel mid-stream; an unhandled 'error' on the response
|
|
828
|
+
// would crash the process for a click that is not an error.
|
|
829
|
+
res.on("close", () => stream.destroy());
|
|
830
|
+
return stream.pipe(res);
|
|
831
|
+
} catch (e) {
|
|
832
|
+
if (e instanceof FsError) {
|
|
833
|
+
return send(res, e.code === "not_found" ? 404 : 400, { error: e.message, code: e.code });
|
|
834
|
+
}
|
|
835
|
+
throw e;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
// Save an edit made in the panel. 409 carries the CURRENT bytes when the file changed on
|
|
839
|
+
// disk while the operator was typing, so the UI can offer to reload instead of choosing
|
|
840
|
+
// silently whose work to lose (see writeTextFile).
|
|
841
|
+
if (url.pathname === "/api/fs/file" && (req.method === "PUT" || req.method === "POST")) {
|
|
842
|
+
const body = await readJson(req);
|
|
843
|
+
try {
|
|
844
|
+
const path0 = typeof body.path === "string" ? body.path : "";
|
|
845
|
+
if (!path0) return send(res, 400, { error: "path is required", code: "bad_path" });
|
|
846
|
+
const content = typeof body.content === "string" ? body.content : "";
|
|
847
|
+
const ifHash = typeof body.ifHash === "string" ? body.ifHash : undefined;
|
|
848
|
+
return send(res, 200, { ok: true, ...writeTextFile(path0, content, { ifHash }) });
|
|
849
|
+
} catch (e) {
|
|
850
|
+
if (e instanceof ChangedOnDiskError) {
|
|
851
|
+
return send(res, 409, {
|
|
852
|
+
error: "changed_on_disk",
|
|
853
|
+
path: e.target,
|
|
854
|
+
hash: e.currentHash,
|
|
855
|
+
content: e.currentContent,
|
|
856
|
+
truncated: e.truncated,
|
|
857
|
+
hint: "the file changed on disk since you opened it",
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
if (e instanceof FsError) {
|
|
861
|
+
return send(res, e.code === "not_found" ? 404 : 400, { error: e.message, code: e.code });
|
|
862
|
+
}
|
|
863
|
+
throw e;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
743
866
|
// The windows remembered per model (the settings page and scripts read this).
|
|
744
867
|
if (url.pathname === "/api/context-limits" && req.method === "GET") {
|
|
745
868
|
return send(res, 200, { limits: store.listModelLimits() });
|
|
@@ -756,7 +879,8 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
756
879
|
if (body.theme && typeof body.theme === "object") saveTheme(body.theme as Record<string, unknown>);
|
|
757
880
|
if (body.call && typeof body.call === "object") saveCall(body.call as Record<string, unknown>);
|
|
758
881
|
if (body.prefs && typeof body.prefs === "object") savePrefs(body.prefs as Record<string, unknown>);
|
|
759
|
-
if (
|
|
882
|
+
if (body.agent && typeof body.agent === "object") saveAgent(body.agent as Record<string, unknown>);
|
|
883
|
+
if (!body.voice && !body.theme && !body.call && !body.prefs && !body.agent) saveSettings(body);
|
|
760
884
|
return send(res, 200, publicSettings());
|
|
761
885
|
} catch (e) {
|
|
762
886
|
return send(res, 400, { error: String((e as Error)?.message ?? e) });
|
|
@@ -872,6 +996,30 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
872
996
|
}
|
|
873
997
|
return send(res, 404, { error: `no such session: ${id}` });
|
|
874
998
|
}
|
|
999
|
+
if (req.method === "POST" && sub === "/archive") {
|
|
1000
|
+
// 归档: the operator's own storage decision (and it stops a live process — see
|
|
1001
|
+
// SessionManager.setArchived). Distinct from DELETE, which on a LIVE slot only closes it and
|
|
1002
|
+
// on a COLD one purges the record: 归档 is the middle statement, and before this route the UI
|
|
1003
|
+
// had no way to make it.
|
|
1004
|
+
try {
|
|
1005
|
+
const info = await mgr.setArchived(id, true);
|
|
1006
|
+
emit(sessionsEvent());
|
|
1007
|
+
return send(res, 200, info);
|
|
1008
|
+
} catch (e) {
|
|
1009
|
+
return send(res, 404, { error: String((e as Error)?.message ?? e) });
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
if (req.method === "POST" && sub === "/unarchive") {
|
|
1013
|
+
// 取消归档: moves the flag only. Waking the slot is a SEPARATE /resume, so a restore that
|
|
1014
|
+
// cannot load leaves a visible cold slot in 工作空间 instead of an error nobody asked for.
|
|
1015
|
+
try {
|
|
1016
|
+
const info = await mgr.setArchived(id, false);
|
|
1017
|
+
emit(sessionsEvent());
|
|
1018
|
+
return send(res, 200, info);
|
|
1019
|
+
} catch (e) {
|
|
1020
|
+
return send(res, 404, { error: String((e as Error)?.message ?? e) });
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
875
1023
|
if (req.method === "POST" && sub === "/resume") {
|
|
876
1024
|
// AC5's other half: bring a cold slot back to life (respawn + loadSession)
|
|
877
1025
|
try {
|
|
@@ -1226,7 +1374,11 @@ termWss.on("connection", (ws: WebSocket, _req: IncomingMessage, url: URL) => {
|
|
|
1226
1374
|
}
|
|
1227
1375
|
let term: ReturnType<typeof terms.start> | null = null;
|
|
1228
1376
|
try {
|
|
1229
|
-
|
|
1377
|
+
// The client sends its viewport size on the socket URL: the pty must be born at the right size
|
|
1378
|
+
// (the shell reads it when it paints the first prompt). Later changes arrive as term-resize.
|
|
1379
|
+
const cols = Number(url.searchParams.get("cols") || 0);
|
|
1380
|
+
const rows = Number(url.searchParams.get("rows") || 0);
|
|
1381
|
+
term = terms.start(root.path, cols && rows ? { cols, rows } : undefined);
|
|
1230
1382
|
} catch (e) {
|
|
1231
1383
|
ws.send(JSON.stringify({ t: "term-error", error: String((e as Error)?.message ?? e) }));
|
|
1232
1384
|
ws.close();
|
|
@@ -1236,12 +1388,15 @@ termWss.on("connection", (ws: WebSocket, _req: IncomingMessage, url: URL) => {
|
|
|
1236
1388
|
const send = (msg: unknown): void => {
|
|
1237
1389
|
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
|
|
1238
1390
|
};
|
|
1239
|
-
|
|
1391
|
+
// The buffered tail goes out first, so a reconnect (or a second tab) lands on the scrollback the
|
|
1392
|
+
// way ttyd's RESUME does instead of a blank screen.
|
|
1393
|
+
if (session.buffer) send({ t: "term-data", id: session.id, data: session.buffer });
|
|
1394
|
+
send({ t: "term-ready", id: session.id, cwd: session.cwd, pid: session.pid, tty: session.tty });
|
|
1240
1395
|
const onData = (chunk: string): void => send({ t: "term-data", id: session.id, data: chunk });
|
|
1241
1396
|
session.subscribers.add(onData);
|
|
1242
1397
|
session.child.on("exit", () => send({ t: "term-exit", id: session.id }));
|
|
1243
1398
|
ws.on("message", (data) => {
|
|
1244
|
-
let cmd: { t?: string; data?: string };
|
|
1399
|
+
let cmd: { t?: string; data?: string; cols?: number; rows?: number };
|
|
1245
1400
|
try {
|
|
1246
1401
|
cmd = JSON.parse(String(data));
|
|
1247
1402
|
} catch {
|
|
@@ -1249,10 +1404,12 @@ termWss.on("connection", (ws: WebSocket, _req: IncomingMessage, url: URL) => {
|
|
|
1249
1404
|
}
|
|
1250
1405
|
if (cmd.t === "term-input" && typeof cmd.data === "string") {
|
|
1251
1406
|
try {
|
|
1252
|
-
session.child.stdin
|
|
1407
|
+
session.child.stdin?.write(cmd.data);
|
|
1253
1408
|
} catch (e) {
|
|
1254
1409
|
send({ t: "term-error", error: `write failed: ${String((e as Error)?.message ?? e)}` });
|
|
1255
1410
|
}
|
|
1411
|
+
} else if (cmd.t === "term-resize" && typeof cmd.cols === "number" && typeof cmd.rows === "number") {
|
|
1412
|
+
session.resize(cmd.cols, cmd.rows);
|
|
1256
1413
|
} else if (cmd.t === "term-close") {
|
|
1257
1414
|
terms.kill(session.id);
|
|
1258
1415
|
ws.close();
|
|
@@ -197,7 +197,18 @@ export class NotifyCenter {
|
|
|
197
197
|
|
|
198
198
|
/** True when a notification for this session would interrupt the operator looking at it. */
|
|
199
199
|
private watching(sessionId: string): boolean {
|
|
200
|
-
|
|
200
|
+
return this.rules().quietWhenWatching && this.operatorWatching(sessionId);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Is the operator looking at this session RIGHT NOW? A fresh presence report (the SPA re-reports
|
|
204
|
+
* every few seconds) from a visible page or a live call.
|
|
205
|
+
*
|
|
206
|
+
* Public and rule-free on purpose — the ONE question two features share with different gates.
|
|
207
|
+
* `watching()` above answers "should notification be quiet?" and therefore consults the operator's
|
|
208
|
+
* quietWhenWatching preference; the idle reaper asks "would killing this slot's process pull the
|
|
209
|
+
* rug out from under a reply he is reading?", and routing THAT through a notification preference
|
|
210
|
+
* would let a checkbox decide whether agent processes get killed. */
|
|
211
|
+
operatorWatching(sessionId: string): boolean {
|
|
201
212
|
if (this.presence.sessionId !== sessionId) return false;
|
|
202
213
|
if (Date.now() - this.presence.at >= NotifyCenter.WATCH_TTL_MS) return false;
|
|
203
214
|
// A call counts even when nobody has touched the screen for minutes: on a call the hands are free
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run one command on a REAL pseudo-terminal and shovel bytes between it and stdio.
|
|
3
|
+
|
|
4
|
+
This is the whole trick behind a web terminal (ttyd does the same thing in C): `forkpty` gives the
|
|
5
|
+
child a controlling terminal, so the shell believes a human is at the keyboard — line editing,
|
|
6
|
+
prompts, colours, job control, full-screen programs (vim, top) all work. Without it (pipe stdio)
|
|
7
|
+
the shell is in non-interactive mode: no prompt on screen, no echo, no cursor, SIGWINCH meaningless,
|
|
8
|
+
and any terminal emulator in front of it is a lie.
|
|
9
|
+
|
|
10
|
+
Why a Python helper instead of node-pty: node-pty is a build-time native module and this project
|
|
11
|
+
stays dependency-free on install. Python 3 is present on macOS and essentially every Linux; its
|
|
12
|
+
stdlib already has forkpty + termios, which is all a PTY needs. If python3 is missing the server
|
|
13
|
+
falls back to pipes and says so in the panel.
|
|
14
|
+
|
|
15
|
+
Wire format, all raw bytes:
|
|
16
|
+
stdin (fd 0) -> keystrokes from the browser, written straight to the PTY master
|
|
17
|
+
stdout (fd 1) <- everything the PTY produces, written straight out (no filtering, no ANSI
|
|
18
|
+
stripping: a terminal emulator consumes the escape sequences, a <pre> cannot)
|
|
19
|
+
fd 3 <- control channel, one command per line: "resize <cols> <rows>"
|
|
20
|
+
|
|
21
|
+
Sizing: the initial size comes from AGENTUS_PTY_COLS / AGENTUS_PTY_ROWS, and `resize` applies
|
|
22
|
+
TIOCSWINSZ to the master — the kernel then signals the child's foreground process group (SIGWINCH),
|
|
23
|
+
which is what makes vim redraw at the new width.
|
|
24
|
+
|
|
25
|
+
Exit: when the child exits we exit with its status; when stdin hits EOF (the socket closed) or we
|
|
26
|
+
are signalled, we kill the child's process group first so no shell is orphaned.
|
|
27
|
+
"""
|
|
28
|
+
import errno
|
|
29
|
+
import fcntl
|
|
30
|
+
import os
|
|
31
|
+
import pty
|
|
32
|
+
import select
|
|
33
|
+
import signal
|
|
34
|
+
import struct
|
|
35
|
+
import sys
|
|
36
|
+
import termios
|
|
37
|
+
|
|
38
|
+
CTL_FD = 3
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def set_winsize(fd: int, cols: int, rows: int) -> None:
|
|
42
|
+
cols = max(1, min(cols, 1000))
|
|
43
|
+
rows = max(1, min(rows, 1000))
|
|
44
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main() -> int:
|
|
48
|
+
argv = sys.argv[1:] or [os.environ.get("SHELL") or "/bin/sh"]
|
|
49
|
+
try:
|
|
50
|
+
cols = int(os.environ.get("AGENTUS_PTY_COLS") or 80)
|
|
51
|
+
rows = int(os.environ.get("AGENTUS_PTY_ROWS") or 24)
|
|
52
|
+
except ValueError:
|
|
53
|
+
cols, rows = 80, 24
|
|
54
|
+
|
|
55
|
+
pid, master = pty.fork()
|
|
56
|
+
if pid == 0: # child: this IS the shell now
|
|
57
|
+
os.environ["TERM"] = os.environ.get("TERM") or "xterm-256color"
|
|
58
|
+
try:
|
|
59
|
+
os.execvp(argv[0], argv)
|
|
60
|
+
except Exception as exc: # noqa: BLE001 - report it on the terminal rather than dying mute
|
|
61
|
+
os.write(2, f"cannot start {argv[0]}: {exc}\r\n".encode())
|
|
62
|
+
os._exit(127)
|
|
63
|
+
|
|
64
|
+
set_winsize(master, cols, rows)
|
|
65
|
+
|
|
66
|
+
ctl = None
|
|
67
|
+
try:
|
|
68
|
+
ctl = os.fdopen(CTL_FD, "r", buffering=1)
|
|
69
|
+
except OSError:
|
|
70
|
+
ctl = None # no control channel: a fixed-size terminal is still a terminal
|
|
71
|
+
|
|
72
|
+
stdin_fd = 0
|
|
73
|
+
running = True
|
|
74
|
+
while running:
|
|
75
|
+
watch = [master, stdin_fd] + ([CTL_FD] if ctl else [])
|
|
76
|
+
try:
|
|
77
|
+
ready, _, _ = select.select(watch, [], [])
|
|
78
|
+
except OSError as exc:
|
|
79
|
+
if exc.errno == errno.EINTR:
|
|
80
|
+
continue
|
|
81
|
+
break
|
|
82
|
+
|
|
83
|
+
if master in ready:
|
|
84
|
+
try:
|
|
85
|
+
data = os.read(master, 65536)
|
|
86
|
+
except OSError:
|
|
87
|
+
data = b""
|
|
88
|
+
if not data:
|
|
89
|
+
break # the child closed the pty: it is gone
|
|
90
|
+
os.write(1, data)
|
|
91
|
+
|
|
92
|
+
if stdin_fd in ready:
|
|
93
|
+
try:
|
|
94
|
+
data = os.read(stdin_fd, 65536)
|
|
95
|
+
except OSError:
|
|
96
|
+
data = b""
|
|
97
|
+
if not data:
|
|
98
|
+
break # EOF: the browser went away, take the shell with us
|
|
99
|
+
os.write(master, data)
|
|
100
|
+
|
|
101
|
+
if ctl and CTL_FD in ready:
|
|
102
|
+
line = ctl.readline()
|
|
103
|
+
if line == "":
|
|
104
|
+
ctl.close()
|
|
105
|
+
ctl = None
|
|
106
|
+
continue
|
|
107
|
+
parts = line.split()
|
|
108
|
+
if parts and parts[0] == "resize" and len(parts) == 3 and parts[1].isdigit() and parts[2].isdigit():
|
|
109
|
+
set_winsize(master, int(parts[1]), int(parts[2]))
|
|
110
|
+
|
|
111
|
+
# Reap the child, then leave with its status so the server can report a real exit code.
|
|
112
|
+
status = 0
|
|
113
|
+
try:
|
|
114
|
+
_, raw = os.waitpid(pid, 0)
|
|
115
|
+
status = os.waitstatus_to_exitcode(raw) if hasattr(os, "waitstatus_to_exitcode") else 0
|
|
116
|
+
except ChildProcessError:
|
|
117
|
+
pass
|
|
118
|
+
except KeyboardInterrupt:
|
|
119
|
+
pass
|
|
120
|
+
return 0 if status is None else status
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _terminate(_signum, _frame): # noqa: ANN001 - signal handler
|
|
124
|
+
# The server kills our process group; this is the polite path when only we are signalled.
|
|
125
|
+
try:
|
|
126
|
+
os.kill(0, signal.SIGKILL)
|
|
127
|
+
except OSError:
|
|
128
|
+
pass
|
|
129
|
+
raise SystemExit(0)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
signal.signal(signal.SIGTERM, _terminate)
|
|
134
|
+
signal.signal(signal.SIGHUP, _terminate)
|
|
135
|
+
sys.exit(main())
|
|
@@ -114,6 +114,24 @@ export const CALL_DEFAULT: CallSettings = {
|
|
|
114
114
|
|
|
115
115
|
export const INTERRUPT_MODES: InterruptMode[] = ["voice", "button"];
|
|
116
116
|
|
|
117
|
+
/** How the cockpit treats AGENT PROCESSES — as opposed to what a conversation contains. AionUi
|
|
118
|
+
* ships the same knob in 设置 → 系统 as 「Agent 空闲超时(分钟)」 (default 5, 1–60, step 5) with the
|
|
119
|
+
* honest description 「空闲超过该时长后自动终止 Agent 进程以释放内存」. Agentus keeps that meaning
|
|
120
|
+
* and adds ONE thing AionUi does not have: 0 = 关闭. A cockpit deliberately left holding a
|
|
121
|
+
* long-running slot (a build, a tunnel watcher, a call) must be able to say "never reclaim this",
|
|
122
|
+
* and "set it to 60 and hope" is not the same statement. */
|
|
123
|
+
export interface AgentSettings {
|
|
124
|
+
/** minutes of no activity before a live slot's agent process is stopped to reclaim memory;
|
|
125
|
+
* 0 = never. The row and its transcript always survive — this only stops the process. */
|
|
126
|
+
idleKillMin: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const AGENT_DEFAULT: AgentSettings = { idleKillMin: 5 };
|
|
130
|
+
|
|
131
|
+
/** What the settings page may offer: the ends of the number field. Server-side enforcement uses
|
|
132
|
+
* the same pair, so a hand-written request cannot store a nonsense value. */
|
|
133
|
+
export const AGENT_IDLE_RANGE: [number, number] = [0, 60];
|
|
134
|
+
|
|
117
135
|
/** The NUMERIC knobs. `interruptMode` is a two-way choice, not a slider, so it is deliberately
|
|
118
136
|
* outside both the range table and the loops that validate numbers. */
|
|
119
137
|
export const CALL_NUMERIC = ["bargeSensitivity", "bargeMs", "silenceMs", "minChars"] as const;
|
|
@@ -147,6 +165,7 @@ export interface Settings {
|
|
|
147
165
|
theme: ThemeSettings;
|
|
148
166
|
call: CallSettings;
|
|
149
167
|
prefs: PrefsSettings;
|
|
168
|
+
agent: AgentSettings;
|
|
150
169
|
updatedAt: number;
|
|
151
170
|
}
|
|
152
171
|
|
|
@@ -173,7 +192,7 @@ let db: Store | null = null;
|
|
|
173
192
|
let legacyFile = "";
|
|
174
193
|
let cache: Settings = {
|
|
175
194
|
voice: { ...DEFAULTS }, theme: { ...THEME_DEFAULT }, call: { ...CALL_DEFAULT },
|
|
176
|
-
prefs: { ...PREFS_DEFAULT }, updatedAt: 0,
|
|
195
|
+
prefs: { ...PREFS_DEFAULT }, agent: { ...AGENT_DEFAULT }, updatedAt: 0,
|
|
177
196
|
};
|
|
178
197
|
/** keys that came from env/.env (shown as "auto-detected" and used when settings are empty) */
|
|
179
198
|
let envCreds: { apiKey: string; baseUrl: string; source: string } | null = null;
|
|
@@ -237,6 +256,7 @@ export function initSettings(dataDir: string, store: Store): Settings {
|
|
|
237
256
|
theme: { ...THEME_DEFAULT, ...(stored.theme ?? {}) },
|
|
238
257
|
call: pickCall(stored.call),
|
|
239
258
|
prefs: pickPrefs(stored.prefs),
|
|
259
|
+
agent: pickAgent(stored.agent),
|
|
240
260
|
updatedAt: Number((stored as Settings).updatedAt ?? 0),
|
|
241
261
|
};
|
|
242
262
|
// an unset baseUrl/apiKey bootstrap from the environment: the operator said the
|
|
@@ -266,6 +286,20 @@ function pickCall(raw: unknown): CallSettings {
|
|
|
266
286
|
return out;
|
|
267
287
|
}
|
|
268
288
|
|
|
289
|
+
/** The agent-process knobs. Out-of-range or unparseable values fall back to the shipped default
|
|
290
|
+
* rather than throwing: this section decides whether a slot's process is reclaimed, and a settings
|
|
291
|
+
* row that cannot be read must never leave the reaper in an undefined state (an undefined `> 0`
|
|
292
|
+
* comparison would silently turn reclaiming OFF — the safe failure is the documented default). */
|
|
293
|
+
function pickAgent(raw: unknown): AgentSettings {
|
|
294
|
+
const out = { ...AGENT_DEFAULT };
|
|
295
|
+
if (raw && typeof raw === "object") {
|
|
296
|
+
const v = Number((raw as Record<string, unknown>).idleKillMin);
|
|
297
|
+
const [lo, hi] = AGENT_IDLE_RANGE;
|
|
298
|
+
if (Number.isFinite(v) && v >= lo && v <= hi) out.idleKillMin = v;
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
|
|
269
303
|
function pickPrefs(raw: unknown): PrefsSettings {
|
|
270
304
|
const out = { ...PREFS_DEFAULT };
|
|
271
305
|
if (raw && typeof raw === "object") {
|
|
@@ -289,6 +323,7 @@ function persist(): void {
|
|
|
289
323
|
db.setSetting("theme", cache.theme);
|
|
290
324
|
db.setSetting("call", cache.call);
|
|
291
325
|
db.setSetting("prefs", cache.prefs);
|
|
326
|
+
db.setSetting("agent", cache.agent);
|
|
292
327
|
} catch (e) {
|
|
293
328
|
console.error(`[agentus] cannot write settings: ${(e as Error).message}`);
|
|
294
329
|
}
|
|
@@ -331,6 +366,9 @@ export function publicSettings(): Record<string, unknown> {
|
|
|
331
366
|
hotwordLimit: v.hotwordLimit,
|
|
332
367
|
prefs: cache.prefs,
|
|
333
368
|
prefsDefaults: PREFS_DEFAULT,
|
|
369
|
+
agent: cache.agent,
|
|
370
|
+
agentDefaults: AGENT_DEFAULT,
|
|
371
|
+
agentIdleRange: AGENT_IDLE_RANGE,
|
|
334
372
|
// the defaults, so the UI can offer "reset to Bailian defaults"
|
|
335
373
|
defaults: { ...DEFAULTS, apiKey: undefined, baseUrl: undefined },
|
|
336
374
|
updatedAt: cache.updatedAt,
|
|
@@ -456,6 +494,31 @@ export function savePrefs(patch: Record<string, unknown>): Settings {
|
|
|
456
494
|
return cache;
|
|
457
495
|
}
|
|
458
496
|
|
|
497
|
+
/** Agent-process knobs: its own update, like theme/call, so a bad number here cannot lock the
|
|
498
|
+
* operator out of the page that would fix it. Range-checked against AGENT_IDLE_RANGE before it is
|
|
499
|
+
* stored — this number decides whether agent processes get killed, so it is not a text field. */
|
|
500
|
+
export function saveAgent(patch: Record<string, unknown>): Settings {
|
|
501
|
+
const a = { ...cache.agent };
|
|
502
|
+
if ("idleKillMin" in patch) {
|
|
503
|
+
const raw = typeof patch.idleKillMin === "number" ? patch.idleKillMin : Number(patch.idleKillMin);
|
|
504
|
+
const [lo, hi] = AGENT_IDLE_RANGE;
|
|
505
|
+
if (!Number.isFinite(raw) || raw < lo || raw > hi) {
|
|
506
|
+
throw new Error(`invalid idleKillMin: ${JSON.stringify(patch.idleKillMin)} (expected ${lo}…${hi} minutes, 0 = off)`);
|
|
507
|
+
}
|
|
508
|
+
a.idleKillMin = raw;
|
|
509
|
+
}
|
|
510
|
+
cache = { ...cache, agent: a, updatedAt: Date.now() };
|
|
511
|
+
persist();
|
|
512
|
+
return cache;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** The agent-process knobs as the reaper reads them — on every tick, so a settings change applies
|
|
516
|
+
* without a restart. Returns the live cache object, not a copy: a caller that wants a snapshot has
|
|
517
|
+
* no business holding this one. */
|
|
518
|
+
export function agentSettings(): AgentSettings {
|
|
519
|
+
return cache.agent;
|
|
520
|
+
}
|
|
521
|
+
|
|
459
522
|
/** Normalise the two URL shapes operators paste: MaaS root or .../compatible-mode/v1. */
|
|
460
523
|
export function dashscopeUrls(baseUrl: string): { root: string; compat: string; ws: string } {
|
|
461
524
|
const trimmed = baseUrl.replace(/\/+$/, "");
|