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.
- package/README.md +18 -50
- package/README.zh-CN.md +12 -42
- package/bin/agentus.mjs +4 -4
- package/docs/dev/01-dev-loop.md +1 -2
- package/docs/dev/02-verify.md +0 -1
- package/docs/dev/04-recover.md +6 -26
- package/docs/dev/05-agent-driven.md +1 -1
- package/docs/dev/README.md +4 -5
- package/docs/installation.md +76 -30
- package/docs/installation.zh-CN.md +68 -29
- package/package.json +8 -3
- package/packages/server/mock/agent.mjs +38 -0
- package/packages/server/src/acp/registry.ts +28 -1
- package/packages/server/src/acp/session-manager.ts +192 -60
- package/packages/server/src/index.ts +44 -64
- package/packages/server/src/notify/center.ts +118 -26
- package/packages/server/src/notify/types.ts +25 -0
- package/packages/server/src/settings.ts +46 -0
- package/packages/shared/src/index.ts +40 -0
- package/packages/web/dist/assets/index-DQdijjaT.css +32 -0
- package/packages/web/dist/assets/index-eHg6Cshr.js +119 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +39 -0
- package/packages/web/dist/assets/index-CCvfL56d.css +0 -32
- package/packages/web/dist/assets/index-DCBs8dsw.js +0 -119
- package/scripts/make-cert.sh +0 -101
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
932
|
-
//
|
|
933
|
-
|
|
934
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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;
|