agentus 0.3.4 → 0.3.5

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.3.4",
3
+ "version": "0.3.5",
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": {
@@ -52,6 +52,10 @@
52
52
  "workspace-smoke": "node scripts/workspace-smoke.mjs",
53
53
  "voice-smoke": "node scripts/voice-smoke.mjs",
54
54
  "sentence-units": "tsx scripts/qa/sentence-units.mts",
55
+ "token-format": "tsx scripts/qa/token-format.mts",
56
+ "terminal-palette": "tsx scripts/qa/terminal-palette.mts",
57
+ "android-trust-merge": "node scripts/qa/android-trust-merge.mjs",
58
+ "plan-hint-sweep": "node scripts/qa/plan-hint-sweep.mjs",
55
59
  "plan-service-sweep": "node scripts/qa/plan-service-sweep.mjs",
56
60
  "plan-mcp-sweep": "node scripts/qa/plan-mcp-sweep.mjs",
57
61
  "plan-v2-sweep": "tsx scripts/qa/plan-v2-sweep.mts",
@@ -204,7 +204,17 @@ export interface BackendHandshake {
204
204
  /** agentCapabilities.sessionCapabilities.fork */
205
205
  fork: boolean | null;
206
206
  modes: { currentModeId: string | null; available: string[]; names?: Record<string, string> } | null;
207
- configOptions: { id: string; name: string | null; currentValue: string | null }[];
207
+ /** The ACP `select`s, kept WHOLE rather than reduced to id/name/currentValue: the new-session
208
+ * start page has no session to read configOptions from, so the controls it draws come from
209
+ * here — and a control without its `options` is a button that cannot be turned. */
210
+ configOptions: {
211
+ id: string;
212
+ name: string | null;
213
+ currentValue: string | null;
214
+ type?: string | null;
215
+ category?: string | null;
216
+ options?: { value: string; name: string | null; description?: string | null }[] | null;
217
+ }[];
208
218
  models: { currentModelId: string | null; available: string[] } | null;
209
219
  /** slash commands the agent announced (session/update or newSession) */
210
220
  commands: string[];
@@ -621,6 +631,23 @@ export function handshakeFrom(
621
631
  id: o.id,
622
632
  name: o.name ?? null,
623
633
  currentValue: o.currentValue === undefined || o.currentValue === null ? null : String(o.currentValue),
634
+ // Passed through defensively like everything else here: the SDK types cover only part of what
635
+ // the wire carries, and a session's real control set differs per agent.
636
+ type: typeof (o as { type?: unknown }).type === "string" ? String((o as { type?: string }).type) : null,
637
+ category:
638
+ typeof (o as { category?: unknown }).category === "string"
639
+ ? String((o as { category?: string }).category)
640
+ : null,
641
+ options: Array.isArray((o as { options?: unknown }).options)
642
+ ? ((o as unknown as { options: { value?: unknown; name?: unknown; description?: unknown }[] }).options ?? []).map(
643
+ (opt) => ({
644
+ value: String(opt.value ?? ""),
645
+ name: opt.name === undefined || opt.name === null ? null : String(opt.name),
646
+ description:
647
+ opt.description === undefined || opt.description === null ? null : String(opt.description),
648
+ }),
649
+ )
650
+ : null,
624
651
  })),
625
652
  models,
626
653
  commands: (s.availableCommands ?? []).map((c) => c.name),
@@ -36,6 +36,7 @@ import type {
36
36
  TurnTrace,
37
37
  } from "../../../shared/src/index.js";
38
38
  import { acceptsToolWrite, acceptsWriteFrom, clampExplanation, demoteInProgress, foldPlanUpdate, hasUnfinished, normalizeItems, v2PlanEnabled } from "../plan/plan.js";
39
+ import type { PlanHintSettings } from "../settings.js";
39
40
 
40
41
  // Permission prompts must not hang a session forever (design.md §8-4).
41
42
  // Env-tunable so QA can exercise the timeout path in seconds instead of minutes.
@@ -108,6 +109,14 @@ const TITLE_FORK_TIMEOUT_MS = Number(process.env.AGENTUS_TITLE_TIMEOUT_MS || 90_
108
109
  // (an agent that ignores session/cancel must not hang the operator's next sentence forever).
109
110
  const TURN_SETTLE_TIMEOUT_MS = Number(process.env.AGENTUS_SETTLE_TIMEOUT_MS || 15_000);
110
111
 
112
+ /** How long a close/archive waits for a slot's in-flight handshake before killing it anyway.
113
+ *
114
+ * `#stop` waits because the agent-side session id only exists once the handshake lands, and a row
115
+ * closed without it can never be resumed (see `#stop`). The wait is bounded so a spawn that never
116
+ * answers cannot hold the operator's click open forever: past this, the slot closes with whatever it
117
+ * has — a broken agent is not made worse by a row we cannot wake. */
118
+ const CLOSE_STARTUP_WAIT_MS = Number(process.env.AGENTUS_CLOSE_WAIT_MS || 30_000);
119
+
111
120
  /** A cold slot the agent cannot adopt, and why — the only two answers there are.
112
121
  *
113
122
  * Resuming has to *ask* the agent: `session/load` is "do you still have this session?", and the
@@ -159,6 +168,14 @@ interface LiveSession {
159
168
  * so a second interrupting utterance that already started its own turn is not mistaken for
160
169
  * "the one I cancelled is still running". */
161
170
  turnSeq: number;
171
+ /** The spawn-time handshake, while it is still running.
172
+ *
173
+ * `create()` no longer waits for it (that wait is what made 「新建会话」 feel broken), so the slot
174
+ * exists — and can be closed, archived or reaped — before the agent has answered. This handle is
175
+ * how the teardown waits for the one thing only the handshake can tell us: the agent-side session
176
+ * id. Killing the child before it arrives leaves a row with `acpSessionId: null` that `resume()`
177
+ * refuses forever (「was never handed to an agent」) — see `#stop`. */
178
+ startup?: Promise<void>;
162
179
  /** When the operator last USED this slot — he opened it, or his page reported it as the one on
163
180
  * screen. In memory on purpose: this is the half of the idle rule that says "he was just here",
164
181
  * and the store's message clock (which survives restarts) is the other half. Reading only the
@@ -203,12 +220,22 @@ export class SessionManager {
203
220
  #sessions = new Map<string, LiveSession>();
204
221
  #store: Store;
205
222
  #emit: Emitter;
223
+ /** How the operator wants the plan-tool hint injected into a prompt — read on EVERY prompt, like
224
+ * the idle thresholds, because the requirement is that a change in 设置 applies to the very next
225
+ * turn with no restart and no session to hibernate. */
226
+ #planHint: () => PlanHintSettings = () => ({ on: true, everyTurn: false });
206
227
 
207
228
  constructor(store: Store, emit: Emitter) {
208
229
  this.#store = store;
209
230
  this.#emit = emit;
210
231
  }
211
232
 
233
+ /** Wire the cockpit's plan-hint setting in. Optional on purpose: with no source wired the
234
+ * manager behaves exactly as it did before the setting existed (on, 仅首条). */
235
+ setPlanHintSource(read: () => PlanHintSettings): void {
236
+ this.#planHint = read;
237
+ }
238
+
212
239
  list(): SessionInfo[] {
213
240
  // Most recently CHATTED first — the cockpit's left rail is a launch pad, not a log file, and
214
241
  // creation order is the wrong key for that: the session you talked to a minute ago is the
@@ -417,6 +444,33 @@ export class SessionManager {
417
444
  });
418
445
  child.on("exit", (code, sig) => this.#onChildExit(live, code, sig));
419
446
 
447
+ // The row is complete and already persisted with `status: "starting"` — hand it back to the
448
+ // caller NOW and let the handshake finish OFF the request. See `#startup`.
449
+ // Kept on the slot (not dropped with `void`) so a close during the window can wait for the
450
+ // agent-side id instead of orphaning the session — see `#stop`.
451
+ live.startup = this.#startup(live, backend, spec, cwd, spawnStarted);
452
+ void live.startup.catch(() => {}); // `#startup` reports failure on the slot; nobody is awaiting
453
+ return live.info;
454
+ }
455
+
456
+ /** The spawn-time handshake — `initialize` + `newSession` + the row's default permission mode.
457
+ *
458
+ * Deliberately NOT awaited by `create()`. Holding the HTTP response open for the whole handshake
459
+ * is what made 「新建会话」 feel broken: hermes boots a python process and answers in 10–40 s, the
460
+ * page sat on a busy dialog with no progress to show and a 120 s timeout behind it (measured; the
461
+ * old `state.ts` comment said so in as many words). The row is born `starting` — the rail can
462
+ * draw it, the page can open it and file the first message — and when this resolves,
463
+ * `#updateSession` broadcasts `ready`, which is also the signal the client drains that queued
464
+ * first message on (`state.ts`, `case "session"`).
465
+ *
466
+ * Failure is reported INSIDE the session, never to a caller that has long since answered: the
467
+ * status goes `error` with `lastError` (the reason + the agent's stderr tail, delivered while the
468
+ * slot is still in the live map so the frame carries it), then the slot is dropped and the row is
469
+ * closed. That is what makes 「启动失败 → 因为什么 → 再试一次」 possible at all. */
470
+ async #startup(live: LiveSession, backend: BackendId, spec: BackendSpec, cwd: string, spawnStarted: number): Promise<void> {
471
+ const id = live.info.id;
472
+ const child = live.child;
473
+ if (!child) return; // the slot was torn down between `create()` returning and this line
420
474
  try {
421
475
  const conn = new ClientSideConnection(
422
476
  () => ({
@@ -458,18 +512,19 @@ export class SessionManager {
458
512
  // this method — that is exactly what keeps a per-session pick from being silently undone.
459
513
  await this.#applyDefaultMode(live);
460
514
  this.#updateSession(live);
461
- return live.info;
462
515
  } catch (err) {
463
516
  child.kill("SIGKILL");
464
517
  live.info.status = "error";
465
518
  live.info.lastError = errMessage(err) + stderrTail(live);
466
- this.#sessions.delete(id); // failed handshake => don't keep zombie session rows
519
+ // Announce the failure WHILE the slot is still live: `{t:"session"}` is the only frame that
520
+ // carries `lastError`, and the page needs it to say why nothing came up.
521
+ this.#updateSession(live);
522
+ this.#sessions.delete(id); // failed handshake => don't keep a zombie slot
467
523
  this.#store.upsertSession({ ...sessionRow(live.info), closedAt: Date.now() });
468
524
  this.#noteBackendCheck(String(backend), {
469
525
  ok: false, error: live.info.lastError, errorCode: classifyError(err),
470
526
  latencyMs: Date.now() - spawnStarted,
471
527
  });
472
- throw err instanceof Error ? err : new Error(String(err));
473
528
  }
474
529
  }
475
530
 
@@ -841,10 +896,24 @@ export class SessionManager {
841
896
  if (!blocks.length) throw new Error("empty prompt");
842
897
  // The plan hand-over, owed once per agent process (§7): a restarted agent never knew about the
843
898
  // plan, and nothing else on this wire would tell it (ACP has no server->agent state channel).
844
- if (s.planReminderPending) {
899
+ //
900
+ // It carries a second thing the agent cannot learn on its own: that this session HAS a plan
901
+ // tool. Its own description says "prefer this over any todo tool", but a runtime that keeps MCP
902
+ // tools in a lazy-loaded catalog (Hermes) never shows that text to the model unless the model
903
+ // goes looking — so the plan card stayed empty while the agent kept a todo list only its own
904
+ // process could see. The nudge therefore has to come from the wire; Studio does the same
905
+ // (its `task-plan-runs.ts` appends a template to the latest input every turn).
906
+ //
907
+ // Read per prompt, and only CONSUME the hand-over when it is actually injected: an operator who
908
+ // turns the hint off and later back on gets the plan hand-over then, rather than losing it.
909
+ const hint = this.#planHint();
910
+ if (hint.on) {
911
+ const owed = Boolean(s.planReminderPending);
845
912
  s.planReminderPending = false;
846
- const reminder = this.#planReminder(s);
847
- if (reminder) blocks.push(reminder);
913
+ if (owed || hint.everyTurn) {
914
+ const reminder = this.#planReminder(s, !owed);
915
+ if (reminder) blocks.push(reminder);
916
+ }
848
917
  }
849
918
  s.turnSeq += 1;
850
919
  s.busy = true;
@@ -1181,46 +1250,78 @@ export class SessionManager {
1181
1250
  /** The plan, handed to an agent that just (re)started, as an ACP `resource` content block: a
1182
1251
  * stable URI plus the steps still owed. The card is fed by the plan OBJECT, but the AGENT's own
1183
1252
  * copy died with its process — this block is the only thing that tells it the plan exists
1184
- * (design-plan-service.md §7). Returns null when there is nothing to hand over: no plan at all,
1185
- * or one whose steps are all closed (spending context to say "you are done" is worse than
1186
- * saying nothing). */
1187
- #planReminder(s: LiveSession): unknown | null {
1253
+ * (design-plan-service.md §7).
1254
+ *
1255
+ * `perTurn` = this is a mid-conversation nudge (the 每回合 setting), not the once-per-process
1256
+ * hand-over. It changes only what is worth SAYING: with no plan on the card there is nothing to
1257
+ * hand over and repeating the introduction every turn would be the noise the operator asked to
1258
+ * avoid, so a per-turn call with nothing to show returns null. Returns null too when a plan
1259
+ * exists but every step is closed — spending context to say "you are done" is worse than saying
1260
+ * nothing. */
1261
+ #planReminder(s: LiveSession, perTurn = false): unknown | null {
1188
1262
  const plan = this.#store.getPlan(s.info.id);
1189
- if (!plan) return null;
1190
- const open = plan.items.filter((i) => i.status !== "completed" && i.status !== "cancelled");
1191
- if (!open.length) return null;
1192
- const lines = open.map((i) => `- [${i.status}] ${i.content}`).join("\n");
1263
+ const open = plan
1264
+ ? plan.items.filter((i) => i.status !== "completed" && i.status !== "cancelled")
1265
+ : [];
1266
+ if (plan && !open.length) return null;
1267
+ if (!open.length && perTurn) return null;
1268
+
1193
1269
  // How the agent keeps it current depends on which channel it actually has: an agent whose row
1194
1270
  // opted into native frames must use its own todo tool (those frames are what feed the card),
1195
1271
  // while an MCP-driven agent — which is every backend by default, Hermes included — was handed
1196
1272
  // `update_plan` in this very handshake, and its own todo tool does NOT reach the card.
1197
1273
  const mcpDriven = nativePlanSourceOf(this.#specFor(s.info.backend)) === "none";
1198
- const closing = mcpDriven
1199
- ? [
1200
- "Carry on with it, and keep it updated with the `update_plan` tool as you go — that list is",
1201
- "the ONLY one the cockpit shows the operator, so a built-in todo/checklist tool of yours will",
1202
- "not be seen there. Mark a step completed once its work is actually verified, and drop or",
1203
- "cancel what you are no longer doing. If you disagree with an item, change it rather than",
1204
- "quietly skipping it.",
1205
- ]
1206
- : [
1207
- "Carry on with it, and keep it updated with your own plan/todo tool as you go: mark a step",
1208
- "completed only once its work is actually verified, and drop or cancel what you are no",
1209
- "longer doing. If you disagree with an item, change it rather than quietly skipping it.",
1210
- ];
1274
+ const body: string[] = [];
1275
+
1276
+ if (open.length) {
1277
+ body.push(
1278
+ "This session has an unfinished plan. It is kept by the cockpit and survives your",
1279
+ "restarts; your own in-memory task list does not.",
1280
+ "",
1281
+ ...open.map((i) => `- [${i.status}] ${i.content}`),
1282
+ "",
1283
+ );
1284
+ }
1285
+
1286
+ if (mcpDriven && open.length) {
1287
+ body.push(
1288
+ "Carry on with it, and keep it updated with the `update_plan` tool as you go — that list is",
1289
+ "the ONLY one the cockpit shows the operator, so a built-in todo/checklist tool of yours will",
1290
+ "not be seen there. Mark a step completed once its work is actually verified, and drop or",
1291
+ "cancel what you are no longer doing. If you disagree with an item, change it rather than",
1292
+ "quietly skipping it.",
1293
+ );
1294
+ } else if (mcpDriven) {
1295
+ // No plan yet. This is the case the cockpit used to say NOTHING about — which is exactly why
1296
+ // an agent reached for its own todo list and the card stayed empty: `update_plan`'s own
1297
+ // description ("prefer this over any todo tool") lives on a tool a lazy-loaded MCP catalog
1298
+ // never puts in front of the model. Keep it short; it rides on every first prompt.
1299
+ body.push(
1300
+ "This session has an `update_plan` tool mounted on it (MCP server `agentus_plan`), and the",
1301
+ "plan it holds is the one the operator sees — a todo list of your own is invisible there and",
1302
+ "dies with your process. For anything with more than one step, publish the steps with",
1303
+ "`update_plan` and update it as you go: mark a step completed only once its work is actually",
1304
+ "verified, and drop or cancel what you are no longer doing. If `update_plan` is not in your",
1305
+ "tool list it is deferred — search for `agentus_plan update_plan` to find it, then call it by",
1306
+ "that exact name. A one-step request needs no plan.",
1307
+ );
1308
+ } else if (open.length) {
1309
+ body.push(
1310
+ "Carry on with it, and keep it updated with your own plan/todo tool as you go: mark a step",
1311
+ "completed only once its work is actually verified, and drop or cancel what you are no",
1312
+ "longer doing. If you disagree with an item, change it rather than quietly skipping it.",
1313
+ );
1314
+ } else {
1315
+ // A native-frames backend with nothing to hand over: nothing worth saying.
1316
+ return null;
1317
+ }
1318
+
1211
1319
  return {
1212
1320
  type: "resource",
1213
1321
  resource: {
1214
1322
  uri: `agentus://plan/${s.info.id}`,
1215
1323
  mimeType: "text/plain",
1216
- text: [
1217
- "This session has an unfinished plan. It is kept by the cockpit and survives your",
1218
- "restarts; your own in-memory task list does not.",
1219
- "",
1220
- lines,
1221
- "",
1222
- ...closing,
1223
- ].join("\n"),
1324
+ text: body.join("\n"),
1224
1325
  },
1225
1326
  // ACP reserves `_meta` for the client: tagging our own blocks is what lets us tell them from
1226
1327
  // the operator's words later (and strip them from anything we echo back).
@@ -1230,7 +1331,10 @@ export class SessionManager {
1230
1331
 
1231
1332
  async closeSession(sessionId: string): Promise<void> {
1232
1333
  const s = this.#need(sessionId);
1233
- this.#stop(s, "closed");
1334
+ // Awaited, not fired and forgotten: the row has to be settled (and carry the agent-side id) by the
1335
+ // time the DELETE that asked for this answers, or the page loads a slot the store still calls
1336
+ // `starting`.
1337
+ await this.#stop(s, "closed");
1234
1338
  this.#emitSessions();
1235
1339
  }
1236
1340
 
@@ -1245,7 +1349,7 @@ export class SessionManager {
1245
1349
  const live = this.#sessions.get(id);
1246
1350
  const row = this.#store.getSession(id);
1247
1351
  if (!row) throw new Error(`no such session: ${id}`);
1248
- if (on && live) this.#stop(live, "archived");
1352
+ if (on && live) await this.#stop(live, "archived");
1249
1353
  this.#store.setArchived(id, on ? Date.now() : null);
1250
1354
  this.#emitSessions();
1251
1355
  return this.#sessions.get(id)?.info ?? this.#coldInfo(id);
@@ -1257,11 +1361,30 @@ export class SessionManager {
1257
1361
  * `reaped` is its own status on purpose — 「它怎么变冷了」 has three different answers (I closed
1258
1362
  * it / it crashed / the cockpit reclaimed it for being idle) and a rail that shows one word for
1259
1363
  * all three teaches the operator to distrust the column. */
1260
- #stop(live: LiveSession, why: "closed" | "archived" | "reaped"): void {
1364
+ async #stop(live: LiveSession, why: "closed" | "archived" | "reaped"): Promise<void> {
1261
1365
  this.#dropPending(live);
1262
- live.child?.kill("SIGTERM");
1263
- live.info.status = why === "reaped" ? "reaped" : "closed";
1366
+ // A slot can be closed while its handshake is still in flight — `create()` no longer waits for it
1367
+ // (see there). Killing the child on the spot was WRONG in a way the operator would eventually hit:
1368
+ // the agent had already created a session, but the row was written with `acpSessionId: null`, and
1369
+ // `resume()` refuses such a row outright (「was never handed to an agent」) — the slot became a
1370
+ // transcript nobody could ever wake again. So let the startup land first: it is the only thing that
1371
+ // knows the agent-side id, and the child it spawned is the one really there to kill.
1372
+ //
1373
+ // The slot stays in the live map for the duration, on purpose: the child IS alive and about to be
1374
+ // `ready`, and calling it cold while it boots is the mirror-image lie (a cold row that wakes up by
1375
+ // itself). The wait is bounded — see `CLOSE_STARTUP_WAIT_MS`.
1376
+ if (live.startup && live.info.status === "starting") {
1377
+ await Promise.race([
1378
+ live.startup.catch(() => {}),
1379
+ new Promise<void>((r) => { setTimeout(r, CLOSE_STARTUP_WAIT_MS).unref?.(); }),
1380
+ ]);
1381
+ }
1382
+ // Applied LAST, and only over a non-terminal status: `#startup` writes `ready` when the handshake
1383
+ // lands and `error` (with `lastError`) when it fails — and a handshake that failed on its own has a
1384
+ // better answer than 「closed」, so it keeps it.
1385
+ if (live.info.status !== "error") live.info.status = why === "reaped" ? "reaped" : "closed";
1264
1386
  live.info.pid = null;
1387
+ live.child?.kill("SIGTERM");
1265
1388
  const row = this.#store.getSession(live.info.id);
1266
1389
  if (row) {
1267
1390
  this.#store.upsertSession({
@@ -1382,7 +1505,9 @@ export class SessionManager {
1382
1505
  `[agentus] idle reap: ${s.info.id} (${Math.round(age / 60_000)} min idle, limit ${Math.round(idleMs / 60_000)} min)`,
1383
1506
  );
1384
1507
  }
1385
- this.#stop(s, "reaped");
1508
+ // Fire and forget: the reaper skips `starting` slots, so there is no in-flight handshake to
1509
+ // wait for here and nothing downstream needs the row settled in this tick.
1510
+ void this.#stop(s, "reaped");
1386
1511
  reaped += 1;
1387
1512
  }
1388
1513
  // Only speak when something changed: a frame every tick would be noise on the socket (and on
@@ -19,7 +19,7 @@ import { terms } from "./term.js";
19
19
  import { VoiceError, listVoiceModels, setHotwordSource, synthesize, transcribe, voiceCapabilities } from "./voice.js";
20
20
  import { hotwordsFor, vocabularyOf } from "./hotwords.js";
21
21
  import { openDashscopeStream } from "./dashscope.js";
22
- import { agentSettings, initSettings, publicSettings, saveAgent, saveCall, savePrefs, saveSettings, saveTheme } from "./settings.js";
22
+ import { agentSettings, initSettings, planHintSettings, publicSettings, saveAgent, saveCall, savePrefs, saveSettings, saveTheme } from "./settings.js";
23
23
  import * as auth from "./auth.js";
24
24
  import { NotifyCenter } from "./notify/center.js";
25
25
  import type { BackendId, ClientCommand, PermissionDecision, PromptAttachment, ServerEvent } from "../../shared/src/index.js";
@@ -298,6 +298,9 @@ setHotwordSource((sessionId) => hotwordsFor(store, sessionId).map((h) => h.word)
298
298
  envWarmTtlMs != null ? envWarmTtlMs / 3_600_000 : agentSettings().warmTtlHours
299
299
  } h untouched (${envWarm != null || envWarmTtlMs != null ? "AGENTUS_WARM_*" : "settings.agent"}), 0 = off / never`,
300
300
  );
301
+ // The plan-tool hint: read per prompt, so the 设置 switch applies to the next turn with nothing
302
+ // to restart (the session's own plan card is what it fills).
303
+ mgr.setPlanHintSource(planHintSettings);
301
304
  mgr.startIdleReaper({
302
305
  idleMs: () => envIdleMs ?? agentSettings().idleKillMin * 60_000,
303
306
  warmSlots: () => envWarm ?? agentSettings().warmSlots,
@@ -102,10 +102,29 @@ export interface PrefsSettings {
102
102
  serverTts: boolean;
103
103
  /** which recogniser to use, when the caller does not force one */
104
104
  stt: "auto" | "browser" | "stream" | "server";
105
+ /** Whether — and how often — the cockpit tells the agent about the plan MCP it was handed.
106
+ * The tool's own description says "prefer this over any todo tool", but on a runtime that
107
+ * keeps MCP tools in a lazy-loaded catalog the agent never reads it unless it goes looking,
108
+ * so the session's plan card stays empty while the agent keeps a todo list nobody can see.
109
+ * A per-turn nudge in the prompt is what closes that last mile; this is its switch. */
110
+ planHint: PlanHintSettings;
111
+ }
112
+
113
+ export interface PlanHintSettings {
114
+ /** Inject the plan-tool hint at all. Off = the prompt carries the operator's words and
115
+ * nothing else (some operators do not want anything appended to their message). */
116
+ on: boolean;
117
+ /** true = every turn (the agent is re-told as the plan moves); false = only the first prompt
118
+ * of each agent process (a fresh process has no memory of the plan; a running one maintains
119
+ * it itself, and re-sending it every turn costs context and invites re-deriving finished work). */
120
+ everyTurn: boolean;
105
121
  }
106
122
 
107
123
  export const PREFS_DEFAULT: PrefsSettings = {
108
124
  autoRead: false, voiceURI: "", rate: 1, lang: "", serverTts: false, stt: "auto",
125
+ // On by default: the feature exists because the tool was there and nobody used it. 仅首条 is
126
+ // the conservative default — it is also what the cockpit did before this was configurable.
127
+ planHint: { on: true, everyTurn: false },
109
128
  };
110
129
 
111
130
  export const CALL_DEFAULT: CallSettings = {
@@ -332,6 +351,12 @@ function pickPrefs(raw: unknown): PrefsSettings {
332
351
  const rate = Number(r.rate);
333
352
  if (Number.isFinite(rate) && rate >= 0.5 && rate <= 2) out.rate = rate;
334
353
  if (r.stt === "auto" || r.stt === "browser" || r.stt === "stream" || r.stt === "server") out.stt = r.stt;
354
+ const h = r.planHint;
355
+ if (h && typeof h === "object") {
356
+ const hr = h as Record<string, unknown>;
357
+ if (typeof hr.on === "boolean") out.planHint.on = hr.on;
358
+ if (typeof hr.everyTurn === "boolean") out.planHint.everyTurn = hr.everyTurn;
359
+ }
335
360
  }
336
361
  return out;
337
362
  }
@@ -512,6 +537,21 @@ export function savePrefs(patch: Record<string, unknown>): Settings {
512
537
  if (!["auto", "browser", "stream", "server"].includes(v)) throw new Error(`invalid stt: ${JSON.stringify(patch.stt)}`);
513
538
  next.stt = v as PrefsSettings["stt"];
514
539
  }
540
+ if ("planHint" in patch) {
541
+ const h = patch.planHint;
542
+ if (!h || typeof h !== "object") throw new Error("invalid planHint: expected an object");
543
+ const hr = h as Record<string, unknown>;
544
+ const nextHint = { ...next.planHint };
545
+ if ("on" in hr) {
546
+ if (typeof hr.on !== "boolean") throw new Error("invalid planHint.on: expected true/false");
547
+ nextHint.on = hr.on;
548
+ }
549
+ if ("everyTurn" in hr) {
550
+ if (typeof hr.everyTurn !== "boolean") throw new Error("invalid planHint.everyTurn: expected true/false");
551
+ nextHint.everyTurn = hr.everyTurn;
552
+ }
553
+ next.planHint = nextHint;
554
+ }
515
555
  cache = { ...cache, prefs: next, updatedAt: Date.now() };
516
556
  persist();
517
557
  return cache;
@@ -547,6 +587,12 @@ export function agentSettings(): AgentSettings {
547
587
  return cache.agent;
548
588
  }
549
589
 
590
+ /** The plan-hint knobs. Read on EVERY prompt (not cached here), so the switch in 设置 takes effect
591
+ * on the operator's very next turn — no restart, no session to hibernate. */
592
+ export function planHintSettings(): PlanHintSettings {
593
+ return cache.prefs.planHint;
594
+ }
595
+
550
596
  /** Normalise the two URL shapes operators paste: MaaS root or .../compatible-mode/v1. */
551
597
  export function dashscopeUrls(baseUrl: string): { root: string; compat: string; ws: string } {
552
598
  const trimmed = baseUrl.replace(/\/+$/, "");