@femtomc/mu-server 26.2.75 → 26.2.77

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  HTTP API server for mu control-plane infrastructure. Powers `mu serve`, messaging frontend transport routes, and control-plane/session coordination endpoints.
4
4
 
5
- > Scope note: server-routed business query/mutation gateway endpoints have been removed. Business reads/writes are CLI-first, while long-lived runtime coordination (runs/activities/heartbeats/cron) stays server-owned.
5
+ > Scope note: server-routed business query/mutation gateway endpoints have been removed. Business reads/writes are CLI-first, while long-lived runtime coordination (runs/heartbeats/cron) stays server-owned.
6
6
 
7
7
  ## Installation
8
8
 
@@ -37,7 +37,7 @@ Bun.serve(server);
37
37
 
38
38
  ### Status
39
39
 
40
- - `GET /api/status` - Returns repository + control-plane runtime status
40
+ - `GET /api/control-plane/status` - Returns repository + control-plane runtime status
41
41
  ```json
42
42
  {
43
43
  "repo_root": "/path/to/repo",
@@ -76,8 +76,8 @@ Bun.serve(server);
76
76
 
77
77
  ### Config + Control Plane Admin
78
78
 
79
- - `GET /api/config` - Read redacted `.mu/config.json` plus presence booleans
80
- - `POST /api/config` - Apply a partial patch to `.mu/config.json`
79
+ - `GET /api/control-plane/config` - Read redacted `.mu/config.json` plus presence booleans
80
+ - `POST /api/control-plane/config` - Apply a partial patch to `.mu/config.json`
81
81
  - Body:
82
82
  ```json
83
83
  {
@@ -85,6 +85,10 @@ Bun.serve(server);
85
85
  "control_plane": {
86
86
  "adapters": {
87
87
  "slack": { "signing_secret": "..." }
88
+ },
89
+ "memory_index": {
90
+ "enabled": true,
91
+ "every_ms": 300000
88
92
  }
89
93
  }
90
94
  }
@@ -101,26 +105,10 @@ Bun.serve(server);
101
105
  - `POST /api/control-plane/rollback` - Explicit rollback trigger (same pipeline, reason=`rollback`)
102
106
  - `GET /api/control-plane/channels` - Capability/discovery snapshot for mounted adapter channels (route, verification contract, configured/active/frontend flags)
103
107
 
104
- ### Session Flash Inbox (cross-session context handoff)
108
+ ### Session Turn Injection (control-plane)
105
109
 
106
- - `POST /api/session-flash` - Create a session-targeted flash message
107
- ```json
108
- {
109
- "session_id": "operator-abc123",
110
- "session_kind": "cp_operator",
111
- "body": "Use context id ctx-123 for this question",
112
- "context_ids": ["ctx-123"],
113
- "source": "neovim"
114
- }
115
- ```
116
- - `GET /api/session-flash` - List flash messages
117
- - Query params: `session_id`, `session_kind`, `status=pending|delivered|all`, `contains`, `limit`
118
- - `GET /api/session-flash/:flash_id` - Get one flash message by id
119
- - `POST /api/session-flash/ack` - Mark a flash message delivered/acknowledged
120
-
121
- ### Session Turn Injection (canonical transcript turn)
122
-
123
- - `POST /api/session-turn` - Run a real turn in an existing target session and return reply + new context cursor
110
+ - `POST /api/control-plane/turn` - Run a real turn in an existing target session and return reply + new context cursor
111
+ - Requires Neovim frontend shared-secret header (`x-mu-neovim-secret`)
124
112
  ```json
125
113
  {
126
114
  "session_id": "operator-abc123",
@@ -135,24 +123,25 @@ Bun.serve(server);
135
123
  ### Control-plane Coordination Endpoints
136
124
 
137
125
  - Runs:
138
- - `GET /api/runs`
139
- - `POST /api/runs/start`
140
- - `POST /api/runs/resume`
141
- - `POST /api/runs/interrupt`
142
- - `GET /api/runs/:id`
143
- - `GET /api/runs/:id/trace`
126
+ - `GET /api/control-plane/runs`
127
+ - `POST /api/control-plane/runs/start`
128
+ - `POST /api/control-plane/runs/resume`
129
+ - `POST /api/control-plane/runs/interrupt`
130
+ - `GET /api/control-plane/runs/:id`
131
+ - `GET /api/control-plane/runs/:id/trace`
144
132
  - Scheduling + orchestration:
145
133
  - `GET|POST|PATCH|DELETE /api/heartbeats...`
146
134
  - `GET|POST|PATCH|DELETE /api/cron...`
147
- - `GET|POST /api/activities...`
135
+ - Heartbeat programs support an optional free-form `prompt` field; when present it becomes the primary wake instruction sent to the operator turn path.
148
136
  - Heartbeat/cron ticks dispatch operator wake turns and broadcast the resulting operator reply.
137
+ - Built-in memory-index maintenance runs on the server heartbeat scheduler (config: `control_plane.memory_index`).
149
138
  - Identity bindings:
150
- - `GET /api/identities`
151
- - `POST /api/identities/link`
152
- - `POST /api/identities/unlink`
139
+ - `GET /api/control-plane/identities`
140
+ - `POST /api/control-plane/identities/link`
141
+ - `POST /api/control-plane/identities/unlink`
153
142
  - Observability:
154
- - `GET /api/events`
155
- - `GET /api/events/tail`
143
+ - `GET /api/control-plane/events`
144
+ - `GET /api/control-plane/events/tail`
156
145
 
157
146
  ## Running the Server
158
147
 
@@ -1,4 +1,9 @@
1
1
  import { CONTROL_PLANE_CHANNEL_ADAPTER_SPECS } from "@femtomc/mu-control-plane";
2
+ import { configRoutes } from "./config.js";
3
+ import { eventRoutes } from "./events.js";
4
+ import { identityRoutes } from "./identities.js";
5
+ import { runRoutes } from "./runs.js";
6
+ import { sessionTurnRoutes } from "./session_turn.js";
2
7
  function asRecord(value) {
3
8
  if (!value || typeof value !== "object" || Array.isArray(value)) {
4
9
  return null;
@@ -32,6 +37,30 @@ function configuredForChannel(config, channel) {
32
37
  }
33
38
  export async function controlPlaneRoutes(request, url, deps, headers) {
34
39
  const path = url.pathname;
40
+ if (path === "/api/control-plane" || path === "/api/control-plane/status") {
41
+ if (request.method !== "GET") {
42
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
43
+ }
44
+ return Response.json({
45
+ repo_root: deps.context.repoRoot,
46
+ control_plane: deps.getControlPlaneStatus(),
47
+ }, { headers });
48
+ }
49
+ if (path === "/api/control-plane/config") {
50
+ return configRoutes(request, url, deps, headers);
51
+ }
52
+ if (path === "/api/control-plane/identities" ||
53
+ path === "/api/control-plane/identities/link" ||
54
+ path === "/api/control-plane/identities/unlink") {
55
+ return identityRoutes(request, url, deps, headers);
56
+ }
57
+ if (path.startsWith("/api/control-plane/events")) {
58
+ const response = await eventRoutes(request, deps.context);
59
+ headers.forEach((value, key) => {
60
+ response.headers.set(key, value);
61
+ });
62
+ return response;
63
+ }
35
64
  if (path === "/api/control-plane/reload") {
36
65
  if (request.method !== "POST") {
37
66
  return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
@@ -80,5 +109,11 @@ export async function controlPlaneRoutes(request, url, deps, headers) {
80
109
  channels,
81
110
  }, { headers });
82
111
  }
112
+ if (path === "/api/control-plane/runs" || path.startsWith("/api/control-plane/runs/")) {
113
+ return runRoutes(request, url, deps, headers);
114
+ }
115
+ if (path === "/api/control-plane/turn") {
116
+ return sessionTurnRoutes(request, url, deps, headers);
117
+ }
83
118
  return Response.json({ error: "Not Found" }, { status: 404, headers });
84
119
  }
@@ -64,7 +64,11 @@ function parseSinceMs(value) {
64
64
  }
65
65
  export async function eventRoutes(request, context) {
66
66
  const url = new URL(request.url);
67
- const path = url.pathname.replace("/api/events", "") || "/";
67
+ const pathname = url.pathname;
68
+ if (!pathname.startsWith("/api/control-plane/events")) {
69
+ return new Response("Not Found", { status: 404 });
70
+ }
71
+ const path = pathname.slice("/api/control-plane/events".length) || "/";
68
72
  const method = request.method;
69
73
  if (method !== "GET") {
70
74
  return new Response("Method Not Allowed", { status: 405 });
@@ -79,13 +83,13 @@ export async function eventRoutes(request, context) {
79
83
  sinceMs: parseSinceMs(url.searchParams.get("since")),
80
84
  contains: trimOrNull(url.searchParams.get("contains")),
81
85
  };
82
- // Tail - GET /api/events/tail?n=50
86
+ // Tail - GET /api/control-plane/events/tail?n=50
83
87
  if (path === "/tail") {
84
88
  const n = Math.min(Math.max(1, parseInt(url.searchParams.get("n") ?? "50", 10) || 50), 500);
85
89
  const filtered = applyEventFilters(allEvents, filters);
86
90
  return Response.json(filtered.slice(-n));
87
91
  }
88
- // Query - GET /api/events?type=...&source=...&issue_id=...&run_id=...&since=...&contains=...&limit=50
92
+ // Query - GET /api/control-plane/events?type=...&source=...&issue_id=...&run_id=...&since=...&contains=...&limit=50
89
93
  if (path === "/") {
90
94
  const limit = Math.min(Math.max(1, parseInt(url.searchParams.get("limit") ?? "50", 10) || 50), 500);
91
95
  const filtered = applyEventFilters(allEvents, filters);
@@ -26,6 +26,10 @@ export async function heartbeatRoutes(request, url, deps, headers) {
26
26
  if (!title) {
27
27
  return Response.json({ error: "title is required" }, { status: 400, headers });
28
28
  }
29
+ if ("prompt" in body && typeof body.prompt !== "string" && body.prompt !== null) {
30
+ return Response.json({ error: "prompt must be string or null" }, { status: 400, headers });
31
+ }
32
+ const prompt = typeof body.prompt === "string" ? body.prompt : body.prompt === null ? null : undefined;
29
33
  const everyMs = typeof body.every_ms === "number" && Number.isFinite(body.every_ms)
30
34
  ? Math.max(0, Math.trunc(body.every_ms))
31
35
  : undefined;
@@ -34,6 +38,7 @@ export async function heartbeatRoutes(request, url, deps, headers) {
34
38
  try {
35
39
  const program = await deps.heartbeatPrograms.create({
36
40
  title,
41
+ prompt,
37
42
  everyMs,
38
43
  reason,
39
44
  enabled,
@@ -63,7 +68,7 @@ export async function heartbeatRoutes(request, url, deps, headers) {
63
68
  return Response.json({ error: "program_id is required" }, { status: 400, headers });
64
69
  }
65
70
  try {
66
- const result = await deps.heartbeatPrograms.update({
71
+ const updateOpts = {
67
72
  programId,
68
73
  title: typeof body.title === "string" ? body.title : undefined,
69
74
  everyMs: typeof body.every_ms === "number" && Number.isFinite(body.every_ms)
@@ -74,7 +79,19 @@ export async function heartbeatRoutes(request, url, deps, headers) {
74
79
  metadata: body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata)
75
80
  ? body.metadata
76
81
  : undefined,
77
- });
82
+ };
83
+ if ("prompt" in body) {
84
+ if (typeof body.prompt === "string") {
85
+ updateOpts.prompt = body.prompt;
86
+ }
87
+ else if (body.prompt === null) {
88
+ updateOpts.prompt = null;
89
+ }
90
+ else {
91
+ return Response.json({ error: "prompt must be string or null" }, { status: 400, headers });
92
+ }
93
+ }
94
+ const result = await deps.heartbeatPrograms.update(updateOpts);
78
95
  if (result.ok) {
79
96
  return Response.json(result, { headers });
80
97
  }
@@ -4,7 +4,7 @@ export async function identityRoutes(request, url, deps, headers) {
4
4
  const cpPaths = getControlPlanePaths(deps.context.repoRoot);
5
5
  const identityStore = new IdentityStore(cpPaths.identitiesPath);
6
6
  await identityStore.load();
7
- if (path === "/api/identities") {
7
+ if (path === "/api/control-plane/identities") {
8
8
  if (request.method !== "GET") {
9
9
  return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
10
10
  }
@@ -12,7 +12,7 @@ export async function identityRoutes(request, url, deps, headers) {
12
12
  const bindings = identityStore.listBindings({ includeInactive });
13
13
  return Response.json({ count: bindings.length, bindings }, { headers });
14
14
  }
15
- if (path === "/api/identities/link") {
15
+ if (path === "/api/control-plane/identities/link") {
16
16
  if (request.method !== "POST") {
17
17
  return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
18
18
  }
@@ -64,7 +64,7 @@ export async function identityRoutes(request, url, deps, headers) {
64
64
  return Response.json({ ok: false, kind: "principal_already_linked", binding: decision.binding }, { status: 409, headers });
65
65
  }
66
66
  }
67
- if (path === "/api/identities/unlink") {
67
+ if (path === "/api/control-plane/identities/unlink") {
68
68
  if (request.method !== "POST") {
69
69
  return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
70
70
  }
package/dist/api/runs.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export async function runRoutes(request, url, deps, headers) {
2
2
  const path = url.pathname;
3
- if (path === "/api/runs") {
3
+ if (path === "/api/control-plane/runs") {
4
4
  if (request.method !== "GET") {
5
5
  return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
6
6
  }
@@ -10,7 +10,7 @@ export async function runRoutes(request, url, deps, headers) {
10
10
  const runs = await deps.controlPlaneProxy.listRuns?.({ status, limit });
11
11
  return Response.json({ count: runs?.length ?? 0, runs: runs ?? [] }, { headers });
12
12
  }
13
- if (path === "/api/runs/start") {
13
+ if (path === "/api/control-plane/runs/start") {
14
14
  if (request.method !== "POST") {
15
15
  return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
16
16
  }
@@ -39,7 +39,7 @@ export async function runRoutes(request, url, deps, headers) {
39
39
  return Response.json({ error: deps.describeError(err) }, { status: 500, headers });
40
40
  }
41
41
  }
42
- if (path === "/api/runs/resume") {
42
+ if (path === "/api/control-plane/runs/resume") {
43
43
  if (request.method !== "POST") {
44
44
  return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
45
45
  }
@@ -68,7 +68,7 @@ export async function runRoutes(request, url, deps, headers) {
68
68
  return Response.json({ error: deps.describeError(err) }, { status: 500, headers });
69
69
  }
70
70
  }
71
- if (path === "/api/runs/interrupt") {
71
+ if (path === "/api/control-plane/runs/interrupt") {
72
72
  if (request.method !== "POST") {
73
73
  return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
74
74
  }
@@ -90,8 +90,8 @@ export async function runRoutes(request, url, deps, headers) {
90
90
  }
91
91
  return Response.json(result, { status: result.ok ? 200 : 404, headers });
92
92
  }
93
- if (path.startsWith("/api/runs/")) {
94
- const rest = path.slice("/api/runs/".length);
93
+ if (path.startsWith("/api/control-plane/runs/")) {
94
+ const rest = path.slice("/api/control-plane/runs/".length);
95
95
  const [rawId, maybeSub] = rest.split("/");
96
96
  const idOrRoot = decodeURIComponent(rawId ?? "").trim();
97
97
  if (idOrRoot.length === 0) {
@@ -1,38 +1,2 @@
1
- import { type CreateMuSessionOpts, type MuSession } from "@femtomc/mu-agent";
2
1
  import type { ServerRoutingDependencies } from "../server_routing.js";
3
- export type SessionTurnRequest = {
4
- session_id: string;
5
- session_kind: string | null;
6
- body: string;
7
- source: string | null;
8
- provider: string | null;
9
- model: string | null;
10
- thinking: string | null;
11
- session_file: string | null;
12
- session_dir: string | null;
13
- extension_profile: string | null;
14
- };
15
- export type SessionTurnResult = {
16
- session_id: string;
17
- session_kind: string | null;
18
- session_file: string;
19
- context_entry_id: string | null;
20
- reply: string;
21
- source: string | null;
22
- completed_at_ms: number;
23
- };
24
- export declare class SessionTurnError extends Error {
25
- readonly status: number;
26
- constructor(status: number, message: string);
27
- }
28
- export declare function parseSessionTurnRequest(body: Record<string, unknown>): {
29
- request: SessionTurnRequest | null;
30
- error: string | null;
31
- };
32
- export declare function executeSessionTurn(opts: {
33
- repoRoot: string;
34
- request: SessionTurnRequest;
35
- sessionFactory?: (opts: CreateMuSessionOpts) => Promise<MuSession>;
36
- nowMs?: () => number;
37
- }): Promise<SessionTurnResult>;
38
2
  export declare function sessionTurnRoutes(request: Request, url: URL, deps: ServerRoutingDependencies, headers: Headers): Promise<Response>;