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.
- package/README.md +5 -5
- package/README.zh-CN.md +5 -5
- package/bin/agentus.mjs +3 -3
- package/docs/dev/01-dev-loop.md +2 -2
- package/docs/dev/03-release.md +18 -5
- package/docs/dev/04-recover.md +14 -8
- package/docs/dev/README.md +24 -1
- package/docs/installation.md +8 -8
- package/docs/installation.zh-CN.md +8 -8
- package/docs/transcript-folding.md +1 -1
- package/package.json +7 -7
- package/packages/server/mock/agent.mjs +8 -0
- package/packages/server/src/acp/session-manager.ts +110 -10
- package/packages/server/src/index.ts +62 -2
- package/packages/server/src/notify/center.ts +15 -0
- package/packages/server/src/pty-helper.py +73 -15
- package/packages/server/src/settings.ts +40 -12
- package/packages/server/src/term.ts +46 -2
- package/packages/web/dist/assets/index-CCvfL56d.css +32 -0
- package/packages/web/dist/assets/index-DCBs8dsw.js +119 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-Cqb6QVmU.css +0 -32
- package/packages/web/dist/assets/index-YJcTF0IM.js +0 -283
|
@@ -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 ??
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
1258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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 ??
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
122
|
+
data = os.read(master, READ_CHUNK)
|
|
123
|
+
except (BlockingIOError, InterruptedError):
|
|
124
|
+
data = None
|
|
86
125
|
except OSError:
|
|
87
126
|
data = b""
|
|
88
|
-
if
|
|
127
|
+
if data == b"":
|
|
89
128
|
break # the child closed the pty: it is gone
|
|
90
|
-
|
|
129
|
+
if data:
|
|
130
|
+
pending += data
|
|
91
131
|
|
|
92
|
-
if stdin_fd in
|
|
132
|
+
if stdin_open and stdin_fd in ready_r:
|
|
93
133
|
try:
|
|
94
|
-
data = os.read(stdin_fd,
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
297
|
-
const [
|
|
298
|
-
|
|
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
|
|
499
|
-
* stored —
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
const [
|
|
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
|
|
531
|
+
throw new Error(`invalid ${key}: ${JSON.stringify(patch[key])} (expected ${lo}…${hi} ${unit}, ${off})`);
|
|
507
532
|
}
|
|
508
|
-
a
|
|
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
|
-
|
|
140
|
-
|
|
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`));
|