@tea-agent/loop-agent 0.18.1 → 0.20.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 (52) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +33 -0
  3. package/README.md +4 -6
  4. package/dist/application/dag/generate-task-dag.js +12 -1
  5. package/dist/commands/init.js +3 -3
  6. package/dist/executors/shell-executor.js +188 -2
  7. package/dist/governance/exec-plans.js +4 -0
  8. package/dist/worker/cli.js +13 -12
  9. package/dist/worker/console/doctor.js +55 -2
  10. package/dist/worker/console/index.js +1 -0
  11. package/dist/worker/console/loopback.js +2 -2
  12. package/dist/worker/console/observe-link.js +7 -2
  13. package/dist/worker/console/operator-actions.js +30 -2
  14. package/dist/worker/console/operator-selection.js +92 -0
  15. package/dist/worker/console/operator-surface-health.js +23 -0
  16. package/dist/worker/console/recovery-cta.js +2 -2
  17. package/dist/worker/console/routes.js +45 -19
  18. package/dist/worker/console/security.js +29 -4
  19. package/dist/worker/console/server.js +106 -8
  20. package/dist/worker/console/static/assets/index-3vsjZJHq.js +16 -0
  21. package/dist/worker/console/static/assets/index-i1wV4LrY.css +1 -0
  22. package/dist/worker/console/static/index.html +2 -2
  23. package/dist/worker/observe/routes.js +63 -21
  24. package/dist/worker/observe/server.js +3 -10
  25. package/dist/worker/observe/static/index.html +6 -3
  26. package/dist/worker/observe/static/styles.css +53 -0
  27. package/dist/workflows/dag/backend-test-markdown-workflow.js +163 -41
  28. package/dist/workflows/dag/backend-test-result-contract.js +30 -7
  29. package/dist/workflows/dag/frontend-prewrite-gate.js +77 -0
  30. package/dist/workflows/dag/frontend-repair.js +7 -1
  31. package/dist/workflows/dag/frontend-review-context.js +43 -0
  32. package/dist/workflows/dag/frontend-verification-trace.js +34 -15
  33. package/dist/workflows/dag/governance-profile.js +14 -6
  34. package/dist/workflows/dag/init-hybrid.js +143 -399
  35. package/dist/workflows/dag/types.js +30 -0
  36. package/dist/workflows/dag/validate.js +22 -1
  37. package/docs/README.md +4 -2
  38. package/docs/architecture/evolution.md +6 -6
  39. package/docs/architecture/worker-and-feature.md +9 -9
  40. package/docs/templates/agent-dag.schema.json +40 -0
  41. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +23 -192
  42. package/docs/templates/backend-test-dag.json +8 -8
  43. package/docs/templates/backend-test-dag.review-cases.prompt.md +22 -75
  44. package/package.json +1 -1
  45. package/skills/agent-worker/references/agent-worker-operator.md +2 -2
  46. package/skills/frontend-implementation/references/node-contracts.md +6 -8
  47. package/skills/frontend-review/SKILL.md +5 -8
  48. package/skills/loop-agent/references/command-reference.md +4 -2
  49. package/skills/loop-agent/references/harness-policy.md +1 -1
  50. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  51. package/dist/worker/console/static/assets/index-KUSib7aM.js +0 -16
  52. package/dist/worker/console/static/assets/index-ucIzpaGJ.css +0 -1
@@ -0,0 +1,92 @@
1
+ function segment(value) {
2
+ return encodeURIComponent(value);
3
+ }
4
+ function nonEmpty(value) {
5
+ return typeof value === "string" && value.length > 0;
6
+ }
7
+ export function encodeInspectHash(selection) {
8
+ switch (selection.kind) {
9
+ case "feature":
10
+ return `#/feature/${segment(selection.featureId)}`;
11
+ case "feature-task":
12
+ return `#/feature/${segment(selection.featureId)}/task/${segment(selection.taskId)}`;
13
+ case "legacy-task":
14
+ return `#/task/${segment(selection.taskId)}`;
15
+ case "dag-run":
16
+ // Inspect currently owns DAG-level routing. nodeId remains URL-adjacent
17
+ // selection state until the static router explicitly supports it.
18
+ return `#/dag/${segment(selection.dagRunId)}`;
19
+ case "worker-run":
20
+ return `#/run/${segment(selection.workerRunId)}`;
21
+ case "batch":
22
+ return `#/batch/${segment(selection.batchRunId)}`;
23
+ }
24
+ }
25
+ export function buildInspectPath(selection) {
26
+ return `/inspect/${selection ? encodeInspectHash(selection) : "#/"}`;
27
+ }
28
+ export function buildInspectUrl(origin, selection) {
29
+ const base = origin.trim().replace(/\/+$/, "");
30
+ if (!base)
31
+ throw new Error("buildInspectUrl: origin is required");
32
+ return `${base}${buildInspectPath(selection)}`;
33
+ }
34
+ function decodeSegment(value) {
35
+ if (!nonEmpty(value))
36
+ return null;
37
+ try {
38
+ const decoded = decodeURIComponent(value);
39
+ return decoded ? decoded : null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ export function decodeInspectHash(hash) {
46
+ const normalized = hash.startsWith("#") ? hash.slice(1) : hash;
47
+ const parts = normalized.split("/").filter(Boolean);
48
+ if (parts.length === 0)
49
+ return null;
50
+ const [kind, first, third, fourth] = parts;
51
+ const id = decodeSegment(first);
52
+ if (!id)
53
+ return null;
54
+ if (kind === "feature" && parts.length === 2) {
55
+ return { kind: "feature", featureId: id };
56
+ }
57
+ if (kind === "feature" && parts.length === 4 && third === "task") {
58
+ const taskId = decodeSegment(fourth);
59
+ return taskId ? { kind: "feature-task", featureId: id, taskId } : null;
60
+ }
61
+ if (kind === "task" && parts.length === 2)
62
+ return { kind: "legacy-task", taskId: id };
63
+ if (kind === "dag" && parts.length === 2)
64
+ return { kind: "dag-run", dagRunId: id };
65
+ if (kind === "run" && parts.length === 2)
66
+ return { kind: "worker-run", workerRunId: id };
67
+ if (kind === "batch" && parts.length === 2)
68
+ return { kind: "batch", batchRunId: id };
69
+ return null;
70
+ }
71
+ export function selectionFromObserveLinkTarget(target) {
72
+ switch (target.kind) {
73
+ case "dag":
74
+ return { kind: "dag-run", dagRunId: target.dagRunId };
75
+ case "task":
76
+ return { kind: "legacy-task", taskId: target.taskId };
77
+ case "feature":
78
+ return { kind: "feature", featureId: target.featureId };
79
+ case "worker":
80
+ return { kind: "worker-run", workerRunId: target.workerRunId };
81
+ case "batch":
82
+ return { kind: "batch", batchRunId: target.batchRunId };
83
+ }
84
+ }
85
+ /** Canonicalize only the historical Console root hash shape. */
86
+ export function normalizeLegacyConsoleDagHash(pathnameAndHash) {
87
+ const [pathname, hash = ""] = pathnameAndHash.split("#", 2);
88
+ if ((pathname === "/" || pathname === "") && /^\/dag\/[^/]+$/.test(hash)) {
89
+ return `/inspect/#${hash}`;
90
+ }
91
+ return pathnameAndHash;
92
+ }
@@ -0,0 +1,23 @@
1
+ import { deriveObserveRouteCapabilities, resolveObservePackageIdentity, } from "../observe/health.js";
2
+ import { repoFingerprintV1 } from "./repo-fingerprint.js";
3
+ export function buildOperatorSurfaceHealthV1(input) {
4
+ const { packageName, packageVersion } = resolveObservePackageIdentity();
5
+ const snapshotAvailable = input.routes.some((route) => route.method === "GET" && route.pattern.test("/api/snapshot"));
6
+ const eventStreamAvailable = input.routes.some((route) => route.method === "GET" && route.pattern.test("/api/events/stream"));
7
+ return {
8
+ schemaVersion: 1,
9
+ ok: true,
10
+ product: "loop-operator-console",
11
+ repoFingerprint: repoFingerprintV1(input.repoRoot),
12
+ packageName,
13
+ packageVersion,
14
+ piReadiness: input.piReadiness,
15
+ inspect: {
16
+ ready: input.inspectReady,
17
+ routeCapabilities: deriveObserveRouteCapabilities(input.routes),
18
+ snapshotAvailable,
19
+ eventStreamAvailable,
20
+ },
21
+ generatedAt: input.generatedAt ?? new Date().toISOString(),
22
+ };
23
+ }
@@ -38,9 +38,9 @@ const CTA = {
38
38
  },
39
39
  observeLink: {
40
40
  id: "observeLink",
41
- label: "打开 Observe 深链",
41
+ label: "打开检视",
42
42
  action: "observeLink",
43
- commandHint: "agent-worker observe serve --repo . --port 8787",
43
+ commandHint: "agent-worker console serve --repo . --port 8790",
44
44
  },
45
45
  };
46
46
  const MATRIX = {
@@ -3,7 +3,8 @@ import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { dispatchOperatorAction } from "./operator-actions.js";
5
5
  import { openSseResponse, parseLastEventId, writeSseEvent, } from "./operation-sse.js";
6
- import { repoFingerprintV1 } from "./repo-fingerprint.js";
6
+ import { buildOperatorSurfaceHealthV1 } from "./operator-surface-health.js";
7
+ import { ROUTES } from "../observe/routes.js";
7
8
  import { evaluateMutationGate, injectConfirmationTokenScript, isMutationMethod, } from "./security.js";
8
9
  export function sendJson(res, status, body, extraHeaders) {
9
10
  const payload = JSON.stringify(body);
@@ -58,24 +59,33 @@ async function readJsonBody(req, maxBytes = 10 * 1024 * 1024) {
58
59
  throw new Error(`invalid JSON body: ${error instanceof Error ? error.message : String(error)}`);
59
60
  }
60
61
  }
61
- async function handleHealth(_req, res, ctx) {
62
- let fingerprint;
62
+ export async function handleConsoleHealthRequest(_req, res, ctx) {
63
63
  try {
64
- fingerprint = repoFingerprintV1(ctx.repoRoot);
64
+ sendJson(res, 200, buildOperatorSurfaceHealthV1({
65
+ repoRoot: ctx.repoRoot,
66
+ piReadiness: ctx.operator?.getReadiness().state ?? "setup-required",
67
+ inspectReady: ctx.inspect.ready,
68
+ routes: ROUTES,
69
+ }));
65
70
  }
66
71
  catch {
67
- fingerprint = "unavailable";
72
+ sendJson(res, 200, {
73
+ schemaVersion: 1,
74
+ ok: true,
75
+ product: "loop-operator-console",
76
+ repoFingerprint: "unavailable",
77
+ packageName: "unknown",
78
+ packageVersion: "0.0.0",
79
+ piReadiness: ctx.operator?.getReadiness().state ?? "setup-required",
80
+ inspect: {
81
+ ready: ctx.inspect.ready,
82
+ routeCapabilities: ctx.inspect.routeCapabilities,
83
+ snapshotAvailable: true,
84
+ eventStreamAvailable: true,
85
+ },
86
+ generatedAt: new Date().toISOString(),
87
+ });
68
88
  }
69
- const readiness = ctx.operator?.getReadiness();
70
- sendJson(res, 200, {
71
- ok: true,
72
- product: "loop-operator-console",
73
- productName: "Loop Operator Console",
74
- repoRoot: ctx.repoRoot,
75
- repoFingerprint: fingerprint,
76
- piReadiness: readiness?.state ?? "setup-required",
77
- generatedAt: new Date().toISOString(),
78
- });
79
89
  }
80
90
  async function handleReadiness(_req, res, ctx) {
81
91
  const report = ctx.operator?.getReadiness();
@@ -93,6 +103,7 @@ async function handleSessionPing(req, res, ctx) {
93
103
  bootToken: ctx.bootToken.value,
94
104
  confirmationToken: ctx.bootToken.confirmationToken,
95
105
  consoleOrigin: ctx.getConsoleOrigin(),
106
+ allowNonLoopbackAccess: ctx.allowNonLoopbackAccess === true,
96
107
  });
97
108
  if (failure) {
98
109
  sendJson(res, failure.status, {
@@ -118,6 +129,7 @@ async function handleCreateOperation(req, res, ctx) {
118
129
  bootToken: ctx.bootToken.value,
119
130
  confirmationToken: ctx.bootToken.confirmationToken,
120
131
  consoleOrigin: ctx.getConsoleOrigin(),
132
+ allowNonLoopbackAccess: ctx.allowNonLoopbackAccess === true,
121
133
  });
122
134
  if (failure) {
123
135
  sendJson(res, failure.status, {
@@ -304,13 +316,13 @@ export async function serveConsoleStatic(req, res, ctx) {
304
316
  res.writeHead(200, headers);
305
317
  res.end(content);
306
318
  }
307
- export async function handleConsoleRequest(req, res, ctx) {
319
+ export async function handleConsoleOperatorRequest(req, res, ctx) {
308
320
  const method = (req.method ?? "GET").toUpperCase();
309
321
  const url = new URL(req.url ?? "/", "http://localhost");
310
322
  const pathname = decodeURIComponent(url.pathname);
311
- if (method === "GET" && pathname === "/api/health") {
312
- await handleHealth(req, res, ctx);
313
- return true;
323
+ if (!pathname.startsWith("/api/operator/v1/") &&
324
+ !pathname.startsWith("/api/session/")) {
325
+ return false;
314
326
  }
315
327
  if (method === "GET" && pathname === "/api/operator/v1/readiness") {
316
328
  await handleReadiness(req, res, ctx);
@@ -342,6 +354,7 @@ export async function handleConsoleRequest(req, res, ctx) {
342
354
  bootToken: ctx.bootToken.value,
343
355
  confirmationToken: ctx.bootToken.confirmationToken,
344
356
  consoleOrigin: ctx.getConsoleOrigin(),
357
+ allowNonLoopbackAccess: ctx.allowNonLoopbackAccess === true,
345
358
  });
346
359
  if (failure) {
347
360
  sendJson(res, failure.status, {
@@ -360,6 +373,19 @@ export async function handleConsoleRequest(req, res, ctx) {
360
373
  });
361
374
  return true;
362
375
  }
376
+ sendJson(res, 404, { error: "Not found" });
377
+ return true;
378
+ }
379
+ export async function handleConsoleRequest(req, res, ctx) {
380
+ const method = (req.method ?? "GET").toUpperCase();
381
+ const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://localhost").pathname);
382
+ if (method === "GET" && pathname === "/api/health") {
383
+ await handleConsoleHealthRequest(req, res, ctx);
384
+ return true;
385
+ }
386
+ const handled = await handleConsoleOperatorRequest(req, res, ctx);
387
+ if (handled)
388
+ return true;
363
389
  if (pathname.startsWith("/api/")) {
364
390
  sendJson(res, 404, { error: "Not found" });
365
391
  return true;
@@ -51,8 +51,8 @@ function safeEqualToken(a, b) {
51
51
  }
52
52
  /**
53
53
  * Minimum mutation challenge (design §9):
54
- * cookie boot token AND custom confirmation header/token + Host loopback +
55
- * Origin matches Console + Sec-Fetch-Site not cross-site.
54
+ * cookie boot token AND custom confirmation header/token + Host/Origin checks +
55
+ * Sec-Fetch-Site not cross-site.
56
56
  */
57
57
  export function evaluateMutationGate(req, ctx) {
58
58
  const cookieToken = parseCookieHeader(req.headers.cookie, BOOT_CAPABILITY_COOKIE);
@@ -92,7 +92,16 @@ export function evaluateMutationGate(req, ctx) {
92
92
  }
93
93
  const hostHeader = req.headers.host ?? "";
94
94
  const hostOnly = hostHeader.split(":")[0] ?? "";
95
- if (!isLoopbackHost(hostOnly)) {
95
+ if (ctx.allowNonLoopbackAccess) {
96
+ if (!hostHeader.trim()) {
97
+ return {
98
+ status: 403,
99
+ code: "host-not-loopback",
100
+ message: "Host header is required for non-loopback Console binds",
101
+ };
102
+ }
103
+ }
104
+ else if (!isLoopbackHost(hostOnly)) {
96
105
  return {
97
106
  status: 403,
98
107
  code: "host-not-loopback",
@@ -100,7 +109,23 @@ export function evaluateMutationGate(req, ctx) {
100
109
  };
101
110
  }
102
111
  const origin = req.headers.origin;
103
- if (typeof origin !== "string" ||
112
+ if (ctx.allowNonLoopbackAccess) {
113
+ const expectedFromHost = hostHeader.trim()
114
+ ? `http://${hostHeader.trim()}`
115
+ : "";
116
+ const originOk = typeof origin === "string" &&
117
+ origin.length > 0 &&
118
+ (origin === ctx.consoleOrigin ||
119
+ (Boolean(expectedFromHost) && origin === expectedFromHost));
120
+ if (!originOk) {
121
+ return {
122
+ status: 403,
123
+ code: "origin-mismatch",
124
+ message: "Origin does not match Console access URL",
125
+ };
126
+ }
127
+ }
128
+ else if (typeof origin !== "string" ||
104
129
  origin.length === 0 ||
105
130
  origin !== ctx.consoleOrigin) {
106
131
  return {
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import { existsSync } from "node:fs";
2
3
  import { createServer } from "node:http";
3
4
  import path from "node:path";
4
5
  import { fileURLToPath } from "node:url";
@@ -12,25 +13,35 @@ import { isLoopbackHost } from "./loopback.js";
12
13
  import { createOperationEventStore, } from "./operation-sse.js";
13
14
  import { OperationStore } from "./operation-store.js";
14
15
  import { probePiReadiness, } from "./pi-readiness.js";
15
- import { handleConsoleRequest, serveConsoleStatic, } from "./routes.js";
16
+ import { handleConsoleHealthRequest, handleConsoleOperatorRequest, sendJson, serveConsoleStatic, } from "./routes.js";
16
17
  import { issueBootCapabilityToken } from "./security.js";
17
18
  import { resolveSiblingLoopAgentBin } from "./sibling-controller.js";
19
+ import { deriveObserveRouteCapabilities } from "../observe/health.js";
20
+ import { createObserveRouteContext, defaultObserveStaticDir, handleMatchedObserveRoute, matchObserveGetRoute, ROUTES, serveObserveStatic, } from "../observe/routes.js";
18
21
  /** Built static assets live next to the compiled server under dist/worker/console/static. */
19
22
  export function defaultConsoleStaticDir(fromFileUrl = import.meta.url) {
20
23
  return path.join(path.dirname(fileURLToPath(fromFileUrl)), "static");
21
24
  }
22
25
  /**
23
26
  * Create Loop Operator Console HTTP server.
24
- * Non-loopback host throws before listen (fail closed).
27
+ * Default bind is loopback; non-loopback hosts warn and stay allowed for LAN,
28
+ * matching Observe serve (mutations still require cookie + confirmation + Origin).
25
29
  */
26
30
  export async function createConsoleServer(options) {
27
31
  const host = options.host ?? "127.0.0.1";
28
- if (!isLoopbackHost(host)) {
29
- throw new Error(`console serve: non-loopback host "${host}" is not allowed (fail closed; use 127.0.0.1 or localhost)`);
32
+ const loopbackBind = isLoopbackHost(host);
33
+ if (!loopbackBind) {
34
+ process.stderr.write(`console serve: binding non-loopback host ${host} (Operator mutations enabled; no network auth — expose only on trusted networks)\n`);
30
35
  }
31
36
  const repoRoot = path.resolve(options.repoRoot);
32
37
  const port = options.port ?? 8790;
38
+ const debug = options.debug === true;
39
+ const keepAliveTimeoutMs = options.keepAliveTimeoutMs ?? 65_000;
33
40
  const staticDir = options.staticDir ?? defaultConsoleStaticDir();
41
+ const inspectStaticDir = options.inspectStaticDir ?? defaultObserveStaticDir();
42
+ const observeRouteContext = createObserveRouteContext(repoRoot, {
43
+ staticDir: inspectStaticDir,
44
+ });
34
45
  const bootToken = issueBootCapabilityToken();
35
46
  const operatorSessionId = `sess_${randomBytes(16).toString("hex")}`;
36
47
  const appData = openConsoleAppData({
@@ -83,6 +94,7 @@ export async function createConsoleServer(options) {
83
94
  refreshReadiness,
84
95
  observeBaseUrl: options.observeBaseUrl,
85
96
  fetchImpl: options.fetchImpl,
97
+ getConsoleOrigin: () => `http://${boundHost}:${boundPort}`,
86
98
  };
87
99
  let boundHost = host;
88
100
  let boundPort = port;
@@ -92,6 +104,11 @@ export async function createConsoleServer(options) {
92
104
  staticDir,
93
105
  bootToken,
94
106
  getConsoleOrigin,
107
+ allowNonLoopbackAccess: !loopbackBind,
108
+ inspect: {
109
+ ready: existsSync(path.join(inspectStaticDir, "index.html")),
110
+ routeCapabilities: deriveObserveRouteCapabilities(ROUTES),
111
+ },
95
112
  operator: {
96
113
  actionContext,
97
114
  operations,
@@ -101,6 +118,21 @@ export async function createConsoleServer(options) {
101
118
  };
102
119
  return new Promise((resolve, reject) => {
103
120
  const server = createServer((req, res) => {
121
+ const startedAt = Date.now();
122
+ if (debug) {
123
+ let logged = false;
124
+ const onceLog = (note) => {
125
+ if (logged)
126
+ return;
127
+ logged = true;
128
+ logConsoleRequest(req, res, startedAt, note);
129
+ };
130
+ res.on("finish", () => onceLog());
131
+ res.on("close", () => {
132
+ if (!res.writableFinished)
133
+ onceLog("aborted");
134
+ });
135
+ }
104
136
  void dispatch(req, res).catch((error) => {
105
137
  if (!res.headersSent) {
106
138
  const payload = JSON.stringify({
@@ -114,13 +146,54 @@ export async function createConsoleServer(options) {
114
146
  }
115
147
  });
116
148
  });
149
+ server.keepAliveTimeout = keepAliveTimeoutMs;
150
+ server.headersTimeout = keepAliveTimeoutMs + 5_000;
151
+ server.requestTimeout = 0;
152
+ server.on("connection", (socket) => {
153
+ socket.on("error", () => {
154
+ // Ignore client resets while retaining one listener per connection.
155
+ });
156
+ });
157
+ server.on("clientError", (err, socket) => {
158
+ if (debug) {
159
+ process.stderr.write(`[console] ${new Date().toISOString()} clientError ${err.message}\n`);
160
+ }
161
+ if (!socket.destroyed) {
162
+ socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
163
+ }
164
+ });
117
165
  async function dispatch(req, res) {
118
166
  // Refresh readiness pointer on each request surface
119
167
  actionContext.readiness = getReadiness();
120
- const handled = await handleConsoleRequest(req, res, routeContext);
121
- if (!handled) {
122
- await serveConsoleStatic(req, res, routeContext);
168
+ const method = (req.method ?? "GET").toUpperCase();
169
+ const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://localhost").pathname);
170
+ if (method === "GET" && pathname === "/api/health") {
171
+ await handleConsoleHealthRequest(req, res, routeContext);
172
+ return;
123
173
  }
174
+ if (pathname.startsWith("/api/operator/v1/") ||
175
+ pathname.startsWith("/api/session/")) {
176
+ await handleConsoleOperatorRequest(req, res, routeContext);
177
+ return;
178
+ }
179
+ if (method === "GET" && pathname.startsWith("/api/")) {
180
+ const match = matchObserveGetRoute(pathname);
181
+ if (match) {
182
+ await handleMatchedObserveRoute(req, res, match, observeRouteContext);
183
+ return;
184
+ }
185
+ }
186
+ if (pathname.startsWith("/inspect")) {
187
+ await serveObserveStatic(req, res, inspectStaticDir, {
188
+ urlPrefix: "/inspect",
189
+ });
190
+ return;
191
+ }
192
+ if (pathname.startsWith("/api/")) {
193
+ sendJson(res, 404, { error: "Not found" });
194
+ return;
195
+ }
196
+ await serveConsoleStatic(req, res, routeContext);
124
197
  }
125
198
  server.on("error", reject);
126
199
  server.listen(port, host, () => {
@@ -132,7 +205,12 @@ export async function createConsoleServer(options) {
132
205
  boundHost = host;
133
206
  boundPort = addr.port;
134
207
  const url = `http://${host}:${addr.port}`;
135
- process.stderr.write(`[console] Loop Operator Console listening on ${url} (loopback only)\n`);
208
+ if (debug) {
209
+ process.stderr.write(`[console] debug logging enabled; keepAliveTimeout=${keepAliveTimeoutMs}ms\n`);
210
+ }
211
+ process.stderr.write(`[console] Loop Operator Console listening on ${url}${loopbackBind
212
+ ? " (loopback)"
213
+ : " (non-loopback; trusted networks only)"}\n`);
136
214
  resolve({
137
215
  url,
138
216
  host,
@@ -149,6 +227,26 @@ export async function createConsoleServer(options) {
149
227
  });
150
228
  });
151
229
  }
230
+ function requestSource(req) {
231
+ const forwarded = req.headers["x-forwarded-for"];
232
+ if (typeof forwarded === "string" && forwarded.trim()) {
233
+ return forwarded.split(",")[0]?.trim() || forwarded.trim();
234
+ }
235
+ if (Array.isArray(forwarded) && forwarded[0]) {
236
+ return forwarded[0].split(",")[0]?.trim() || forwarded[0];
237
+ }
238
+ const realIp = req.headers["x-real-ip"];
239
+ if (typeof realIp === "string" && realIp.trim()) {
240
+ return realIp.trim();
241
+ }
242
+ return req.socket.remoteAddress ?? "-";
243
+ }
244
+ function logConsoleRequest(req, res, startedAt, note) {
245
+ const ms = Date.now() - startedAt;
246
+ const ua = String(req.headers["user-agent"] ?? "-").slice(0, 120);
247
+ const suffix = note ? ` ${note}` : "";
248
+ process.stderr.write(`[console] ${new Date().toISOString()} ${req.method ?? "?"} ${req.url ?? "/"} ${res.statusCode} ${ms}ms from=${requestSource(req)} ua=${JSON.stringify(ua)}${suffix}\n`);
249
+ }
152
250
  /** Test helper: replace readiness report in-process. */
153
251
  export function setConsoleReadinessForTests(server, report) {
154
252
  if (!server.getReadiness)