agentus 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/server/src/acp/registry.ts +51 -2
- package/packages/server/src/acp/session-manager.ts +200 -39
- package/packages/server/src/index.ts +85 -10
- package/packages/server/src/notify/center.ts +12 -1
- package/packages/server/src/settings.ts +64 -1
- package/packages/server/src/store/store.ts +35 -3
- package/packages/shared/src/index.ts +11 -1
- package/packages/web/dist/assets/index-C5XlgEcj.js +283 -0
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/assets/index-d9NzwPFu.js +0 -283
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Multi-session web cockpit for ACP-speaking coding agents (Qoder CLI / Hermes). UI has no intelligence — the agent lives in spawned CLI subprocesses.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -53,6 +53,18 @@ export interface BackendRow {
|
|
|
53
53
|
profile: string | null;
|
|
54
54
|
/** default working directory for new slots (null = the dialog asks) */
|
|
55
55
|
cwd: string | null;
|
|
56
|
+
/**
|
|
57
|
+
* The permission mode a NEW session from this row starts in — an ACP mode id (`default` /
|
|
58
|
+
* `accept_edits` / `dont_ask` today), or null for "send nothing, the agent keeps its own default".
|
|
59
|
+
*
|
|
60
|
+
* Applied at CREATION only: a resumed session already carries the mode its operator picked for it
|
|
61
|
+
* (Hermes persists it in the session's own meta), so re-applying the row's default on load would
|
|
62
|
+
* silently undo a per-session choice — the row declares where a conversation STARTS, nothing more.
|
|
63
|
+
*
|
|
64
|
+
* A mode the agent did not advertise is not sent (see rowWarnings): the session then comes up in
|
|
65
|
+
* the agent's own mode, and the row says so instead of the pick quietly doing nothing.
|
|
66
|
+
*/
|
|
67
|
+
defaultMode: string | null;
|
|
56
68
|
notes: string;
|
|
57
69
|
/** seeded row (the ones that used to be env-driven); editable, but flagged in the UI */
|
|
58
70
|
builtin: boolean;
|
|
@@ -191,7 +203,7 @@ export interface BackendHandshake {
|
|
|
191
203
|
loadSession: boolean | null;
|
|
192
204
|
/** agentCapabilities.sessionCapabilities.fork */
|
|
193
205
|
fork: boolean | null;
|
|
194
|
-
modes: { currentModeId: string | null; available: string[] } | null;
|
|
206
|
+
modes: { currentModeId: string | null; available: string[]; names?: Record<string, string> } | null;
|
|
195
207
|
configOptions: { id: string; name: string | null; currentValue: string | null }[];
|
|
196
208
|
models: { currentModelId: string | null; available: string[] } | null;
|
|
197
209
|
/** slash commands the agent announced (session/update or newSession) */
|
|
@@ -223,6 +235,9 @@ export function seedRows(now = Date.now()): BackendRow[] {
|
|
|
223
235
|
home: null,
|
|
224
236
|
profile: null,
|
|
225
237
|
cwd: null,
|
|
238
|
+
// Nothing is sent: a freshly seeded row behaves exactly as the pre-feature cockpit did, and
|
|
239
|
+
// the operator opts a row in from the settings page.
|
|
240
|
+
defaultMode: null,
|
|
226
241
|
notes: notes[id] ?? "",
|
|
227
242
|
builtin: true,
|
|
228
243
|
health: emptyHealth(),
|
|
@@ -283,8 +298,30 @@ export function planFor(row: BackendRow): BackendPlan {
|
|
|
283
298
|
}
|
|
284
299
|
}
|
|
285
300
|
|
|
286
|
-
|
|
301
|
+
/**
|
|
302
|
+
* Everything a row should wear as a warning in the settings list, resolved in ONE place: the spawn
|
|
303
|
+
* concerns (`planFor`) plus the declarations that are silently INERT when they disagree with what the
|
|
304
|
+
* agent advertised. The failure this exists to prevent is a pick the operator set, believes in, and
|
|
305
|
+
* never sees take effect.
|
|
306
|
+
*
|
|
307
|
+
* The default mode is only judgeable against a real handshake — before the row's first session the
|
|
308
|
+
* cockpit knows nothing about that agent's modes, and a "cannot check yet" line would sit on every
|
|
309
|
+
* fresh row.
|
|
310
|
+
*/
|
|
311
|
+
export function rowWarnings(row: BackendRow): string[] {
|
|
312
|
+
const out = [...planFor(row).warnings];
|
|
313
|
+
const wanted = (row.defaultMode ?? "").trim();
|
|
314
|
+
const advertised = row.handshake?.modes?.available ?? [];
|
|
315
|
+
if (wanted && advertised.length && !advertised.includes(wanted)) {
|
|
316
|
+
out.push(
|
|
317
|
+
`默认权限「${wanted}」不在这个后端上次握手报出的档位里(${advertised.join(" / ")})` +
|
|
318
|
+
`—— 新建会话不会用它,会话会照 agent 自己的默认来。`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
287
323
|
|
|
324
|
+
const ID_RE = /^[a-z0-9][a-z0-9._-]{0,40}$/;
|
|
288
325
|
/** Shape an arbitrary request body into a row. `base` = the row being edited (PATCH). */
|
|
289
326
|
export function coerceRow(body: Record<string, unknown>, base?: BackendRow): { row?: BackendRow; error?: string } {
|
|
290
327
|
const now = Date.now();
|
|
@@ -337,6 +374,10 @@ export function coerceRow(body: Record<string, unknown>, base?: BackendRow): { r
|
|
|
337
374
|
home: body.home === undefined ? (base?.home ?? null) : (s(body.home) || null),
|
|
338
375
|
profile: body.profile === undefined ? (base?.profile ?? null) : (s(body.profile) || null),
|
|
339
376
|
cwd: body.cwd === undefined ? (base?.cwd ?? null) : (s(body.cwd) || null),
|
|
377
|
+
// Not spawn-relevant, so it is deliberately ABSENT from `edited` below: which mode a new
|
|
378
|
+
// session starts in says nothing about which command this row resolves to, and clearing the
|
|
379
|
+
// health/handshake evidence over it would forget what the agent can do.
|
|
380
|
+
defaultMode: body.defaultMode === undefined ? (base?.defaultMode ?? null) : (s(body.defaultMode) || null),
|
|
340
381
|
notes: body.notes === undefined ? (base?.notes ?? "") : s(body.notes),
|
|
341
382
|
builtin: base?.builtin ?? false,
|
|
342
383
|
// Health is evidence about the CURRENT spawn definition, so changing that definition
|
|
@@ -554,6 +595,14 @@ export function handshakeFrom(
|
|
|
554
595
|
? {
|
|
555
596
|
currentModeId: s.modes.currentModeId ?? null,
|
|
556
597
|
available: (s.modes.availableModes ?? []).map((m) => m.id),
|
|
598
|
+
// The DISPLAY name travels with the id: the settings page offers these as the default-mode
|
|
599
|
+
// picker (「Accept Edits」, not `accept_edits`), and this cache is the only place a row that
|
|
600
|
+
// is not running can learn what its agent calls its modes.
|
|
601
|
+
names: Object.fromEntries(
|
|
602
|
+
(s.modes.availableModes ?? [])
|
|
603
|
+
.filter((m) => m.id)
|
|
604
|
+
.map((m) => [m.id, (m as { name?: string }).name || m.id]),
|
|
605
|
+
),
|
|
557
606
|
}
|
|
558
607
|
: null;
|
|
559
608
|
const models = s.models
|
|
@@ -209,10 +209,17 @@ export class SessionManager {
|
|
|
209
209
|
// one you come back to, even when it is the oldest row in the list.
|
|
210
210
|
const lastAt = this.#store.lastActivityAt();
|
|
211
211
|
return [...this.#sessions.values()]
|
|
212
|
+
// An ARCHIVED session is not 工作空间 material, whatever its process is doing (resume leaves the
|
|
213
|
+
// flag alone, so a restored-then-archived slot can be live). It is listed by `archived()`
|
|
214
|
+
// instead — one row in one bucket, or the rail shows the same slot twice.
|
|
215
|
+
.filter((s) => this.#store.getSession(s.info.id)?.archivedAt == null)
|
|
212
216
|
.map((s) => ({
|
|
213
217
|
...s.info,
|
|
214
218
|
lastSeq: this.#store.maxSeq(s.info.id),
|
|
215
219
|
lastAt: lastAt.get(s.info.id) ?? s.info.createdAt,
|
|
220
|
+
archivedAt: null,
|
|
221
|
+
// a row in the manager's map has a process, by definition
|
|
222
|
+
cold: false,
|
|
216
223
|
}))
|
|
217
224
|
.sort((a, b) => (b.lastAt ?? 0) - (a.lastAt ?? 0) || b.createdAt - a.createdAt);
|
|
218
225
|
}
|
|
@@ -416,6 +423,11 @@ export class SessionManager {
|
|
|
416
423
|
live.info.models = readModels(res);
|
|
417
424
|
// the row just proved itself: remember that, plus what the agent said it can do
|
|
418
425
|
this.#noteBackendCheck(String(backend), { ok: true, latencyMs: Date.now() - spawnStarted }, handshakeFrom(init, res));
|
|
426
|
+
// The row's DEFAULT permission mode, applied HERE: on a brand-new session, before the first
|
|
427
|
+
// prompt. Hermes reads its mode through a live getter (acp_adapter/server.py `policy_getter`),
|
|
428
|
+
// so the very first turn is already governed by it. A later `resume()` never passes through
|
|
429
|
+
// this method — that is exactly what keeps a per-session pick from being silently undone.
|
|
430
|
+
await this.#applyDefaultMode(live);
|
|
419
431
|
this.#updateSession(live);
|
|
420
432
|
return live.info;
|
|
421
433
|
} catch (err) {
|
|
@@ -699,33 +711,74 @@ export class SessionManager {
|
|
|
699
711
|
});
|
|
700
712
|
}
|
|
701
713
|
|
|
702
|
-
/**
|
|
703
|
-
archived
|
|
714
|
+
/** Rows the OPERATOR put away — 已归档. Chosen by the archive FLAG, not by liveness: a session
|
|
715
|
+
* the operator archived belongs there whatever its process is doing, and a session that merely
|
|
716
|
+
* lost its process (crashed, was closed, or was reaped for being idle) does NOT — that was the
|
|
717
|
+
* old rule, and it turned 已归档 into a drawer where the operator's own intent was
|
|
718
|
+
* indistinguishable from debris.
|
|
719
|
+
*
|
|
720
|
+
* No cap: this list is curated by hand, so hiding its tail would hide the operator's own work.
|
|
721
|
+
* Real removal is the ✕ purge button (DELETE on a cold row). */
|
|
722
|
+
archived(): SessionInfo[] {
|
|
723
|
+
const lastAtAll = this.#store.lastActivityAt();
|
|
704
724
|
return this.#store
|
|
705
|
-
// include closed rows: "close" means "kill the process, keep the transcript as a cold
|
|
706
|
-
// slot" (that is what the UI's close prompt promises) — filtering status!='closed' made
|
|
707
|
-
// the slot vanish from the rail entirely, leaving the transcript unreachable even though
|
|
708
|
-
// it was still on disk. Real deletion is the separate ✕ purge button.
|
|
709
725
|
.listSessions(true)
|
|
710
|
-
.filter((r) =>
|
|
711
|
-
.map((r) =>
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
726
|
+
.filter((r) => r.archivedAt != null)
|
|
727
|
+
.map((r) => {
|
|
728
|
+
const live = this.#sessions.get(r.id);
|
|
729
|
+
// An archived row can still have a process — resume does not touch the flag — and then it is
|
|
730
|
+
// NOT cold: the rail must draw a live row (and clicking it hands over the floor rather than
|
|
731
|
+
// waking anything), or the operator is told a slot is asleep while it is running.
|
|
732
|
+
return live
|
|
733
|
+
? {
|
|
734
|
+
...live.info,
|
|
735
|
+
lastSeq: this.#store.maxSeq(r.id),
|
|
736
|
+
lastAt: lastAtAll.get(r.id) ?? r.createdAt,
|
|
737
|
+
archivedAt: r.archivedAt ?? null,
|
|
738
|
+
cold: false,
|
|
739
|
+
}
|
|
740
|
+
: this.#infoFromRow(r);
|
|
741
|
+
})
|
|
742
|
+
.sort((a, b) => (b.archivedAt ?? 0) - (a.archivedAt ?? 0) || (b.lastAt ?? 0) - (a.lastAt ?? 0));
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** Cold rows that are NOT archived: on disk, no process, still part of 工作空间. A crashed slot,
|
|
746
|
+
* one the operator closed, or one THIS cockpit reaped for being idle — all of them must stay
|
|
747
|
+
* reachable in the rail (the old rule showed them under 已归档; the older one dropped them out of
|
|
748
|
+
* the list entirely, which is how a transcript became unreachable while still on disk).
|
|
749
|
+
*
|
|
750
|
+
* Capped, unlike `archived()`: crash debris accumulates without anyone choosing it, and the rail
|
|
751
|
+
* has its own recent-N rule anyway. */
|
|
752
|
+
cold(limit = 20): SessionInfo[] {
|
|
753
|
+
return this.#store
|
|
754
|
+
.listSessions(true)
|
|
755
|
+
.filter((r) => r.archivedAt == null && !this.#sessions.has(r.id))
|
|
756
|
+
.map((r) => this.#infoFromRow(r))
|
|
724
757
|
// cold rows by the same rule as live ones (recent chat first), THEN take the page
|
|
725
758
|
.sort((a, b) => (b.lastAt ?? 0) - (a.lastAt ?? 0) || b.createdAt - a.createdAt)
|
|
726
759
|
.slice(0, limit);
|
|
727
760
|
}
|
|
728
761
|
|
|
762
|
+
/** One row in the shape list()/cold()/archived() produce, from a stored row. */
|
|
763
|
+
#infoFromRow(r: SessionRow): SessionInfo {
|
|
764
|
+
return {
|
|
765
|
+
id: r.id, backend: r.backend, acpSessionId: r.acpSessionId, cwd: r.cwd,
|
|
766
|
+
workspace: r.workspace ?? null,
|
|
767
|
+
models: (r.models ?? null) as SessionInfo["models"],
|
|
768
|
+
contextLimit: r.contextLimit ?? null,
|
|
769
|
+
title: r.title, autoTitle: r.autoTitle ?? null, status: r.status, pid: null, createdAt: r.createdAt,
|
|
770
|
+
modes: (r.modes ?? null) as SessionModeState | null,
|
|
771
|
+
configOptions: (r.configOptions ?? []) as ConfigOptionView[],
|
|
772
|
+
usage: (r.usage ?? null) as SessionInfo["usage"],
|
|
773
|
+
commands: normCommands(r.commands),
|
|
774
|
+
lastSeq: this.#store.maxSeq(r.id),
|
|
775
|
+
lastAt: this.#store.lastActivityAt().get(r.id) ?? r.createdAt,
|
|
776
|
+
archivedAt: r.archivedAt ?? null,
|
|
777
|
+
// no process by construction: this path only ever runs for a row that is not in the map
|
|
778
|
+
cold: true,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
|
|
729
782
|
async prompt(
|
|
730
783
|
sessionId: string,
|
|
731
784
|
text: string,
|
|
@@ -827,6 +880,47 @@ export class SessionManager {
|
|
|
827
880
|
await s.conn.cancel({ sessionId: s.info.acpSessionId }).catch(() => {});
|
|
828
881
|
}
|
|
829
882
|
|
|
883
|
+
/**
|
|
884
|
+
* Put a NEW session into its backend row's default permission mode (BackendRow.defaultMode).
|
|
885
|
+
* Called from `create()` only — see the call site for why a resume must never do this.
|
|
886
|
+
*
|
|
887
|
+
* Three refusals are deliberate:
|
|
888
|
+
* · a row with no default sends NOTHING (the agent keeps its own default) — so a registry the
|
|
889
|
+
* operator never touched behaves exactly as it did before this existed;
|
|
890
|
+
* · a mode the agent did not advertise is not sent either: it would come back as a refusal nobody
|
|
891
|
+
* sees, and the settings row already wears a warning saying the pick does not match this
|
|
892
|
+
* agent's handshake (registry.rowWarnings);
|
|
893
|
+
* · a failure NEVER fails the slot. The session is usable, just in the agent's own mode — and the
|
|
894
|
+
* log line is the only evidence of which of those two happened, so it is not optional.
|
|
895
|
+
*/
|
|
896
|
+
async #applyDefaultMode(live: LiveSession): Promise<void> {
|
|
897
|
+
const wanted = (this.#store.getBackend(String(live.info.backend))?.defaultMode ?? "").trim();
|
|
898
|
+
if (!wanted) return;
|
|
899
|
+
const modes = live.info.modes;
|
|
900
|
+
const acpSessionId = live.info.acpSessionId;
|
|
901
|
+
if (!modes || !live.conn || !acpSessionId) return;
|
|
902
|
+
const ids = (modes.availableModes ?? []).map((m) => m.id);
|
|
903
|
+
if (!ids.includes(wanted)) {
|
|
904
|
+
console.log(
|
|
905
|
+
`[agentus] session ${live.info.id}: default permission mode "${wanted}" is not advertised by ` +
|
|
906
|
+
`${String(live.info.backend)} (${ids.join(", ") || "no modes"}) — sending nothing`,
|
|
907
|
+
);
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
if (modes.currentModeId === wanted) return;
|
|
911
|
+
try {
|
|
912
|
+
await live.conn.setSessionMode({ sessionId: acpSessionId, modeId: wanted });
|
|
913
|
+
live.info.modes = { ...modes, currentModeId: wanted };
|
|
914
|
+
console.log(`[agentus] session ${live.info.id}: default permission mode -> ${wanted}`);
|
|
915
|
+
} catch (e) {
|
|
916
|
+
// qodercli advertises modes but answered -32601 in the design probe, so "advertised but not
|
|
917
|
+
// implemented" is a real case — same tolerance as setMode(), and never a failed spawn.
|
|
918
|
+
console.log(
|
|
919
|
+
`[agentus] session ${live.info.id}: default permission mode "${wanted}" not applied: ${errMessage(e)}`,
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
830
924
|
async setMode(sessionId: string, modeId: string): Promise<void> {
|
|
831
925
|
const s = this.#need(sessionId);
|
|
832
926
|
if (!s.conn || !s.info.acpSessionId) throw new Error("session not ready");
|
|
@@ -1107,17 +1201,87 @@ export class SessionManager {
|
|
|
1107
1201
|
|
|
1108
1202
|
async closeSession(sessionId: string): Promise<void> {
|
|
1109
1203
|
const s = this.#need(sessionId);
|
|
1110
|
-
|
|
1204
|
+
this.#stop(s, "closed");
|
|
1205
|
+
this.#emitSessions();
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/** 归档 / 取消归档 — the operator's own STORAGE decision, taken independently of the process.
|
|
1209
|
+
*
|
|
1210
|
+
* Archiving a LIVE session stops its process too: 「我把它收起来了」 and 「它还在后台吃内存」
|
|
1211
|
+
* contradict each other, and demanding a close first would make the archive a two-step ritual that
|
|
1212
|
+
* says nothing about intent. Un-archiving only moves the flag — the row lands back in 工作空间 as
|
|
1213
|
+
* a cold slot, and the rail's 「取消归档并恢复」 wakes it with a separate resume (so a restore that
|
|
1214
|
+
* fails to load leaves a visible cold slot rather than an error nobody asked for). */
|
|
1215
|
+
async setArchived(id: string, on: boolean): Promise<SessionInfo> {
|
|
1216
|
+
const live = this.#sessions.get(id);
|
|
1217
|
+
const row = this.#store.getSession(id);
|
|
1218
|
+
if (!row) throw new Error(`no such session: ${id}`);
|
|
1219
|
+
if (on && live) this.#stop(live, "archived");
|
|
1220
|
+
this.#store.setArchived(id, on ? Date.now() : null);
|
|
1221
|
+
this.#emitSessions();
|
|
1222
|
+
return this.#sessions.get(id)?.info ?? this.#coldInfo(id);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/** Stop a slot's agent process and record WHY it went away. The row always survives with its
|
|
1226
|
+
* transcript: the transcript is the operator's, the process is ours to reclaim.
|
|
1227
|
+
*
|
|
1228
|
+
* `reaped` is its own status on purpose — 「它怎么变冷了」 has three different answers (I closed
|
|
1229
|
+
* it / it crashed / the cockpit reclaimed it for being idle) and a rail that shows one word for
|
|
1230
|
+
* all three teaches the operator to distrust the column. */
|
|
1231
|
+
#stop(live: LiveSession, why: "closed" | "archived" | "reaped"): void {
|
|
1232
|
+
for (const { resolve, timer } of live.pendingPermissions.values()) {
|
|
1111
1233
|
clearTimeout(timer);
|
|
1112
1234
|
resolve({ outcome: { outcome: "cancelled" } });
|
|
1113
1235
|
}
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1236
|
+
live.pendingPermissions.clear();
|
|
1237
|
+
live.child?.kill("SIGTERM");
|
|
1238
|
+
live.info.status = why === "reaped" ? "reaped" : "closed";
|
|
1239
|
+
live.info.pid = null;
|
|
1240
|
+
const row = this.#store.getSession(live.info.id);
|
|
1241
|
+
if (row) {
|
|
1242
|
+
this.#store.upsertSession({
|
|
1243
|
+
...row, status: live.info.status, closedAt: Date.now(), pid: null,
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
this.#sessions.delete(live.info.id);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/** Reclaim idle slots: stop the agent process of a session nobody has touched for the operator's
|
|
1250
|
+
* threshold. Deliberately NOT an archive — the row stays in 工作空间 wearing `reaped` and a click
|
|
1251
|
+
* brings it back. AionUi ships exactly this switch (设置 → 系统 → 「Agent 空闲超时(分钟)」,
|
|
1252
|
+
* default 5, 1–60) and the guards below are what its changelog had to add after the first version
|
|
1253
|
+
* killed agents that were still working: never touch a slot mid-turn, one waiting on a human, or
|
|
1254
|
+
* one the operator is WATCHING right now (a reply that lands while he reads it must not be killed
|
|
1255
|
+
* under him).
|
|
1256
|
+
*
|
|
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 {
|
|
1259
|
+
const interval = opts.intervalMs ?? 30_000;
|
|
1260
|
+
const tick = (): void => {
|
|
1261
|
+
const idleMs = opts.idleMs();
|
|
1262
|
+
if (!(idleMs > 0)) return;
|
|
1263
|
+
const now = Date.now();
|
|
1264
|
+
let reaped = 0;
|
|
1265
|
+
for (const s of [...this.#sessions.values()]) {
|
|
1266
|
+
if (s.busy || s.pendingPermissions.size) continue;
|
|
1267
|
+
if (s.info.status === "starting" || s.info.status === "running") continue;
|
|
1268
|
+
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
|
+
);
|
|
1274
|
+
this.#stop(s, "reaped");
|
|
1275
|
+
reaped += 1;
|
|
1276
|
+
}
|
|
1277
|
+
// Only speak when something changed: a frame every tick would be noise on the socket (and on
|
|
1278
|
+
// the phone's notification path, which observes this same stream).
|
|
1279
|
+
if (reaped) this.#emitSessions();
|
|
1280
|
+
};
|
|
1281
|
+
const timer = setInterval(tick, interval);
|
|
1282
|
+
// A server that will not exit because of the reclaim timer is a worse bug than the memory it saves.
|
|
1283
|
+
timer.unref?.();
|
|
1284
|
+
return () => clearInterval(timer);
|
|
1121
1285
|
}
|
|
1122
1286
|
|
|
1123
1287
|
/** The list every client needs to be current: the rows PLUS what is already waiting for an
|
|
@@ -1689,22 +1853,14 @@ export class SessionManager {
|
|
|
1689
1853
|
this.#store.touchActivity(live.info.id, at);
|
|
1690
1854
|
}
|
|
1691
1855
|
|
|
1692
|
-
/** One cold row, in the same shape list()/archived() produce. */
|
|
1856
|
+
/** One cold row, in the same shape list()/cold()/archived() produce. */
|
|
1693
1857
|
#coldInfo(id: string): SessionInfo {
|
|
1694
1858
|
const r = this.#store.getSession(id);
|
|
1695
1859
|
if (!r) throw new Error(`no such session: ${id}`);
|
|
1696
1860
|
return {
|
|
1697
|
-
|
|
1698
|
-
|
|
1861
|
+
...this.#infoFromRow(r),
|
|
1862
|
+
// the one difference from the list path: the model's own window, which the resume card prints
|
|
1699
1863
|
modelContextLimit: this.#store.getModelLimit(((r.models ?? null) as SessionInfo["models"])?.currentModelId ?? null),
|
|
1700
|
-
models: (r.models ?? null) as SessionInfo["models"],
|
|
1701
|
-
title: r.title, autoTitle: r.autoTitle ?? null, status: r.status, pid: null, createdAt: r.createdAt,
|
|
1702
|
-
modes: (r.modes ?? null) as SessionModeState | null,
|
|
1703
|
-
configOptions: (r.configOptions ?? []) as ConfigOptionView[],
|
|
1704
|
-
usage: (r.usage ?? null) as SessionInfo["usage"],
|
|
1705
|
-
commands: normCommands(r.commands),
|
|
1706
|
-
lastSeq: this.#store.maxSeq(id),
|
|
1707
|
-
lastAt: this.#store.lastActivityAt().get(id) ?? r.createdAt,
|
|
1708
1864
|
};
|
|
1709
1865
|
}
|
|
1710
1866
|
|
|
@@ -1748,6 +1904,8 @@ export class SessionManager {
|
|
|
1748
1904
|
pid: null,
|
|
1749
1905
|
createdAt: now,
|
|
1750
1906
|
closedAt: null,
|
|
1907
|
+
// a fork is a NEW conversation: it is nobody's archive (the parent's flag is not inherited)
|
|
1908
|
+
archivedAt: null,
|
|
1751
1909
|
// the agent just told us the fork's modes/options — keep them so the new slot shows
|
|
1752
1910
|
// the right permission mode and thinking depth before it is even resumed
|
|
1753
1911
|
modes: res?.modes ?? source.info.modes ?? null,
|
|
@@ -1798,6 +1956,9 @@ function sessionRow(i: SessionInfo, home: string | null = null) {
|
|
|
1798
1956
|
return {
|
|
1799
1957
|
id: i.id, backend: i.backend, acpSessionId: i.acpSessionId, cwd: i.cwd,
|
|
1800
1958
|
title: i.title, status: i.status, pid: i.pid, createdAt: i.createdAt, closedAt: null,
|
|
1959
|
+
// never written by this upsert (the archive flag has its own statement — see setArchived):
|
|
1960
|
+
// carried here only so the row type stays honest.
|
|
1961
|
+
archivedAt: i.archivedAt ?? null,
|
|
1801
1962
|
modes: i.modes ?? null, configOptions: i.configOptions ?? [],
|
|
1802
1963
|
usage: i.usage ?? null, commands: i.commands ?? [],
|
|
1803
1964
|
models: i.models ?? null,
|
|
@@ -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";
|
|
17
|
+
import { classifyError, checkedHealth, coerceRow, inspectRow, planFor, rowWarnings, seedRows, startupCheck } from "./acp/registry.js";
|
|
18
18
|
import { ChangedOnDiskError, FsError, listDirs, readTextFile, statFile, writeTextFile } from "./fs.js";
|
|
19
19
|
import { terms } from "./term.js";
|
|
20
20
|
import { VoiceError, listVoiceModels, setHotwordSource, synthesize, transcribe, voiceCapabilities } from "./voice.js";
|
|
21
21
|
import { hotwordsFor, vocabularyOf } from "./hotwords.js";
|
|
22
22
|
import { openDashscopeStream } from "./dashscope.js";
|
|
23
|
-
import { initSettings, publicSettings, saveCall, savePrefs, saveSettings, saveTheme } from "./settings.js";
|
|
23
|
+
import { agentSettings, initSettings, publicSettings, saveAgent, saveCall, savePrefs, saveSettings, saveTheme } from "./settings.js";
|
|
24
24
|
import * as auth from "./auth.js";
|
|
25
25
|
import { NotifyCenter } from "./notify/center.js";
|
|
26
26
|
import type { BackendId, ClientCommand, PermissionDecision, PromptAttachment, ServerEvent } from "../../shared/src/index.js";
|
|
@@ -169,9 +169,19 @@ const mgr = new SessionManager(store, emit);
|
|
|
169
169
|
|
|
170
170
|
/** The session list every client stays current with: the rows PLUS what is already waiting for
|
|
171
171
|
* an answer. Pending approvals are STATE, not just an event — a page that connects or refreshes
|
|
172
|
-
* after the request was raised still has to be able to show it.
|
|
172
|
+
* after the request was raised still has to be able to show it.
|
|
173
|
+
*
|
|
174
|
+
* Three buckets, because the rail has three answers to 「这个会话在哪」 and one field cannot carry
|
|
175
|
+
* them: `sessions` = live (a process is attached), `cold` = on disk with no process and NOT
|
|
176
|
+
* archived (they belong in 工作空间 too — a crashed slot must not look archived), `archived` = the
|
|
177
|
+
* operator put it away. Each row also carries `cold`/`archivedAt`, so a client never has to infer
|
|
178
|
+
* state from which list a row arrived in (that inference is exactly what merged 已归档 with 死了). */
|
|
173
179
|
const sessionsEvent = (): ServerEvent => ({
|
|
174
|
-
t: "sessions",
|
|
180
|
+
t: "sessions",
|
|
181
|
+
sessions: mgr.list(),
|
|
182
|
+
cold: mgr.cold(),
|
|
183
|
+
archived: mgr.archived(),
|
|
184
|
+
pending: mgr.pendingPermissions(),
|
|
175
185
|
});
|
|
176
186
|
|
|
177
187
|
// ---- notify channel (docs/android-notify-contract.md) ----------------------------
|
|
@@ -234,6 +244,43 @@ initSettings(DATA_DIR, store);
|
|
|
234
244
|
// so the lookup is injected rather than imported.
|
|
235
245
|
setHotwordSource((sessionId) => hotwordsFor(store, sessionId).map((h) => h.word));
|
|
236
246
|
|
|
247
|
+
// ---- idle reclaim (AionUi parity: 设置 → 系统 → 「Agent 空闲超时(分钟)」) ---------------------
|
|
248
|
+
// The operator's standing answer to 「一个 ready 了好几个小时的槽位还挂着 agent 进程」. It STOPS the
|
|
249
|
+
// process and nothing else: the row stays in 工作空间 wearing `reaped`, one click brings it back, and
|
|
250
|
+
// its transcript is untouched — reaping is not archiving (that is the operator's own decision now).
|
|
251
|
+
//
|
|
252
|
+
// The threshold is read on every tick, so changing it in 设置 applies immediately (no restart — a
|
|
253
|
+
// restart would kill exactly the slots this feature exists to keep alive cheaply). Two env knobs for
|
|
254
|
+
// QA: AGENTUS_IDLE_KILL_MIN accepts a FRACTION of a minute, which is how the reclaim path is
|
|
255
|
+
// exercised in seconds instead of in five minutes (AionUi made its own timeouts env-configurable for
|
|
256
|
+
// the same reason), and AGENTUS_IDLE_SWEEP_MS moves the tick.
|
|
257
|
+
{
|
|
258
|
+
// ABSENT must mean "follow 设置", not 0 — `Number("")` is 0, which would silently disable the
|
|
259
|
+
// reaper on every instance that never sets the env (measured: the startup line said 「0 min」 on an
|
|
260
|
+
// instance whose setting was 5). Only a real, non-empty value is an override.
|
|
261
|
+
const envNum = (raw: string | undefined): number | null => {
|
|
262
|
+
const s = String(raw ?? "").trim();
|
|
263
|
+
if (!s) return null;
|
|
264
|
+
const n = Number(s);
|
|
265
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
266
|
+
};
|
|
267
|
+
const envIdle = envNum(process.env.AGENTUS_IDLE_KILL_MIN);
|
|
268
|
+
const envIdleMs = envIdle != null ? envIdle * 60_000 : null;
|
|
269
|
+
const envSweep = envNum(process.env.AGENTUS_IDLE_SWEEP_MS);
|
|
270
|
+
const sweepMs = envSweep != null && envSweep >= 1000 ? envSweep : 30_000;
|
|
271
|
+
const source = envIdleMs != null ? "AGENTUS_IDLE_KILL_MIN" : "settings.agent.idleKillMin";
|
|
272
|
+
console.log(
|
|
273
|
+
`[agentus] idle reclaim: ${
|
|
274
|
+
envIdleMs != null ? envIdle : agentSettings().idleKillMin
|
|
275
|
+
} min (${source}), sweep ${Math.round(sweepMs / 1000)}s, 0 = off`,
|
|
276
|
+
);
|
|
277
|
+
mgr.startIdleReaper({
|
|
278
|
+
idleMs: () => envIdleMs ?? agentSettings().idleKillMin * 60_000,
|
|
279
|
+
watched: (id) => notify.operatorWatching(id),
|
|
280
|
+
intervalMs: sweepMs,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
237
284
|
/** The tail of the session being dictated into, for DashScope's context enhancement
|
|
238
285
|
* ("根据上下文"): user/assistant turns only, capped at 5 by the API. */
|
|
239
286
|
function dictationContext(sessionId?: string): { role: "user" | "assistant"; text: string }[] {
|
|
@@ -620,7 +667,10 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
620
667
|
// settings page shows it next to the row: it is the one declaration an operator may
|
|
621
668
|
// want to flip when an agent gains (or loses) native plan support.
|
|
622
669
|
nativePlanSource: row.nativePlanSource,
|
|
623
|
-
|
|
670
|
+
// Which permission mode a NEW session from this row starts in (null = the agent's own
|
|
671
|
+
// default); the settings page edits it next to the other row declarations.
|
|
672
|
+
defaultMode: row.defaultMode,
|
|
673
|
+
warnings: rowWarnings(row),
|
|
624
674
|
health: row.health, handshake: row.handshake,
|
|
625
675
|
};
|
|
626
676
|
}),
|
|
@@ -633,7 +683,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
633
683
|
if (store.getBackend(row.id)) return send(res, 409, { error: `backend ${row.id} already exists` });
|
|
634
684
|
store.upsertBackend(row);
|
|
635
685
|
const plan = planFor(row);
|
|
636
|
-
return send(res, 201, { ok: true, id: row.id, warnings:
|
|
686
|
+
return send(res, 201, { ok: true, id: row.id, warnings: rowWarnings(row) });
|
|
637
687
|
}
|
|
638
688
|
const beMatch = url.pathname.match(/^\/api\/backends\/([\w.-]+)(\/.*)?$/);
|
|
639
689
|
if (beMatch) {
|
|
@@ -643,7 +693,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
643
693
|
if (!existing) return send(res, 404, { error: `unknown backend: ${beId}` });
|
|
644
694
|
if (req.method === "GET" && beSub === "") {
|
|
645
695
|
const plan = planFor(existing);
|
|
646
|
-
return send(res, 200, { ...existing, home: plan.home, warnings:
|
|
696
|
+
return send(res, 200, { ...existing, home: plan.home, warnings: rowWarnings(existing) });
|
|
647
697
|
}
|
|
648
698
|
if ((req.method === "PATCH" || req.method === "PUT") && beSub === "") {
|
|
649
699
|
const body = (await readJson(req)) as Record<string, unknown>;
|
|
@@ -651,7 +701,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
651
701
|
if (!row) return send(res, 400, { error });
|
|
652
702
|
store.upsertBackend(row);
|
|
653
703
|
const plan = planFor(row);
|
|
654
|
-
return send(res, 200, { ok: true, id: row.id, warnings:
|
|
704
|
+
return send(res, 200, { ok: true, id: row.id, warnings: rowWarnings(row) });
|
|
655
705
|
}
|
|
656
706
|
if (req.method === "DELETE" && beSub === "") {
|
|
657
707
|
// A row that OPEN sessions still name must stay: deleting it would leave them pointing
|
|
@@ -700,7 +750,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
700
750
|
if (url.pathname === "/api/sessions" && req.method === "GET") {
|
|
701
751
|
// `pending` rides along because it is STATE, not an event: a page that loads or refreshes
|
|
702
752
|
// after the request was raised never saw the event, and would otherwise show nothing.
|
|
703
|
-
return send(res, 200, { live: mgr.list(), archived: mgr.archived(), pending: mgr.pendingPermissions() });
|
|
753
|
+
return send(res, 200, { live: mgr.list(), cold: mgr.cold(), archived: mgr.archived(), pending: mgr.pendingPermissions() });
|
|
704
754
|
}
|
|
705
755
|
// The session's plan object — what the cockpit's card renders. STATE too, and the one piece of
|
|
706
756
|
// it that cannot be replayed: after the agent process died there are no frames left to read,
|
|
@@ -829,7 +879,8 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
829
879
|
if (body.theme && typeof body.theme === "object") saveTheme(body.theme as Record<string, unknown>);
|
|
830
880
|
if (body.call && typeof body.call === "object") saveCall(body.call as Record<string, unknown>);
|
|
831
881
|
if (body.prefs && typeof body.prefs === "object") savePrefs(body.prefs as Record<string, unknown>);
|
|
832
|
-
if (
|
|
882
|
+
if (body.agent && typeof body.agent === "object") saveAgent(body.agent as Record<string, unknown>);
|
|
883
|
+
if (!body.voice && !body.theme && !body.call && !body.prefs && !body.agent) saveSettings(body);
|
|
833
884
|
return send(res, 200, publicSettings());
|
|
834
885
|
} catch (e) {
|
|
835
886
|
return send(res, 400, { error: String((e as Error)?.message ?? e) });
|
|
@@ -945,6 +996,30 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
945
996
|
}
|
|
946
997
|
return send(res, 404, { error: `no such session: ${id}` });
|
|
947
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
|
+
}
|
|
948
1023
|
if (req.method === "POST" && sub === "/resume") {
|
|
949
1024
|
// AC5's other half: bring a cold slot back to life (respawn + loadSession)
|
|
950
1025
|
try {
|
|
@@ -197,7 +197,18 @@ export class NotifyCenter {
|
|
|
197
197
|
|
|
198
198
|
/** True when a notification for this session would interrupt the operator looking at it. */
|
|
199
199
|
private watching(sessionId: string): boolean {
|
|
200
|
-
|
|
200
|
+
return this.rules().quietWhenWatching && this.operatorWatching(sessionId);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Is the operator looking at this session RIGHT NOW? A fresh presence report (the SPA re-reports
|
|
204
|
+
* every few seconds) from a visible page or a live call.
|
|
205
|
+
*
|
|
206
|
+
* Public and rule-free on purpose — the ONE question two features share with different gates.
|
|
207
|
+
* `watching()` above answers "should notification be quiet?" and therefore consults the operator's
|
|
208
|
+
* quietWhenWatching preference; the idle reaper asks "would killing this slot's process pull the
|
|
209
|
+
* rug out from under a reply he is reading?", and routing THAT through a notification preference
|
|
210
|
+
* would let a checkbox decide whether agent processes get killed. */
|
|
211
|
+
operatorWatching(sessionId: string): boolean {
|
|
201
212
|
if (this.presence.sessionId !== sessionId) return false;
|
|
202
213
|
if (Date.now() - this.presence.at >= NotifyCenter.WATCH_TTL_MS) return false;
|
|
203
214
|
// A call counts even when nobody has touched the screen for minutes: on a call the hands are free
|