agentus 0.3.3 → 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.
@@ -26,8 +26,10 @@ import type {
26
26
  NotifyFrame,
27
27
  NotifyRef,
28
28
  NotifyRules,
29
+ NotifyWebRules,
29
30
  Presence,
30
31
  OpenTarget,
32
+ WebNotice,
31
33
  } from "./types.js";
32
34
 
33
35
  export interface NotifyCenterOptions {
@@ -152,6 +154,8 @@ export class NotifyCenter {
152
154
  /** actionKey -> when it was consumed, so a button can only fire once. */
153
155
  private readonly consumed = new Map<string, number>();
154
156
  private onActionCb: ((e: ActionEvent) => void) | null = null;
157
+ /** The page channel's sink: index.ts fans this out to every cockpit websocket. */
158
+ private onNoticeCb: ((n: WebNotice) => void) | null = null;
155
159
  private timers: NodeJS.Timeout[] = [];
156
160
  private wss: WebSocketServer | null = null;
157
161
  /** Which session the cockpit is showing, and whether its tab is visible. Deliberately in
@@ -163,14 +167,34 @@ export class NotifyCenter {
163
167
 
164
168
  /** The operator's switches, always complete (a state file may predate them). */
165
169
  rules(): NotifyRules {
166
- return { ...DEFAULT_NOTIFY_RULES, ...(this.state.rules ?? {}) };
170
+ // A state file written before the web channel existed has no `web` key at all, so the stored
171
+ // value is really partial — the cast says that out loud instead of pretending otherwise.
172
+ const stored = this.state.rules as Partial<NotifyRules> | undefined;
173
+ // `web` is merged one level deeper than the rest: a partial patch (one switch from the page) must
174
+ // not drop the other two — a nested object replaced wholesale is how a channel silently forgets
175
+ // its settings.
176
+ return {
177
+ ...DEFAULT_NOTIFY_RULES,
178
+ ...(stored ?? {}),
179
+ web: { ...DEFAULT_NOTIFY_RULES.web, ...(stored?.web ?? {}) },
180
+ };
167
181
  }
168
182
 
169
183
  setRules(patch: Partial<NotifyRules>): NotifyRules {
170
- const next: NotifyRules = { ...this.rules(), ...patch };
184
+ const current = this.rules();
185
+ const next: NotifyRules = {
186
+ ...current,
187
+ ...patch,
188
+ web: { ...current.web, ...(patch.web ?? {}) },
189
+ };
171
190
  this.state.rules = next;
172
191
  this.save();
173
- this.log(`[notify] rules turnStart=${next.turnStart} approval=${next.approval} completion=${next.completion} quietWhenWatching=${next.quietWhenWatching}`);
192
+ const w = next.web;
193
+ this.log(
194
+ `[notify] rules turnStart=${next.turnStart} approval=${next.approval} completion=${next.completion} ` +
195
+ `quietWhenWatching=${next.quietWhenWatching} | web enabled=${w.enabled} turnStart=${w.turnStart} ` +
196
+ `approval=${w.approval} completion=${w.completion}`,
197
+ );
174
198
  return next;
175
199
  }
176
200
 
@@ -473,6 +497,27 @@ export class NotifyCenter {
473
497
  return this.events.filter((e) => e.seq > cursor && e.activity.op !== "clear_all");
474
498
  }
475
499
 
500
+ // ---- the page channel (browser notifications) -----------------------------
501
+ //
502
+ // The same decision, a second renderer. Kept here (not in the page) so that strings, dedup and the
503
+ // card lifecycle have ONE owner: the page cannot re-derive "what happened" from the transcript, and
504
+ // a rule change must apply to both channels without shipping a bundle.
505
+ //
506
+ // What this channel deliberately does NOT do: consult presence. The page knows whether it is the
507
+ // tab in front (`document.hidden`) and stays quiet by itself, which is the same judgement made where
508
+ // the fact lives — and it must stay that way, because one browser tab cannot see the other machine.
509
+
510
+ onNotice(cb: (n: WebNotice) => void): void {
511
+ this.onNoticeCb = cb;
512
+ }
513
+
514
+ /** Emit one page notice, gated on the web channel's own switches. */
515
+ private notice(n: Omit<WebNotice, "at">, rule: keyof Omit<NotifyWebRules, "enabled">): void {
516
+ const web = this.rules().web;
517
+ if (!web.enabled || !web[rule]) return;
518
+ this.onNoticeCb?.({ ...n, at: Date.now() });
519
+ }
520
+
476
521
  // ---- actions -------------------------------------------------------------
477
522
 
478
523
  onAction(cb: (e: ActionEvent) => void): void {
@@ -927,11 +972,11 @@ export class NotifyCenter {
927
972
  const title = (sessionId: string): string => this.sessionTitle?.(sessionId) ?? sessionId.slice(0, 8);
928
973
  const link = (sessionId: string): string => `/?session=${encodeURIComponent(sessionId)}`;
929
974
  switch (evt.t) {
930
- case "turn-start":
931
- // The rules gate BEFORE anything else: an event that is switched off must not even be
932
- // evaluated against presence (and a suppressed event must not be mistaken for a bug).
933
- if (!this.rules().turnStart || this.watching(evt.sessionId)) return;
934
- this.publish({
975
+ case "turn-start": {
976
+ // Rules gate the PHONE first, and that is why the page's copy is emitted before the gate: the
977
+ // two channels have independent switches, and an early return for one must not starve the
978
+ // other. Both render the SAME object, so the words can never drift apart.
979
+ const running: ActivityObject = {
935
980
  schema: 1, op: "upsert", activityId: `turn:${evt.sessionId}`, revision: this.nextRevision(`turn:${evt.sessionId}`),
936
981
  kind: "agent_running", priority: "low", ongoing: true, promotable: true,
937
982
  title: "Agentus · 运行中",
@@ -939,29 +984,55 @@ export class NotifyCenter {
939
984
  progress: { indeterminate: true },
940
985
  channel: { id: "agent_running", name: "任务运行中", importance: "low", sound: false },
941
986
  smallIcon: "agentus", ...tapTarget(link(evt.sessionId)), visibility: "public",
942
- });
987
+ };
988
+ this.notice({
989
+ op: "upsert", noticeId: `turn:${evt.sessionId}`, kind: "agent_running",
990
+ // The browser card drops the `Agentus ·` prefix the phone title carries: a browser already
991
+ // shows the origin and the icon, so the product's own name would spend the visible line.
992
+ title: "运行中", body: running.body ?? "", open: link(evt.sessionId),
993
+ }, "turnStart");
994
+ if (!this.rules().turnStart || this.watching(evt.sessionId)) return;
995
+ this.publish(running);
943
996
  return;
944
- case "permission":
997
+ }
998
+ case "permission": {
945
999
  // Never suppressed by presence: this is a decision waiting on a human, and the whole
946
1000
  // point of the phone being in the loop is answering it while away from the desk.
1001
+ const { activity, actionRefs } = this.approvalActivity(evt.request, title(evt.request.sessionId), link(evt.request.sessionId));
1002
+ this.notice({
1003
+ op: "upsert", noticeId: `perm:${evt.request.requestId}`, kind: "approval",
1004
+ title: activity.title, body: activity.body ?? "", open: link(evt.request.sessionId),
1005
+ // Sticky: a browser card that fades away while the agent is still blocked is the same
1006
+ // failure as a dead button on a lock screen.
1007
+ sticky: true,
1008
+ }, "approval");
947
1009
  if (!this.rules().approval) return;
948
- this.publishApproval(evt.request, title(evt.request.sessionId), link(evt.request.sessionId));
1010
+ this.publish(activity, { actionRefs });
949
1011
  return;
1012
+ }
950
1013
  case "permission-resolved":
951
1014
  // Always clear the card, even if the request itself was never published — a stale
952
- // "waiting for you" on the phone is worse than no notification at all.
1015
+ // "waiting for you" on the phone is worse than no notification at all. The page's card goes
1016
+ // away for the same reason and with the same unconditionality — and it is NOT switch-gated:
1017
+ // taking a dead card off the screen is not an interruption, and gating it on a preference
1018
+ // would leave a card nobody can answer.
953
1019
  this.dismiss(`perm:${evt.requestId}`);
1020
+ this.onNoticeCb?.({
1021
+ op: "dismiss", noticeId: `perm:${evt.requestId}`, kind: "approval", title: "", body: "", at: Date.now(),
1022
+ });
954
1023
  return;
955
1024
  case "turn-end": {
956
1025
  // The running card goes away unconditionally — a zombie "still running" on the phone is worse
957
1026
  // than no card at all, even when the frame that started it was suppressed.
958
1027
  this.dismiss(`turn:${evt.sessionId}`);
1028
+ this.onNoticeCb?.({
1029
+ op: "dismiss", noticeId: `turn:${evt.sessionId}`, kind: "agent_running", title: "", body: "", at: Date.now(),
1030
+ });
959
1031
  // Both gates belong here and both were missing: `completion` was stored but never read, and
960
1032
  // presence was only consulted for turn-start — so the completion card fired while the operator was watching
961
1033
  // the very session (and, mid-call, with a sound: the reported bug).
962
- if (!this.rules().completion || this.watching(evt.sessionId)) return;
963
1034
  const failed = Boolean(evt.error);
964
- this.publish({
1035
+ const done: ActivityObject = {
965
1036
  schema: 1, op: "upsert", activityId: `done:${evt.sessionId}:${Date.now()}`, revision: 1,
966
1037
  kind: failed ? "error" : "agent_done", priority: "high", ongoing: false, promotable: false,
967
1038
  title: failed ? "执行失败" : "已完成",
@@ -974,10 +1045,23 @@ export class NotifyCenter {
974
1045
  },
975
1046
  actions: [{ id: "open", label: "查看", style: "primary" }],
976
1047
  smallIcon: "agentus", ...tapTarget(link(evt.sessionId)), visibility: "private",
977
- }, { actionRefs: { open: { type: "open", sessionId: evt.sessionId } } });
1048
+ };
1049
+ // The page's card is keyed per SESSION (`done:<id>`), unlike the phone's per-occurrence id:
1050
+ // a browser card carries a `tag`, so one per session is what makes the newest replace the
1051
+ // previous one instead of stacking a wall of 「已完成」. Nothing is lost — the transcript is
1052
+ // the history; the card only has to say what just happened.
1053
+ this.notice({
1054
+ op: "upsert", noticeId: `done:${evt.sessionId}`, kind: failed ? "error" : "agent_done",
1055
+ title: done.title, body: done.body ?? "", open: link(evt.sessionId),
1056
+ }, "completion");
1057
+ if (!this.rules().completion || this.watching(evt.sessionId)) return;
1058
+ this.publish(done, { actionRefs: { open: { type: "open", sessionId: evt.sessionId } } });
978
1059
  return;
979
1060
  }
980
1061
  case "error":
1062
+ // Phone-only, on purpose. This is the cockpit's own error with no session in scope, and on a
1063
+ // desktop it is already ON SCREEN — the page is drawing that error right now. A browser card
1064
+ // repeating something the operator is looking at is the one thing this channel must not do.
981
1065
  this.publish({
982
1066
  schema: 1, op: "upsert", activityId: `error:${Date.now()}`, revision: 1, kind: "error",
983
1067
  priority: "high", ongoing: false, promotable: false, title: "Agentus 报错",
@@ -991,7 +1075,12 @@ export class NotifyCenter {
991
1075
  }
992
1076
  }
993
1077
 
994
- private publishApproval(request: PermissionRequestView, sessionTitle: string, link: string): void {
1078
+ /** The approval card, built but not published: the phone's activity and the page's notice are two
1079
+ * renderings of ONE object, so the wording lives here and nowhere else. */
1080
+ private approvalActivity(request: PermissionRequestView, sessionTitle: string, link: string): {
1081
+ activity: ActivityObject;
1082
+ actionRefs: Record<string, NotifyRef>;
1083
+ } {
995
1084
  const actions = request.options.slice(0, 3).map((o) => ({
996
1085
  id: o.optionId,
997
1086
  label: o.name.slice(0, 24),
@@ -1006,15 +1095,18 @@ export class NotifyCenter {
1006
1095
  };
1007
1096
  }
1008
1097
  const tool = splitToolTitle(request.toolCallTitle);
1009
- this.publish({
1010
- schema: 1, op: "upsert", activityId: `perm:${request.requestId}`, revision: 1, kind: "approval",
1011
- priority: "high", ongoing: true, promotable: true,
1012
- title: tool.head ? `待你确认:${tool.head.slice(0, 60)}` : "待你确认",
1013
- body: nameAndDetail(sessionTitle, tool.target || request.kind),
1014
- channel: { id: "agent_approval", name: "权限请求", importance: "high", sound: true, vibration: true },
1015
- actions: actions.length ? actions : [{ id: "open", label: "查看", style: "primary" }],
1016
- input: { enabled: true, placeholder: "或直接回一句" },
1017
- smallIcon: "agentus", ...tapTarget(link), visibility: "private",
1018
- }, { actionRefs });
1098
+ return {
1099
+ activity: {
1100
+ schema: 1, op: "upsert", activityId: `perm:${request.requestId}`, revision: 1, kind: "approval",
1101
+ priority: "high", ongoing: true, promotable: true,
1102
+ title: tool.head ? `待你确认:${tool.head.slice(0, 60)}` : "待你确认",
1103
+ body: nameAndDetail(sessionTitle, tool.target || request.kind),
1104
+ channel: { id: "agent_approval", name: "权限请求", importance: "high", sound: true, vibration: true },
1105
+ actions: actions.length ? actions : [{ id: "open", label: "查看", style: "primary" }],
1106
+ input: { enabled: true, placeholder: "或直接回一句" },
1107
+ smallIcon: "agentus", ...tapTarget(link), visibility: "private",
1108
+ },
1109
+ actionRefs,
1110
+ };
1019
1111
  }
1020
1112
  }
@@ -200,6 +200,21 @@ export interface NotifyRules {
200
200
  completion: boolean;
201
201
  /** Do not push while the operator is looking at that very session in the cockpit. */
202
202
  quietWhenWatching: boolean;
203
+ /** The OTHER channel: browser notifications on a machine that has the cockpit open. Same events,
204
+ * rendered by the page (see WebNotice). Its own switches because the two channels are used in
205
+ * different places: a "running" card belongs on a lock screen and is noise on a desktop that is
206
+ * already showing the transcript, and a desktop has no `quietWhenWatching` need — the page knows
207
+ * its own tab is in front and stays silent by itself. */
208
+ web: NotifyWebRules;
209
+ }
210
+
211
+ /** The web channel's own view of the same three events. */
212
+ export interface NotifyWebRules {
213
+ /** The whole channel: off means the server sends pages nothing, whatever the per-kind switches say. */
214
+ enabled: boolean;
215
+ turnStart: boolean;
216
+ approval: boolean;
217
+ completion: boolean;
203
218
  }
204
219
 
205
220
  export const DEFAULT_NOTIFY_RULES: NotifyRules = {
@@ -207,8 +222,18 @@ export const DEFAULT_NOTIFY_RULES: NotifyRules = {
207
222
  approval: true,
208
223
  completion: true,
209
224
  quietWhenWatching: true,
225
+ // On by default, but only for the two events worth interrupting a desktop for: a decision waiting
226
+ // on a human, and a reply that finished while the tab was in the background.
227
+ web: { enabled: true, turnStart: false, approval: true, completion: true },
210
228
  };
211
229
 
230
+ /**
231
+ * Server -> page: one browser notification. The type itself lives in `shared` (it is part of the
232
+ * browser protocol now — it rides the cockpit's websocket as `{t:"notify"}`), and is re-exported here
233
+ * so the notify centre keeps one import site.
234
+ */
235
+ export type { WebNotice } from "../../../shared/src/index.js";
236
+
212
237
  /** Which session the cockpit is showing right now (in-memory only — presence is ephemeral). */
213
238
  export interface Presence {
214
239
  sessionId: string | null;
@@ -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(/\/+$/, "");
@@ -194,8 +194,48 @@ export type ServerEvent =
194
194
  | { t: "plan"; sessionId: string; plan: PlanSnapshot }
195
195
  /** context-window usage for a slot (ACP usage_update) */
196
196
  | { t: "usage"; sessionId: string; usage: UsageView }
197
+ /** A browser notification for this machine (the web channel of the notify feature).
198
+ *
199
+ * Same decision as the phone's cards, second renderer: the server maps an ACP event to a
200
+ * title/body ONCE (notify/center.ts §ACP mapping) and hands the page a render-ready object, so the
201
+ * page never re-derives what happened. Nothing here is Android's `ActivityObject` — no channels,
202
+ * importances or actions; a browser ignores them, and sending fields a renderer cannot use is how a
203
+ * second contract starts.
204
+ *
205
+ * No cursor and no replay, unlike the phone's device socket: a tab that was closed has a
206
+ * transcript to catch up in, and a wall of stale cards after a reload is worse than none. */
207
+ | { t: "notify"; notice: WebNotice }
197
208
  | { t: "error"; error: string };
198
209
 
210
+ /**
211
+ * Server -> page, one browser notification (see the `notify` frame above).
212
+ *
213
+ * NOTE for whoever touches drawing: the page's ONLY jobs are (1) is this device allowed and willing
214
+ * (`Notification` exists, secure context, permission granted, the operator's local switch on), (2) is
215
+ * this tab in the background (`document.hidden` — the page's own answer to 「我正在看就不弹」, made
216
+ * where the fact lives), (3) draw it with `noticeId` as the notification `tag`. Everything about WHAT
217
+ * happened and WHEN to say it is decided server-side. */
218
+ export interface WebNotice {
219
+ /** `upsert` = show (or replace) the card for `noticeId`; `dismiss` = take it away. */
220
+ op: "upsert" | "dismiss";
221
+ /** The page's slot AND its notification `tag`: one id = one card, a newer one replaces the old.
222
+ * Chosen so a session's running/done cards replace themselves while each pending approval keeps
223
+ * its own (every one of those is a decision still waiting for an answer). */
224
+ noticeId: string;
225
+ /** Semantic kind, for the page's own choices (per-kind sound, and whether a click is expected). */
226
+ kind: "agent_running" | "approval" | "agent_done" | "error";
227
+ /** What happened (short — the line the OS draws first). */
228
+ title: string;
229
+ /** `<会话名> · <detail>`, the same shape the phone's cards use. */
230
+ body: string;
231
+ /** Where a click lands, relative to this server (`/?session=<id>`). The phone's tap target is the
232
+ * same URL, so "open the session that notified me" has one owner. */
233
+ open?: string | null;
234
+ /** True when the card must WAIT for the operator (an approval) rather than fade on its own. */
235
+ sticky?: boolean;
236
+ at: number;
237
+ }
238
+
199
239
  export interface TurnTrace {
200
240
  model?: string;
201
241
  provider?: string;