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.
@@ -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", sessions: mgr.list(), pending: mgr.pendingPermissions(),
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
- warnings: plan.warnings,
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: plan.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: plan.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: plan.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 `![shot](/Users/…/shot.png)` 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 (!body.voice && !body.theme && !body.call && !body.prefs) saveSettings(body);
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
- term = terms.start(root.path);
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
- send({ t: "term-ready", id: session.id, cwd: session.cwd, pid: session.pid });
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.write(cmd.data);
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
- if (!this.rules().quietWhenWatching) return false;
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(/\/+$/, "");