holo-codex 0.1.0

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.
Files changed (149) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/CONTRIBUTING.md +54 -0
  3. package/LICENSE +21 -0
  4. package/README.md +215 -0
  5. package/README.zh-CN.md +215 -0
  6. package/SECURITY.md +39 -0
  7. package/assets/brand/README.md +35 -0
  8. package/assets/brand/holo-codex-icon.svg +28 -0
  9. package/assets/brand/holo-codex-lockup.svg +49 -0
  10. package/assets/brand/holo-codex-mark.svg +33 -0
  11. package/assets/brand/holo-codex-plugin-card.png +0 -0
  12. package/assets/brand/holo-codex-plugin-card.svg +81 -0
  13. package/assets/brand/holo-codex-readme-hero.png +0 -0
  14. package/assets/brand/holo-codex-readme-hero.svg +140 -0
  15. package/assets/brand/holo-codex-social-preview.png +0 -0
  16. package/assets/brand/holo-codex-social-preview.svg +130 -0
  17. package/assets/brand/holo-codex-wordmark-options.svg +52 -0
  18. package/docs/checklists/agent-loop-first-delivery-audit.md +129 -0
  19. package/docs/examples/generic-loop-repo-hygiene.md +168 -0
  20. package/docs/install.md +190 -0
  21. package/docs/local-release-readiness.md +206 -0
  22. package/docs/release-checklist.md +144 -0
  23. package/docs/self-bootstrap.md +150 -0
  24. package/docs/trust-and-safety.md +45 -0
  25. package/package.json +83 -0
  26. package/plugins/autonomous-pr-loop/.codex-plugin/plugin.json +17 -0
  27. package/plugins/autonomous-pr-loop/.mcp.json +13 -0
  28. package/plugins/autonomous-pr-loop/bin/agent-loop.mjs +31 -0
  29. package/plugins/autonomous-pr-loop/core/artifacts.ts +164 -0
  30. package/plugins/autonomous-pr-loop/core/autonomy-policy.ts +206 -0
  31. package/plugins/autonomous-pr-loop/core/ci.ts +131 -0
  32. package/plugins/autonomous-pr-loop/core/cli-i18n.ts +123 -0
  33. package/plugins/autonomous-pr-loop/core/cli.ts +1413 -0
  34. package/plugins/autonomous-pr-loop/core/command-runner.ts +446 -0
  35. package/plugins/autonomous-pr-loop/core/command.ts +47 -0
  36. package/plugins/autonomous-pr-loop/core/config-editor.ts +140 -0
  37. package/plugins/autonomous-pr-loop/core/config.ts +293 -0
  38. package/plugins/autonomous-pr-loop/core/controller-host.ts +19 -0
  39. package/plugins/autonomous-pr-loop/core/dashboard-server.ts +536 -0
  40. package/plugins/autonomous-pr-loop/core/delivery-work-item.ts +217 -0
  41. package/plugins/autonomous-pr-loop/core/doctor.ts +335 -0
  42. package/plugins/autonomous-pr-loop/core/errors.ts +82 -0
  43. package/plugins/autonomous-pr-loop/core/gate-recovery.ts +176 -0
  44. package/plugins/autonomous-pr-loop/core/gates.ts +26 -0
  45. package/plugins/autonomous-pr-loop/core/generic-lifecycle.ts +399 -0
  46. package/plugins/autonomous-pr-loop/core/git.ts +213 -0
  47. package/plugins/autonomous-pr-loop/core/github.ts +269 -0
  48. package/plugins/autonomous-pr-loop/core/gitnexus.ts +90 -0
  49. package/plugins/autonomous-pr-loop/core/happy.ts +42 -0
  50. package/plugins/autonomous-pr-loop/core/hook-capture.ts +115 -0
  51. package/plugins/autonomous-pr-loop/core/hook-events.ts +22 -0
  52. package/plugins/autonomous-pr-loop/core/hook-installation.ts +85 -0
  53. package/plugins/autonomous-pr-loop/core/hook-observer.ts +84 -0
  54. package/plugins/autonomous-pr-loop/core/hook-policy.ts +423 -0
  55. package/plugins/autonomous-pr-loop/core/hook-router.ts +452 -0
  56. package/plugins/autonomous-pr-loop/core/index.ts +32 -0
  57. package/plugins/autonomous-pr-loop/core/local-install.ts +778 -0
  58. package/plugins/autonomous-pr-loop/core/locale.ts +60 -0
  59. package/plugins/autonomous-pr-loop/core/loop-shapes.ts +190 -0
  60. package/plugins/autonomous-pr-loop/core/mcp-controller.ts +1479 -0
  61. package/plugins/autonomous-pr-loop/core/notification-feed.ts +263 -0
  62. package/plugins/autonomous-pr-loop/core/plan-parser.ts +206 -0
  63. package/plugins/autonomous-pr-loop/core/plugin-paths.ts +32 -0
  64. package/plugins/autonomous-pr-loop/core/policy.ts +65 -0
  65. package/plugins/autonomous-pr-loop/core/pr-lifecycle.ts +464 -0
  66. package/plugins/autonomous-pr-loop/core/pr-selector.ts +284 -0
  67. package/plugins/autonomous-pr-loop/core/profiles.ts +439 -0
  68. package/plugins/autonomous-pr-loop/core/redaction.ts +17 -0
  69. package/plugins/autonomous-pr-loop/core/repo-root.ts +22 -0
  70. package/plugins/autonomous-pr-loop/core/review-comments.ts +77 -0
  71. package/plugins/autonomous-pr-loop/core/scope-guard.ts +179 -0
  72. package/plugins/autonomous-pr-loop/core/state-machine.ts +828 -0
  73. package/plugins/autonomous-pr-loop/core/state-types.ts +130 -0
  74. package/plugins/autonomous-pr-loop/core/storage.ts +2527 -0
  75. package/plugins/autonomous-pr-loop/core/types.ts +567 -0
  76. package/plugins/autonomous-pr-loop/core/worker-events.ts +412 -0
  77. package/plugins/autonomous-pr-loop/core/worker-policy.ts +72 -0
  78. package/plugins/autonomous-pr-loop/core/worker-prompts.ts +182 -0
  79. package/plugins/autonomous-pr-loop/core/worker.ts +809 -0
  80. package/plugins/autonomous-pr-loop/core/workflow-board.ts +1515 -0
  81. package/plugins/autonomous-pr-loop/hooks/dist/permission-request.js +2462 -0
  82. package/plugins/autonomous-pr-loop/hooks/dist/post-compact.js +2462 -0
  83. package/plugins/autonomous-pr-loop/hooks/dist/post-tool-use.js +2462 -0
  84. package/plugins/autonomous-pr-loop/hooks/dist/pre-compact.js +2462 -0
  85. package/plugins/autonomous-pr-loop/hooks/dist/pre-tool-use.js +3460 -0
  86. package/plugins/autonomous-pr-loop/hooks/dist/session-start.js +2462 -0
  87. package/plugins/autonomous-pr-loop/hooks/dist/stop.js +2462 -0
  88. package/plugins/autonomous-pr-loop/hooks/dist/user-prompt-submit.js +2462 -0
  89. package/plugins/autonomous-pr-loop/hooks/hooks.json +106 -0
  90. package/plugins/autonomous-pr-loop/hooks/observe-runner.ts +25 -0
  91. package/plugins/autonomous-pr-loop/hooks/permission-request.ts +4 -0
  92. package/plugins/autonomous-pr-loop/hooks/post-compact.ts +4 -0
  93. package/plugins/autonomous-pr-loop/hooks/post-tool-use.ts +4 -0
  94. package/plugins/autonomous-pr-loop/hooks/pre-compact.ts +4 -0
  95. package/plugins/autonomous-pr-loop/hooks/pre-tool-use.ts +44 -0
  96. package/plugins/autonomous-pr-loop/hooks/session-start.ts +4 -0
  97. package/plugins/autonomous-pr-loop/hooks/stop.ts +4 -0
  98. package/plugins/autonomous-pr-loop/hooks/user-prompt-submit.ts +4 -0
  99. package/plugins/autonomous-pr-loop/mcp-server/src/index.ts +87 -0
  100. package/plugins/autonomous-pr-loop/mcp-server/src/tools.ts +205 -0
  101. package/plugins/autonomous-pr-loop/package.json +9 -0
  102. package/plugins/autonomous-pr-loop/schemas/config.schema.json +74 -0
  103. package/plugins/autonomous-pr-loop/schemas/marketplace.schema.json +46 -0
  104. package/plugins/autonomous-pr-loop/schemas/plugin.schema.json +32 -0
  105. package/plugins/autonomous-pr-loop/schemas/state.schema.json +19 -0
  106. package/plugins/autonomous-pr-loop/schemas/worker-event.schema.json +19 -0
  107. package/plugins/autonomous-pr-loop/schemas/worker-result.schema.json +58 -0
  108. package/plugins/autonomous-pr-loop/scripts/agent-loop.ts +44 -0
  109. package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/SKILL.md +26 -0
  110. package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/agents/openai.yaml +6 -0
  111. package/plugins/autonomous-pr-loop/ui/index.html +26 -0
  112. package/plugins/autonomous-pr-loop/ui/public/favicon.svg +7 -0
  113. package/plugins/autonomous-pr-loop/ui/src/api.ts +639 -0
  114. package/plugins/autonomous-pr-loop/ui/src/app.tsx +238 -0
  115. package/plugins/autonomous-pr-loop/ui/src/components/ActivityBadge.tsx +31 -0
  116. package/plugins/autonomous-pr-loop/ui/src/components/BrandMark.tsx +36 -0
  117. package/plugins/autonomous-pr-loop/ui/src/components/Collapsible.tsx +6 -0
  118. package/plugins/autonomous-pr-loop/ui/src/components/CommandPreview.tsx +15 -0
  119. package/plugins/autonomous-pr-loop/ui/src/components/ConfigEditor.tsx +389 -0
  120. package/plugins/autonomous-pr-loop/ui/src/components/EmptyState.tsx +10 -0
  121. package/plugins/autonomous-pr-loop/ui/src/components/ErrorState.tsx +12 -0
  122. package/plugins/autonomous-pr-loop/ui/src/components/List.tsx +7 -0
  123. package/plugins/autonomous-pr-loop/ui/src/components/MetricRow.tsx +6 -0
  124. package/plugins/autonomous-pr-loop/ui/src/components/ResponsiveTable.tsx +65 -0
  125. package/plugins/autonomous-pr-loop/ui/src/components/RiskBadge.tsx +10 -0
  126. package/plugins/autonomous-pr-loop/ui/src/components/StatusBadge.tsx +29 -0
  127. package/plugins/autonomous-pr-loop/ui/src/components/TopMetric.tsx +10 -0
  128. package/plugins/autonomous-pr-loop/ui/src/fixtures.ts +1152 -0
  129. package/plugins/autonomous-pr-loop/ui/src/i18n.ts +1105 -0
  130. package/plugins/autonomous-pr-loop/ui/src/main.tsx +14 -0
  131. package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenter.tsx +470 -0
  132. package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenterParts.tsx +276 -0
  133. package/plugins/autonomous-pr-loop/ui/src/pages/agent-timeline/AgentTimelineView.tsx +73 -0
  134. package/plugins/autonomous-pr-loop/ui/src/pages/artifact-viewer/ArtifactViewer.tsx +44 -0
  135. package/plugins/autonomous-pr-loop/ui/src/pages/dry-run-preview/DryRunPreview.tsx +66 -0
  136. package/plugins/autonomous-pr-loop/ui/src/pages/event-ledger/EventLedger.tsx +17 -0
  137. package/plugins/autonomous-pr-loop/ui/src/pages/gate-center/GateCenter.tsx +34 -0
  138. package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/MissionControl.tsx +104 -0
  139. package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/WorkflowBoard.tsx +577 -0
  140. package/plugins/autonomous-pr-loop/ui/src/pages/notifications/NotificationsView.tsx +30 -0
  141. package/plugins/autonomous-pr-loop/ui/src/pages/plan-navigator/PlanNavigator.tsx +19 -0
  142. package/plugins/autonomous-pr-loop/ui/src/pages/policy-config/PolicyConfig.tsx +22 -0
  143. package/plugins/autonomous-pr-loop/ui/src/pages/pr-inbox/PrInbox.tsx +26 -0
  144. package/plugins/autonomous-pr-loop/ui/src/pages/recovery-center/RecoveryCenter.tsx +125 -0
  145. package/plugins/autonomous-pr-loop/ui/src/pages/scope-guard/ScopeGuard.tsx +16 -0
  146. package/plugins/autonomous-pr-loop/ui/src/pages/worker-runs/WorkerRuns.tsx +39 -0
  147. package/plugins/autonomous-pr-loop/ui/src/styles.css +2673 -0
  148. package/plugins/autonomous-pr-loop/ui/src/theme.ts +57 -0
  149. package/tsconfig.json +18 -0
@@ -0,0 +1,536 @@
1
+ import { createServer as createHttpServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
2
+ import { randomBytes } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { createControllerHost, type ControllerHost } from "./controller-host.js";
6
+ import { loadConfig } from "./config.js";
7
+ import { AgentLoopError, toErrorPayload } from "./errors.js";
8
+ import { dashboardUiRoot, defaultPackageRoot } from "./plugin-paths.js";
9
+ import type { AgentTimelineSource, GateDecisionInput } from "./types.js";
10
+
11
+ export interface DashboardServerOptions {
12
+ /** Target repository root used for config, storage, gates, and controller operations. */
13
+ targetRepoRoot?: string;
14
+ /** Backward-compatible target repository root fallback. Prefer targetRepoRoot for new callers. */
15
+ repoRoot: string;
16
+ /** Repository root that contains this plugin and its dashboard UI assets. */
17
+ pluginRoot?: string;
18
+ /** Explicit dashboard UI root; overrides pluginRoot when provided by tests or tooling. */
19
+ uiRoot?: string;
20
+ host?: string;
21
+ port?: number;
22
+ token?: string;
23
+ serveUi?: boolean;
24
+ controllerHost?: ControllerHost;
25
+ }
26
+
27
+ export interface DashboardServerHandle {
28
+ host: string;
29
+ port: number;
30
+ token: string;
31
+ url: string;
32
+ close(): Promise<void>;
33
+ }
34
+
35
+ type JsonValue = Record<string, unknown>;
36
+
37
+ /** Start the local dashboard HTTP server with loopback and token guards. */
38
+ export async function startDashboardServer(options: DashboardServerOptions): Promise<DashboardServerHandle> {
39
+ const targetRepoRoot = options.targetRepoRoot ?? options.repoRoot;
40
+ const configResult = safeLoadConfig(targetRepoRoot);
41
+ const host = options.host ?? configResult?.dashboard?.host ?? "127.0.0.1";
42
+ assertLoopbackHost(host);
43
+ const port = options.port ?? configResult?.dashboard?.port ?? 0;
44
+ const token = options.token ?? process.env.AGENT_LOOP_MCP_TOKEN ?? randomBytes(24).toString("base64url");
45
+ const controllerHost = options.controllerHost ?? createControllerHost({ repoRoot: targetRepoRoot, mcpToken: token });
46
+ const ownsHost = options.controllerHost === undefined;
47
+ const uiRoot = options.uiRoot ?? dashboardUiRoot(options.pluginRoot ?? defaultPackageRoot());
48
+ const vite = options.serveUi === false
49
+ ? undefined
50
+ : await createViteMiddleware(uiRoot);
51
+
52
+ const server = createHttpServer(async (request, response) => {
53
+ setSecurityHeaders(response);
54
+ if (!isLoopbackAddress(request.socket.remoteAddress)) {
55
+ sendJson(response, 403, { ok: false, error: { code: "forbidden", message: "Dashboard accepts loopback clients only." } });
56
+ return;
57
+ }
58
+ const handled = await handleApiRequest(request, response, controllerHost, token);
59
+ if (handled) {
60
+ return;
61
+ }
62
+ if (vite) {
63
+ if (isViteClientRequest(request)) {
64
+ sendEmptyModule(response);
65
+ return;
66
+ }
67
+ if (isDashboardIndexRequest(request)) {
68
+ sendDashboardIndex(response, uiRoot, token);
69
+ return;
70
+ }
71
+ vite.middlewares(request, response, () => sendNotFound(response));
72
+ return;
73
+ }
74
+ sendNotFound(response);
75
+ });
76
+
77
+ const actualPort = await listen(server, host, port);
78
+ const url = `http://${host}:${actualPort}/`;
79
+ return {
80
+ host,
81
+ port: actualPort,
82
+ token,
83
+ url,
84
+ close: async () => {
85
+ await new Promise<void>((resolve, reject) => {
86
+ server.close((error) => error ? reject(error) : resolve());
87
+ });
88
+ await vite?.close();
89
+ if (ownsHost) {
90
+ controllerHost.dispose();
91
+ }
92
+ }
93
+ };
94
+ }
95
+
96
+ function isViteClientRequest(request: IncomingMessage): boolean {
97
+ const pathname = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
98
+ return request.method === "GET" && pathname === "/@vite/client";
99
+ }
100
+
101
+ function sendEmptyModule(response: ServerResponse): void {
102
+ response.writeHead(200, { "content-type": "application/javascript; charset=utf-8" });
103
+ response.end([
104
+ "const noop = () => {};",
105
+ "const styles = new Map();",
106
+ "export function updateStyle(id, content) {",
107
+ " let style = styles.get(id);",
108
+ " if (!style) {",
109
+ " style = document.createElement('style');",
110
+ " style.setAttribute('type', 'text/css');",
111
+ " style.setAttribute('data-vite-dev-id', id);",
112
+ " document.head.appendChild(style);",
113
+ " styles.set(id, style);",
114
+ " }",
115
+ " style.textContent = content;",
116
+ "}",
117
+ "export function removeStyle(id) {",
118
+ " const style = styles.get(id);",
119
+ " if (style) style.remove();",
120
+ " styles.delete(id);",
121
+ "}",
122
+ "export function createHotContext() {",
123
+ " return { accept: noop, prune: noop, dispose: noop, decline: noop, invalidate: noop, on: noop, off: noop, send: noop, data: {} };",
124
+ "}",
125
+ ""
126
+ ].join("\n"));
127
+ }
128
+
129
+ function isDashboardIndexRequest(request: IncomingMessage): boolean {
130
+ if (request.method !== "GET" && request.method !== "HEAD") {
131
+ return false;
132
+ }
133
+ const pathname = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
134
+ return pathname === "/" || pathname === "/index.html";
135
+ }
136
+
137
+ function sendDashboardIndex(response: ServerResponse, uiRoot: string, token: string): void {
138
+ response.writeHead(200, {
139
+ "content-type": "text/html; charset=utf-8",
140
+ "cache-control": "no-store"
141
+ });
142
+ response.end(injectDashboardToken(stripViteClient(readFileSync(join(uiRoot, "index.html"), "utf8")), token));
143
+ }
144
+
145
+ function safeLoadConfig(repoRoot: string): ReturnType<typeof loadConfig>["config"] | undefined {
146
+ try {
147
+ return loadConfig(repoRoot).config;
148
+ } catch {
149
+ return undefined;
150
+ }
151
+ }
152
+
153
+ async function createViteMiddleware(uiRoot: string): Promise<import("vite").ViteDevServer> {
154
+ const vite = await import("vite");
155
+ return await vite.createServer({
156
+ root: uiRoot,
157
+ configFile: false,
158
+ appType: "spa",
159
+ logLevel: "silent",
160
+ plugins: [{
161
+ name: "agent-loop-strip-vite-client",
162
+ transformIndexHtml(html) {
163
+ return stripViteClient(html);
164
+ }
165
+ }],
166
+ server: { middlewareMode: true, hmr: false }
167
+ });
168
+ }
169
+
170
+ function stripViteClient(html: string): string {
171
+ return html.replace(/<script type="module" src="\/@vite\/client"><\/script>\s*/g, "");
172
+ }
173
+
174
+ function injectDashboardToken(html: string, token: string): string {
175
+ const bootstrap = [
176
+ "<script>",
177
+ `window.__AGENT_LOOP_DASHBOARD_TOKEN__ = ${jsonForInlineScript(token)};`,
178
+ "</script>"
179
+ ].join("");
180
+ const appScript = "<script type=\"module\" src=\"/src/main.tsx\"></script>";
181
+ if (html.includes(appScript)) {
182
+ return html.replace(appScript, `${bootstrap}\n ${appScript}`);
183
+ }
184
+ return html.replace("</head>", ` ${bootstrap}\n </head>`);
185
+ }
186
+
187
+ function jsonForInlineScript(value: string): string {
188
+ return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (character) => {
189
+ if (character === "<") return "\\u003c";
190
+ if (character === ">") return "\\u003e";
191
+ if (character === "&") return "\\u0026";
192
+ if (character === "\u2028") return "\\u2028";
193
+ return "\\u2029";
194
+ });
195
+ }
196
+
197
+ async function handleApiRequest(
198
+ request: IncomingMessage,
199
+ response: ServerResponse,
200
+ host: ControllerHost,
201
+ token: string
202
+ ): Promise<boolean> {
203
+ if (!request.url?.startsWith("/api/")) {
204
+ return false;
205
+ }
206
+ try {
207
+ const url = new URL(request.url, "http://127.0.0.1");
208
+ const controller = host.getController();
209
+ const path = url.pathname;
210
+ const method = request.method ?? "GET";
211
+ if (isMutation(method, path)) {
212
+ enforceMutationRequest(request, url, token);
213
+ }
214
+
215
+ if (method === "GET" && path === "/api/status") return sendController(response, controller.loopStatus());
216
+ if (method === "GET" && path === "/api/mission-control") return sendController(response, controller.loopMissionControl());
217
+ if (method === "GET" && path === "/api/observe") return sendController(response, controller.loopObserve(numberParam(url, "limit") ?? 20));
218
+ if (method === "GET" && path === "/api/next-action") return sendController(response, controller.loopNextAction());
219
+ if (method === "GET" && path === "/api/gates") return sendController(response, controller.loopListGates());
220
+ if (method === "GET" && path.startsWith("/api/gates/")) {
221
+ return sendController(response, controller.loopExplainGate(decodeTail(path, "/api/gates/")));
222
+ }
223
+ if (method === "GET" && path === "/api/runs") return sendController(response, controller.loopListRuns(numberParam(url, "limit")));
224
+ if (method === "GET" && path === "/api/events") {
225
+ return sendController(response, controller.loopListEvents(numberParam(url, "since"), numberParam(url, "limit")));
226
+ }
227
+ if (method === "GET" && path === "/api/agent-timeline") {
228
+ return sendController(response, controller.loopAgentTimeline(timelineQuery(url)));
229
+ }
230
+ if (method === "GET" && path.startsWith("/api/artifacts/")) {
231
+ enforceTokenHeader(request, token);
232
+ return sendController(response, controller.loopReadArtifact(decodeTail(path, "/api/artifacts/")));
233
+ }
234
+ if (method === "GET" && path === "/api/artifacts") return sendController(response, controller.loopListArtifacts());
235
+ if (method === "GET" && path === "/api/pr") return sendController(response, controller.loopGetPrStatus());
236
+ if (method === "GET" && path === "/api/ci") return sendController(response, controller.loopGetCiStatus());
237
+ if (method === "GET" && path === "/api/review-comments") return sendController(response, controller.loopGetReviewComments());
238
+ if (method === "GET" && path === "/api/workers") {
239
+ const limit = numberParam(url, "limit");
240
+ const workerId = stringParam(url, "workerId");
241
+ return sendController(response, controller.loopListWorkers({
242
+ ...(limit === undefined ? {} : { limit }),
243
+ ...(workerId ? { workerId } : {}),
244
+ includeEvents: truthyParam(url, "events")
245
+ }));
246
+ }
247
+ if (method === "GET" && path === "/api/audit-export") {
248
+ return sendController(response, controller.loopExportAudit({
249
+ runId: requiredStringParam(url, "runId"),
250
+ format: auditFormatParam(url)
251
+ }));
252
+ }
253
+ if (method === "GET" && path === "/api/dashboard-meta") return sendController(response, controller.loopDashboardMeta());
254
+ if (method === "GET" && path === "/api/plan") return sendController(response, controller.loopPlanNavigator());
255
+ if (method === "GET" && path === "/api/policy-config") return sendController(response, controller.loopPolicyConfig());
256
+ if (method === "GET" && path === "/api/dry-run-preview") return sendController(response, controller.loopDryRunPreview());
257
+ if (method === "GET" && path === "/api/notifications") return sendController(response, controller.loopNotifications());
258
+ if (method === "GET" && path === "/api/workflow-board") {
259
+ return sendController(response, controller.loopWorkflowBoard({
260
+ ...(stringParam(url, "runId") ? { runId: requiredStringParam(url, "runId") } : {})
261
+ }));
262
+ }
263
+
264
+ if (method === "POST" && path === "/api/run-until-gate") return sendController(response, controller.loopRunUntilGate(token));
265
+ if (method === "POST" && path === "/api/resume") return sendController(response, await controller.loopResume(token));
266
+ if (method === "POST" && path === "/api/step") return sendController(response, await controller.loopStep(token));
267
+ if (method === "POST" && path === "/api/stop") return sendController(response, controller.loopStop(token));
268
+ if (method === "POST" && path === "/api/recover") return sendController(response, controller.loopRecover(token));
269
+ if (method === "POST" && path === "/api/policy-config") {
270
+ const body = await readJsonBody(request);
271
+ return sendController(response, controller.loopSavePolicyConfig(body, token));
272
+ }
273
+ if (method === "POST" && path === "/api/notifications/mark-read") {
274
+ const body = await readJsonBody(request);
275
+ return sendController(response, controller.loopMarkNotificationsRead(body, token));
276
+ }
277
+ if (method === "POST" && path === "/api/notifications/dismiss") {
278
+ const body = await readJsonBody(request);
279
+ return sendController(response, controller.loopDismissNotifications(body, token));
280
+ }
281
+ if (method === "POST" && path === "/api/workflow-board/evidence") {
282
+ const body = await readJsonBody(request);
283
+ return sendController(response, controller.loopAppendWorkflowEvidence(body, token));
284
+ }
285
+ if (method === "POST" && path.endsWith("/mark-handled") && path.startsWith("/api/gates/")) {
286
+ const gateId = decodeTail(path.slice(0, -"mark-handled".length - 1), "/api/gates/");
287
+ return sendController(response, controller.loopMarkHistoricalGateHandled(gateId, token));
288
+ }
289
+ if (method === "POST" && path.endsWith("/re-evaluate") && path.startsWith("/api/gates/")) {
290
+ const gateId = decodeTail(path.slice(0, -"re-evaluate".length - 1), "/api/gates/");
291
+ return sendController(response, controller.loopReevaluateHistoricalGate(gateId, token));
292
+ }
293
+ if (method === "POST" && path.endsWith("/approve") && path.startsWith("/api/gates/")) {
294
+ const body = await readJsonBody(request);
295
+ const gateId = decodeTail(path.slice(0, -"approve".length - 1), "/api/gates/");
296
+ return sendController(response, controller.loopApproveGate(gateId, gateDecisionBody(body), token));
297
+ }
298
+ if (method === "POST" && path.endsWith("/reject") && path.startsWith("/api/gates/")) {
299
+ const body = await readJsonBody(request);
300
+ const gateId = decodeTail(path.slice(0, -"reject".length - 1), "/api/gates/");
301
+ return sendController(response, controller.loopRejectGate(gateId, gateDecisionBody(body), token));
302
+ }
303
+ sendJson(response, 404, { ok: false, error: { code: "not_found", message: `Unknown dashboard endpoint: ${path}` } });
304
+ return true;
305
+ } catch (error) {
306
+ const payload = toErrorPayload(error);
307
+ sendJson(response, httpStatusForCode(payload.code), { ok: false, error: payload });
308
+ return true;
309
+ }
310
+ }
311
+
312
+ function sendController(response: ServerResponse, result: unknown): true {
313
+ const status = isControllerFailure(result) ? httpStatusForCode(result.error?.code) : 200;
314
+ sendJson(response, status, result);
315
+ return true;
316
+ }
317
+
318
+ function isMutation(method: string, path: string): boolean {
319
+ if (method === "GET" && path === "/api/policy-config") {
320
+ return false;
321
+ }
322
+ return path === "/api/run-until-gate" ||
323
+ path === "/api/resume" ||
324
+ path === "/api/step" ||
325
+ path === "/api/stop" ||
326
+ path === "/api/recover" ||
327
+ path === "/api/policy-config" ||
328
+ path === "/api/notifications/mark-read" ||
329
+ path === "/api/notifications/dismiss" ||
330
+ path === "/api/workflow-board/evidence" ||
331
+ path.endsWith("/mark-handled") ||
332
+ path.endsWith("/re-evaluate") ||
333
+ path.endsWith("/approve") ||
334
+ path.endsWith("/reject");
335
+ }
336
+
337
+ function enforceMutationRequest(request: IncomingMessage, url: URL, token: string): void {
338
+ if (request.method !== "POST") {
339
+ throw new AgentLoopError("policy_violation", "Dashboard mutations require POST.");
340
+ }
341
+ const supplied = request.headers["x-agent-loop-token"];
342
+ if (supplied !== token) {
343
+ throw new AgentLoopError("needs_secret_or_login", "Dashboard token is missing or invalid.", { exitCode: 2 });
344
+ }
345
+ const origin = request.headers.origin;
346
+ if (origin && !isAllowedOrigin(origin, request.headers.host)) {
347
+ throw new AgentLoopError("policy_violation", "Dashboard mutation origin is not allowed.");
348
+ }
349
+ }
350
+
351
+ function enforceTokenHeader(request: IncomingMessage, token: string): void {
352
+ const supplied = request.headers["x-agent-loop-token"];
353
+ if (supplied !== token) {
354
+ throw new AgentLoopError("needs_secret_or_login", "Dashboard token is missing or invalid.", { exitCode: 2 });
355
+ }
356
+ }
357
+
358
+ function isControllerFailure(result: unknown): result is { ok: false; error?: { code?: string } } {
359
+ return typeof result === "object" &&
360
+ result !== null &&
361
+ "ok" in result &&
362
+ (result as { ok?: unknown }).ok === false;
363
+ }
364
+
365
+ function httpStatusForCode(code: string | undefined): number {
366
+ if (code === "needs_secret_or_login") return 401;
367
+ if (code === "policy_violation") return 403;
368
+ if (code === "needs_repo_init" || code === "invalid_config") return 400;
369
+ if (code === "artifact_integrity_error" || code === "storage_error") return 404;
370
+ return 500;
371
+ }
372
+
373
+ function isAllowedOrigin(origin: string, hostHeader: string | undefined): boolean {
374
+ try {
375
+ const parsed = new URL(origin);
376
+ return isLoopbackHost(parsed.hostname) && (!hostHeader || parsed.host === hostHeader);
377
+ } catch {
378
+ return false;
379
+ }
380
+ }
381
+
382
+ function assertLoopbackHost(host: string): void {
383
+ if (!isLoopbackHost(host)) {
384
+ throw new AgentLoopError("policy_violation", "Dashboard host must be loopback.", { details: { host } });
385
+ }
386
+ }
387
+
388
+ function isLoopbackHost(host: string): boolean {
389
+ return host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "[::1]";
390
+ }
391
+
392
+ function isLoopbackAddress(address: string | undefined): boolean {
393
+ return address === undefined ||
394
+ address === "127.0.0.1" ||
395
+ address === "::1" ||
396
+ address === "::ffff:127.0.0.1";
397
+ }
398
+
399
+ function numberParam(url: URL, name: string): number | undefined {
400
+ const value = url.searchParams.get(name);
401
+ if (!value) {
402
+ return undefined;
403
+ }
404
+ const parsed = Number(value);
405
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined;
406
+ }
407
+
408
+ function stringParam(url: URL, name: string): string | undefined {
409
+ const value = url.searchParams.get(name);
410
+ return value && value.length > 0 ? value : undefined;
411
+ }
412
+
413
+ function requiredStringParam(url: URL, name: string): string {
414
+ const value = stringParam(url, name);
415
+ if (!value) {
416
+ throw new AgentLoopError("invalid_config", `Missing required query parameter: ${name}`);
417
+ }
418
+ return value;
419
+ }
420
+
421
+ function truthyParam(url: URL, name: string): boolean {
422
+ const value = url.searchParams.get(name);
423
+ return value === "1" || value === "true" || value === "yes";
424
+ }
425
+
426
+ function auditFormatParam(url: URL): "markdown" | "json" {
427
+ const value = url.searchParams.get("format") ?? "markdown";
428
+ if (value === "markdown" || value === "json") {
429
+ return value;
430
+ }
431
+ throw new AgentLoopError("invalid_config", "audit-export format must be markdown or json.");
432
+ }
433
+
434
+ function sourceParams(url: URL): AgentTimelineSource[] | undefined {
435
+ const values = [
436
+ ...url.searchParams.getAll("source"),
437
+ ...url.searchParams.getAll("sources").flatMap((value) => value.split(","))
438
+ ]
439
+ .map((value) => value.trim())
440
+ .filter((value) => value.length > 0);
441
+ return values.length ? values as AgentTimelineSource[] : undefined;
442
+ }
443
+
444
+ function timelineQuery(url: URL): {
445
+ cursor?: string;
446
+ limit?: number;
447
+ sources?: AgentTimelineSource[];
448
+ runId?: string;
449
+ workerId?: string;
450
+ } {
451
+ const cursor = stringParam(url, "cursor");
452
+ const limit = numberParam(url, "limit");
453
+ const sources = sourceParams(url);
454
+ const runId = stringParam(url, "runId");
455
+ const workerId = stringParam(url, "workerId");
456
+ return {
457
+ ...(cursor ? { cursor } : {}),
458
+ ...(limit === undefined ? {} : { limit }),
459
+ ...(sources ? { sources } : {}),
460
+ ...(runId ? { runId } : {}),
461
+ ...(workerId ? { workerId } : {})
462
+ };
463
+ }
464
+
465
+ function decodeTail(path: string, prefix: string): string {
466
+ return decodeURIComponent(path.slice(prefix.length));
467
+ }
468
+
469
+ async function readJsonBody(request: IncomingMessage): Promise<JsonValue> {
470
+ const chunks: Buffer[] = [];
471
+ for await (const chunk of request) {
472
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
473
+ }
474
+ if (chunks.length === 0) {
475
+ return {};
476
+ }
477
+ let parsed: unknown;
478
+ try {
479
+ parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown;
480
+ } catch {
481
+ throw new AgentLoopError("invalid_config", "Request body must be valid JSON.");
482
+ }
483
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as JsonValue : {};
484
+ }
485
+
486
+ function stringBody(body: JsonValue, name: string): string {
487
+ const value = body[name];
488
+ return typeof value === "string" ? value : "";
489
+ }
490
+
491
+ function gateDecisionBody(body: JsonValue): GateDecisionInput {
492
+ const source = stringBody(body, "source");
493
+ const payload = body.payload;
494
+ return {
495
+ note: stringBody(body, "note"),
496
+ source: source === "cli" || source === "api" || source === "ui" || source === "nl" ? source : "ui",
497
+ payload: typeof payload === "object" && payload !== null && !Array.isArray(payload) ? payload as Record<string, unknown> : {}
498
+ };
499
+ }
500
+
501
+ function sendJson(response: ServerResponse, statusCode: number, payload: unknown): void {
502
+ response.writeHead(statusCode, {
503
+ "content-type": "application/json; charset=utf-8",
504
+ "cache-control": "no-store",
505
+ "content-security-policy": "frame-ancestors 'none'",
506
+ "x-frame-options": "DENY",
507
+ "referrer-policy": "no-referrer"
508
+ });
509
+ response.end(`${JSON.stringify(payload, null, 2)}\n`);
510
+ }
511
+
512
+ function sendNotFound(response: ServerResponse): void {
513
+ response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
514
+ response.end("Not found\n");
515
+ }
516
+
517
+ function setSecurityHeaders(response: ServerResponse): void {
518
+ response.setHeader("content-security-policy", "frame-ancestors 'none'");
519
+ response.setHeader("x-frame-options", "DENY");
520
+ response.setHeader("referrer-policy", "no-referrer");
521
+ }
522
+
523
+ function listen(server: Server, host: string, port: number): Promise<number> {
524
+ return new Promise((resolve, reject) => {
525
+ server.once("error", reject);
526
+ server.listen(port, host, () => {
527
+ server.off("error", reject);
528
+ const address = server.address();
529
+ if (typeof address === "object" && address) {
530
+ resolve(address.port);
531
+ return;
532
+ }
533
+ reject(new Error("Dashboard server did not expose a port."));
534
+ });
535
+ });
536
+ }