infinity-harness 2.0.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.
- package/CHANGELOG.md +114 -0
- package/LICENSE +21 -0
- package/README.md +266 -0
- package/extensions/infinity-harness/index.ts +870 -0
- package/harness/docs/ARCHITECTURE.md +159 -0
- package/harness/docs/CONSTRAINTS.md +19 -0
- package/harness/docs/DECISIONS.md +107 -0
- package/harness/docs/DOMAIN.md +13 -0
- package/harness/docs/agents/evaluator.md +14 -0
- package/harness/docs/agents/generator.md +13 -0
- package/harness/docs/agents/planner.md +13 -0
- package/harness/docs/agents/simplifier.md +13 -0
- package/harness/docs/api-patterns.md +23 -0
- package/harness/docs/phases/build.md +47 -0
- package/harness/docs/phases/define.md +58 -0
- package/harness/docs/phases/plan.md +50 -0
- package/harness/docs/phases/review.md +47 -0
- package/harness/docs/phases/ship.md +43 -0
- package/harness/docs/phases/simplify.md +45 -0
- package/harness/docs/phases/verify.md +46 -0
- package/harness/model-router.json +28 -0
- package/harness/skills/README.md +60 -0
- package/harness/skills/auth-security.md +56 -0
- package/harness/skills/building-mcp-servers.md +70 -0
- package/harness/skills/building-tools.md +60 -0
- package/harness/skills/capability-acquisition.md +72 -0
- package/harness/skills/cli-design.md +55 -0
- package/harness/skills/code-review.md +57 -0
- package/harness/skills/codebase-design.md +70 -0
- package/harness/skills/concurrency-async.md +61 -0
- package/harness/skills/config-and-secrets.md +52 -0
- package/harness/skills/context-hygiene.md +51 -0
- package/harness/skills/databases.md +63 -0
- package/harness/skills/diagnosing-bugs.md +84 -0
- package/harness/skills/domain-modeling.md +65 -0
- package/harness/skills/error-handling-logging.md +56 -0
- package/harness/skills/frontend-ui.md +56 -0
- package/harness/skills/grilling.md +48 -0
- package/harness/skills/http-apis.md +60 -0
- package/harness/skills/performance.md +53 -0
- package/harness/skills/pi-todo-adapted.md +41 -0
- package/harness/skills/planning-tasks.md +86 -0
- package/harness/skills/prototype.md +39 -0
- package/harness/skills/research.md +32 -0
- package/harness/skills/resolving-merge-conflicts.md +30 -0
- package/harness/skills/scope-discipline.md +49 -0
- package/harness/skills/self-review.md +45 -0
- package/harness/skills/stuck-protocol.md +51 -0
- package/harness/skills/tdd.md +80 -0
- package/harness/skills/testing-infra.md +57 -0
- package/harness/skills/writing-skills.md +60 -0
- package/package.json +61 -0
- package/src/core/brief.ts +242 -0
- package/src/core/config.ts +265 -0
- package/src/core/exec.ts +130 -0
- package/src/core/featureList.ts +286 -0
- package/src/core/fsx.ts +119 -0
- package/src/core/gates.ts +444 -0
- package/src/core/lock.ts +192 -0
- package/src/core/paths.ts +95 -0
- package/src/core/phases.ts +143 -0
- package/src/core/settings.ts +445 -0
- package/src/core/types.ts +245 -0
- package/src/goalLoop.ts +628 -0
- package/src/goalSpec.ts +679 -0
- package/src/goalState.ts +338 -0
- package/src/loop.ts +355 -0
- package/src/modelRouter.ts +184 -0
- package/src/remote.ts +244 -0
- package/src/replan.ts +300 -0
- package/src/review.ts +53 -0
- package/src/rework.ts +274 -0
- package/src/taskList.ts +355 -0
- package/src/ui/config.ts +286 -0
- package/src/ui/dashboard.ts +1066 -0
- package/src/ui/theme.ts +317 -0
- package/src/ui/widget.ts +370 -0
- package/src/unstuck.ts +214 -0
- package/src/worker.ts +351 -0
- package/types/proper-lockfile.d.ts +19 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* modelRouter — difficulty tiers + MASTER ladder, fresh-read each call, optional via harness/model-router.json v1
|
|
3
|
+
* Priority: task.modelHint > byDifficulty[difficulty] > byFeature > bySprint > byPhase > byRole > default
|
|
4
|
+
* Ladder: easy -> moderate -> difficult -> MASTER (MASTER never directly assigned)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import { writeJsonAtomic, stripBom } from "./core/fsx.ts";
|
|
10
|
+
|
|
11
|
+
export const ROUTER_FILE = "harness/model-router.json";
|
|
12
|
+
export const ROUTER_VERSION = 1;
|
|
13
|
+
|
|
14
|
+
export interface RouterConfig {
|
|
15
|
+
version: number;
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
default: string;
|
|
18
|
+
byDifficulty?: Record<string, string>;
|
|
19
|
+
master?: string;
|
|
20
|
+
byPhase?: Record<string, string>;
|
|
21
|
+
byRole?: Record<string, string>;
|
|
22
|
+
byFeature?: Record<string, string>;
|
|
23
|
+
bySprint?: Record<string, string>;
|
|
24
|
+
byTask?: Record<string, string>;
|
|
25
|
+
consultation?: { enabled: boolean; maxPerTask: number; oneStepOnly: boolean; requireExhaustion: boolean };
|
|
26
|
+
budgets?: { maxReworksPerRun: number; maxReplansPerRun: number; maxReviewBounces: number };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Router defaults.
|
|
31
|
+
*
|
|
32
|
+
* Every model slot is empty on purpose. An empty string means "whatever model
|
|
33
|
+
* pi is already configured with", so installing the harness never silently
|
|
34
|
+
* redirects work to some vendor's model that the user did not choose. Routing
|
|
35
|
+
* only takes effect once someone fills in harness/model-router.json and sets
|
|
36
|
+
* `enabled: true`.
|
|
37
|
+
*/
|
|
38
|
+
export const DEFAULT_ROUTER: RouterConfig = {
|
|
39
|
+
version: 1,
|
|
40
|
+
enabled: false,
|
|
41
|
+
default: "",
|
|
42
|
+
byDifficulty: {
|
|
43
|
+
easy: "",
|
|
44
|
+
moderate: "",
|
|
45
|
+
difficult: "",
|
|
46
|
+
},
|
|
47
|
+
master: "",
|
|
48
|
+
byPhase: {},
|
|
49
|
+
byRole: {},
|
|
50
|
+
byFeature: {},
|
|
51
|
+
bySprint: {},
|
|
52
|
+
byTask: {},
|
|
53
|
+
consultation: { enabled: true, maxPerTask: 1, oneStepOnly: true, requireExhaustion: true },
|
|
54
|
+
budgets: { maxReworksPerRun: 3, maxReplansPerRun: 2, maxReviewBounces: 2 },
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const DIFFICULTY_LADDER: Array<"easy" | "moderate" | "difficult"> = ["easy", "moderate", "difficult"];
|
|
58
|
+
|
|
59
|
+
function routerPath(projectDir = process.cwd()): string { return resolve(projectDir, ROUTER_FILE); }
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Persist the router config.
|
|
63
|
+
*
|
|
64
|
+
* Written atomically and read fresh on every resolution, so an edit made from
|
|
65
|
+
* the config TUI takes effect on the next task without restarting the session.
|
|
66
|
+
*/
|
|
67
|
+
export function saveRouterConfig(projectDir: string, cfg: RouterConfig): void {
|
|
68
|
+
writeJsonAtomic(routerPath(projectDir), cfg);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function loadRouterConfig(projectDir?: string): RouterConfig {
|
|
72
|
+
const p = routerPath(projectDir);
|
|
73
|
+
if (!existsSync(p)) return { ...DEFAULT_ROUTER, byDifficulty: { ...DEFAULT_ROUTER.byDifficulty! }, byPhase: {}, byRole: {}, byFeature: {}, bySprint: {}, byTask: {}, consultation: { ...DEFAULT_ROUTER.consultation! }, budgets: { ...DEFAULT_ROUTER.budgets! } };
|
|
74
|
+
try {
|
|
75
|
+
const raw = JSON.parse(stripBom(readFileSync(p, "utf-8")));
|
|
76
|
+
// merge with defaults to ensure fields
|
|
77
|
+
const cfg: RouterConfig = {
|
|
78
|
+
version: typeof raw.version === "number" ? raw.version : 1,
|
|
79
|
+
enabled: typeof raw.enabled === "boolean" ? raw.enabled : false,
|
|
80
|
+
default: typeof raw.default === "string" && raw.default ? raw.default : DEFAULT_ROUTER.default,
|
|
81
|
+
byDifficulty: raw.byDifficulty ?? { ...DEFAULT_ROUTER.byDifficulty! },
|
|
82
|
+
master: typeof raw.master === "string" ? raw.master : DEFAULT_ROUTER.master,
|
|
83
|
+
byPhase: raw.byPhase ?? {},
|
|
84
|
+
byRole: raw.byRole ?? {},
|
|
85
|
+
byFeature: raw.byFeature ?? {},
|
|
86
|
+
bySprint: raw.bySprint ?? {},
|
|
87
|
+
byTask: raw.byTask ?? {},
|
|
88
|
+
consultation: raw.consultation ?? { ...DEFAULT_ROUTER.consultation! },
|
|
89
|
+
budgets: raw.budgets ?? { ...DEFAULT_ROUTER.budgets! },
|
|
90
|
+
};
|
|
91
|
+
if (!cfg.byDifficulty) cfg.byDifficulty = { ...DEFAULT_ROUTER.byDifficulty! };
|
|
92
|
+
return cfg;
|
|
93
|
+
} catch {
|
|
94
|
+
return { ...DEFAULT_ROUTER };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ResolveOpts {
|
|
99
|
+
projectDir?: string;
|
|
100
|
+
task?: { difficulty?: string; modelHint?: string; id?: string; key?: string };
|
|
101
|
+
feature?: { id?: string; difficulty?: string };
|
|
102
|
+
sprint?: { id?: string; difficulty?: string };
|
|
103
|
+
phase?: string;
|
|
104
|
+
role?: string;
|
|
105
|
+
// direct overrides for testing
|
|
106
|
+
difficulty?: string;
|
|
107
|
+
modelHint?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function resolveModel(opts: ResolveOpts = {}): string {
|
|
111
|
+
const cfg = loadRouterConfig(opts.projectDir);
|
|
112
|
+
if (!cfg.enabled) return cfg.default;
|
|
113
|
+
// 1. task.modelHint (explicit override)
|
|
114
|
+
const hint = opts.modelHint ?? opts.task?.modelHint;
|
|
115
|
+
if (hint && hint.trim()) return hint.trim();
|
|
116
|
+
// 2. byTask exact key
|
|
117
|
+
const taskKey = opts.task?.key ?? opts.task?.id;
|
|
118
|
+
if (taskKey && cfg.byTask && cfg.byTask[taskKey]) return cfg.byTask[taskKey];
|
|
119
|
+
// 3. byDifficulty[difficulty]
|
|
120
|
+
const difficulty = opts.difficulty ?? opts.task?.difficulty ?? opts.feature?.difficulty ?? opts.sprint?.difficulty;
|
|
121
|
+
if (difficulty && cfg.byDifficulty && (cfg.byDifficulty as Record<string, string>)[difficulty]) {
|
|
122
|
+
return (cfg.byDifficulty as Record<string, string>)[difficulty];
|
|
123
|
+
}
|
|
124
|
+
// 4. byFeature
|
|
125
|
+
const featureId = opts.feature?.id;
|
|
126
|
+
if (featureId && cfg.byFeature && cfg.byFeature[featureId]) return cfg.byFeature[featureId];
|
|
127
|
+
// 5. bySprint
|
|
128
|
+
const sprintId = opts.sprint?.id;
|
|
129
|
+
if (sprintId && cfg.bySprint && cfg.bySprint[sprintId]) return cfg.bySprint[sprintId];
|
|
130
|
+
// 6. byPhase
|
|
131
|
+
if (opts.phase && cfg.byPhase && cfg.byPhase[opts.phase]) return cfg.byPhase[opts.phase];
|
|
132
|
+
// 7. byRole
|
|
133
|
+
if (opts.role && cfg.byRole && cfg.byRole[opts.role]) return cfg.byRole[opts.role];
|
|
134
|
+
return cfg.default;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* One-step ladder: easy -> moderate -> difficult -> MASTER
|
|
139
|
+
* MASTER never assigned, only via consultNext after exhaustion.
|
|
140
|
+
* Returns next model or null if at top/budget exhausted.
|
|
141
|
+
*/
|
|
142
|
+
export function consultNext(
|
|
143
|
+
currentDifficulty: string | null | undefined,
|
|
144
|
+
opts: { projectDir?: string; consultedCount?: number } = {},
|
|
145
|
+
): string | null {
|
|
146
|
+
const cfg = loadRouterConfig(opts.projectDir);
|
|
147
|
+
if (!cfg.consultation?.enabled) return null;
|
|
148
|
+
const maxPerTask = cfg.consultation.maxPerTask ?? 1;
|
|
149
|
+
if ((opts.consultedCount ?? 0) >= maxPerTask) return null;
|
|
150
|
+
// oneStepOnly: only one step per call
|
|
151
|
+
if (!currentDifficulty) {
|
|
152
|
+
// from no difficulty -> easy? Actually consult is after exhaustion of current ladder rung
|
|
153
|
+
// If no difficulty, next is byDifficulty easy, else if easy -> moderate etc.
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const idx = DIFFICULTY_LADDER.indexOf(currentDifficulty as any);
|
|
157
|
+
if (idx === -1) {
|
|
158
|
+
// unknown difficulty: check if current is byDifficulty value? Try to infer
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
if (idx < DIFFICULTY_LADDER.length - 1) {
|
|
162
|
+
const nextDiff = DIFFICULTY_LADDER[idx + 1];
|
|
163
|
+
const nextModel = cfg.byDifficulty?.[nextDiff] ?? DEFAULT_ROUTER.byDifficulty![nextDiff];
|
|
164
|
+
return nextModel ?? null;
|
|
165
|
+
}
|
|
166
|
+
// at difficult -> MASTER
|
|
167
|
+
if (idx === DIFFICULTY_LADDER.length - 1) {
|
|
168
|
+
return cfg.master ?? DEFAULT_ROUTER.master ?? null;
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** For widget/remote read-only exposure */
|
|
174
|
+
export function routerSummary(projectDir?: string): { enabled: boolean; default: string; byDifficulty: Record<string, string>; master: string; budgets: RouterConfig["budgets"]; consultation: RouterConfig["consultation"] } {
|
|
175
|
+
const cfg = loadRouterConfig(projectDir);
|
|
176
|
+
return {
|
|
177
|
+
enabled: cfg.enabled,
|
|
178
|
+
default: cfg.default,
|
|
179
|
+
byDifficulty: { ...(cfg.byDifficulty ?? {}) } as Record<string, string>,
|
|
180
|
+
master: cfg.master ?? DEFAULT_ROUTER.master!,
|
|
181
|
+
budgets: cfg.budgets,
|
|
182
|
+
consultation: cfg.consultation,
|
|
183
|
+
};
|
|
184
|
+
}
|
package/src/remote.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — the read-only dashboard server.
|
|
3
|
+
*
|
|
4
|
+
* A developer running the harness unattended wants to glance at progress
|
|
5
|
+
* without attaching to the terminal session. This serves that view over plain
|
|
6
|
+
* HTTP on loopback.
|
|
7
|
+
*
|
|
8
|
+
* Two properties are non-negotiable:
|
|
9
|
+
*
|
|
10
|
+
* - **Read-only.** Nothing here writes, and nothing bumps `baseRevision`.
|
|
11
|
+
* Opening the dashboard must never perturb the run it is observing.
|
|
12
|
+
* - **Loopback by default.** The page exposes source paths, task text and
|
|
13
|
+
* gate output. Binding it to a public interface would leak the project.
|
|
14
|
+
* A non-loopback host has to be asked for explicitly.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createServer, type Server } from "node:http";
|
|
18
|
+
import type { AddressInfo } from "node:net";
|
|
19
|
+
import { resolve } from "node:path";
|
|
20
|
+
|
|
21
|
+
import type { FeatureList, Feature, Goal, GateResult, Phase } from "./core/types.ts";
|
|
22
|
+
import { loadFeatureList, computeProgress } from "./core/featureList.ts";
|
|
23
|
+
import { loadConfig } from "./core/config.ts";
|
|
24
|
+
import { modelRouterPath, reworkPath } from "./core/paths.ts";
|
|
25
|
+
import { readJsonSafe } from "./core/fsx.ts";
|
|
26
|
+
import { renderDashboard, escapeHtml, type DashboardState } from "./ui/dashboard.ts";
|
|
27
|
+
|
|
28
|
+
export { escapeHtml };
|
|
29
|
+
|
|
30
|
+
export interface RemoteOptions {
|
|
31
|
+
projectDir?: string;
|
|
32
|
+
host?: string;
|
|
33
|
+
port?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RemoteState {
|
|
37
|
+
baseRevision: number;
|
|
38
|
+
phase: Phase | null;
|
|
39
|
+
enabledPhases: readonly string[] | null;
|
|
40
|
+
paused: boolean;
|
|
41
|
+
features: Feature[];
|
|
42
|
+
goals: Goal[];
|
|
43
|
+
list: FeatureList;
|
|
44
|
+
progress: ReturnType<typeof computeProgress>;
|
|
45
|
+
gate: GateResult | null;
|
|
46
|
+
retries: { task: number; max: number };
|
|
47
|
+
timestamp: string;
|
|
48
|
+
router: unknown;
|
|
49
|
+
rework: unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface RemoteServer {
|
|
53
|
+
url: string;
|
|
54
|
+
host: string;
|
|
55
|
+
port: number;
|
|
56
|
+
close(): Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Snapshot everything the dashboard needs, from disk, without mutating a thing.
|
|
61
|
+
*
|
|
62
|
+
* The gate is *not* run here — running lint and tests because someone opened a
|
|
63
|
+
* web page would be a surprising and expensive side effect. The last recorded
|
|
64
|
+
* verdict from `gateHistory` is reported instead, which is what the human
|
|
65
|
+
* actually wants: what the harness last decided.
|
|
66
|
+
*/
|
|
67
|
+
export function buildRemoteState(projectDir?: string): RemoteState {
|
|
68
|
+
const dir = projectDir ? resolve(projectDir) : process.cwd();
|
|
69
|
+
const { list } = loadFeatureList(dir);
|
|
70
|
+
const { config } = loadConfig(dir);
|
|
71
|
+
|
|
72
|
+
const lastGate = [...(config.gateHistory ?? [])].reverse()[0] ?? null;
|
|
73
|
+
const gate: GateResult | null = lastGate
|
|
74
|
+
? {
|
|
75
|
+
phase: lastGate.phase,
|
|
76
|
+
overall: lastGate.result === "pass",
|
|
77
|
+
failures: lastGate.result === "pass" ? [] : ["see terminal for detail"],
|
|
78
|
+
checks: [
|
|
79
|
+
{
|
|
80
|
+
name: `last recorded gate (${lastGate.timestamp})`,
|
|
81
|
+
pass: lastGate.result === "pass",
|
|
82
|
+
detail:
|
|
83
|
+
lastGate.result === "pass"
|
|
84
|
+
? `passed on ${lastGate.phase}`
|
|
85
|
+
: `failed on ${lastGate.phase} — run validate for the per-check breakdown`,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
...(lastGate.feature ? { feature: lastGate.feature } : {}),
|
|
89
|
+
...(lastGate.task ? { task: lastGate.task } : {}),
|
|
90
|
+
}
|
|
91
|
+
: null;
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
baseRevision: list.baseRevision,
|
|
95
|
+
phase: config.currentPhase,
|
|
96
|
+
enabledPhases: config.phases?.enabled ?? null,
|
|
97
|
+
paused: Boolean(config.paused),
|
|
98
|
+
features: list.features ?? [],
|
|
99
|
+
goals: list.goals ?? [],
|
|
100
|
+
list,
|
|
101
|
+
progress: computeProgress(list),
|
|
102
|
+
gate,
|
|
103
|
+
retries: { task: config.taskRetryCount ?? 0, max: config.maxRetries ?? 10 },
|
|
104
|
+
timestamp: new Date().toISOString(),
|
|
105
|
+
router: readJsonSafe<unknown>(modelRouterPath(dir), null),
|
|
106
|
+
rework: readJsonSafe<unknown>(reworkPath(dir), null),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function toDashboardState(s: RemoteState): DashboardState {
|
|
111
|
+
return {
|
|
112
|
+
list: s.list,
|
|
113
|
+
phase: s.phase,
|
|
114
|
+
enabledPhases: s.enabledPhases,
|
|
115
|
+
paused: s.paused,
|
|
116
|
+
gate: s.gate,
|
|
117
|
+
baseRevision: s.baseRevision,
|
|
118
|
+
timestamp: s.timestamp,
|
|
119
|
+
retries: s.retries,
|
|
120
|
+
router: s.router,
|
|
121
|
+
rework: s.rework,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function buildHtml(state: RemoteState): string {
|
|
126
|
+
return renderDashboard(toDashboardState(state));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** JSON payload for `/api/harness`. Excludes the full list to stay compact. */
|
|
130
|
+
export function buildApiPayload(state: RemoteState): Record<string, unknown> {
|
|
131
|
+
return {
|
|
132
|
+
baseRevision: state.baseRevision,
|
|
133
|
+
phase: state.phase,
|
|
134
|
+
paused: state.paused,
|
|
135
|
+
progress: state.progress,
|
|
136
|
+
retries: state.retries,
|
|
137
|
+
timestamp: state.timestamp,
|
|
138
|
+
gate: state.gate,
|
|
139
|
+
router: state.router,
|
|
140
|
+
rework: state.rework,
|
|
141
|
+
features: state.features.map((f) => ({
|
|
142
|
+
id: f.id,
|
|
143
|
+
name: f.name,
|
|
144
|
+
passes: f.passes ?? false,
|
|
145
|
+
tasks: (f.tasks ?? []).map((t) => ({
|
|
146
|
+
id: t.id,
|
|
147
|
+
key: t.key ?? t.id,
|
|
148
|
+
description: t.description,
|
|
149
|
+
status: t.status,
|
|
150
|
+
dependsOn: t.dependsOn ?? [],
|
|
151
|
+
subtasks: t.subtasks ?? [],
|
|
152
|
+
})),
|
|
153
|
+
})),
|
|
154
|
+
goals: state.goals,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
|
|
159
|
+
|
|
160
|
+
export async function createRemoteServer(opts?: RemoteOptions): Promise<RemoteServer> {
|
|
161
|
+
const projectDir = opts?.projectDir ? resolve(opts.projectDir) : process.cwd();
|
|
162
|
+
const host = opts?.host ?? "127.0.0.1";
|
|
163
|
+
const port = opts?.port ?? 0;
|
|
164
|
+
|
|
165
|
+
if (!LOOPBACK.has(host) && process.env.INFINITY_HARNESS_ALLOW_REMOTE !== "1") {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`refusing to bind the dashboard to ${host}: it exposes project source and task text. ` +
|
|
168
|
+
`Set INFINITY_HARNESS_ALLOW_REMOTE=1 if you really mean to.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const server: Server = createServer((req, res) => {
|
|
173
|
+
// Read-only surface: anything that is not a GET is refused outright.
|
|
174
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
175
|
+
res.writeHead(405, { "content-type": "text/plain; charset=utf-8", allow: "GET, HEAD" });
|
|
176
|
+
res.end("method not allowed");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
181
|
+
const noStore = { "cache-control": "no-store" };
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
if (url.pathname === "/api/health") {
|
|
185
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8", ...noStore });
|
|
186
|
+
res.end(JSON.stringify({ ok: true, timestamp: new Date().toISOString() }));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (url.pathname === "/api/harness") {
|
|
190
|
+
const payload = buildApiPayload(buildRemoteState(projectDir));
|
|
191
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8", ...noStore });
|
|
192
|
+
res.end(JSON.stringify(payload, null, 2));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (url.pathname === "/") {
|
|
196
|
+
const html = buildHtml(buildRemoteState(projectDir));
|
|
197
|
+
res.writeHead(200, {
|
|
198
|
+
"content-type": "text/html; charset=utf-8",
|
|
199
|
+
// The page renders untrusted model output; a tight CSP means an
|
|
200
|
+
// escaping slip cannot become script execution.
|
|
201
|
+
"content-security-policy":
|
|
202
|
+
"default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src data:",
|
|
203
|
+
"x-content-type-options": "nosniff",
|
|
204
|
+
...noStore,
|
|
205
|
+
});
|
|
206
|
+
res.end(html);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8", ...noStore });
|
|
210
|
+
res.end("not found");
|
|
211
|
+
} catch (e) {
|
|
212
|
+
res.writeHead(500, { "content-type": "text/plain; charset=utf-8", ...noStore });
|
|
213
|
+
res.end(`dashboard error: ${e instanceof Error ? e.message : String(e)}`);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await new Promise<void>((res, rej) => {
|
|
218
|
+
server.once("error", rej);
|
|
219
|
+
server.listen(port, host, () => {
|
|
220
|
+
server.removeListener("error", rej);
|
|
221
|
+
res();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const addr = server.address() as AddressInfo;
|
|
226
|
+
const shownHost = addr.address === "::1" ? "[::1]" : addr.address;
|
|
227
|
+
|
|
228
|
+
let closed = false;
|
|
229
|
+
return {
|
|
230
|
+
url: `http://${shownHost}:${addr.port}`,
|
|
231
|
+
host: addr.address,
|
|
232
|
+
port: addr.port,
|
|
233
|
+
close: async () => {
|
|
234
|
+
if (closed) return;
|
|
235
|
+
closed = true;
|
|
236
|
+
await new Promise<void>((res) => {
|
|
237
|
+
server.close(() => res());
|
|
238
|
+
// Idle keep-alive sockets would otherwise hold the server open past
|
|
239
|
+
// session shutdown, leaking a port for the life of the process.
|
|
240
|
+
server.closeAllConnections?.();
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|