agentus 0.3.1 → 0.3.3

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.
@@ -159,6 +159,12 @@ interface LiveSession {
159
159
  * so a second interrupting utterance that already started its own turn is not mistaken for
160
160
  * "the one I cancelled is still running". */
161
161
  turnSeq: number;
162
+ /** When the operator last USED this slot — he opened it, or his page reported it as the one on
163
+ * screen. In memory on purpose: this is the half of the idle rule that says "he was just here",
164
+ * and the store's message clock (which survives restarts) is the other half. Reading only the
165
+ * message clock reaped a slot he had clicked into a minute earlier (see `touch()` and the clock
166
+ * in `startIdleReaper`). */
167
+ touchedAt: number;
162
168
  /** Text sinks for sessions that live on this connection but are NOT the one in the rail —
163
169
  * today only the throwaway fork used to summarise the conversation for a title. Anything
164
170
  * whose sessionId is not `info.acpSessionId` is routed here and never persisted. */
@@ -367,6 +373,10 @@ export class SessionManager {
367
373
  child, busy: false, pendingPermissions: new Map(), alwaysAllow: new Set(), stderrBuf: [],
368
374
  caps: { fork: false, load: false }, turnSeq: 0, collectors: new Map(), planReminderPending: true,
369
375
  planToken: randomUUID(),
376
+ // A brand-new process is, by definition, freshly used: the click that spawned it is the
377
+ // activity. Without this the slot inherits the store's message clock — yesterday's — and the
378
+ // reaper's first tick after the spawn kills the process the operator just asked for.
379
+ touchedAt: Date.now(),
370
380
  // Until this slot's first PROMPT, anything the agent says is its own history being replayed,
371
381
  // not an answer (see #onSessionUpdate). A resumed agent re-sends its whole transcript on
372
382
  // `session/load` — full-length blocks, no messageId — and persisting those wrote 1240 rows
@@ -515,6 +525,10 @@ export class SessionManager {
515
525
  child, busy: false, pendingPermissions: new Map(), alwaysAllow: new Set(), stderrBuf: [],
516
526
  caps: { fork: false, load: false }, turnSeq: 0, collectors: new Map(), planReminderPending: true,
517
527
  planToken: randomUUID(),
528
+ // A brand-new process is, by definition, freshly used: the click that spawned it is the
529
+ // activity. Without this the slot inherits the store's message clock — yesterday's — and the
530
+ // reaper's first tick after the spawn kills the process the operator just asked for.
531
+ touchedAt: Date.now(),
518
532
  // Until this slot's first PROMPT, anything the agent says is its own history being replayed,
519
533
  // not an answer (see #onSessionUpdate). A resumed agent re-sends its whole transcript on
520
534
  // `session/load` — full-length blocks, no messageId — and persisting those wrote 1240 rows
@@ -1007,7 +1021,7 @@ export class SessionManager {
1007
1021
  command: process.execPath,
1008
1022
  args: [path.join(path.dirname(fileURLToPath(import.meta.url)), "../../mcp/plan-server.mjs")],
1009
1023
  env: [
1010
- { name: "AGENTUS_PLAN_ENDPOINT", value: `http://127.0.0.1:${process.env.AGENTUS_PORT ?? 8787}` },
1024
+ { name: "AGENTUS_PLAN_ENDPOINT", value: `http://127.0.0.1:${process.env.AGENTUS_PORT ?? 8788}` },
1011
1025
  { name: "AGENTUS_PLAN_TOKEN", value: live.planToken },
1012
1026
  { name: "AGENTUS_PLAN_SESSION", value: live.info.id },
1013
1027
  ],
@@ -1246,7 +1260,50 @@ export class SessionManager {
1246
1260
  this.#sessions.delete(live.info.id);
1247
1261
  }
1248
1262
 
1249
- /** Reclaim idle slots: stop the agent process of a session nobody has touched for the operator's
1263
+ /** The operator just USED this slot: he opened it, or his page reported it as the one on screen.
1264
+ *
1265
+ * Idempotent and cheap — the cockpit reports presence every few seconds, so while a slot is on
1266
+ * screen this is called continuously and the idle clock never runs out under a conversation he
1267
+ * is reading; the moment he switches away it stops, and the slot gets the full threshold from
1268
+ * the last report instead of from its last MESSAGE.
1269
+ *
1270
+ * Only the in-memory clock moves. `info.lastAt` stays the store's message time on purpose: it is
1271
+ * what the rail's relative stamps and the "last activity" hint mean, and making it say 刚刚 for a
1272
+ * session nobody has written to would be the UI lying. */
1273
+ touch(id: string): void {
1274
+ const live = this.#sessions.get(id);
1275
+ if (!live || live.info.status === "closed" || live.info.status === "reaped") return;
1276
+ live.touchedAt = Date.now();
1277
+ }
1278
+
1279
+ /** The `n` most recently USED sessions that are not archived — 「热备」, the slots whose process is
1280
+ * kept no matter how quiet they go. Explicitly a FLOOR, not a cap: it names which slots may not be
1281
+ * reclaimed for being idle; it never bounds how many may run.
1282
+ *
1283
+ * Ranked over live AND COLD rows by the same clock the sweep uses (`max(touchedAt, lastActivity,
1284
+ * createdAt)`). Ranking only the live ones would leak the meaning: after a reap the seat would fall
1285
+ * to whatever still happens to hold a process, so a session nobody has opened in days could inherit
1286
+ * it and be shielded by the very rule that just fired on his own recent session.
1287
+ *
1288
+ * Archived rows never take a seat — 归档 is 「收起来」, its process is meant to go — so the seat
1289
+ * passes to the next most recent one instead of being held by a row he put away. */
1290
+ #warmSet(n: number): Set<string> {
1291
+ const lastAt = this.#store.lastActivityAt();
1292
+ return new Set(
1293
+ this.#store
1294
+ .listSessions(true)
1295
+ .filter((r) => r.archivedAt == null)
1296
+ .map((r) => ({
1297
+ id: r.id,
1298
+ at: Math.max(this.#sessions.get(r.id)?.touchedAt ?? 0, lastAt.get(r.id) ?? 0, r.createdAt),
1299
+ }))
1300
+ .sort((a, b) => b.at - a.at)
1301
+ .slice(0, n)
1302
+ .map((r) => r.id),
1303
+ );
1304
+ }
1305
+
1306
+ /** Reclaim slots: stop the agent process of a session nobody has touched for the operator's
1250
1307
  * threshold. Deliberately NOT an archive — the row stays in 工作空间 wearing `reaped` and a click
1251
1308
  * brings it back. AionUi ships exactly this switch (设置 → 系统 → 「Agent 空闲超时(分钟)」,
1252
1309
  * default 5, 1–60) and the guards below are what its changelog had to add after the first version
@@ -1254,23 +1311,66 @@ export class SessionManager {
1254
1311
  * one the operator is WATCHING right now (a reply that lands while he reads it must not be killed
1255
1312
  * under him).
1256
1313
  *
1257
- * `idleMs()` is read on every tick, so a settings change applies with no restart; 0 = off. */
1258
- startIdleReaper(opts: { idleMs: () => number; watched?: (id: string) => boolean; intervalMs?: number }): () => void {
1314
+ * TWO deadlines, and the difference is the whole point (2026-10-10): `idleMs` is the operator's
1315
+ * answer to 「空闲多久收掉」 for the sessions he is NOT working in, while the 热备 set (`warmSlots`,
1316
+ * the most recently used ones) is exempt from it — 「即使空闲也保留在那里」 — and only obeys its own
1317
+ * much longer `warmTtlMs`. A single time rule was what made 「我只是离开浏览器」 cost him the agent
1318
+ * of the session he had been in a minute earlier.
1319
+ *
1320
+ * Every callback is read on EVERY tick, so a settings change applies with no restart (a restart
1321
+ * would kill exactly the slots this feature exists to keep alive cheaply); 0 = off for `idleMs`,
1322
+ * 0 = 不过期 for `warmTtlMs`. */
1323
+ startIdleReaper(opts: {
1324
+ idleMs: () => number;
1325
+ /** How many of the most recent sessions are 热备 — exempt from `idleMs`. Read per tick, like the
1326
+ * threshold itself: changing it in 设置 must apply without a restart (a restart would kill
1327
+ * exactly the slots this feature exists to keep alive cheaply). */
1328
+ warmSlots: () => number;
1329
+ /** How long a warm slot may stay untouched before even its exemption expires; 0 = 不过期.
1330
+ * Read per tick too, and it is a SEPARATE deadline from `idleMs` (hours, not minutes). */
1331
+ warmTtlMs: () => number;
1332
+ watched?: (id: string) => boolean;
1333
+ intervalMs?: number;
1334
+ }): () => void {
1259
1335
  const interval = opts.intervalMs ?? 30_000;
1336
+ /** Two decimals where it matters: a QA run drives these thresholds in SECONDS, and 「0 min」 on
1337
+ * both sides of the message is a log line that cannot be read. */
1338
+ const fmt = (ms: number): string =>
1339
+ ms < 3_600_000 ? `${(ms / 60_000).toFixed(1)} min` : `${(ms / 3_600_000).toFixed(1)} h`;
1260
1340
  const tick = (): void => {
1261
1341
  const idleMs = opts.idleMs();
1262
- if (!(idleMs > 0)) return;
1342
+ const warmTtlMs = opts.warmTtlMs();
1343
+ // Either rule alone is a reason to sweep (idle off + a warm TTL still expires warm slots).
1344
+ if (!(idleMs > 0) && !(warmTtlMs > 0)) return;
1345
+ const warmN = opts.warmSlots();
1346
+ const warm = warmN > 0 ? this.#warmSet(warmN) : null;
1263
1347
  const now = Date.now();
1264
1348
  let reaped = 0;
1265
1349
  for (const s of [...this.#sessions.values()]) {
1266
1350
  if (s.busy || s.pendingPermissions.size) continue;
1267
1351
  if (s.info.status === "starting" || s.info.status === "running") continue;
1268
1352
  if (opts.watched?.(s.info.id)) continue;
1269
- const lastAt = s.info.lastAt ?? s.info.createdAt;
1270
- if (now - lastAt < idleMs) continue;
1271
- console.log(
1272
- `[agentus] idle reap: ${s.info.id} (${Math.round((now - lastAt) / 60_000)} min idle, limit ${Math.round(idleMs / 60_000)} min)`,
1273
- );
1353
+ // The clock is the LATER of two things: the last MESSAGE this session has (store time, which
1354
+ // survives restarts) and the last time the operator actually USED the slot (`touchedAt` — the
1355
+ // click that respawned it, or the page reporting it as the one on screen). Reading only the
1356
+ // message clock is how a slot he had just opened got reaped a minute later (2026-10-10:
1357
+ // 「刚点进去、没发消息、换个会话过一分钟 acp 就被回收」) — the fresh process inherited a
1358
+ // days-old stamp, so the very next tick found it over the threshold.
1359
+ const lastAt = Math.max(s.touchedAt, s.info.lastAt ?? 0, s.info.createdAt);
1360
+ const age = now - lastAt;
1361
+ if (warm?.has(s.info.id)) {
1362
+ // 热备 = 「即使空闲也保留在这里」. The idle rule does not apply; only its own, much longer
1363
+ // deadline does — otherwise five quiet slots are a permanent ~700 MB floor.
1364
+ if (!(warmTtlMs > 0) || age < warmTtlMs) continue;
1365
+ console.log(
1366
+ `[agentus] warm slot expired: ${s.info.id} (${fmt(age)} untouched, warm ttl ${fmt(warmTtlMs)})`,
1367
+ );
1368
+ } else {
1369
+ if (!(idleMs > 0) || age < idleMs) continue;
1370
+ console.log(
1371
+ `[agentus] idle reap: ${s.info.id} (${Math.round(age / 60_000)} min idle, limit ${Math.round(idleMs / 60_000)} min)`,
1372
+ );
1373
+ }
1274
1374
  this.#stop(s, "reaped");
1275
1375
  reaped += 1;
1276
1376
  }
@@ -25,7 +25,7 @@ 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";
27
27
 
28
- const PORT = Number(process.env.AGENTUS_PORT ?? 8787);
28
+ const PORT = Number(process.env.AGENTUS_PORT ?? 8788);
29
29
  // Data + web build resolve against THIS FILE, not the shell's cwd: `scripts/start.sh`
30
30
  // launches from the repo root while the dev server runs from packages/server, and a
31
31
  // cwd-relative path would silently open a second database (the operator's slots would
@@ -196,6 +196,10 @@ const notify = new NotifyCenter({
196
196
  operator: (req, url) => Boolean(auth.authenticate(req.headers, url)),
197
197
  // The companion artifact, so the phone panel in 设置 can show its size without a second request.
198
198
  apkPath: APK_FILE,
199
+ // The page reporting which session it is showing is also the idle reaper's clock: opening a slot
200
+ // (and staying in it) counts as USED, so switching away gives it a full threshold instead of
201
+ // reaping it on the next tick because its last MESSAGE was days ago.
202
+ onFocus: (sessionId: string) => mgr.touch(sessionId),
199
203
  // Username+password pairing: the phone types the same login the browser uses. The check and
200
204
  // its rate limit stay here, in auth.ts, so a phone cannot have a laxer door than the UI.
201
205
  credentials: (username, password, ip) => {
@@ -266,6 +270,11 @@ setHotwordSource((sessionId) => hotwordsFor(store, sessionId).map((h) => h.word)
266
270
  };
267
271
  const envIdle = envNum(process.env.AGENTUS_IDLE_KILL_MIN);
268
272
  const envIdleMs = envIdle != null ? envIdle * 60_000 : null;
273
+ // 热备 (warm slots): same ABSENT≠0 discipline as above. The TTL takes a FRACTION of a minute so the
274
+ // warm-EXPIRY path can be exercised in seconds rather than by waiting out a day.
275
+ const envWarm = envNum(process.env.AGENTUS_WARM_SLOTS);
276
+ const envWarmTtlMin = envNum(process.env.AGENTUS_WARM_TTL_MIN);
277
+ const envWarmTtlMs = envWarmTtlMin != null ? envWarmTtlMin * 60_000 : null;
269
278
  const envSweep = envNum(process.env.AGENTUS_IDLE_SWEEP_MS);
270
279
  const sweepMs = envSweep != null && envSweep >= 1000 ? envSweep : 30_000;
271
280
  const source = envIdleMs != null ? "AGENTUS_IDLE_KILL_MIN" : "settings.agent.idleKillMin";
@@ -274,8 +283,18 @@ setHotwordSource((sessionId) => hotwordsFor(store, sessionId).map((h) => h.word)
274
283
  envIdleMs != null ? envIdle : agentSettings().idleKillMin
275
284
  } min (${source}), sweep ${Math.round(sweepMs / 1000)}s, 0 = off`,
276
285
  );
286
+ // The warm set is the other half of the same decision (which slots survive being quiet) and it is
287
+ // NEVER inferred from the idle value: 「最近 N 个即使空闲也保留」 and 「其余空闲多久收」 are two
288
+ // answers. Printing both with their sources is what turns 「为什么它被收了」 into one glance.
289
+ console.log(
290
+ `[agentus] warm slots: ${envWarm ?? agentSettings().warmSlots} kept up to ${
291
+ envWarmTtlMs != null ? envWarmTtlMs / 3_600_000 : agentSettings().warmTtlHours
292
+ } h untouched (${envWarm != null || envWarmTtlMs != null ? "AGENTUS_WARM_*" : "settings.agent"}), 0 = off / never`,
293
+ );
277
294
  mgr.startIdleReaper({
278
295
  idleMs: () => envIdleMs ?? agentSettings().idleKillMin * 60_000,
296
+ warmSlots: () => envWarm ?? agentSettings().warmSlots,
297
+ warmTtlMs: () => envWarmTtlMs ?? agentSettings().warmTtlHours * 3_600_000,
279
298
  watched: (id) => notify.operatorWatching(id),
280
299
  intervalMs: sweepMs,
281
300
  });
@@ -1385,9 +1404,42 @@ termWss.on("connection", (ws: WebSocket, _req: IncomingMessage, url: URL) => {
1385
1404
  return;
1386
1405
  }
1387
1406
  const session = term;
1407
+ // ---- backpressure, half two: OUR OWN socket queue ---------------------------
1408
+ // The page's watermarks (see ToolPanel's TerminalTab) can only count bytes that REACH it;
1409
+ // everything still queued in this process is invisible from there. A slow page plus a fast
1410
+ // producer therefore grows node's send queue without bound — measured on a `yes` flood: ~340 MB
1411
+ // queued in seconds while the page only ever received ~106 frames/s. So the socket's own backlog
1412
+ // is an independent brake, OR-ed with the client's request into one pause state, and the chain it
1413
+ // walks back is the same one: stop reading the child -> the pty helper blocks in write(1) -> the
1414
+ // shell blocks in write(2).
1415
+ const NET_HIGH = 1 << 20; // 1 MiB queued -> stop draining the pty
1416
+ const NET_LOW = 256 * 1024;
1417
+ let clientPaused = false;
1418
+ let socketPaused = false;
1419
+ let paused = false;
1420
+ const applyPause = (): void => {
1421
+ const want = clientPaused || socketPaused;
1422
+ if (want === paused) return;
1423
+ paused = want;
1424
+ if (want) session.pause();
1425
+ else session.resume();
1426
+ };
1388
1427
  const send = (msg: unknown): void => {
1389
- if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
1428
+ if (ws.readyState !== WebSocket.OPEN) return;
1429
+ ws.send(JSON.stringify(msg));
1430
+ if (!socketPaused && ws.bufferedAmount >= NET_HIGH) {
1431
+ socketPaused = true;
1432
+ applyPause();
1433
+ }
1390
1434
  };
1435
+ // `ws` emits no drain event for its send queue, so the way back up is a cheap poll that only
1436
+ // runs while we are actually holding the pty back.
1437
+ const backlogTimer = setInterval(() => {
1438
+ if (socketPaused && ws.bufferedAmount <= NET_LOW) {
1439
+ socketPaused = false;
1440
+ applyPause();
1441
+ }
1442
+ }, 100);
1391
1443
  // The buffered tail goes out first, so a reconnect (or a second tab) lands on the scrollback the
1392
1444
  // way ttyd's RESUME does instead of a blank screen.
1393
1445
  if (session.buffer) send({ t: "term-data", id: session.id, data: session.buffer });
@@ -1410,12 +1462,20 @@ termWss.on("connection", (ws: WebSocket, _req: IncomingMessage, url: URL) => {
1410
1462
  }
1411
1463
  } else if (cmd.t === "term-resize" && typeof cmd.cols === "number" && typeof cmd.rows === "number") {
1412
1464
  session.resize(cmd.cols, cmd.rows);
1465
+ } else if (cmd.t === "term-pause") {
1466
+ // The page's emulator fell behind: hold the pty so the backlog cannot grow there instead.
1467
+ clientPaused = true;
1468
+ applyPause();
1469
+ } else if (cmd.t === "term-resume") {
1470
+ clientPaused = false;
1471
+ applyPause();
1413
1472
  } else if (cmd.t === "term-close") {
1414
1473
  terms.kill(session.id);
1415
1474
  ws.close();
1416
1475
  }
1417
1476
  });
1418
1477
  const cleanup = (): void => {
1478
+ clearInterval(backlogTimer);
1419
1479
  session.subscribers.delete(onData);
1420
1480
  terms.kill(session.id);
1421
1481
  };
@@ -41,6 +41,10 @@ export interface NotifyCenterOptions {
41
41
  sessionTitle?: (sessionId: string) => string | null;
42
42
  /** The companion APK, so the web panel can report its size (optional). */
43
43
  apkPath?: string;
44
+ /** Called when the cockpit reports WHICH session it is showing (the presence route) — the one
45
+ * signal that says "the operator is using this slot right now". The idle reaper needs it: a
46
+ * message clock alone reaped slots nobody had written to (see SessionManager.touch). */
47
+ onFocus?: (sessionId: string) => void;
44
48
  log?: (line: string) => void;
45
49
  }
46
50
 
@@ -137,6 +141,8 @@ export class NotifyCenter {
137
141
  private readonly sessionTitle: ((sessionId: string) => string | null) | null;
138
142
  /** The APK the phone installs (optional): the web panel reports its size from here. */
139
143
  private readonly apkPath: string | null = null;
144
+ /** "The operator is on this session right now" — the idle reaper's use-activity clock. */
145
+ private readonly onFocus: ((sessionId: string) => void) | null = null;
140
146
 
141
147
  private state: NotifyState;
142
148
  private events: ActivityRecord[] = [];
@@ -176,6 +182,14 @@ export class NotifyCenter {
176
182
  call: Boolean(call),
177
183
  at: Date.now(),
178
184
  };
185
+ // The idle reaper's "he was just here" half. Only while the page says it is VISIBLE (or on a
186
+ // call): a tab left open on a session in the background must not keep an agent process alive
187
+ // forever, and `visible` is the page's own answer to "is anyone looking". A call counts for the
188
+ // same reason it counts as watching — the hands are free, the screen is not touched, and the
189
+ // session IS the conversation.
190
+ if (this.presence.sessionId && (this.presence.visible || this.presence.call)) {
191
+ this.onFocus?.(this.presence.sessionId);
192
+ }
179
193
  return this.presence;
180
194
  }
181
195
 
@@ -228,6 +242,7 @@ export class NotifyCenter {
228
242
  this.credentials = opts.credentials ?? null;
229
243
  this.sessionTitle = opts.sessionTitle ?? null;
230
244
  this.apkPath = opts.apkPath ?? null;
245
+ this.onFocus = opts.onFocus ?? null;
231
246
  this.state = this.loadState();
232
247
  this.events = this.loadEvents();
233
248
  }
@@ -18,6 +18,15 @@ Wire format, all raw bytes:
18
18
  stripping: a terminal emulator consumes the escape sequences, a <pre> cannot)
19
19
  fd 3 <- control channel, one command per line: "resize <cols> <rows>"
20
20
 
21
+ **Never block on stdout.** stdout is a pipe into the server, and the server stops reading it on
22
+ purpose when a consumer falls behind (see the flow-control notes in term.ts / index.ts: the brake is
23
+ applied by pausing this process's stdout). A blocking `write(1)` would stop this loop dead — and this
24
+ loop is also what forwards the keyboard, so a slow page would take Ctrl-C down with it and the
25
+ operator would have nothing left to abort the flood with. So stdout is NON-BLOCKING, output
26
+ accumulates in a local buffer, and reading the PTY is suspended while that buffer is at its cap: the
27
+ backpressure travels back to the producing program instead of parking in here. The input path is
28
+ never gated by output.
29
+
21
30
  Sizing: the initial size comes from AGENTUS_PTY_COLS / AGENTUS_PTY_ROWS, and `resize` applies
22
31
  TIOCSWINSZ to the master — the kernel then signals the child's foreground process group (SIGWINCH),
23
32
  which is what makes vim redraw at the new width.
@@ -36,6 +45,10 @@ import sys
36
45
  import termios
37
46
 
38
47
  CTL_FD = 3
48
+ READ_CHUNK = 65536
49
+ # Stop reading the PTY once this much output is waiting to go out: the producing program then blocks
50
+ # in its own write(2), which is the backpressure we want and what keeps this buffer bounded.
51
+ OUT_CAP = 1 << 20
39
52
 
40
53
 
41
54
  def set_winsize(fd: int, cols: int, rows: int) -> None:
@@ -70,35 +83,72 @@ def main() -> int:
70
83
  ctl = None # no control channel: a fixed-size terminal is still a terminal
71
84
 
72
85
  stdin_fd = 0
73
- running = True
74
- while running:
75
- watch = [master, stdin_fd] + ([CTL_FD] if ctl else [])
86
+ # Non-blocking stdout: the server stops reading it on purpose (flow control), and blocking here
87
+ # would freeze the input path with it.
88
+ try:
89
+ os.set_blocking(1, False)
90
+ except (OSError, AttributeError):
91
+ pass # no os.set_blocking: the writes below fall back to blocking behaviour
92
+ pending = bytearray()
93
+ stdin_open = True
94
+ while True:
95
+ # While we are behind on output we stop reading the PTY — that IS the backpressure. stdin and
96
+ # the control channel stay watched, so a keystroke (Ctrl-C above all) is never stuck behind
97
+ # a flood.
98
+ readable = [stdin_fd] if stdin_open else []
99
+ if ctl is not None:
100
+ readable.append(CTL_FD)
101
+ if len(pending) < OUT_CAP:
102
+ readable.append(master)
103
+ writable = [1] if pending else []
76
104
  try:
77
- ready, _, _ = select.select(watch, [], [])
105
+ ready_r, ready_w, _ = select.select(readable, writable, [])
78
106
  except OSError as exc:
79
107
  if exc.errno == errno.EINTR:
80
108
  continue
81
109
  break
82
110
 
83
- if master in ready:
111
+ if ready_w and pending:
112
+ try:
113
+ written = os.write(1, pending)
114
+ del pending[:written]
115
+ except (BlockingIOError, InterruptedError):
116
+ pass
117
+ except OSError:
118
+ break # stdout is gone: the server died, so are we
119
+
120
+ if master in ready_r:
84
121
  try:
85
- data = os.read(master, 65536)
122
+ data = os.read(master, READ_CHUNK)
123
+ except (BlockingIOError, InterruptedError):
124
+ data = None
86
125
  except OSError:
87
126
  data = b""
88
- if not data:
127
+ if data == b"":
89
128
  break # the child closed the pty: it is gone
90
- os.write(1, data)
129
+ if data:
130
+ pending += data
91
131
 
92
- if stdin_fd in ready:
132
+ if stdin_open and stdin_fd in ready_r:
93
133
  try:
94
- data = os.read(stdin_fd, 65536)
134
+ data = os.read(stdin_fd, READ_CHUNK)
135
+ except (BlockingIOError, InterruptedError):
136
+ data = None
95
137
  except OSError:
96
138
  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:
139
+ if data == b"":
140
+ # EOF: the browser went away. Let the child finish its exit path rather than
141
+ # dropping output we still owe the scrollback.
142
+ stdin_open = False
143
+ if ctl is None:
144
+ break
145
+ elif data:
146
+ try:
147
+ os.write(master, data)
148
+ except OSError:
149
+ break
150
+
151
+ if ctl is not None and CTL_FD in ready_r:
102
152
  line = ctl.readline()
103
153
  if line == "":
104
154
  ctl.close()
@@ -108,6 +158,14 @@ def main() -> int:
108
158
  if parts and parts[0] == "resize" and len(parts) == 3 and parts[1].isdigit() and parts[2].isdigit():
109
159
  set_winsize(master, int(parts[1]), int(parts[2]))
110
160
 
161
+ # Best effort: hand over whatever is left, but never hang on it.
162
+ try:
163
+ while pending:
164
+ written = os.write(1, pending)
165
+ del pending[:written]
166
+ except OSError:
167
+ pass
168
+
111
169
  # Reap the child, then leave with its status so the server can report a real exit code.
112
170
  status = 0
113
171
  try:
@@ -124,13 +124,28 @@ export interface AgentSettings {
124
124
  /** minutes of no activity before a live slot's agent process is stopped to reclaim memory;
125
125
  * 0 = never. The row and its transcript always survive — this only stops the process. */
126
126
  idleKillMin: number;
127
+ /** 「热备」= how many of the most recently USED sessions keep their agent process no matter how
128
+ * quiet they go. A FLOOR, not a ceiling: it names the slots that may not be reclaimed for being
129
+ * idle, it does not bound how many may run. Ranked over live AND cold rows by the same clock the
130
+ * sweep uses — a slot already reaped must not hand its seat to a session nobody has touched in
131
+ * days. Archived rows never occupy a seat. 0 = off (every slot obeys idleKillMin). */
132
+ warmSlots: number;
133
+ /** Hours a warm slot may go untouched before even its exemption expires. Without this the warm set
134
+ * is a permanent memory floor (5 × ~137 MB RSS measured); with it, a cockpit left alone for a day
135
+ * comes back to nothing running. 0 = 不过期. */
136
+ warmTtlHours: number;
127
137
  }
128
138
 
129
- export const AGENT_DEFAULT: AgentSettings = { idleKillMin: 5 };
139
+ export const AGENT_DEFAULT: AgentSettings = { idleKillMin: 5, warmSlots: 5, warmTtlHours: 24 };
130
140
 
131
141
  /** What the settings page may offer: the ends of the number field. Server-side enforcement uses
132
142
  * the same pair, so a hand-written request cannot store a nonsense value. */
133
143
  export const AGENT_IDLE_RANGE: [number, number] = [0, 60];
144
+ /** 0 = off. The upper end is a memory decision (one live agent ≈ 137 MB RSS measured), not a limit
145
+ * the server has to defend. */
146
+ export const AGENT_WARM_RANGE: [number, number] = [0, 20];
147
+ /** 0 = 不过期; a week is the longest span worth offering on the page. */
148
+ export const AGENT_WARM_TTL_RANGE: [number, number] = [0, 168];
134
149
 
135
150
  /** The NUMERIC knobs. `interruptMode` is a two-way choice, not a slider, so it is deliberately
136
151
  * outside both the range table and the loops that validate numbers. */
@@ -293,9 +308,15 @@ function pickCall(raw: unknown): CallSettings {
293
308
  function pickAgent(raw: unknown): AgentSettings {
294
309
  const out = { ...AGENT_DEFAULT };
295
310
  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;
311
+ const r = raw as Record<string, unknown>;
312
+ const take = (key: keyof AgentSettings, range: [number, number]): void => {
313
+ const v = Number(r[key]);
314
+ const [lo, hi] = range;
315
+ if (Number.isFinite(v) && v >= lo && v <= hi) out[key] = v;
316
+ };
317
+ take("idleKillMin", AGENT_IDLE_RANGE);
318
+ take("warmSlots", AGENT_WARM_RANGE);
319
+ take("warmTtlHours", AGENT_WARM_TTL_RANGE);
299
320
  }
300
321
  return out;
301
322
  }
@@ -369,6 +390,8 @@ export function publicSettings(): Record<string, unknown> {
369
390
  agent: cache.agent,
370
391
  agentDefaults: AGENT_DEFAULT,
371
392
  agentIdleRange: AGENT_IDLE_RANGE,
393
+ agentWarmRange: AGENT_WARM_RANGE,
394
+ agentWarmTtlRange: AGENT_WARM_TTL_RANGE,
372
395
  // the defaults, so the UI can offer "reset to Bailian defaults"
373
396
  defaults: { ...DEFAULTS, apiKey: undefined, baseUrl: undefined },
374
397
  updatedAt: cache.updatedAt,
@@ -495,18 +518,23 @@ export function savePrefs(patch: Record<string, unknown>): Settings {
495
518
  }
496
519
 
497
520
  /** 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. */
521
+ * operator out of the page that would fix it. Range-checked against the section's range table
522
+ * before it is stored — these numbers decide whether agent processes get killed, so they are not
523
+ * text fields. */
500
524
  export function saveAgent(patch: Record<string, unknown>): Settings {
501
525
  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;
526
+ const set = (key: keyof AgentSettings, range: [number, number], unit: string, off: string): void => {
527
+ if (!(key in patch)) return;
528
+ const raw = typeof patch[key] === "number" ? (patch[key] as number) : Number(patch[key]);
529
+ const [lo, hi] = range;
505
530
  if (!Number.isFinite(raw) || raw < lo || raw > hi) {
506
- throw new Error(`invalid idleKillMin: ${JSON.stringify(patch.idleKillMin)} (expected ${lo}…${hi} minutes, 0 = off)`);
531
+ throw new Error(`invalid ${key}: ${JSON.stringify(patch[key])} (expected ${lo}…${hi} ${unit}, ${off})`);
507
532
  }
508
- a.idleKillMin = raw;
509
- }
533
+ a[key] = raw;
534
+ };
535
+ set("idleKillMin", AGENT_IDLE_RANGE, "minutes", "0 = off");
536
+ set("warmSlots", AGENT_WARM_RANGE, "slots", "0 = off");
537
+ set("warmTtlHours", AGENT_WARM_TTL_RANGE, "hours", "0 = never expires");
510
538
  cache = { ...cache, agent: a, updatedAt: Date.now() };
511
539
  persist();
512
540
  return cache;
@@ -40,9 +40,19 @@ export interface TermSession {
40
40
  /** Resize the tty (no-op on a pipe shell). The kernel then SIGWINCHes the child's foreground
41
41
  * process group, which is what makes a full-screen program redraw at the new width. */
42
42
  resize: (cols: number, rows: number) => void;
43
+ /** Stop draining the child's output. The backpressure chain is: this pause -> we stop reading
44
+ * the child's stdout -> the pty helper blocks in its own write(1) -> it stops reading the pty
45
+ * master -> the shell/program blocks in write(2). That is the ONLY honest way to slow a fast
46
+ * producer (xterm's own guidance: without it the emulator grows a write buffer until it is
47
+ * unresponsive — see the flow-control note in ToolPanel's TerminalTab). */
48
+ pause: () => void;
49
+ resume: () => void;
43
50
  }
44
51
 
45
52
  const MAX_BUFFER = 256 * 1024;
53
+ /** How long output is held to be coalesced, and the size that makes holding it pointless. */
54
+ const FLUSH_MS = 8;
55
+ const FLUSH_BYTES = 64 * 1024;
46
56
 
47
57
  /** The PTY helper that ships with the server. */
48
58
  function ptyHelperPath(): string | null {
@@ -135,9 +145,34 @@ export class TermService {
135
145
  /* the helper is gone; the exit event says so */
136
146
  }
137
147
  },
148
+ pause: (): void => {
149
+ child.stdout?.pause();
150
+ child.stderr?.pause();
151
+ },
152
+ resume: (): void => {
153
+ child.stdout?.resume();
154
+ child.stderr?.resume();
155
+ },
138
156
  };
139
- const push = (chunk: string): void => {
140
- session.buffer = (session.buffer + chunk).slice(-MAX_BUFFER);
157
+ // ---- output framing --------------------------------------------------------
158
+ // One `data` event per write(2) is what a pty hands us, and a full-screen program repainting
159
+ // (or `yes`) emits hundreds of them per second. Every one used to become its own WebSocket
160
+ // frame, and the browser then spends more time in message callbacks than in the emulator
161
+ // (upstream measured ~70% of the main thread: ttyd#247). Subscribers therefore get COALESCED
162
+ // frames — whatever lands inside one ~FLUSH_MS window ships as a single message.
163
+ //
164
+ // The scrollback buffer is deliberately NOT batched: a late joiner or a reconnect has to see
165
+ // every byte, and appending to a string costs nothing per frame.
166
+ let pending = "";
167
+ let flushTimer: NodeJS.Timeout | null = null;
168
+ const flush = (): void => {
169
+ if (flushTimer) {
170
+ clearTimeout(flushTimer);
171
+ flushTimer = null;
172
+ }
173
+ if (!pending) return;
174
+ const chunk = pending;
175
+ pending = "";
141
176
  for (const fn of session.subscribers) {
142
177
  try {
143
178
  fn(chunk);
@@ -146,6 +181,13 @@ export class TermService {
146
181
  }
147
182
  }
148
183
  };
184
+ const push = (chunk: string): void => {
185
+ session.buffer = (session.buffer + chunk).slice(-MAX_BUFFER);
186
+ pending += chunk;
187
+ // Past this size, holding it any longer only delays a redraw.
188
+ if (pending.length >= FLUSH_BYTES) flush();
189
+ else if (!flushTimer) flushTimer = setTimeout(flush, FLUSH_MS);
190
+ };
149
191
  child.stdout?.setEncoding("utf8");
150
192
  child.stderr?.setEncoding("utf8");
151
193
  // stderr is part of the terminal picture (a missing command prints there). With a pty both
@@ -155,6 +197,8 @@ export class TermService {
155
197
  child.on("exit", (code: number | null, signal: string | null) => {
156
198
  session.exited = { code, signal };
157
199
  push(`\r\n[process exited${code != null ? ` with code ${code}` : ""}${signal ? ` (${signal})` : ""}]\r\n`);
200
+ // The exit line must not wait out the coalescing window — nothing follows it.
201
+ flush();
158
202
  this.#sessions.delete(session.id);
159
203
  });
160
204
  child.on("error", (err: Error) => push(`\r\n[cannot start shell: ${err.message}]\r\n`));