milhouse 1.0.1

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.
@@ -0,0 +1,369 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { spawn } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import express from "express";
7
+ import { resolveDefaultWorkdir, resolveStateBaseDir } from "../src/paths.js";
8
+ function readArtifacts(stateDir) {
9
+ const snapshot = {};
10
+ const planLog = path.join(stateDir, "plan_out.log");
11
+ const buildLog = path.join(stateDir, "build_out.log");
12
+ const planFile = path.join(stateDir, "IMPLEMENTATION_PLAN.md");
13
+ const threadFile = path.join(stateDir, "thread_id");
14
+ if (fs.existsSync(planLog))
15
+ snapshot.planLog = fs.readFileSync(planLog, "utf8");
16
+ if (fs.existsSync(buildLog))
17
+ snapshot.buildLog = fs.readFileSync(buildLog, "utf8");
18
+ if (fs.existsSync(planFile))
19
+ snapshot.planFile = fs.readFileSync(planFile, "utf8");
20
+ if (fs.existsSync(threadFile))
21
+ snapshot.threadId = fs.readFileSync(threadFile, "utf8").trim();
22
+ return snapshot;
23
+ }
24
+ function resolveLoopRunner(runtimeRoot) {
25
+ const jsPath = path.join(runtimeRoot, "src", "loop-runner.js");
26
+ if (fs.existsSync(jsPath)) {
27
+ return { command: process.execPath, args: [jsPath] };
28
+ }
29
+ const tsPath = path.join(runtimeRoot, "src", "loop-runner.ts");
30
+ if (fs.existsSync(tsPath)) {
31
+ const bin = process.platform === "win32" ? "tsx.cmd" : "tsx";
32
+ const tsxPath = path.join(runtimeRoot, "node_modules", ".bin", bin);
33
+ if (fs.existsSync(tsxPath)) {
34
+ return { command: tsxPath, args: [tsPath] };
35
+ }
36
+ }
37
+ throw new Error("Loop runner not found (expected dist build output).");
38
+ }
39
+ function createServerContext(options) {
40
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
41
+ const runtimeRoot = path.resolve(__dirname, "..");
42
+ const defaultWorkdir = options.defaultWorkdir ?? resolveDefaultWorkdir();
43
+ const stateBaseDir = options.stateBaseDir ?? resolveStateBaseDir();
44
+ fs.mkdirSync(stateBaseDir, { recursive: true });
45
+ const sessionsFile = path.join(stateBaseDir, "sessions.json");
46
+ const loopRunner = resolveLoopRunner(runtimeRoot);
47
+ const app = express();
48
+ app.use(express.json({ limit: "1mb" }));
49
+ app.use(express.static(path.join(__dirname, "public")));
50
+ const clients = [];
51
+ let child = null;
52
+ let currentSession = null;
53
+ let logBuffer = [];
54
+ function broadcast(line) {
55
+ logBuffer.push(line);
56
+ const payload = `data: ${line}\n\n`;
57
+ clients.forEach((c) => c.res.write(payload));
58
+ }
59
+ function saveSessions(update) {
60
+ let sessions = [];
61
+ if (fs.existsSync(sessionsFile)) {
62
+ try {
63
+ sessions = JSON.parse(fs.readFileSync(sessionsFile, "utf8"));
64
+ }
65
+ catch {
66
+ sessions = [];
67
+ }
68
+ }
69
+ const next = update(sessions);
70
+ fs.writeFileSync(sessionsFile, JSON.stringify(next, null, 2));
71
+ }
72
+ function normalizeWorkdir(input) {
73
+ if (!input)
74
+ return defaultWorkdir;
75
+ return path.isAbsolute(input) ? input : path.resolve(defaultWorkdir, input);
76
+ }
77
+ function startSession(goal, maxIterations, workdirInput, createIfMissing) {
78
+ if (child) {
79
+ throw new Error("A run is already in progress");
80
+ }
81
+ let workdir = normalizeWorkdir(workdirInput);
82
+ if (!fs.existsSync(workdir)) {
83
+ if (createIfMissing) {
84
+ fs.mkdirSync(workdir, { recursive: true });
85
+ }
86
+ else {
87
+ throw new Error(`Workdir not found: ${workdir}`);
88
+ }
89
+ }
90
+ const stateDir = path.join(stateBaseDir, Buffer.from(workdir).toString("base64url"));
91
+ fs.mkdirSync(stateDir, { recursive: true });
92
+ logBuffer = [];
93
+ const id = randomUUID();
94
+ const startedAt = new Date().toISOString();
95
+ currentSession = {
96
+ id,
97
+ goal,
98
+ maxIterations,
99
+ workdir,
100
+ stateDir,
101
+ startedAt,
102
+ status: "running",
103
+ };
104
+ saveSessions((sessions) => [...sessions, currentSession]);
105
+ const args = [
106
+ ...loopRunner.args,
107
+ "--goal",
108
+ goal,
109
+ "--max-iterations",
110
+ String(maxIterations),
111
+ "--workdir",
112
+ workdir,
113
+ "--state-dir",
114
+ stateDir,
115
+ ];
116
+ child = spawn(loopRunner.command, args, {
117
+ cwd: runtimeRoot,
118
+ env: process.env,
119
+ });
120
+ child.stdout.on("data", (data) => {
121
+ const lines = data.toString().split(/\r?\n/).filter(Boolean);
122
+ lines.forEach((l) => {
123
+ broadcast(l);
124
+ const threadMatch = l.match(/thread:\s*([0-9a-zA-Z-]+)/);
125
+ if (threadMatch && currentSession)
126
+ currentSession.threadId = threadMatch[1];
127
+ });
128
+ });
129
+ child.stderr.on("data", (data) => {
130
+ data
131
+ .toString()
132
+ .split(/\r?\n/)
133
+ .filter(Boolean)
134
+ .forEach((l) => broadcast(`[stderr] ${l}`));
135
+ });
136
+ child.on("exit", (code, signal) => {
137
+ broadcast(`[exit] code=${code ?? "null"} signal=${signal ?? "null"}`);
138
+ if (currentSession) {
139
+ currentSession.status = code === 0 ? "succeeded" : signal === "SIGTERM" ? "stopped" : "failed";
140
+ currentSession.endedAt = new Date().toISOString();
141
+ saveSessions((sessions) => sessions.map((s) => (s.id === currentSession.id ? currentSession : s)));
142
+ }
143
+ child = null;
144
+ });
145
+ }
146
+ function stopSession() {
147
+ if (child) {
148
+ child.kill();
149
+ }
150
+ }
151
+ async function browseFolder(defaultPath) {
152
+ return await new Promise((resolve, reject) => {
153
+ const isWindows = process.platform === "win32";
154
+ const isMac = process.platform === "darwin";
155
+ const isLinux = process.platform === "linux";
156
+ if (isWindows) {
157
+ const initialDirectory = defaultPath?.trim() ? defaultPath.replace(/'/g, "''") : "";
158
+ const script = `
159
+ Add-Type -AssemblyName System.Windows.Forms
160
+ Add-Type -AssemblyName System.Drawing
161
+ Add-Type -TypeDefinition @"
162
+ using System;
163
+ using System.Runtime.InteropServices;
164
+ public static class User32 {
165
+ [DllImport("user32.dll")]
166
+ public static extern bool SetForegroundWindow(IntPtr hWnd);
167
+ }
168
+ "@
169
+
170
+ $topForm = New-Object System.Windows.Forms.Form
171
+ $topForm.TopMost = $true
172
+ $topForm.ShowInTaskbar = $false
173
+ $topForm.FormBorderStyle = 'FixedToolWindow'
174
+ $topForm.StartPosition = 'Manual'
175
+ $topForm.Location = New-Object System.Drawing.Point(-32000, -32000)
176
+ $topForm.Size = New-Object System.Drawing.Size(1, 1)
177
+ $topForm.Opacity = 0
178
+ $topForm.Show()
179
+ $topForm.Activate()
180
+ $topForm.BringToFront()
181
+ $topForm.Focus()
182
+ [User32]::SetForegroundWindow($topForm.Handle) | Out-Null
183
+ [System.Windows.Forms.Application]::DoEvents()
184
+
185
+ $dlg = New-Object System.Windows.Forms.OpenFileDialog
186
+ $dlg.Title = 'Select project folder'
187
+ $dlg.ValidateNames = $false
188
+ $dlg.CheckFileExists = $false
189
+ $dlg.CheckPathExists = $true
190
+ $dlg.FileName = 'Select Folder'
191
+ ${initialDirectory ? `$dlg.InitialDirectory = '${initialDirectory}'` : ""}
192
+
193
+ $result = $dlg.ShowDialog($topForm)
194
+ if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
195
+ Write-Output (Split-Path -Parent $dlg.FileName)
196
+ }
197
+ $topForm.Close()
198
+ `;
199
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
200
+ const ps = spawn("powershell", ["-NoProfile", "-STA", "-EncodedCommand", encoded]);
201
+ let out = "";
202
+ ps.stdout.on("data", (d) => (out += d.toString()));
203
+ ps.stderr.on("data", (d) => broadcast(`[browse stderr] ${d.toString().trim()}`));
204
+ ps.on("error", (err) => reject(err));
205
+ ps.on("exit", (code) => {
206
+ const trimmed = out.trim();
207
+ if (code === 0 && trimmed) {
208
+ resolve(trimmed);
209
+ }
210
+ else {
211
+ reject(new Error("Folder selection cancelled or failed"));
212
+ }
213
+ });
214
+ return;
215
+ }
216
+ if (isMac) {
217
+ const osa = spawn("osascript", [
218
+ "-e",
219
+ 'set p to POSIX path of (choose folder with prompt "Select project folder")',
220
+ ]);
221
+ let out = "";
222
+ osa.stdout.on("data", (d) => (out += d.toString()));
223
+ osa.stderr.on("data", (d) => broadcast(`[browse stderr] ${d.toString().trim()}`));
224
+ osa.on("error", (err) => reject(err));
225
+ osa.on("exit", (code) => {
226
+ const trimmed = out.trim();
227
+ if (code === 0 && trimmed) {
228
+ resolve(trimmed);
229
+ }
230
+ else {
231
+ reject(new Error("Folder selection cancelled or failed"));
232
+ }
233
+ });
234
+ return;
235
+ }
236
+ if (isLinux) {
237
+ const run = (command, args, onMissing) => {
238
+ const proc = spawn(command, args);
239
+ let out = "";
240
+ proc.stdout.on("data", (d) => (out += d.toString()));
241
+ proc.stderr.on("data", (d) => broadcast(`[browse stderr] ${d.toString().trim()}`));
242
+ proc.on("error", (err) => {
243
+ if (err.code === "ENOENT")
244
+ onMissing();
245
+ else
246
+ reject(err);
247
+ });
248
+ proc.on("exit", (code) => {
249
+ const trimmed = out.trim();
250
+ if (code === 0 && trimmed)
251
+ resolve(trimmed);
252
+ else
253
+ reject(new Error("Folder selection cancelled or failed"));
254
+ });
255
+ };
256
+ run("zenity", [
257
+ "--file-selection",
258
+ "--directory",
259
+ "--title=Select project folder",
260
+ ...(defaultPath?.trim() ? [`--filename=${defaultPath}`] : []),
261
+ ], () => run("kdialog", ["--getexistingdirectory", defaultPath?.trim() ? defaultPath : ".", "--title", "Select project folder"], () => reject(new Error("Folder picker not available (install zenity or kdialog, or enter the path manually)."))));
262
+ return;
263
+ }
264
+ reject(new Error("Folder picker not supported on this OS"));
265
+ });
266
+ }
267
+ async function handleBrowse(req, res) {
268
+ try {
269
+ const path = await browseFolder(req.body?.defaultPath);
270
+ res.json({ path });
271
+ }
272
+ catch (err) {
273
+ res.status(400).json({ error: err.message });
274
+ }
275
+ }
276
+ app.get("/api/events", (req, res) => {
277
+ res.writeHead(200, {
278
+ "Content-Type": "text/event-stream",
279
+ "Cache-Control": "no-cache",
280
+ Connection: "keep-alive",
281
+ });
282
+ const id = randomUUID();
283
+ clients.push({ id, res });
284
+ logBuffer.forEach((line) => res.write(`data: ${line}\n\n`));
285
+ req.on("close", () => {
286
+ const idx = clients.findIndex((c) => c.id === id);
287
+ if (idx >= 0)
288
+ clients.splice(idx, 1);
289
+ });
290
+ });
291
+ app.get("/api/status", (_req, res) => {
292
+ res.json({
293
+ running: Boolean(child),
294
+ session: currentSession,
295
+ artifacts: currentSession ? readArtifacts(currentSession.stateDir) : {},
296
+ });
297
+ });
298
+ app.get("/api/sessions", (_req, res) => {
299
+ let sessions = [];
300
+ if (fs.existsSync(sessionsFile)) {
301
+ try {
302
+ sessions = JSON.parse(fs.readFileSync(sessionsFile, "utf8"));
303
+ }
304
+ catch {
305
+ sessions = [];
306
+ }
307
+ }
308
+ res.json({ sessions });
309
+ });
310
+ app.post("/api/start", (req, res) => {
311
+ const { goal, maxIterations = 0, workdir = defaultWorkdir, createIfMissing = true } = req.body || {};
312
+ if (!goal || typeof goal !== "string") {
313
+ return res.status(400).json({ error: "goal is required" });
314
+ }
315
+ try {
316
+ startSession(goal, Number(maxIterations) || 0, workdir, Boolean(createIfMissing));
317
+ res.json({ ok: true, session: currentSession });
318
+ }
319
+ catch (err) {
320
+ res.status(400).json({ error: err.message });
321
+ }
322
+ });
323
+ app.post("/api/stop", (_req, res) => {
324
+ stopSession();
325
+ res.json({ ok: true });
326
+ });
327
+ app.get("/api/artifacts", (_req, res) => {
328
+ if (!currentSession)
329
+ return res.json({});
330
+ res.json(readArtifacts(currentSession.stateDir));
331
+ });
332
+ app.post("/api/browse", handleBrowse);
333
+ app.get("/api/browse", handleBrowse);
334
+ return { app, stop: stopSession };
335
+ }
336
+ function listenOnce(app, host, port) {
337
+ return new Promise((resolve, reject) => {
338
+ const server = app.listen(port, host, () => resolve(server));
339
+ server.on("error", (err) => reject(err));
340
+ });
341
+ }
342
+ export async function startServer(options = {}) {
343
+ const host = options.host ?? "127.0.0.1";
344
+ const preferredPort = options.port ?? (process.env.PORT ? Number(process.env.PORT) : 4173);
345
+ const ctx = createServerContext(options);
346
+ let server;
347
+ try {
348
+ server = await listenOnce(ctx.app, host, preferredPort);
349
+ }
350
+ catch (err) {
351
+ const code = err.code;
352
+ if (code === "EADDRINUSE" && preferredPort !== 0) {
353
+ server = await listenOnce(ctx.app, host, 0);
354
+ }
355
+ else {
356
+ throw err;
357
+ }
358
+ }
359
+ const address = server.address();
360
+ const port = typeof address === "object" && address ? address.port : preferredPort;
361
+ const url = `http://${host}:${port}`;
362
+ const close = async () => {
363
+ ctx.stop();
364
+ await new Promise((resolve, reject) => {
365
+ server.close((err) => (err ? reject(err) : resolve()));
366
+ });
367
+ };
368
+ return { url, host, port, close };
369
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "milhouse",
3
+ "version": "1.0.1",
4
+ "description": "Milhouse Van Houten - Local CLI + web UI for running autonomous Codex AI agent loops",
5
+ "bin": {
6
+ "milhouse": "dist/src/cli.js"
7
+ },
8
+ "main": "dist/src/cli.js",
9
+ "files": [
10
+ "dist/**",
11
+ "prompts/**",
12
+ "README.md",
13
+ "AGENTS.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "dev": "tsx src/cli.ts",
18
+ "build": "tsc && node scripts/copy-assets.mjs",
19
+ "typecheck": "tsc --noEmit",
20
+ "start": "node dist/src/cli.js",
21
+ "ui": "npm run build --silent && node dist/src/cli.js ui",
22
+ "codex": "tsx src/index.ts",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "keywords": [
26
+ "cli",
27
+ "codex",
28
+ "ai",
29
+ "automation",
30
+ "agent",
31
+ "openai",
32
+ "web-ui",
33
+ "loop-runner"
34
+ ],
35
+ "author": "Milhouse Van Houten",
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/ordinalOS/Milhouse-Van-Houten.git"
40
+ },
41
+ "bugs": {
42
+ "url": "https://github.com/ordinalOS/Milhouse-Van-Houten/issues"
43
+ },
44
+ "homepage": "https://github.com/ordinalOS/Milhouse-Van-Houten",
45
+ "type": "module",
46
+ "engines": {
47
+ "node": ">=18"
48
+ },
49
+ "dependencies": {
50
+ "@openai/codex-sdk": "^0.80.0",
51
+ "env-paths": "^3.0.0",
52
+ "express": "^5.2.1",
53
+ "figlet": "^1.8.0",
54
+ "open": "^10.2.0"
55
+ },
56
+ "devDependencies": {
57
+ "@types/express": "^5.0.6",
58
+ "@types/figlet": "^1.7.0",
59
+ "@types/node": "^25.0.6",
60
+ "tsx": "^4.21.0",
61
+ "typescript": "^5.9.3"
62
+ }
63
+ }
@@ -0,0 +1,11 @@
1
+ Build for goal: {{GOAL}}
2
+
3
+ Plan file: {{PLAN_PATH}}
4
+
5
+ You are the builder. Follow the plan at {{PLAN_PATH}}:
6
+ - Pick the top unchecked item and complete it fully (no placeholders).
7
+ - Make direct edits to the repo under `workdir`.
8
+ - Run any obvious quick checks for touched stack (skip if none are applicable).
9
+ - Update {{PLAN_PATH}}: mark done items `- [x]`, add new `- [ ]` if needed, keep priorities. End with `STATUS: DONE` if nothing remains, else `STATUS: READY`.
10
+
11
+ Keep `AGENTS.md` operational only; no diaries.
@@ -0,0 +1,11 @@
1
+ Plan for goal: {{GOAL}}
2
+
3
+ Plan file: {{PLAN_PATH}}
4
+
5
+ You are the planner. Ignore any existing plan content and write a fresh `IMPLEMENTATION_PLAN.md` with:
6
+ - A 1–2 sentence scope summary for the goal.
7
+ - A prioritized checklist with `- [ ]` items (or `- [x]` if something is already done).
8
+ - Keep tasks concise and concrete (code files or assets to touch).
9
+ - End with a status line: `STATUS: READY` if work remains, `STATUS: DONE` if nothing to do.
10
+
11
+ Do not implement code. Write the plan to {{PLAN_PATH}}. Output only the new plan content.