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.
@@ -114,6 +114,24 @@ export const CALL_DEFAULT: CallSettings = {
114
114
 
115
115
  export const INTERRUPT_MODES: InterruptMode[] = ["voice", "button"];
116
116
 
117
+ /** How the cockpit treats AGENT PROCESSES — as opposed to what a conversation contains. AionUi
118
+ * ships the same knob in 设置 → 系统 as 「Agent 空闲超时(分钟)」 (default 5, 1–60, step 5) with the
119
+ * honest description 「空闲超过该时长后自动终止 Agent 进程以释放内存」. Agentus keeps that meaning
120
+ * and adds ONE thing AionUi does not have: 0 = 关闭. A cockpit deliberately left holding a
121
+ * long-running slot (a build, a tunnel watcher, a call) must be able to say "never reclaim this",
122
+ * and "set it to 60 and hope" is not the same statement. */
123
+ export interface AgentSettings {
124
+ /** minutes of no activity before a live slot's agent process is stopped to reclaim memory;
125
+ * 0 = never. The row and its transcript always survive — this only stops the process. */
126
+ idleKillMin: number;
127
+ }
128
+
129
+ export const AGENT_DEFAULT: AgentSettings = { idleKillMin: 5 };
130
+
131
+ /** What the settings page may offer: the ends of the number field. Server-side enforcement uses
132
+ * the same pair, so a hand-written request cannot store a nonsense value. */
133
+ export const AGENT_IDLE_RANGE: [number, number] = [0, 60];
134
+
117
135
  /** The NUMERIC knobs. `interruptMode` is a two-way choice, not a slider, so it is deliberately
118
136
  * outside both the range table and the loops that validate numbers. */
119
137
  export const CALL_NUMERIC = ["bargeSensitivity", "bargeMs", "silenceMs", "minChars"] as const;
@@ -147,6 +165,7 @@ export interface Settings {
147
165
  theme: ThemeSettings;
148
166
  call: CallSettings;
149
167
  prefs: PrefsSettings;
168
+ agent: AgentSettings;
150
169
  updatedAt: number;
151
170
  }
152
171
 
@@ -173,7 +192,7 @@ let db: Store | null = null;
173
192
  let legacyFile = "";
174
193
  let cache: Settings = {
175
194
  voice: { ...DEFAULTS }, theme: { ...THEME_DEFAULT }, call: { ...CALL_DEFAULT },
176
- prefs: { ...PREFS_DEFAULT }, updatedAt: 0,
195
+ prefs: { ...PREFS_DEFAULT }, agent: { ...AGENT_DEFAULT }, updatedAt: 0,
177
196
  };
178
197
  /** keys that came from env/.env (shown as "auto-detected" and used when settings are empty) */
179
198
  let envCreds: { apiKey: string; baseUrl: string; source: string } | null = null;
@@ -237,6 +256,7 @@ export function initSettings(dataDir: string, store: Store): Settings {
237
256
  theme: { ...THEME_DEFAULT, ...(stored.theme ?? {}) },
238
257
  call: pickCall(stored.call),
239
258
  prefs: pickPrefs(stored.prefs),
259
+ agent: pickAgent(stored.agent),
240
260
  updatedAt: Number((stored as Settings).updatedAt ?? 0),
241
261
  };
242
262
  // an unset baseUrl/apiKey bootstrap from the environment: the operator said the
@@ -266,6 +286,20 @@ function pickCall(raw: unknown): CallSettings {
266
286
  return out;
267
287
  }
268
288
 
289
+ /** The agent-process knobs. Out-of-range or unparseable values fall back to the shipped default
290
+ * rather than throwing: this section decides whether a slot's process is reclaimed, and a settings
291
+ * row that cannot be read must never leave the reaper in an undefined state (an undefined `> 0`
292
+ * comparison would silently turn reclaiming OFF — the safe failure is the documented default). */
293
+ function pickAgent(raw: unknown): AgentSettings {
294
+ const out = { ...AGENT_DEFAULT };
295
+ if (raw && typeof raw === "object") {
296
+ const v = Number((raw as Record<string, unknown>).idleKillMin);
297
+ const [lo, hi] = AGENT_IDLE_RANGE;
298
+ if (Number.isFinite(v) && v >= lo && v <= hi) out.idleKillMin = v;
299
+ }
300
+ return out;
301
+ }
302
+
269
303
  function pickPrefs(raw: unknown): PrefsSettings {
270
304
  const out = { ...PREFS_DEFAULT };
271
305
  if (raw && typeof raw === "object") {
@@ -289,6 +323,7 @@ function persist(): void {
289
323
  db.setSetting("theme", cache.theme);
290
324
  db.setSetting("call", cache.call);
291
325
  db.setSetting("prefs", cache.prefs);
326
+ db.setSetting("agent", cache.agent);
292
327
  } catch (e) {
293
328
  console.error(`[agentus] cannot write settings: ${(e as Error).message}`);
294
329
  }
@@ -331,6 +366,9 @@ export function publicSettings(): Record<string, unknown> {
331
366
  hotwordLimit: v.hotwordLimit,
332
367
  prefs: cache.prefs,
333
368
  prefsDefaults: PREFS_DEFAULT,
369
+ agent: cache.agent,
370
+ agentDefaults: AGENT_DEFAULT,
371
+ agentIdleRange: AGENT_IDLE_RANGE,
334
372
  // the defaults, so the UI can offer "reset to Bailian defaults"
335
373
  defaults: { ...DEFAULTS, apiKey: undefined, baseUrl: undefined },
336
374
  updatedAt: cache.updatedAt,
@@ -456,6 +494,31 @@ export function savePrefs(patch: Record<string, unknown>): Settings {
456
494
  return cache;
457
495
  }
458
496
 
497
+ /** Agent-process knobs: its own update, like theme/call, so a bad number here cannot lock the
498
+ * operator out of the page that would fix it. Range-checked against AGENT_IDLE_RANGE before it is
499
+ * stored — this number decides whether agent processes get killed, so it is not a text field. */
500
+ export function saveAgent(patch: Record<string, unknown>): Settings {
501
+ const a = { ...cache.agent };
502
+ if ("idleKillMin" in patch) {
503
+ const raw = typeof patch.idleKillMin === "number" ? patch.idleKillMin : Number(patch.idleKillMin);
504
+ const [lo, hi] = AGENT_IDLE_RANGE;
505
+ if (!Number.isFinite(raw) || raw < lo || raw > hi) {
506
+ throw new Error(`invalid idleKillMin: ${JSON.stringify(patch.idleKillMin)} (expected ${lo}…${hi} minutes, 0 = off)`);
507
+ }
508
+ a.idleKillMin = raw;
509
+ }
510
+ cache = { ...cache, agent: a, updatedAt: Date.now() };
511
+ persist();
512
+ return cache;
513
+ }
514
+
515
+ /** The agent-process knobs as the reaper reads them — on every tick, so a settings change applies
516
+ * without a restart. Returns the live cache object, not a copy: a caller that wants a snapshot has
517
+ * no business holding this one. */
518
+ export function agentSettings(): AgentSettings {
519
+ return cache.agent;
520
+ }
521
+
459
522
  /** Normalise the two URL shapes operators paste: MaaS root or .../compatible-mode/v1. */
460
523
  export function dashscopeUrls(baseUrl: string): { root: string; compat: string; ws: string } {
461
524
  const trimmed = baseUrl.replace(/\/+$/, "");
@@ -75,6 +75,9 @@ export interface SessionRow {
75
75
  pid: number | null;
76
76
  createdAt: number;
77
77
  closedAt: number | null;
78
+ /** When the OPERATOR put this session away (已归档). Null = it belongs in 工作空间. A storage
79
+ * state: it says nothing about whether a process is running (see SessionInfo.archivedAt). */
80
+ archivedAt: number | null;
78
81
  /** JSON round-trip of the ACP mode state + config options, so a resumed
79
82
  * session (M4-lite) comes back with the operator's mode/effort intact. */
80
83
  modes?: unknown;
@@ -105,7 +108,7 @@ export interface SessionRow {
105
108
  interface RawSessionRow {
106
109
  id: string; backend: BackendId; acp_session_id: string | null; cwd: string;
107
110
  title: string; auto_title?: string | null; status: SessionStatus; pid: number | null;
108
- created_at: number; closed_at: number | null;
111
+ created_at: number; closed_at: number | null; archived_at?: number | null;
109
112
  modes?: string | null; config_options?: string | null;
110
113
  usage?: string | null; commands?: string | null;
111
114
  workspace?: string | null;
@@ -169,7 +172,7 @@ function rowToSession(r: RawSessionRow): SessionRow {
169
172
  return {
170
173
  id: r.id, backend: r.backend, acpSessionId: r.acp_session_id, cwd: r.cwd,
171
174
  title: r.title, autoTitle: r.auto_title ?? null, status: r.status, pid: r.pid,
172
- createdAt: r.created_at, closedAt: r.closed_at,
175
+ createdAt: r.created_at, closedAt: r.closed_at, archivedAt: r.archived_at ?? null,
173
176
  modes: parseJson(r.modes), configOptions: parseJson(r.config_options) ?? [],
174
177
  usage: parseJson(r.usage), commands: parseJson(r.commands) ?? [],
175
178
  workspace: r.workspace ?? null,
@@ -182,6 +185,7 @@ function rowToSession(r: RawSessionRow): SessionRow {
182
185
  interface RawBackendRow {
183
186
  id: string; label: string; kind: string; cmd: string; args: string; env: string;
184
187
  home: string | null; profile: string | null; cwd: string | null; notes: string;
188
+ default_mode: string | null;
185
189
  native_plan_source: string | null;
186
190
  builtin: number; created_at: number; updated_at: number;
187
191
  // health snapshot (M6.1) — system-written, see registry.BackendHealth
@@ -212,6 +216,9 @@ function rowToBackend(r: RawBackendRow): BackendRow {
212
216
  home: r.home ?? null,
213
217
  profile: r.profile ?? null,
214
218
  cwd: r.cwd ?? null,
219
+ // A row predating the column (or a seeded one) says nothing: send no mode at all, so the agent
220
+ // keeps its own default. No mode name is invented here — "缺省" must mean "不上手".
221
+ defaultMode: (r.default_mode ?? "").trim() || null,
215
222
  notes: r.notes ?? "",
216
223
  builtin: r.builtin === 1,
217
224
  health: {
@@ -304,6 +311,8 @@ export class Store {
304
311
  id text primary key, label text not null, kind text not null,
305
312
  cmd text not null, args text not null default '[]', env text not null default '{}',
306
313
  home text, profile text, cwd text, notes text not null default '',
314
+ -- the ACP mode a NEW session from this row starts in (null = the agent's own default)
315
+ default_mode text,
307
316
  builtin integer not null default 0,
308
317
  created_at integer not null, updated_at integer not null
309
318
  );
@@ -334,6 +343,13 @@ export class Store {
334
343
  this.#db.exec("update sessions set auto_title = title where auto_title is null or auto_title = ''");
335
344
  // integer column, so it gets its own migration (the loop above assumes text)
336
345
  if (!cols.has("context_limit")) this.#db.exec("alter table sessions add column context_limit integer");
346
+ // WHEN THE OPERATOR PUT THE SESSION AWAY (已归档 — a STORAGE state). Before this column the rail
347
+ // inferred "archived" from "has no process", so a slot that merely crashed (or was killed by an
348
+ // idle reap) filed itself under 已归档 next to the ones the operator really put away, and
349
+ // 「我在归档一栏里看到的」 stopped meaning anything. NULL is the honest default for every existing
350
+ // row: nobody ever archived them, they were just dead. The 已归档 section starts empty and fills
351
+ // with what the operator actually archives.
352
+ if (!cols.has("archived_at")) this.#db.exec("alter table sessions add column archived_at integer");
337
353
  // The session's own ACTIVITY clock — when the operator or the agent last really said something
338
354
  // in it. A COLUMN, not a derived `max(messages.created_at)` (AionUi's model: `modified_at` on
339
355
  // the conversation row, indexed, which is what its sidebar sorts by): every ingestion path
@@ -362,6 +378,9 @@ export class Store {
362
378
  // P1 (plan service): which channel carries this backend's plan, `acp` | `none`. Nullable on
363
379
  // purpose — a row that predates it follows the same default as a seeded one (see rowToBackend).
364
380
  "native_plan_source",
381
+ // The permission mode a new session starts in. Nullable like the rest: a row that predates the
382
+ // column means "send nothing" (the agent's own default), not "some mode we invented".
383
+ "default_mode",
365
384
  ]) {
366
385
  if (!beCols.has(col)) this.#db.exec(`alter table backends add column ${col} text`);
367
386
  }
@@ -693,6 +712,16 @@ export class Store {
693
712
  }
694
713
  }
695
714
 
715
+ /** Put this session away — or take it back out (已归档). Its OWN statement, like `home`: the
716
+ * archive flag is the one thing the operator sets directly, and a routine row upsert (a status
717
+ * change, a rename, an idle reap) must not be able to clear it. Returns whether a row matched. */
718
+ setArchived(sessionId: string, at: number | null): boolean {
719
+ const info = this.#db
720
+ .prepare("update sessions set archived_at = ? where id = ?")
721
+ .run(at, sessionId);
722
+ return Number(info.changes ?? 0) > 0;
723
+ }
724
+
696
725
  /** Record (or adopt) the home a session was created under. Used when a legacy row — created
697
726
  * before the column existed — is resumed successfully: the successful load is itself the
698
727
  * proof of which home holds its state, so the row stops being ambiguous. */
@@ -1153,16 +1182,18 @@ export class Store {
1153
1182
  this.#db
1154
1183
  .prepare(
1155
1184
  `insert into backends (id, label, kind, cmd, args, env, home, profile, cwd, notes,
1185
+ default_mode,
1156
1186
  native_plan_source,
1157
1187
  builtin, created_at, updated_at,
1158
1188
  last_check_status, last_check_kind, last_check_error_code,
1159
1189
  last_check_error_message, last_check_guidance, last_check_latency_ms,
1160
1190
  last_check_at, last_success_at, last_failure_at, handshake, handshake_at)
1161
- values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1191
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1162
1192
  on conflict(id) do update set
1163
1193
  label = excluded.label, kind = excluded.kind, cmd = excluded.cmd, args = excluded.args,
1164
1194
  env = excluded.env, home = excluded.home, profile = excluded.profile, cwd = excluded.cwd,
1165
1195
  notes = excluded.notes,
1196
+ default_mode = excluded.default_mode,
1166
1197
  native_plan_source = excluded.native_plan_source,
1167
1198
  updated_at = excluded.updated_at,
1168
1199
  -- the evidence travels with the row: a spawn-relevant edit replaces it with a cleared
@@ -1179,6 +1210,7 @@ export class Store {
1179
1210
  .run(
1180
1211
  row.id, row.label, row.kind, row.cmd, JSON.stringify(row.args), JSON.stringify(row.env ?? {}),
1181
1212
  row.home, row.profile, row.cwd, row.notes ?? "",
1213
+ row.defaultMode ?? null,
1182
1214
  row.nativePlanSource ?? null,
1183
1215
  row.builtin ? 1 : 0, row.createdAt, row.updatedAt,
1184
1216
  row.health?.status ?? null, row.health?.kind ?? null, row.health?.errorCode ?? null,
@@ -22,6 +22,7 @@ export type SessionStatus =
22
22
  | "starting" // subprocess spawned, initialize/newSession in flight
23
23
  | "ready" // accepts prompts
24
24
  | "running" // a prompt turn is in flight
25
+ | "reaped" // no process: it was idle past the operator's threshold (settings.agent.idleKillMin)
25
26
  | "error" // subprocess died or handshake failed
26
27
  | "closed";
27
28
 
@@ -74,6 +75,15 @@ export interface SessionInfo {
74
75
  * This — not `createdAt`, and not `lastSeq` (a per-session counter) — is what the rail
75
76
  * orders by: "which of these did I last talk to". */
76
77
  lastAt?: number;
78
+ /** The operator PUT THIS SESSION AWAY: it belongs in the rail's 已归档 section, whatever its
79
+ * process is doing. This is a STORAGE state, not a liveness one — the rail used to infer
80
+ * `archived` from `has no process`, which filed a crashed slot and a deliberately archived
81
+ * one in the same place (the operator's complaint: 「我设想的点击归档是一种存储态」). */
82
+ archivedAt?: number | null;
83
+ /** No agent process right now — it was closed, it crashed, or it was REAPED for being idle.
84
+ * Derived server-side and sent with the row, so the client never infers liveness from which
85
+ * list a row arrived in (that inference is what merged 已归档 with 死了). */
86
+ cold?: boolean;
77
87
  }
78
88
 
79
89
  export interface AvailableCommandView {
@@ -148,7 +158,7 @@ export interface PermissionDiff {
148
158
  // ---- WS envelope (server -> browser) ----
149
159
  export type ServerEvent =
150
160
  | { t: "hello"; clientId: string; resumed: boolean }
151
- | { t: "sessions"; sessions: SessionInfo[]; /** requests already waiting for an answer, so a page that connects or refreshes learns about them (they are STATE; the events happened before it existed) */ pending?: PermissionRequestView[] }
161
+ | { t: "sessions"; sessions: SessionInfo[]; /** rows on disk with NO process and no archive flag — they belong in 工作空间 beside the live ones (a crashed slot must not look archived, and must not vanish from the rail either) */ cold?: SessionInfo[]; /** rows the operator explicitly archived (archivedAt set) — the 已归档 section, whatever their process state */ archived?: SessionInfo[]; /** requests already waiting for an answer, so a page that connects or refreshes learns about them (they are STATE; the events happened before it existed) */ pending?: PermissionRequestView[] }
152
162
  | { t: "session"; session: SessionInfo }
153
163
  | { t: "messages"; sessionId: string; messages: StoredMessage[]; hasMore: boolean; partial?: boolean }
154
164
  | { t: "message"; message: StoredMessage; /** text to APPEND for a streamed block that grew (absent