milhouse 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,10 @@
1
- # Milhouse Van Houten
2
-
3
- ![Milhouse](milhouse.png)
4
-
5
- Milhouse Van Houten is a chad pair programmer that provides a lightweight web UI for running Codex threads, starting with a quick planning phase and iterating through builds until the job is done. Inspired by a Ralph Wiggum-style autonomous loop, Milhouse focuses on simplicity, approachability, and making experimentation fun and easy to modify.
1
+ # Milhouse Van Houten
2
+
3
+ ![Milhouse](milhouse.png)
4
+
5
+ [milhouse on npm](https://www.npmjs.com/package/milhouse)
6
+
7
+ Milhouse Van Houten is a chad pair programmer that provides a lightweight web UI for running Codex threads, starting with a quick planning phase and iterating through builds until the job is done. Inspired by a Ralph Wiggum-style autonomous loop, Milhouse focuses on simplicity, approachability, and making experimentation fun and easy to modify.
6
8
 
7
9
  This repository contains the **bare-bones** version of Milhouse. It represents the core foundation, with many new features, experiments, and feedback loops currently in the pipeline.
8
10
 
@@ -19,14 +21,32 @@ This repository contains the **bare-bones** version of Milhouse. It represents t
19
21
  * Node.js 18+
20
22
  * `CODEX_API_KEY` set in your environment **(optional if your Codex CLI is already authenticated)**
21
23
 
22
- ## Install
23
-
24
+ ## Install
25
+
24
26
  ```bash
25
27
  npm install -g milhouse
26
28
  ```
27
-
28
- ## Usage
29
-
29
+
30
+ ## Quick Start
31
+
32
+ 1. Authenticate Codex (either works):
33
+ - Set `CODEX_API_KEY`, or
34
+ - Use local Codex auth if your Codex CLI is already authenticated.
35
+
36
+ Example (PowerShell):
37
+
38
+ ```powershell
39
+ $env:CODEX_API_KEY="..."
40
+ ```
41
+
42
+ 2. Launch the UI:
43
+
44
+ ```bash
45
+ milhouse ui
46
+ ```
47
+
48
+ ## Usage
49
+
30
50
  Start the Web UI:
31
51
 
32
52
  ```bash
@@ -40,14 +60,16 @@ This opens a local web panel at `http://127.0.0.1:4173` (falls back to a free po
40
60
  ```text
41
61
  milhouse ui [OPTIONS]
42
62
 
43
- Options:
44
- --host <ip> Server host (default: 127.0.0.1)
45
- --port <n>, -p <n> Server port (default: 4173)
46
- --workdir <path>, -w Working directory for Codex (default: current directory)
47
- --state-dir <path> State/logs directory (default: OS user data directory)
48
- --no-open Don't auto-open browser
49
- --help, -h Show help
50
- ```
63
+ Options:
64
+ --host <ip> Server host (default: 127.0.0.1)
65
+ --port <n>, -p <n> Server port (default: 4173)
66
+ --workdir <path>, -w Working directory for Codex (default: current directory)
67
+ --state-dir <path> State/logs directory (default: OS user data directory)
68
+ --no-open Don't auto-open browser
69
+ --help, -h Show help
70
+ ```
71
+
72
+ Note: the UI's folder picker is best-effort. If it fails, you can always paste a path into the Workdir field.
51
73
 
52
74
  ### Examples
53
75
 
@@ -466,6 +466,7 @@
466
466
 
467
467
  let es;
468
468
  let running = false;
469
+ let streamConnected = false;
469
470
 
470
471
  function appendLog(line) {
471
472
  logsEl.textContent += line + "\n";
@@ -550,7 +551,11 @@
550
551
  );
551
552
  });
552
553
 
553
- artifactsEl.innerHTML = parts.join("") || "<em>No artifacts yet.</em>";
554
+ if (parts.length === 0) {
555
+ artifactsEl.innerHTML = running ? "<em>Waiting for artifacts...</em>" : "<em>No artifacts yet.</em>";
556
+ return;
557
+ }
558
+ artifactsEl.innerHTML = parts.join("");
554
559
  }
555
560
 
556
561
  function escapeHtml(str) {
@@ -569,7 +574,20 @@
569
574
 
570
575
  function startStream() {
571
576
  if (es) es.close();
577
+ streamConnected = false;
578
+ appendLog("[ui] Connecting to live logs...");
572
579
  es = new EventSource("/api/events");
580
+ es.onopen = () => {
581
+ if (streamConnected) return;
582
+ streamConnected = true;
583
+ appendLog("[ui] Live logs connected.");
584
+ };
585
+ es.onerror = () => {
586
+ if (streamConnected) {
587
+ streamConnected = false;
588
+ appendLog("[ui] Live logs disconnected (will retry)...");
589
+ }
590
+ };
573
591
  es.onmessage = (ev) => {
574
592
  appendLog(ev.data);
575
593
  const threadMatch = ev.data.match(/thread:\s*([0-9a-zA-Z-]+)/);
@@ -594,6 +612,8 @@
594
612
  workdir: workdirEl.value.trim() || undefined,
595
613
  };
596
614
  setRunning(true);
615
+ setStatus("Starting...", "running");
616
+ appendLog("[ui] Starting run...");
597
617
  const res = await fetch("/api/start", {
598
618
  method: "POST",
599
619
  headers: { "Content-Type": "application/json" },
@@ -607,6 +627,7 @@
607
627
  return;
608
628
  }
609
629
  setStatus("Running", "running");
630
+ appendLog("[ui] Run started. Waiting for first logs/artifacts...");
610
631
  fetchStatus();
611
632
  fetchSessions();
612
633
  };
@@ -631,10 +652,14 @@
631
652
  })
632
653
  .then((r) => r.text().then((t) => ({ ok: r.ok, text: t })))
633
654
  .then(({ ok, text }) => {
655
+ if (!text.trim()) return;
634
656
  try {
635
657
  const data = JSON.parse(text);
636
658
  if (data.path) workdirEl.value = data.path;
637
- else if (data.error) appendLog("Browse error: " + data.error);
659
+ else if (data.error) {
660
+ if (String(data.error).toLowerCase().includes("cancel")) return;
661
+ appendLog("Browse error: " + data.error);
662
+ }
638
663
  } catch {
639
664
  if (!ok) appendLog("Browse error: " + text.slice(0, 200));
640
665
  }
@@ -643,13 +668,19 @@
643
668
  };
644
669
 
645
670
  newProjectBtn.onclick = () => {
646
- fetch("/api/browse", { method: "POST", headers: { "Content-Type": "application/json" } })
671
+ fetch("/api/browse", {
672
+ method: "POST",
673
+ headers: { "Content-Type": "application/json" },
674
+ body: "{}"
675
+ })
647
676
  .then((r) => r.text().then((t) => ({ ok: r.ok, text: t })))
648
677
  .then(({ ok, text }) => {
678
+ if (!text.trim()) return;
649
679
  let base = "";
650
680
  try {
651
681
  const data = JSON.parse(text);
652
682
  if (!data.path && data.error) {
683
+ if (String(data.error).toLowerCase().includes("cancel")) return;
653
684
  appendLog("Browse error: " + data.error);
654
685
  return;
655
686
  }
package/dist/ui/server.js CHANGED
@@ -45,7 +45,8 @@ function createServerContext(options) {
45
45
  const sessionsFile = path.join(stateBaseDir, "sessions.json");
46
46
  const loopRunner = resolveLoopRunner(runtimeRoot);
47
47
  const app = express();
48
- app.use(express.json({ limit: "1mb" }));
48
+ const jsonParser = express.json({ limit: "1mb" });
49
+ app.get("/favicon.ico", (_req, res) => res.status(204).end());
49
50
  app.use(express.static(path.join(__dirname, "public")));
50
51
  const clients = [];
51
52
  let child = null;
@@ -102,6 +103,9 @@ function createServerContext(options) {
102
103
  status: "running",
103
104
  };
104
105
  saveSessions((sessions) => [...sessions, currentSession]);
106
+ broadcast(`[milhouse] Starting run…`);
107
+ broadcast(`[milhouse] workdir: ${workdir}`);
108
+ broadcast(`[milhouse] state dir: ${stateDir}`);
105
109
  const args = [
106
110
  ...loopRunner.args,
107
111
  "--goal",
@@ -148,6 +152,28 @@ function createServerContext(options) {
148
152
  child.kill();
149
153
  }
150
154
  }
155
+ function parseBrowseDefaultPath(req) {
156
+ const body = req.body;
157
+ if (!body)
158
+ return undefined;
159
+ if (typeof body === "string") {
160
+ const trimmed = body.trim();
161
+ if (!trimmed)
162
+ return undefined;
163
+ try {
164
+ const parsed = JSON.parse(trimmed);
165
+ return typeof parsed.defaultPath === "string" ? parsed.defaultPath : undefined;
166
+ }
167
+ catch {
168
+ return undefined;
169
+ }
170
+ }
171
+ if (typeof body === "object") {
172
+ const maybe = body;
173
+ return typeof maybe.defaultPath === "string" ? maybe.defaultPath : undefined;
174
+ }
175
+ return undefined;
176
+ }
151
177
  async function browseFolder(defaultPath) {
152
178
  return await new Promise((resolve, reject) => {
153
179
  const isWindows = process.platform === "win32";
@@ -156,9 +182,18 @@ function createServerContext(options) {
156
182
  if (isWindows) {
157
183
  const initialDirectory = defaultPath?.trim() ? defaultPath.replace(/'/g, "''") : "";
158
184
  const script = `
159
- Add-Type -AssemblyName System.Windows.Forms
160
- Add-Type -AssemblyName System.Drawing
161
- Add-Type -TypeDefinition @"
185
+ $ErrorActionPreference = 'Stop'
186
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
187
+ [Console]::InputEncoding = [System.Text.Encoding]::UTF8
188
+
189
+ function Write-Err([string]$msg) {
190
+ try { [Console]::Error.WriteLine($msg) } catch {}
191
+ }
192
+
193
+ function Try-FolderBrowserDialog([string]$initial) {
194
+ Add-Type -AssemblyName System.Windows.Forms
195
+ Add-Type -AssemblyName System.Drawing
196
+ Add-Type -TypeDefinition @"
162
197
  using System;
163
198
  using System.Runtime.InteropServices;
164
199
  public static class User32 {
@@ -167,49 +202,83 @@ public static class User32 {
167
202
  }
168
203
  "@
169
204
 
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()
205
+ $topForm = New-Object System.Windows.Forms.Form
206
+ $topForm.TopMost = $true
207
+ $topForm.ShowInTaskbar = $false
208
+ $topForm.FormBorderStyle = 'FixedToolWindow'
209
+ $topForm.StartPosition = 'Manual'
210
+ $topForm.Location = New-Object System.Drawing.Point(-32000, -32000)
211
+ $topForm.Size = New-Object System.Drawing.Size(1, 1)
212
+ $topForm.Opacity = 0
213
+ $topForm.Show()
214
+ $topForm.Activate()
215
+ $topForm.BringToFront()
216
+ $topForm.Focus()
217
+ [User32]::SetForegroundWindow($topForm.Handle) | Out-Null
218
+ [System.Windows.Forms.Application]::DoEvents()
219
+
220
+ $dlg = New-Object System.Windows.Forms.FolderBrowserDialog
221
+ $dlg.Description = 'Select project folder'
222
+ if ($initial) { $dlg.SelectedPath = $initial }
184
223
 
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}'` : ""}
224
+ $result = $dlg.ShowDialog($topForm)
225
+ $topForm.Close()
192
226
 
193
- $result = $dlg.ShowDialog($topForm)
194
- if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
195
- Write-Output (Split-Path -Parent $dlg.FileName)
227
+ if ($result -eq [System.Windows.Forms.DialogResult]::OK -and $dlg.SelectedPath) {
228
+ return $dlg.SelectedPath
229
+ }
230
+ return $null
231
+ }
232
+
233
+ function Try-ShellBrowseForFolder([string]$initial) {
234
+ $shell = New-Object -ComObject Shell.Application
235
+ $root = 0
236
+ if ($initial) { $root = $initial }
237
+ $folder = $shell.BrowseForFolder(0, 'Select project folder', 0, $root)
238
+ if ($folder -and $folder.Self -and $folder.Self.Path) {
239
+ return $folder.Self.Path
240
+ }
241
+ return $null
242
+ }
243
+
244
+ try {
245
+ $initial = ${initialDirectory ? `'${initialDirectory}'` : "''"}
246
+ $path = $null
247
+ try { $path = Try-FolderBrowserDialog $initial } catch {}
248
+ if (-not $path) {
249
+ try { $path = Try-ShellBrowseForFolder $initial } catch {}
250
+ }
251
+ if ($path) { Write-Output $path }
252
+ exit 0
253
+ } catch {
254
+ Write-Err ($_.Exception.Message)
255
+ exit 1
196
256
  }
197
- $topForm.Close()
198
257
  `;
199
258
  const encoded = Buffer.from(script, "utf16le").toString("base64");
200
- const ps = spawn("powershell", ["-NoProfile", "-STA", "-EncodedCommand", encoded]);
259
+ const ps = spawn("powershell.exe", ["-NoProfile", "-STA", "-EncodedCommand", encoded]);
201
260
  let out = "";
261
+ let errOut = "";
202
262
  ps.stdout.on("data", (d) => (out += d.toString()));
203
- ps.stderr.on("data", (d) => broadcast(`[browse stderr] ${d.toString().trim()}`));
263
+ ps.stderr.on("data", (d) => {
264
+ const line = d.toString().trim();
265
+ if (line)
266
+ broadcast(`[browse stderr] ${line}`);
267
+ errOut += `${line}\n`;
268
+ });
204
269
  ps.on("error", (err) => reject(err));
205
270
  ps.on("exit", (code) => {
206
271
  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
- }
272
+ if (code === 0)
273
+ return resolve(trimmed || null);
274
+ const errTrimmed = errOut.trim();
275
+ if (!trimmed && !errTrimmed)
276
+ return resolve(null);
277
+ if (!trimmed && errTrimmed.toLowerCase().includes("cancel"))
278
+ return resolve(null);
279
+ if (trimmed && fs.existsSync(trimmed))
280
+ return resolve(trimmed);
281
+ reject(new Error(errTrimmed || "Folder selection cancelled or failed"));
213
282
  });
214
283
  return;
215
284
  }
@@ -219,17 +288,23 @@ $topForm.Close()
219
288
  'set p to POSIX path of (choose folder with prompt "Select project folder")',
220
289
  ]);
221
290
  let out = "";
291
+ let errOut = "";
222
292
  osa.stdout.on("data", (d) => (out += d.toString()));
223
- osa.stderr.on("data", (d) => broadcast(`[browse stderr] ${d.toString().trim()}`));
293
+ osa.stderr.on("data", (d) => {
294
+ const line = d.toString().trim();
295
+ if (line)
296
+ broadcast(`[browse stderr] ${line}`);
297
+ errOut += `${line}\n`;
298
+ });
224
299
  osa.on("error", (err) => reject(err));
225
300
  osa.on("exit", (code) => {
226
301
  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
- }
302
+ if (code === 0)
303
+ return resolve(trimmed || null);
304
+ const errTrimmed = errOut.trim();
305
+ if (!trimmed && errTrimmed.toLowerCase().includes("canceled"))
306
+ return resolve(null);
307
+ reject(new Error(errTrimmed || "Folder selection cancelled or failed"));
233
308
  });
234
309
  return;
235
310
  }
@@ -237,8 +312,14 @@ $topForm.Close()
237
312
  const run = (command, args, onMissing) => {
238
313
  const proc = spawn(command, args);
239
314
  let out = "";
315
+ let errOut = "";
240
316
  proc.stdout.on("data", (d) => (out += d.toString()));
241
- proc.stderr.on("data", (d) => broadcast(`[browse stderr] ${d.toString().trim()}`));
317
+ proc.stderr.on("data", (d) => {
318
+ const line = d.toString().trim();
319
+ if (line)
320
+ broadcast(`[browse stderr] ${line}`);
321
+ errOut += `${line}\n`;
322
+ });
242
323
  proc.on("error", (err) => {
243
324
  if (err.code === "ENOENT")
244
325
  onMissing();
@@ -247,10 +328,13 @@ $topForm.Close()
247
328
  });
248
329
  proc.on("exit", (code) => {
249
330
  const trimmed = out.trim();
250
- if (code === 0 && trimmed)
251
- resolve(trimmed);
252
- else
253
- reject(new Error("Folder selection cancelled or failed"));
331
+ if (code === 0)
332
+ return resolve(trimmed || null);
333
+ const errTrimmed = errOut.trim();
334
+ if (!trimmed && (code === 1 || code == null) && (!errTrimmed || errTrimmed.toLowerCase().includes("canceled"))) {
335
+ return resolve(null);
336
+ }
337
+ reject(new Error(errTrimmed || "Folder selection cancelled or failed"));
254
338
  });
255
339
  };
256
340
  run("zenity", [
@@ -266,11 +350,16 @@ $topForm.Close()
266
350
  }
267
351
  async function handleBrowse(req, res) {
268
352
  try {
269
- const path = await browseFolder(req.body?.defaultPath);
353
+ const path = await browseFolder(parseBrowseDefaultPath(req));
354
+ if (!path)
355
+ return res.status(204).end();
270
356
  res.json({ path });
271
357
  }
272
358
  catch (err) {
273
- res.status(400).json({ error: err.message });
359
+ const message = err instanceof Error ? err.message : String(err);
360
+ if (message.toLowerCase().includes("cancel"))
361
+ return res.status(204).end();
362
+ res.status(400).json({ error: message });
274
363
  }
275
364
  }
276
365
  app.get("/api/events", (req, res) => {
@@ -307,7 +396,8 @@ $topForm.Close()
307
396
  }
308
397
  res.json({ sessions });
309
398
  });
310
- app.post("/api/start", (req, res) => {
399
+ app.post("/api/browse", express.text({ type: "*/*", limit: "64kb" }), handleBrowse);
400
+ app.post("/api/start", jsonParser, (req, res) => {
311
401
  const { goal, maxIterations = 0, workdir = defaultWorkdir, createIfMissing = true } = req.body || {};
312
402
  if (!goal || typeof goal !== "string") {
313
403
  return res.status(400).json({ error: "goal is required" });
@@ -320,6 +410,16 @@ $topForm.Close()
320
410
  res.status(400).json({ error: err.message });
321
411
  }
322
412
  });
413
+ app.use((err, req, res, next) => {
414
+ if (req.method === "POST" && req.path === "/api/browse") {
415
+ const status = typeof err?.status === "number" ? err.status : undefined;
416
+ const type = typeof err?.type === "string" ? err.type : undefined;
417
+ if (status === 400 && type === "entity.parse.failed") {
418
+ return res.status(204).end();
419
+ }
420
+ }
421
+ next(err);
422
+ });
323
423
  app.post("/api/stop", (_req, res) => {
324
424
  stopSession();
325
425
  res.json({ ok: true });
@@ -329,8 +429,6 @@ $topForm.Close()
329
429
  return res.json({});
330
430
  res.json(readArtifacts(currentSession.stateDir));
331
431
  });
332
- app.post("/api/browse", handleBrowse);
333
- app.get("/api/browse", handleBrowse);
334
432
  return { app, stop: stopSession };
335
433
  }
336
434
  function listenOnce(app, host, port) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "milhouse",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Milhouse Van Houten - Local CLI + web UI for running autonomous Codex AI agent loops",
5
5
  "bin": {
6
6
  "milhouse": "dist/src/cli.js"