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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentus",
3
- "version": "0.2.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
- const ID_RE = /^[a-z0-9][a-z0-9._-]{0,40}$/;
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
- /** Sessions we know about on disk but have no process for (rail's cold slots). */
703
- archived(limit = 20): SessionInfo[] {
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) => !this.#sessions.has(r.id))
711
- .map((r) => ({
712
- id: r.id, backend: r.backend, acpSessionId: r.acpSessionId, cwd: r.cwd,
713
- workspace: r.workspace ?? null,
714
- models: (r.models ?? null) as SessionInfo["models"],
715
- contextLimit: r.contextLimit ?? null,
716
- title: r.title, autoTitle: r.autoTitle ?? null, status: r.status, pid: null, createdAt: r.createdAt,
717
- modes: (r.modes ?? null) as SessionModeState | null,
718
- configOptions: (r.configOptions ?? []) as ConfigOptionView[],
719
- usage: (r.usage ?? null) as SessionInfo["usage"],
720
- commands: normCommands(r.commands),
721
- lastSeq: this.#store.maxSeq(r.id),
722
- lastAt: this.#store.lastActivityAt().get(r.id) ?? r.createdAt,
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
- for (const { resolve, timer } of s.pendingPermissions.values()) {
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
- s.pendingPermissions.clear();
1115
- s.child?.kill("SIGTERM");
1116
- s.info.status = "closed";
1117
- const row = this.#store.getSession(sessionId);
1118
- if (row) this.#store.upsertSession({ ...row, status: "closed", closedAt: Date.now(), pid: null });
1119
- this.#sessions.delete(sessionId);
1120
- this.#emitSessions();
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
- id: r.id, backend: r.backend, acpSessionId: r.acpSessionId, cwd: r.cwd,
1698
- workspace: r.workspace ?? null, contextLimit: r.contextLimit ?? null,
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", sessions: mgr.list(), pending: mgr.pendingPermissions(),
180
+ t: "sessions",
181
+ sessions: mgr.list(),
182
+ cold: mgr.cold(),
183
+ archived: mgr.archived(),
184
+ pending: mgr.pendingPermissions(),
175
185
  });
176
186
 
177
187
  // ---- notify channel (docs/android-notify-contract.md) ----------------------------
@@ -234,6 +244,43 @@ initSettings(DATA_DIR, store);
234
244
  // so the lookup is injected rather than imported.
235
245
  setHotwordSource((sessionId) => hotwordsFor(store, sessionId).map((h) => h.word));
236
246
 
247
+ // ---- idle reclaim (AionUi parity: 设置 → 系统 → 「Agent 空闲超时(分钟)」) ---------------------
248
+ // The operator's standing answer to 「一个 ready 了好几个小时的槽位还挂着 agent 进程」. It STOPS the
249
+ // process and nothing else: the row stays in 工作空间 wearing `reaped`, one click brings it back, and
250
+ // its transcript is untouched — reaping is not archiving (that is the operator's own decision now).
251
+ //
252
+ // The threshold is read on every tick, so changing it in 设置 applies immediately (no restart — a
253
+ // restart would kill exactly the slots this feature exists to keep alive cheaply). Two env knobs for
254
+ // QA: AGENTUS_IDLE_KILL_MIN accepts a FRACTION of a minute, which is how the reclaim path is
255
+ // exercised in seconds instead of in five minutes (AionUi made its own timeouts env-configurable for
256
+ // the same reason), and AGENTUS_IDLE_SWEEP_MS moves the tick.
257
+ {
258
+ // ABSENT must mean "follow 设置", not 0 — `Number("")` is 0, which would silently disable the
259
+ // reaper on every instance that never sets the env (measured: the startup line said 「0 min」 on an
260
+ // instance whose setting was 5). Only a real, non-empty value is an override.
261
+ const envNum = (raw: string | undefined): number | null => {
262
+ const s = String(raw ?? "").trim();
263
+ if (!s) return null;
264
+ const n = Number(s);
265
+ return Number.isFinite(n) && n >= 0 ? n : null;
266
+ };
267
+ const envIdle = envNum(process.env.AGENTUS_IDLE_KILL_MIN);
268
+ const envIdleMs = envIdle != null ? envIdle * 60_000 : null;
269
+ const envSweep = envNum(process.env.AGENTUS_IDLE_SWEEP_MS);
270
+ const sweepMs = envSweep != null && envSweep >= 1000 ? envSweep : 30_000;
271
+ const source = envIdleMs != null ? "AGENTUS_IDLE_KILL_MIN" : "settings.agent.idleKillMin";
272
+ console.log(
273
+ `[agentus] idle reclaim: ${
274
+ envIdleMs != null ? envIdle : agentSettings().idleKillMin
275
+ } min (${source}), sweep ${Math.round(sweepMs / 1000)}s, 0 = off`,
276
+ );
277
+ mgr.startIdleReaper({
278
+ idleMs: () => envIdleMs ?? agentSettings().idleKillMin * 60_000,
279
+ watched: (id) => notify.operatorWatching(id),
280
+ intervalMs: sweepMs,
281
+ });
282
+ }
283
+
237
284
  /** The tail of the session being dictated into, for DashScope's context enhancement
238
285
  * ("根据上下文"): user/assistant turns only, capped at 5 by the API. */
239
286
  function dictationContext(sessionId?: string): { role: "user" | "assistant"; text: string }[] {
@@ -620,7 +667,10 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
620
667
  // settings page shows it next to the row: it is the one declaration an operator may
621
668
  // want to flip when an agent gains (or loses) native plan support.
622
669
  nativePlanSource: row.nativePlanSource,
623
- warnings: plan.warnings,
670
+ // Which permission mode a NEW session from this row starts in (null = the agent's own
671
+ // default); the settings page edits it next to the other row declarations.
672
+ defaultMode: row.defaultMode,
673
+ warnings: rowWarnings(row),
624
674
  health: row.health, handshake: row.handshake,
625
675
  };
626
676
  }),
@@ -633,7 +683,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
633
683
  if (store.getBackend(row.id)) return send(res, 409, { error: `backend ${row.id} already exists` });
634
684
  store.upsertBackend(row);
635
685
  const plan = planFor(row);
636
- return send(res, 201, { ok: true, id: row.id, warnings: plan.warnings });
686
+ return send(res, 201, { ok: true, id: row.id, warnings: rowWarnings(row) });
637
687
  }
638
688
  const beMatch = url.pathname.match(/^\/api\/backends\/([\w.-]+)(\/.*)?$/);
639
689
  if (beMatch) {
@@ -643,7 +693,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
643
693
  if (!existing) return send(res, 404, { error: `unknown backend: ${beId}` });
644
694
  if (req.method === "GET" && beSub === "") {
645
695
  const plan = planFor(existing);
646
- return send(res, 200, { ...existing, home: plan.home, warnings: plan.warnings });
696
+ return send(res, 200, { ...existing, home: plan.home, warnings: rowWarnings(existing) });
647
697
  }
648
698
  if ((req.method === "PATCH" || req.method === "PUT") && beSub === "") {
649
699
  const body = (await readJson(req)) as Record<string, unknown>;
@@ -651,7 +701,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
651
701
  if (!row) return send(res, 400, { error });
652
702
  store.upsertBackend(row);
653
703
  const plan = planFor(row);
654
- return send(res, 200, { ok: true, id: row.id, warnings: plan.warnings });
704
+ return send(res, 200, { ok: true, id: row.id, warnings: rowWarnings(row) });
655
705
  }
656
706
  if (req.method === "DELETE" && beSub === "") {
657
707
  // A row that OPEN sessions still name must stay: deleting it would leave them pointing
@@ -700,7 +750,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
700
750
  if (url.pathname === "/api/sessions" && req.method === "GET") {
701
751
  // `pending` rides along because it is STATE, not an event: a page that loads or refreshes
702
752
  // after the request was raised never saw the event, and would otherwise show nothing.
703
- return send(res, 200, { live: mgr.list(), archived: mgr.archived(), pending: mgr.pendingPermissions() });
753
+ return send(res, 200, { live: mgr.list(), cold: mgr.cold(), archived: mgr.archived(), pending: mgr.pendingPermissions() });
704
754
  }
705
755
  // The session's plan object — what the cockpit's card renders. STATE too, and the one piece of
706
756
  // it that cannot be replayed: after the agent process died there are no frames left to read,
@@ -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 (!body.voice && !body.theme && !body.call && !body.prefs) saveSettings(body);
882
+ if (body.agent && typeof body.agent === "object") saveAgent(body.agent as Record<string, unknown>);
883
+ if (!body.voice && !body.theme && !body.call && !body.prefs && !body.agent) saveSettings(body);
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
- if (!this.rules().quietWhenWatching) return false;
200
+ return this.rules().quietWhenWatching && this.operatorWatching(sessionId);
201
+ }
202
+
203
+ /** Is the operator looking at this session RIGHT NOW? A fresh presence report (the SPA re-reports
204
+ * every few seconds) from a visible page or a live call.
205
+ *
206
+ * Public and rule-free on purpose — the ONE question two features share with different gates.
207
+ * `watching()` above answers "should notification be quiet?" and therefore consults the operator's
208
+ * quietWhenWatching preference; the idle reaper asks "would killing this slot's process pull the
209
+ * rug out from under a reply he is reading?", and routing THAT through a notification preference
210
+ * would let a checkbox decide whether agent processes get killed. */
211
+ operatorWatching(sessionId: string): boolean {
201
212
  if (this.presence.sessionId !== sessionId) return false;
202
213
  if (Date.now() - this.presence.at >= NotifyCenter.WATCH_TTL_MS) return false;
203
214
  // A call counts even when nobody has touched the screen for minutes: on a call the hands are free