agentus 0.3.8 → 0.4.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.
@@ -0,0 +1,327 @@
1
+ // 工作播报 (issue #16): ONE long-lived observer agent per watched session, driven by the cockpit.
2
+ //
3
+ // WHY AN AGENT AND NOT A TEMPLATE
4
+ // The cockpit could say 「回合结束了」 by itself — that is exactly what #11's plan title does, and it
5
+ // stays as the fallback. What it cannot do is NOTICE: 「第三步失败了,它在重试同一条命令」 is a
6
+ // judgement about a transcript, and a string template that tries to make that judgement is a
7
+ // summariser nobody can debug. So the cockpit hires a reader: a normal ACP session with its own
8
+ // transcript, spawned once, kept alive, and asked one line per event (「看看进展」). Because it is
9
+ // long-lived its context carries over — the second brief costs a line, not the whole story — and
10
+ // because its facts come from the cockpit CLI (`$AGENTUS_COCKPIT_BIN`, issue #15) the trigger line
11
+ // never has to carry data that could be wrong.
12
+ //
13
+ // WHAT THIS MODULE IS ALLOWED TO KNOW
14
+ // Nothing about agents' internals. It reads ServerEvents off the same emitter the browser reads,
15
+ // decides "is this worth a voice", and (for two event kinds) formats the line ITSELF with no model
16
+ // call at all: an approval waiting and a plan step closing are facts, and paying a model to restate a
17
+ // fact is how a cockpit becomes expensive and vague.
18
+ //
19
+ // THE OBSERVER IS A SYSTEM SESSION
20
+ // Its working directory is a scratch directory of its own (`<home>/observers/<sid>`), never the
21
+ // observed session's cwd: an observer runs a full agent runtime with file and shell tools, and it
22
+ // should no more be able to write into the project than a log shipper should. `system: true` keeps it
23
+ // out of the rail and out of the cockpit CLI's scope (see SessionManager.list). It is spawned ONCE
24
+ // and reused; the idle reaper may put it to sleep like any slot, and `ensure()` wakes it.
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+ import type { BackendId, ServerEvent } from "../../../shared/src/index.js";
28
+ import type { ObserverSettings } from "../settings.js";
29
+ import { OBSERVER_PROMPT_DEFAULT } from "../settings.js";
30
+
31
+ /** Spoken lines are short on purpose: they are heard once, in the middle of other work. */
32
+ export const BRIEF_MAX = 120;
33
+
34
+ export interface ObserverDeps {
35
+ /** Live settings, read per event — the switch must work without a restart. */
36
+ settings(): ObserverSettings;
37
+ /** Whether the operator's speaker is in 工作播报 mode. False = do not even try. */
38
+ speakEnabled(): boolean;
39
+ /** Spawn a SYSTEM session (out of the rail); returns our id for it. */
40
+ createSession(backend: BackendId, cwd: string, title: string): Promise<string>;
41
+ /** Send one line to a session. Never `interrupt`: an observer must not take the floor. */
42
+ prompt(sessionId: string, text: string): Promise<void>;
43
+ /** Resolve once a freshly spawned session can accept a prompt (its handshake finished). */
44
+ waitReady(sessionId: string, timeoutMs?: number): Promise<void>;
45
+ /** The title a session is known by (for the trigger line and the prompt header). */
46
+ titleOf(sessionId: string): string | null;
47
+ /** Which backend the observed session runs on (used when the observer follows it). */
48
+ backendOf(sessionId: string): BackendId | null;
49
+ /** #11's one-line task title for a session — the zero-cost fallback when no observer can speak. */
50
+ planTitleOf?(sessionId: string): string | null;
51
+ /** Close (or just forget) an observer whose observed session is gone. */
52
+ disposeSession?(observerId: string): Promise<void>;
53
+ /** Best-effort: move a session into a read-only mode if the backend offers one. */
54
+ preferReadonly?(sessionId: string): void;
55
+ /** Base directory for per-observer scratch dirs. */
56
+ home: string;
57
+ emit(evt: ServerEvent): void;
58
+ now?(): number;
59
+ }
60
+
61
+ interface Watch {
62
+ /** The session being watched (the watch map is keyed by it too; kept for the observer's side). */
63
+ observedId: string;
64
+ /** Our observer session id, once one exists. */
65
+ observerId: string | null;
66
+ /** A spawn is in flight — do not start a second one. */
67
+ spawning: boolean;
68
+ /** When the last MODEL-backed brief went out (for the throttle). */
69
+ lastBriefAt: number;
70
+ /** The trigger is owed: an event arrived while a brief was still being produced. */
71
+ queued: boolean;
72
+ /** Text of the observer's current turn, accumulated from `message` frames. */
73
+ answer: string;
74
+ /** True while the observer is producing a brief. */
75
+ busy: boolean;
76
+ /** Plan step keys already announced, so an unchanged plan is not read out twice. */
77
+ announcedSteps: Set<string>;
78
+ }
79
+
80
+ const NOTHING = "(没有可播报的进展)";
81
+
82
+ /** Cut a model's answer down to one spoken line: first non-empty line, no Markdown furniture, no
83
+ * quotes, hard cap. A brief is read aloud, so anything resembling formatting is noise. */
84
+ export function oneLine(text: string, max = BRIEF_MAX): string {
85
+ const first = text
86
+ .split(/\r?\n/)
87
+ .map((l) => l.replace(/^\s*(?:[-*#>\d.)\s]+)/, "").trim())
88
+ .find((l) => l.length > 0) ?? "";
89
+ const flat = first
90
+ .replace(/[*_`]/g, "")
91
+ .replace(/^["'「『]+|["'」』]+$/g, "")
92
+ .replace(/\s+/g, " ")
93
+ .trim();
94
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
95
+ }
96
+
97
+ export class ObserverHub {
98
+ #deps: ObserverDeps;
99
+ #watch = new Map<string, Watch>();
100
+ /** Observer ids, so their own frames are never mistaken for the operator's sessions. */
101
+ #observers = new Set<string>();
102
+ #now: () => number;
103
+
104
+ constructor(deps: ObserverDeps) {
105
+ this.#deps = deps;
106
+ this.#now = deps.now ?? Date.now;
107
+ }
108
+
109
+ observerIds(): string[] {
110
+ return [...this.#observers];
111
+ }
112
+
113
+ isObserver(sessionId: string): boolean {
114
+ return this.#observers.has(sessionId);
115
+ }
116
+
117
+ /** Forget everything about a session (it was closed, archived or purged). The observer process is
118
+ * the idle reaper's business; we only stop treating this pair as current. */
119
+ forget(sessionId: string): void {
120
+ const w = this.#watch.get(sessionId);
121
+ this.#watch.delete(sessionId);
122
+ if (w?.observerId) this.#observers.delete(w.observerId);
123
+ }
124
+
125
+ /** Every ServerEvent in the process passes through here (index.ts wires it in front of the
126
+ * broadcast). Cheap by construction: a few kind checks per frame. */
127
+ onEvent(evt: ServerEvent): void {
128
+ if (evt.t === "message") {
129
+ // The observer's own answer, being assembled.
130
+ const sid = evt.message.sessionId;
131
+ // `message` frames for an observer carry the OBSERVER's id, while the watch entry is keyed by
132
+ // the OBSERVED session — so this is a lookup by observer id, not by key.
133
+ for (const watch of this.#watch.values()) {
134
+ if (watch.observerId !== sid) continue;
135
+ if (evt.message.kind === "agent") watch.answer += textOfPayload(evt.message.payload);
136
+ }
137
+ return;
138
+ }
139
+
140
+ if (evt.t === "turn-end") {
141
+ // An observer's own turn-end IS the brief's arrival — resolved by observer id, because the
142
+ // watch map is keyed by the OBSERVED session (looking the observer up in it finds nothing,
143
+ // which is how the first version turned every observer turn into another trigger).
144
+ if (this.#observers.has(evt.sessionId)) {
145
+ for (const w of this.#watch.values()) if (w.observerId === evt.sessionId) this.#finishBrief(w);
146
+ return;
147
+ }
148
+ void this.#maybeBrief(evt.sessionId, evt.error);
149
+ return;
150
+ }
151
+
152
+ if (evt.t === "permission") {
153
+ // A request waiting for an answer is the one thing never worth a model call: the fact is
154
+ // right here, and the operator may be away from the screen for a while.
155
+ if (!this.#speaking()) return;
156
+ const sid = evt.request.sessionId;
157
+ const title = evt.request.toolCallTitle?.trim();
158
+ this.#speak(sid, title ? `有请求等你确认:${title}` : "有个请求等你确认", "approval", 2);
159
+ return;
160
+ }
161
+
162
+ if (evt.t === "plan") {
163
+ if (!this.#speaking()) return;
164
+ const w = this.#watchFor(evt.sessionId);
165
+ const plan = evt.plan;
166
+ if (!plan) return;
167
+ for (const item of plan.items) {
168
+ if (item.status !== "completed") continue;
169
+ // PlanItem has no id: its content IS the identity (ACP replaces the whole list each frame).
170
+ const key = item.content;
171
+ if (w.announcedSteps.has(key)) continue;
172
+ w.announcedSteps.add(key);
173
+ // The FIRST completed step of a plan is worth a word; after that the rail is the place to
174
+ // read progress, and a voice per step would be a metronome.
175
+ if (w.announcedSteps.size === 1) {
176
+ this.#speak(evt.sessionId, `完成一步:${oneLine(item.content, 60)}`, "plan-step", 1);
177
+ }
178
+ }
179
+ return;
180
+ }
181
+ }
182
+
183
+ /** A turn ended on a session the operator owns: is it worth a brief? */
184
+ async #maybeBrief(sessionId: string, error?: string): Promise<void> {
185
+ if (!this.#speaking()) return;
186
+ if (this.#observers.has(sessionId)) return;
187
+ const w = this.#watchFor(sessionId);
188
+ const settings = this.#deps.settings();
189
+ if (!settings.enabled) {
190
+ // Degrade to the zero-cost line (#11's plan title) — the observer is off, but the operator
191
+ // still asked to be told what happened.
192
+ const title = this.#deps.titleOf(sessionId);
193
+ const planTitle = this.#deps.planTitleOf?.(sessionId) ?? null;
194
+ if (error) this.#speak(sessionId, `${title ?? "会话"} 出错了:${oneLine(error, 60)}`, "fallback", 1);
195
+ else if (planTitle) this.#speak(sessionId, `${title ?? "会话"} · ${planTitle}`, "fallback", 1);
196
+ return;
197
+ }
198
+ if (w.busy) {
199
+ // A brief is in flight: remember that another one is owed and let the current one finish,
200
+ // rather than interleaving two turns into one voice.
201
+ w.queued = true;
202
+ return;
203
+ }
204
+ const gap = settings.throttleSec * 1000;
205
+ if (gap > 0 && this.#now() - w.lastBriefAt < gap) return;
206
+
207
+ let observerId = w.observerId;
208
+ if (!observerId) {
209
+ if (w.spawning) {
210
+ w.queued = true;
211
+ return;
212
+ }
213
+ w.spawning = true;
214
+ // Busy from here: the observer's FIRST turn (persona + look) is itself a brief, and a second
215
+ // event arriving during the spawn must queue instead of starting a rival turn.
216
+ w.busy = true;
217
+ try {
218
+ observerId = await this.#spawnObserver(w);
219
+ } catch (e) {
220
+ w.spawning = false;
221
+ w.busy = false;
222
+ // Degrade rather than go silent: the operator asked for 工作播报 and a broken observer must
223
+ // not turn the feature off without telling them.
224
+ const planTitle = this.#deps.planTitleOf?.(sessionId) ?? null;
225
+ const title = this.#deps.titleOf(sessionId) ?? "会话";
226
+ this.#speak(
227
+ sessionId,
228
+ planTitle ? `${title} · ${planTitle}` : `${title} 的播报员没起来:${oneLine(String((e as Error)?.message ?? e), 50)}`,
229
+ "fallback",
230
+ 1,
231
+ );
232
+ return;
233
+ }
234
+ w.spawning = false;
235
+ // `#spawnObserver` already registered the id and marked the watch busy: the first brief is
236
+ // produced by the prompt it sent, so there is nothing to send here.
237
+ return;
238
+ }
239
+
240
+ w.busy = true;
241
+ w.lastBriefAt = this.#now();
242
+ // The ONE thing an event passes the observer: that something happened. Everything else is read.
243
+ const why = error ? "这一轮报了错。" : "这一轮结束了。";
244
+ try {
245
+ await this.#deps.prompt(observerId, `${why}看一眼进展,按你的契约给一句播报。`);
246
+ } catch (e) {
247
+ w.busy = false;
248
+ this.#speak(sessionId, `播报失败:${oneLine(String((e as Error)?.message ?? e), 50)}`, "fallback", 1);
249
+ }
250
+ }
251
+
252
+ /** The observer answered: turn its text into the spoken line. */
253
+ #finishBrief(w: Watch): void {
254
+ const text = oneLine(w.answer);
255
+ w.answer = "";
256
+ w.busy = false;
257
+ if (text && text !== NOTHING) this.#speak(w.observedId, text, "brief", 1);
258
+ if (w.queued) {
259
+ w.queued = false;
260
+ void this.#maybeBrief(w.observedId);
261
+ }
262
+ }
263
+
264
+ async #spawnObserver(w: Watch): Promise<string> {
265
+ const observedId = w.observedId;
266
+ const settings = this.#deps.settings();
267
+ const backend = settings.backend ?? this.#deps.backendOf(observedId);
268
+ if (!backend) throw new Error("不知道该用哪个后端");
269
+ const dir = path.join(this.#deps.home, "observers", observedId);
270
+ fs.mkdirSync(dir, { recursive: true });
271
+ const title = this.#deps.titleOf(observedId) ?? observedId;
272
+ const id = await this.#deps.createSession(backend, dir, `观察员 · ${title}`);
273
+ // Registered BEFORE its first prompt: the turn it is about to run is a brief, and its frames must
274
+ // already be attributable to this pair (see the turn-end branch in onEvent).
275
+ w.observerId = id;
276
+ this.#observers.add(id);
277
+ // `create` returns before the handshake by design; an observer is spawned and prompted in the
278
+ // same breath, so wait for the one fact only the agent can give us.
279
+ await this.#deps.waitReady(id);
280
+ this.#deps.preferReadonly?.(id);
281
+ // The prompt is the operator's, plus the one fact the observer cannot read for itself: which
282
+ // session it is watching. Everything else it looks up.
283
+ const wording = settings.systemPrompt.trim() || OBSERVER_PROMPT_DEFAULT;
284
+ const system = `${wording}\n\n你负责的会话 id 是 ${observedId}(名字:${title})。`;
285
+ // ACP has no "system prompt" call: the persona rides as the observer's first turn, and its own
286
+ // transcript keeps it. Everything after that is a one-line trigger.
287
+ await this.#deps.prompt(id, `${system}\n\n现在先看一眼这个会话的现状,给一句播报。`);
288
+ return id;
289
+ }
290
+
291
+ #watchFor(sessionId: string): Watch {
292
+ let w = this.#watch.get(sessionId);
293
+ if (!w) {
294
+ w = { observedId: sessionId, observerId: null, spawning: false, lastBriefAt: 0, queued: false, answer: "", busy: false, announcedSteps: new Set() };
295
+ this.#watch.set(sessionId, w);
296
+ }
297
+ return w;
298
+ }
299
+
300
+ #speaking(): boolean {
301
+ if (!this.#deps.speakEnabled()) return false;
302
+ return true;
303
+ }
304
+
305
+ #speak(sessionId: string, text: string, kind: "approval" | "plan-step" | "brief" | "fallback", priority: 1 | 2): void {
306
+ const clean = oneLine(text);
307
+ if (!clean) return;
308
+ this.#deps.emit({
309
+ t: "speak",
310
+ id: `${sessionId}:${kind}:${this.#now()}`,
311
+ sessionId,
312
+ text: clean,
313
+ kind,
314
+ priority,
315
+ });
316
+ }
317
+ }
318
+
319
+ /** The streamed text inside an `agent`/`thought` payload (ACP: `content.text`). */
320
+ function textOfPayload(payload: unknown): string {
321
+ const c = (payload as { content?: unknown } | null)?.content;
322
+ if (c && typeof c === "object" && typeof (c as { text?: unknown }).text === "string") {
323
+ return (c as { text: string }).text;
324
+ }
325
+ const t = (payload as { text?: unknown } | null)?.text;
326
+ return typeof t === "string" ? t : "";
327
+ }
@@ -19,6 +19,22 @@ import type { PlanItem } from "../../../shared/src/index.js";
19
19
  /** Studio caps its plan remark at 1000 chars; take the same bound rather than invent one. */
20
20
  export const MAX_EXPLANATION = 1000;
21
21
 
22
+ /** How long the plan's `title` may be.
23
+ *
24
+ * Forty is a NOTIFICATION's budget, not a document's: the body line it lands on already carries the
25
+ * session name and the duration (`nameAndDetail`), and the collapsed row of a phone card is one
26
+ * line. A writer that wants to say more writes a remark — `explanation` is the place for prose. */
27
+ export const MAX_TITLE = 40;
28
+
29
+ /** Coerce a writer's `title`. Collapsed whitespace, and `null` for anything that is not a real
30
+ * string: an empty title must land as "no title", never as an empty line on the card. */
31
+ export function clampTitle(raw: unknown): string | null {
32
+ if (typeof raw !== "string") return null;
33
+ const s = raw.replace(/\s+/g, " ").trim();
34
+ if (!s) return null;
35
+ return s.length > MAX_TITLE ? s.slice(0, MAX_TITLE) : s;
36
+ }
37
+
22
38
  export type PlanTerminal = "ended" | "interrupted" | "failed";
23
39
  export type PlanSource = "acp" | "mcp" | "server";
24
40
 
@@ -18,6 +18,7 @@
18
18
  // masked value round-trips as null and keeps the stored secret.
19
19
  import fs from "node:fs";
20
20
  import path from "node:path";
21
+ import type { BackendId } from "../../shared/src/index.js";
21
22
  import type { Store } from "./store/store.js";
22
23
 
23
24
  export interface VoiceSettings {
@@ -91,8 +92,13 @@ export interface CallSettings {
91
92
  * the speed have to be the same on the phone as on the laptop, and a second tab disagreeing
92
93
  * with the first is a bug, not a feature. */
93
94
  export interface PrefsSettings {
94
- /** read a finished reply aloud without being asked */
95
+ /** read a finished reply aloud without being asked. Kept as a DERIVED mirror of `speakMode`:
96
+ * the two used to be one switch, and a client that only knows this name must keep working. */
95
97
  autoRead: boolean;
98
+ /** WHAT gets read aloud (issue #16). "off" = nothing; "reply" = the finished reply, full text
99
+ * (the old autoRead); "brief" = the cockpit's own one-line briefs (`t:"speak"`), which is what
100
+ * a long answer needs — reading a whole reply aloud was the thing operators turned off. */
101
+ speakMode: SpeakMode;
96
102
  /** browser voiceURI ("" = pick the best for the language) */
97
103
  voiceURI: string;
98
104
  rate: number;
@@ -133,8 +139,11 @@ export interface PlanHintSettings {
133
139
  instruction: string;
134
140
  }
135
141
 
142
+ export const SPEAK_MODES = ["off", "reply", "brief"] as const;
143
+ export type SpeakMode = (typeof SPEAK_MODES)[number];
144
+
136
145
  export const PREFS_DEFAULT: PrefsSettings = {
137
- autoRead: false, voiceURI: "", rate: 1, lang: "", serverTts: false, stt: "auto",
146
+ autoRead: false, speakMode: "off", voiceURI: "", rate: 1, lang: "", serverTts: false, stt: "auto",
138
147
  // On + every turn by default: the feature exists because the tool was there and nobody used it,
139
148
  // and 对齐 Studio means the block rides on every turn (see everyTurn's note). Both are visible
140
149
  // and switchable in 设置 — this is a default, not a lock.
@@ -142,7 +151,10 @@ export const PREFS_DEFAULT: PrefsSettings = {
142
151
  };
143
152
 
144
153
  export const CALL_DEFAULT: CallSettings = {
145
- interruptMode: "voice", bargeSensitivity: 60, bargeMs: 300, silenceMs: 1200, minChars: 3,
154
+ /* 说完停顿 1.5s (was 1.2): the operator reads a pause as "it stopped listening" before 1.2 s,
155
+ and a long sentence with a breath in the middle got cut. Both ends of the range still let
156
+ the room decide — this is only where a fresh install starts. */
157
+ interruptMode: "voice", bargeSensitivity: 60, bargeMs: 300, silenceMs: 1500, minChars: 3,
146
158
  };
147
159
 
148
160
  export const INTERRUPT_MODES: InterruptMode[] = ["voice", "button"];
@@ -167,9 +179,18 @@ export interface AgentSettings {
167
179
  * is a permanent memory floor (5 × ~137 MB RSS measured); with it, a cockpit left alone for a day
168
180
  * comes back to nothing running. 0 = 不过期. */
169
181
  warmTtlHours: number;
182
+ /** 驾驶舱互发消息 (issue #15): may an agent hand a message to ANOTHER session through the cockpit
183
+ * CLI (`agentus cockpit send`)? Off by default — two agents that can talk to each other without
184
+ * being asked are a loop generator, and the operator pays for those tokens. Reading the cockpit
185
+ * (`context`/`messages`/`plan`) is NOT gated: it cannot change anything, and it is what the
186
+ * 工作播报 observer runs on. */
187
+ cockpitSend: boolean;
170
188
  }
171
189
 
172
- export const AGENT_DEFAULT: AgentSettings = { idleKillMin: 5, warmSlots: 5, warmTtlHours: 24 };
190
+ export const AGENT_DEFAULT: AgentSettings = { idleKillMin: 5, warmSlots: 5, warmTtlHours: 24, cockpitSend: false };
191
+
192
+ /** The numeric half of `AgentSettings` — the keys the range-checked helpers may write. */
193
+ type AgentNumberKey = "idleKillMin" | "warmSlots" | "warmTtlHours";
173
194
 
174
195
  /** What the settings page may offer: the ends of the number field. Server-side enforcement uses
175
196
  * the same pair, so a hand-written request cannot store a nonsense value. */
@@ -180,6 +201,48 @@ export const AGENT_WARM_RANGE: [number, number] = [0, 20];
180
201
  /** 0 = 不过期; a week is the longest span worth offering on the page. */
181
202
  export const AGENT_WARM_TTL_RANGE: [number, number] = [0, 168];
182
203
 
204
+ /** 观察智能体 (`工作播报`, issue #16): ONE long-lived ACP session per observed session, spawned by the
205
+ * cockpit, in its own scratch directory, read-only, out of the rail. It is the thing that turns
206
+ * 「回合结束了」 into one sentence a person can listen to — it reads the cockpit through the CLI
207
+ * (issue #15) instead of being handed a summary, because a summariser that is told what happened
208
+ * cannot notice what the operator actually cares about.
209
+ *
210
+ * Everything here is the operator's: the switch, which backend/model runs it (a cheap fast model is
211
+ * the right answer), the wording it answers in, and the throttle. */
212
+ export interface ObserverSettings {
213
+ /** 工作播报 on/off. Off = no observer is spawned at all and the speaker falls back to #11's
214
+ * plan title (zero cost), then to nothing. */
215
+ enabled: boolean;
216
+ /** Which backend the observer runs on. null = same backend as the observed session (the default:
217
+ * an install that only configured one agent should not have to configure a second one). */
218
+ backend: BackendId | null;
219
+ /** The observer's own wording. "" = the built-in template (which is also what the settings page
220
+ * shows as the thing it will actually send). Sized like the plan hint's instruction: a few
221
+ * sentences of house style, not a document — it rides on ONE turn per brief. */
222
+ systemPrompt: string;
223
+ /** Shortest gap between two MODEL-backed briefs for the same session, in seconds. Zero-cost
224
+ * events (an approval is waiting, a step completed) never wait on it. Default 120: a brief per
225
+ * turn is fine, a brief per token is a phone that never stops talking. */
226
+ throttleSec: number;
227
+ }
228
+
229
+ export const OBSERVER_PROMPT_MAX = 2000;
230
+ export const OBSERVER_THROTTLE_RANGE: [number, number] = [0, 3600];
231
+
232
+ /** What the observer is told, when the operator writes nothing. Two jobs in one paragraph: say who
233
+ * it is watching and how to find out (the cockpit CLI — never trust the trigger line for facts), and
234
+ * fix the output shape (one sentence, ≤60 characters, spoken aloud by a TTS voice). */
235
+ export const OBSERVER_PROMPT_DEFAULT = [
236
+ "你是这个驾驶舱里某个会话的观察员。用 `$AGENTUS_COCKPIT_BIN session list` 看全景,",
237
+ "用 `session <id>` / `messages <id> --tail 20` / `plan <id>` 核实它正在做什么、卡在哪。",
238
+ "只陈述这些工具读到的事实,不猜、不补、不评价;工具没说的就当不知道。",
239
+ "输出一句中文播报,不超过 60 字,会被语音念出来:不要 Markdown、不要列表、不要引号、不要解释你在做什么。",
240
+ ].join("\n");
241
+
242
+ export const OBSERVER_DEFAULT: ObserverSettings = {
243
+ enabled: false, backend: null, systemPrompt: "", throttleSec: 120,
244
+ };
245
+
183
246
  /** The NUMERIC knobs. `interruptMode` is a two-way choice, not a slider, so it is deliberately
184
247
  * outside both the range table and the loops that validate numbers. */
185
248
  export const CALL_NUMERIC = ["bargeSensitivity", "bargeMs", "silenceMs", "minChars"] as const;
@@ -214,6 +277,7 @@ export interface Settings {
214
277
  call: CallSettings;
215
278
  prefs: PrefsSettings;
216
279
  agent: AgentSettings;
280
+ observer: ObserverSettings;
217
281
  updatedAt: number;
218
282
  }
219
283
 
@@ -240,7 +304,8 @@ let db: Store | null = null;
240
304
  let legacyFile = "";
241
305
  let cache: Settings = {
242
306
  voice: { ...DEFAULTS }, theme: { ...THEME_DEFAULT }, call: { ...CALL_DEFAULT },
243
- prefs: { ...PREFS_DEFAULT }, agent: { ...AGENT_DEFAULT }, updatedAt: 0,
307
+ prefs: { ...PREFS_DEFAULT }, agent: { ...AGENT_DEFAULT },
308
+ observer: { ...OBSERVER_DEFAULT }, updatedAt: 0,
244
309
  };
245
310
  /** keys that came from env/.env (shown as "auto-detected" and used when settings are empty) */
246
311
  let envCreds: { apiKey: string; baseUrl: string; source: string } | null = null;
@@ -305,6 +370,7 @@ export function initSettings(dataDir: string, store: Store): Settings {
305
370
  call: pickCall(stored.call),
306
371
  prefs: pickPrefs(stored.prefs),
307
372
  agent: pickAgent(stored.agent),
373
+ observer: pickObserver(stored.observer),
308
374
  updatedAt: Number((stored as Settings).updatedAt ?? 0),
309
375
  };
310
376
  // an unset baseUrl/apiKey bootstrap from the environment: the operator said the
@@ -342,7 +408,9 @@ function pickAgent(raw: unknown): AgentSettings {
342
408
  const out = { ...AGENT_DEFAULT };
343
409
  if (raw && typeof raw === "object") {
344
410
  const r = raw as Record<string, unknown>;
345
- const take = (key: keyof AgentSettings, range: [number, number]): void => {
411
+ // Explicitly the NUMERIC keys: `AgentSettings` also carries `cockpitSend: boolean`, and a
412
+ // `keyof` assignment would have to satisfy both sides of the union.
413
+ const take = (key: AgentNumberKey, range: [number, number]): void => {
346
414
  const v = Number(r[key]);
347
415
  const [lo, hi] = range;
348
416
  if (Number.isFinite(v) && v >= lo && v <= hi) out[key] = v;
@@ -350,6 +418,27 @@ function pickAgent(raw: unknown): AgentSettings {
350
418
  take("idleKillMin", AGENT_IDLE_RANGE);
351
419
  take("warmSlots", AGENT_WARM_RANGE);
352
420
  take("warmTtlHours", AGENT_WARM_TTL_RANGE);
421
+ if (typeof r.cockpitSend === "boolean") out.cockpitSend = r.cockpitSend;
422
+ }
423
+ return out;
424
+ }
425
+
426
+ /** The observer's own section. Untrusted like the rest: an unknown backend id, a prompt over the
427
+ * cap or a throttle outside the range falls back to the shipped default rather than throwing —
428
+ * this section decides whether the cockpit spawns an agent to talk about another agent, and a
429
+ * corrupt row must leave it in the SHIPPED state (off), not in a half-configured one. */
430
+ function pickObserver(raw: unknown): ObserverSettings {
431
+ const out = { ...OBSERVER_DEFAULT };
432
+ if (raw && typeof raw === "object") {
433
+ const r = raw as Record<string, unknown>;
434
+ if (typeof r.enabled === "boolean") out.enabled = r.enabled;
435
+ if (typeof r.backend === "string" && r.backend) out.backend = r.backend as BackendId;
436
+ if (typeof r.systemPrompt === "string" && r.systemPrompt.length <= OBSERVER_PROMPT_MAX) {
437
+ out.systemPrompt = r.systemPrompt;
438
+ }
439
+ const n = Number(r.throttleSec);
440
+ const [lo, hi] = OBSERVER_THROTTLE_RANGE;
441
+ if (Number.isFinite(n) && n >= lo && n <= hi) out.throttleSec = n;
353
442
  }
354
443
  return out;
355
444
  }
@@ -358,7 +447,12 @@ function pickPrefs(raw: unknown): PrefsSettings {
358
447
  const out = { ...PREFS_DEFAULT };
359
448
  if (raw && typeof raw === "object") {
360
449
  const r = raw as Record<string, unknown>;
361
- if (typeof r.autoRead === "boolean") out.autoRead = r.autoRead;
450
+ // `speakMode` is the source of truth; `autoRead` is the pre-#16 name for its "reply" value. A
451
+ // stored section carries one or the other depending on which build wrote it last, so accept
452
+ // both and let the newer name win when both are present.
453
+ if (r.speakMode === "off" || r.speakMode === "reply" || r.speakMode === "brief") out.speakMode = r.speakMode;
454
+ else if (typeof r.autoRead === "boolean") out.speakMode = r.autoRead ? "reply" : "off";
455
+ out.autoRead = out.speakMode === "reply";
362
456
  if (typeof r.serverTts === "boolean") out.serverTts = r.serverTts;
363
457
  if (typeof r.voiceURI === "string" && r.voiceURI.length <= 200) out.voiceURI = r.voiceURI;
364
458
  if (typeof r.lang === "string" && (!r.lang || /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(r.lang))) out.lang = r.lang;
@@ -387,6 +481,7 @@ function persist(): void {
387
481
  db.setSetting("call", cache.call);
388
482
  db.setSetting("prefs", cache.prefs);
389
483
  db.setSetting("agent", cache.agent);
484
+ db.setSetting("observer", cache.observer);
390
485
  } catch (e) {
391
486
  console.error(`[agentus] cannot write settings: ${(e as Error).message}`);
392
487
  }
@@ -434,6 +529,11 @@ export function publicSettings(): Record<string, unknown> {
434
529
  agentIdleRange: AGENT_IDLE_RANGE,
435
530
  agentWarmRange: AGENT_WARM_RANGE,
436
531
  agentWarmTtlRange: AGENT_WARM_TTL_RANGE,
532
+ observer: cache.observer,
533
+ observerDefaults: OBSERVER_DEFAULT,
534
+ observerPromptDefault: OBSERVER_PROMPT_DEFAULT,
535
+ observerPromptMax: OBSERVER_PROMPT_MAX,
536
+ observerThrottleRange: OBSERVER_THROTTLE_RANGE,
437
537
  // the defaults, so the UI can offer "reset to Bailian defaults"
438
538
  defaults: { ...DEFAULTS, apiKey: undefined, baseUrl: undefined },
439
539
  updatedAt: cache.updatedAt,
@@ -554,6 +654,18 @@ export function savePrefs(patch: Record<string, unknown>): Settings {
554
654
  if (!["auto", "browser", "stream", "server"].includes(v)) throw new Error(`invalid stt: ${JSON.stringify(patch.stt)}`);
555
655
  next.stt = v as PrefsSettings["stt"];
556
656
  }
657
+ // 三档(关 / 自动朗读 / 工作播报). The old boolean still writes, so a cached page from before this
658
+ // change does something sensible instead of failing: autoRead:true = "reply", false = "off".
659
+ if ("speakMode" in patch) {
660
+ const v = String(patch.speakMode ?? "");
661
+ if (!SPEAK_MODES.includes(v as SpeakMode)) throw new Error(`invalid speakMode: ${JSON.stringify(patch.speakMode)}`);
662
+ next.speakMode = v as SpeakMode;
663
+ next.autoRead = v === "reply";
664
+ } else if ("autoRead" in patch) {
665
+ if (typeof patch.autoRead !== "boolean") throw new Error("invalid autoRead: expected true/false");
666
+ next.speakMode = patch.autoRead ? "reply" : "off";
667
+ next.autoRead = patch.autoRead;
668
+ }
557
669
  if ("planHint" in patch) {
558
670
  const h = patch.planHint;
559
671
  if (!h || typeof h !== "object") throw new Error("invalid planHint: expected an object");
@@ -589,7 +701,7 @@ export function savePrefs(patch: Record<string, unknown>): Settings {
589
701
  * text fields. */
590
702
  export function saveAgent(patch: Record<string, unknown>): Settings {
591
703
  const a = { ...cache.agent };
592
- const set = (key: keyof AgentSettings, range: [number, number], unit: string, off: string): void => {
704
+ const set = (key: AgentNumberKey, range: [number, number], unit: string, off: string): void => {
593
705
  if (!(key in patch)) return;
594
706
  const raw = typeof patch[key] === "number" ? (patch[key] as number) : Number(patch[key]);
595
707
  const [lo, hi] = range;
@@ -601,6 +713,11 @@ export function saveAgent(patch: Record<string, unknown>): Settings {
601
713
  set("idleKillMin", AGENT_IDLE_RANGE, "minutes", "0 = off");
602
714
  set("warmSlots", AGENT_WARM_RANGE, "slots", "0 = off");
603
715
  set("warmTtlHours", AGENT_WARM_TTL_RANGE, "hours", "0 = never expires");
716
+ // A boolean next to three numbers: spelled out rather than forced through the numeric `set`.
717
+ if ("cockpitSend" in patch) {
718
+ if (typeof patch.cockpitSend !== "boolean") throw new Error("invalid cockpitSend: expected true or false");
719
+ a.cockpitSend = patch.cockpitSend;
720
+ }
604
721
  cache = { ...cache, agent: a, updatedAt: Date.now() };
605
722
  persist();
606
723
  return cache;
@@ -613,6 +730,47 @@ export function agentSettings(): AgentSettings {
613
730
  return cache.agent;
614
731
  }
615
732
 
733
+ /** 观察智能体 knobs (issue #16). Read per event, like the plan hint: 工作播报 must be switchable
734
+ * without restarting anything. */
735
+ export function observerSettings(): ObserverSettings {
736
+ return cache.observer;
737
+ }
738
+
739
+ /** Save 观察智能体. Its own update, like the agent knobs, so a bad value in this section cannot
740
+ * lock the operator out of the page that fixes it. */
741
+ export function saveObserver(patch: Record<string, unknown>): Settings {
742
+ const o = { ...cache.observer };
743
+ if ("enabled" in patch) {
744
+ if (typeof patch.enabled !== "boolean") throw new Error("invalid enabled: expected true/false");
745
+ o.enabled = patch.enabled;
746
+ }
747
+ if ("backend" in patch) {
748
+ const v = patch.backend;
749
+ if (v === null || v === "") o.backend = null;
750
+ else if (typeof v === "string" && v.length <= 40) o.backend = v as BackendId;
751
+ else throw new Error(`invalid backend: ${JSON.stringify(v).slice(0, 40)}`);
752
+ }
753
+ if ("systemPrompt" in patch) {
754
+ const v = String(patch.systemPrompt ?? "");
755
+ if (v.length > OBSERVER_PROMPT_MAX) {
756
+ throw new Error(`invalid systemPrompt: longer than ${OBSERVER_PROMPT_MAX} characters`);
757
+ }
758
+ // Whitespace-only means "use the built-in template", the same rule the plan hint uses.
759
+ o.systemPrompt = v.trim() ? v : "";
760
+ }
761
+ if ("throttleSec" in patch) {
762
+ const n = Number(patch.throttleSec);
763
+ const [lo, hi] = OBSERVER_THROTTLE_RANGE;
764
+ if (!Number.isFinite(n) || n < lo || n > hi) {
765
+ throw new Error(`invalid throttleSec: ${JSON.stringify(patch.throttleSec)} (expected ${lo}…${hi} seconds)`);
766
+ }
767
+ o.throttleSec = Math.round(n);
768
+ }
769
+ cache = { ...cache, observer: o, updatedAt: Date.now() };
770
+ persist();
771
+ return cache;
772
+ }
773
+
616
774
  /** The plan-hint knobs. Read on EVERY prompt (not cached here), so the switch in 设置 takes effect
617
775
  * on the operator's very next turn — no restart, no session to hibernate. */
618
776
  export function planHintSettings(): PlanHintSettings {