@femtomc/mu-server 26.2.41 โ†’ 26.2.43

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 CHANGED
@@ -115,7 +115,7 @@ Bun.serve(server);
115
115
 
116
116
  ### With Web UI (Recommended)
117
117
 
118
- The easiest way to run the server with the bundled web interface (and default terminal operator chat):
118
+ The easiest way to run the server with the bundled web interface (and default terminal operator session):
119
119
 
120
120
  ```bash
121
121
  # From any mu repository
@@ -124,7 +124,7 @@ mu serve --no-open # Skip browser auto-open (headless/SSH)
124
124
  mu serve --port 8080 # Custom shared API/web UI port
125
125
  ```
126
126
 
127
- Type `/exit` (or press Ctrl+C) to stop both the chat session and server.
127
+ Type `/exit` (or press Ctrl+C) to stop both the operator session and server.
128
128
 
129
129
  ### Standalone Server
130
130
 
@@ -0,0 +1,81 @@
1
+ import { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
2
+ export type ControlPlaneActivityStatus = "running" | "completed" | "failed" | "cancelled";
3
+ export type ControlPlaneActivitySnapshot = {
4
+ activity_id: string;
5
+ kind: string;
6
+ title: string;
7
+ status: ControlPlaneActivityStatus;
8
+ heartbeat_every_ms: number;
9
+ heartbeat_count: number;
10
+ last_heartbeat_at_ms: number | null;
11
+ last_heartbeat_reason: string | null;
12
+ last_progress: string | null;
13
+ final_message: string | null;
14
+ metadata: Record<string, unknown>;
15
+ source: "api" | "command" | "system";
16
+ started_at_ms: number;
17
+ updated_at_ms: number;
18
+ finished_at_ms: number | null;
19
+ };
20
+ export type ControlPlaneActivityEventKind = "activity_started" | "activity_progress" | "activity_heartbeat" | "activity_completed" | "activity_failed" | "activity_cancelled";
21
+ export type ControlPlaneActivityEvent = {
22
+ seq: number;
23
+ ts_ms: number;
24
+ kind: ControlPlaneActivityEventKind;
25
+ message: string;
26
+ activity: ControlPlaneActivitySnapshot;
27
+ };
28
+ export type ControlPlaneActivityMutationResult = {
29
+ ok: boolean;
30
+ reason: "not_found" | "not_running" | "missing_target" | null;
31
+ activity: ControlPlaneActivitySnapshot | null;
32
+ };
33
+ export type ControlPlaneActivitySupervisorOpts = {
34
+ nowMs?: () => number;
35
+ heartbeatScheduler?: ActivityHeartbeatScheduler;
36
+ defaultHeartbeatEveryMs?: number;
37
+ maxHistory?: number;
38
+ maxEventsPerActivity?: number;
39
+ onEvent?: (event: ControlPlaneActivityEvent) => void | Promise<void>;
40
+ };
41
+ export declare class ControlPlaneActivitySupervisor {
42
+ #private;
43
+ constructor(opts?: ControlPlaneActivitySupervisorOpts);
44
+ start(opts: {
45
+ title: string;
46
+ kind?: string;
47
+ heartbeatEveryMs?: number;
48
+ metadata?: Record<string, unknown>;
49
+ source?: "api" | "command" | "system";
50
+ }): ControlPlaneActivitySnapshot;
51
+ list(opts?: {
52
+ status?: ControlPlaneActivityStatus;
53
+ kind?: string;
54
+ limit?: number;
55
+ }): ControlPlaneActivitySnapshot[];
56
+ get(activityId: string): ControlPlaneActivitySnapshot | null;
57
+ events(activityId: string, opts?: {
58
+ limit?: number;
59
+ }): ControlPlaneActivityEvent[] | null;
60
+ progress(opts: {
61
+ activityId?: string | null;
62
+ message?: string | null;
63
+ }): ControlPlaneActivityMutationResult;
64
+ heartbeat(opts: {
65
+ activityId?: string | null;
66
+ reason?: string | null;
67
+ }): ControlPlaneActivityMutationResult;
68
+ complete(opts: {
69
+ activityId?: string | null;
70
+ message?: string | null;
71
+ }): ControlPlaneActivityMutationResult;
72
+ fail(opts: {
73
+ activityId?: string | null;
74
+ message?: string | null;
75
+ }): ControlPlaneActivityMutationResult;
76
+ cancel(opts: {
77
+ activityId?: string | null;
78
+ message?: string | null;
79
+ }): ControlPlaneActivityMutationResult;
80
+ stop(): void;
81
+ }
@@ -0,0 +1,306 @@
1
+ import { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
2
+ const DEFAULT_HEARTBEAT_EVERY_MS = 15_000;
3
+ function defaultNowMs() {
4
+ return Date.now();
5
+ }
6
+ function normalizeKind(value) {
7
+ const trimmed = value?.trim();
8
+ if (!trimmed) {
9
+ return "generic";
10
+ }
11
+ return trimmed.toLowerCase();
12
+ }
13
+ function normalizeTitle(value) {
14
+ const trimmed = value.trim();
15
+ if (trimmed.length === 0) {
16
+ throw new Error("activity_title_required");
17
+ }
18
+ return trimmed;
19
+ }
20
+ function normalizeHeartbeatEveryMs(value, fallback) {
21
+ if (typeof value === "number" && Number.isFinite(value)) {
22
+ return Math.max(0, Math.trunc(value));
23
+ }
24
+ if (typeof value === "string" && /^\d+$/.test(value.trim())) {
25
+ return Math.max(0, Number.parseInt(value.trim(), 10));
26
+ }
27
+ return fallback;
28
+ }
29
+ function toSafeMetadata(value) {
30
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
31
+ return {};
32
+ }
33
+ return { ...value };
34
+ }
35
+ function pushBounded(items, value, max) {
36
+ items.push(value);
37
+ if (items.length <= max) {
38
+ return;
39
+ }
40
+ items.splice(0, items.length - max);
41
+ }
42
+ function elapsedSeconds(snapshot, nowMs) {
43
+ return Math.max(0, Math.trunc((nowMs - snapshot.started_at_ms) / 1_000));
44
+ }
45
+ export class ControlPlaneActivitySupervisor {
46
+ #nowMs;
47
+ #heartbeatScheduler;
48
+ #ownsHeartbeatScheduler;
49
+ #defaultHeartbeatEveryMs;
50
+ #maxHistory;
51
+ #maxEventsPerActivity;
52
+ #onEvent;
53
+ #activities = new Map();
54
+ #seq = 0;
55
+ #counter = 0;
56
+ constructor(opts = {}) {
57
+ this.#nowMs = opts.nowMs ?? defaultNowMs;
58
+ this.#heartbeatScheduler = opts.heartbeatScheduler ?? new ActivityHeartbeatScheduler();
59
+ this.#ownsHeartbeatScheduler = !opts.heartbeatScheduler;
60
+ this.#defaultHeartbeatEveryMs = Math.max(0, Math.trunc(opts.defaultHeartbeatEveryMs ?? DEFAULT_HEARTBEAT_EVERY_MS));
61
+ this.#maxHistory = Math.max(20, Math.trunc(opts.maxHistory ?? 200));
62
+ this.#maxEventsPerActivity = Math.max(20, Math.trunc(opts.maxEventsPerActivity ?? 400));
63
+ this.#onEvent = opts.onEvent ?? null;
64
+ }
65
+ #nextActivityId() {
66
+ this.#counter += 1;
67
+ return `activity-${this.#counter.toString(36)}-${crypto.randomUUID().slice(0, 8)}`;
68
+ }
69
+ #snapshot(activity) {
70
+ return {
71
+ ...activity.snapshot,
72
+ metadata: { ...activity.snapshot.metadata },
73
+ };
74
+ }
75
+ #touch(activity) {
76
+ activity.snapshot.updated_at_ms = Math.trunc(this.#nowMs());
77
+ }
78
+ #emit(kind, activity, message) {
79
+ const event = {
80
+ seq: ++this.#seq,
81
+ ts_ms: Math.trunc(this.#nowMs()),
82
+ kind,
83
+ message,
84
+ activity: this.#snapshot(activity),
85
+ };
86
+ pushBounded(activity.events, event, this.#maxEventsPerActivity);
87
+ if (!this.#onEvent) {
88
+ return;
89
+ }
90
+ void Promise.resolve(this.#onEvent(event)).catch(() => {
91
+ // Do not crash on notifier failures.
92
+ });
93
+ }
94
+ #pruneHistory() {
95
+ const rows = [...this.#activities.values()].sort((a, b) => {
96
+ if (a.snapshot.started_at_ms !== b.snapshot.started_at_ms) {
97
+ return b.snapshot.started_at_ms - a.snapshot.started_at_ms;
98
+ }
99
+ return a.snapshot.activity_id.localeCompare(b.snapshot.activity_id);
100
+ });
101
+ let kept = 0;
102
+ for (const row of rows) {
103
+ if (row.snapshot.status === "running") {
104
+ kept += 1;
105
+ continue;
106
+ }
107
+ kept += 1;
108
+ if (kept <= this.#maxHistory) {
109
+ continue;
110
+ }
111
+ this.#heartbeatScheduler.unregister(row.snapshot.activity_id);
112
+ this.#activities.delete(row.snapshot.activity_id);
113
+ }
114
+ }
115
+ #resolveActivity(activityIdRaw) {
116
+ const id = activityIdRaw?.trim() ?? "";
117
+ if (id.length === 0) {
118
+ return null;
119
+ }
120
+ return this.#activities.get(id) ?? null;
121
+ }
122
+ #heartbeatMessage(activity) {
123
+ const nowMs = Math.trunc(this.#nowMs());
124
+ const elapsed = elapsedSeconds(activity.snapshot, nowMs);
125
+ const progress = activity.snapshot.last_progress ? ` ยท ${activity.snapshot.last_progress}` : "";
126
+ return `โฑ ${activity.snapshot.title} running for ${elapsed}s${progress}`;
127
+ }
128
+ #emitHeartbeat(activity, reason) {
129
+ activity.snapshot.heartbeat_count += 1;
130
+ activity.snapshot.last_heartbeat_at_ms = Math.trunc(this.#nowMs());
131
+ activity.snapshot.last_heartbeat_reason = reason?.trim() || "requested";
132
+ this.#touch(activity);
133
+ this.#emit("activity_heartbeat", activity, this.#heartbeatMessage(activity));
134
+ }
135
+ #registerHeartbeat(activity) {
136
+ if (activity.snapshot.heartbeat_every_ms <= 0) {
137
+ return;
138
+ }
139
+ this.#heartbeatScheduler.register({
140
+ activityId: activity.snapshot.activity_id,
141
+ everyMs: activity.snapshot.heartbeat_every_ms,
142
+ handler: async ({ reason }) => {
143
+ if (activity.snapshot.status !== "running") {
144
+ return { status: "skipped", reason: "not_running" };
145
+ }
146
+ this.#emitHeartbeat(activity, reason);
147
+ return { status: "ran" };
148
+ },
149
+ });
150
+ }
151
+ start(opts) {
152
+ const nowMs = Math.trunc(this.#nowMs());
153
+ const snapshot = {
154
+ activity_id: this.#nextActivityId(),
155
+ kind: normalizeKind(opts.kind),
156
+ title: normalizeTitle(opts.title),
157
+ status: "running",
158
+ heartbeat_every_ms: normalizeHeartbeatEveryMs(opts.heartbeatEveryMs, this.#defaultHeartbeatEveryMs),
159
+ heartbeat_count: 0,
160
+ last_heartbeat_at_ms: null,
161
+ last_heartbeat_reason: null,
162
+ last_progress: null,
163
+ final_message: null,
164
+ metadata: toSafeMetadata(opts.metadata),
165
+ source: opts.source ?? "api",
166
+ started_at_ms: nowMs,
167
+ updated_at_ms: nowMs,
168
+ finished_at_ms: null,
169
+ };
170
+ const activity = {
171
+ snapshot,
172
+ events: [],
173
+ };
174
+ this.#activities.set(snapshot.activity_id, activity);
175
+ this.#registerHeartbeat(activity);
176
+ this.#emit("activity_started", activity, `๐Ÿš€ Started activity ${snapshot.title} (${snapshot.activity_id}).`);
177
+ return this.#snapshot(activity);
178
+ }
179
+ list(opts = {}) {
180
+ const limit = Math.max(1, Math.min(500, Math.trunc(opts.limit ?? 100)));
181
+ const kind = opts.kind?.trim().toLowerCase() || null;
182
+ return [...this.#activities.values()]
183
+ .filter((activity) => {
184
+ if (opts.status && activity.snapshot.status !== opts.status) {
185
+ return false;
186
+ }
187
+ if (kind && activity.snapshot.kind !== kind) {
188
+ return false;
189
+ }
190
+ return true;
191
+ })
192
+ .sort((a, b) => {
193
+ if (a.snapshot.started_at_ms !== b.snapshot.started_at_ms) {
194
+ return b.snapshot.started_at_ms - a.snapshot.started_at_ms;
195
+ }
196
+ return a.snapshot.activity_id.localeCompare(b.snapshot.activity_id);
197
+ })
198
+ .slice(0, limit)
199
+ .map((activity) => this.#snapshot(activity));
200
+ }
201
+ get(activityId) {
202
+ const activity = this.#resolveActivity(activityId);
203
+ return activity ? this.#snapshot(activity) : null;
204
+ }
205
+ events(activityId, opts = {}) {
206
+ const activity = this.#resolveActivity(activityId);
207
+ if (!activity) {
208
+ return null;
209
+ }
210
+ const limit = Math.max(1, Math.min(2_000, Math.trunc(opts.limit ?? 200)));
211
+ return activity.events.slice(-limit).map((event) => ({
212
+ ...event,
213
+ activity: {
214
+ ...event.activity,
215
+ metadata: { ...event.activity.metadata },
216
+ },
217
+ }));
218
+ }
219
+ progress(opts) {
220
+ const activity = this.#resolveActivity(opts.activityId);
221
+ if (!opts.activityId?.trim()) {
222
+ return { ok: false, reason: "missing_target", activity: null };
223
+ }
224
+ if (!activity) {
225
+ return { ok: false, reason: "not_found", activity: null };
226
+ }
227
+ if (activity.snapshot.status !== "running") {
228
+ return { ok: false, reason: "not_running", activity: this.#snapshot(activity) };
229
+ }
230
+ const message = opts.message?.trim() || "progress updated";
231
+ activity.snapshot.last_progress = message;
232
+ this.#touch(activity);
233
+ this.#emit("activity_progress", activity, `๐Ÿ“ˆ ${message}`);
234
+ return { ok: true, reason: null, activity: this.#snapshot(activity) };
235
+ }
236
+ heartbeat(opts) {
237
+ const activityId = opts.activityId?.trim() || "";
238
+ if (activityId.length === 0) {
239
+ return { ok: false, reason: "missing_target", activity: null };
240
+ }
241
+ const activity = this.#activities.get(activityId) ?? null;
242
+ if (!activity) {
243
+ return { ok: false, reason: "not_found", activity: null };
244
+ }
245
+ if (activity.snapshot.status !== "running") {
246
+ return { ok: false, reason: "not_running", activity: this.#snapshot(activity) };
247
+ }
248
+ const reason = opts.reason?.trim() || "manual";
249
+ if (this.#heartbeatScheduler.has(activityId)) {
250
+ this.#heartbeatScheduler.requestNow(activityId, { reason, coalesceMs: 0 });
251
+ }
252
+ else {
253
+ this.#emitHeartbeat(activity, reason);
254
+ }
255
+ return { ok: true, reason: null, activity: this.#snapshot(activity) };
256
+ }
257
+ #finish(opts) {
258
+ const activityId = opts.activityId?.trim() || "";
259
+ if (activityId.length === 0) {
260
+ return { ok: false, reason: "missing_target", activity: null };
261
+ }
262
+ const activity = this.#activities.get(activityId) ?? null;
263
+ if (!activity) {
264
+ return { ok: false, reason: "not_found", activity: null };
265
+ }
266
+ if (activity.snapshot.status !== "running") {
267
+ return { ok: false, reason: "not_running", activity: this.#snapshot(activity) };
268
+ }
269
+ activity.snapshot.status = opts.status;
270
+ activity.snapshot.final_message = opts.message?.trim() || null;
271
+ activity.snapshot.finished_at_ms = Math.trunc(this.#nowMs());
272
+ activity.snapshot.updated_at_ms = activity.snapshot.finished_at_ms;
273
+ this.#heartbeatScheduler.unregister(activity.snapshot.activity_id);
274
+ switch (opts.status) {
275
+ case "completed":
276
+ this.#emit("activity_completed", activity, `โœ… Activity completed: ${activity.snapshot.title}${activity.snapshot.final_message ? ` ยท ${activity.snapshot.final_message}` : ""}`);
277
+ break;
278
+ case "failed":
279
+ this.#emit("activity_failed", activity, `โŒ Activity failed: ${activity.snapshot.title}${activity.snapshot.final_message ? ` ยท ${activity.snapshot.final_message}` : ""}`);
280
+ break;
281
+ case "cancelled":
282
+ this.#emit("activity_cancelled", activity, `๐Ÿ›‘ Activity cancelled: ${activity.snapshot.title}${activity.snapshot.final_message ? ` ยท ${activity.snapshot.final_message}` : ""}`);
283
+ break;
284
+ }
285
+ this.#pruneHistory();
286
+ return { ok: true, reason: null, activity: this.#snapshot(activity) };
287
+ }
288
+ complete(opts) {
289
+ return this.#finish({ ...opts, status: "completed" });
290
+ }
291
+ fail(opts) {
292
+ return this.#finish({ ...opts, status: "failed" });
293
+ }
294
+ cancel(opts) {
295
+ return this.#finish({ ...opts, status: "cancelled" });
296
+ }
297
+ stop() {
298
+ for (const activity of this.#activities.values()) {
299
+ this.#heartbeatScheduler.unregister(activity.snapshot.activity_id);
300
+ }
301
+ if (this.#ownsHeartbeatScheduler) {
302
+ this.#heartbeatScheduler.stop();
303
+ }
304
+ this.#activities.clear();
305
+ }
306
+ }
@@ -1,6 +1,8 @@
1
1
  import { type MessagingOperatorBackend, MessagingOperatorRuntime } from "@femtomc/mu-agent";
2
2
  import { type Channel } from "@femtomc/mu-control-plane";
3
3
  import { type MuConfig } from "./config.js";
4
+ import { type ControlPlaneRunHeartbeatResult, type ControlPlaneRunInterruptResult, type ControlPlaneRunSnapshot, type ControlPlaneRunTrace } from "./run_supervisor.js";
5
+ import type { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
4
6
  export type ActiveAdapter = {
5
7
  name: Channel;
6
8
  route: string;
@@ -8,6 +10,32 @@ export type ActiveAdapter = {
8
10
  export type ControlPlaneHandle = {
9
11
  activeAdapters: ActiveAdapter[];
10
12
  handleWebhook(path: string, req: Request): Promise<Response | null>;
13
+ listRuns?(opts?: {
14
+ status?: string;
15
+ limit?: number;
16
+ }): Promise<ControlPlaneRunSnapshot[]>;
17
+ getRun?(idOrRoot: string): Promise<ControlPlaneRunSnapshot | null>;
18
+ startRun?(opts: {
19
+ prompt: string;
20
+ maxSteps?: number;
21
+ }): Promise<ControlPlaneRunSnapshot>;
22
+ resumeRun?(opts: {
23
+ rootIssueId: string;
24
+ maxSteps?: number;
25
+ }): Promise<ControlPlaneRunSnapshot>;
26
+ interruptRun?(opts: {
27
+ jobId?: string | null;
28
+ rootIssueId?: string | null;
29
+ }): Promise<ControlPlaneRunInterruptResult>;
30
+ heartbeatRun?(opts: {
31
+ jobId?: string | null;
32
+ rootIssueId?: string | null;
33
+ reason?: string | null;
34
+ }): Promise<ControlPlaneRunHeartbeatResult>;
35
+ traceRun?(opts: {
36
+ idOrRoot: string;
37
+ limit?: number;
38
+ }): Promise<ControlPlaneRunTrace | null>;
11
39
  stop(): Promise<void>;
12
40
  };
13
41
  export type ControlPlaneConfig = MuConfig["control_plane"];
@@ -36,6 +64,7 @@ export type TelegramSendMessagePayload = {
36
64
  * while preserving fenced code blocks verbatim.
37
65
  */
38
66
  export declare function renderTelegramMarkdown(text: string): string;
67
+ export declare function containsTelegramMathNotation(text: string): boolean;
39
68
  export declare function buildTelegramSendMessagePayload(opts: {
40
69
  chatId: string;
41
70
  text: string;
@@ -46,6 +75,7 @@ export type BootstrapControlPlaneOpts = {
46
75
  config?: ControlPlaneConfig;
47
76
  operatorRuntime?: MessagingOperatorRuntime | null;
48
77
  operatorBackend?: MessagingOperatorBackend;
78
+ heartbeatScheduler?: ActivityHeartbeatScheduler;
49
79
  };
50
80
  export declare function bootstrapControlPlane(opts: BootstrapControlPlaneOpts): Promise<ControlPlaneHandle | null>;
51
81
  export {};