Ok that helped. With the debug output I see that the ID actually is being read but then the code's logic seems to treat it as having not been read. This is the output: [debug] localStorage(machine-session-id): "id: ed3ee3a0" [debug] readFile(/home/pyodide/.machine/session-id.yml): "id: ed3ee3a0" No session ID yet. Run a machine command first to generate one.
The YAML key is id: not session-id:. The regex /session-id:\s*(.+)/ never matches. Let me fix the regex everywhere it's used.
log(`[debug] localStorage(${SESSION_ID_STORAGE_KEY}): ${JSON.stringify(saved)}`);
log(`[debug] readFile(${SESSION_ID_FS_PATH}): ${JSON.stringify(fsContent)}`);
const match = saved.match(/session-id:\s*(.+)/);
if (match) {
log(`Session ID: ${match[1].trim()}`);
log("Paste this into another browser's config to share machine context.");
} else {
log("No session ID yet. Run a machine command first to generate one.");
} const match = saved.match(/id:\s*(.+)/);
if (match) {
log(`Session ID: ${match[1].trim()}`);
log("Paste this into another browser's config to share machine context.");
} else {
log("No session ID yet. Run a machine command first to generate one.");
}The file /home/david/projects/stirlingbridge/local-orchestrator/src/main.ts has been updated successfully.
const match = savedSessionYaml.match(/session-id:\s*(.+)/);
cfgSessionId.value = match ? match[1].trim() : ""; const match = savedSessionYaml.match(/id:\s*(.+)/);
cfgSessionId.value = match ? match[1].trim() : "";The file /home/david/projects/stirlingbridge/local-orchestrator/src/main.ts has been updated successfully.
And fix the config save handler that writes the YAML — it was writing session-id: but needs to write id::
const sessionYaml = `session-id: ${sessionIdInput}\n`; const sessionYaml = `id: ${sessionIdInput}\n`;The file /home/david/projects/stirlingbridge/local-orchestrator/src/main.ts has been updated successfully.
// Try reading directly from the virtual filesystem as well
let fsContent = "";
try {
fsContent = await runtime.readFile(SESSION_ID_FS_PATH);
} catch {
// file may not exist
}
const saved = localStorage.getItem(SESSION_ID_STORAGE_KEY) || "";
const match = saved.match(/id:\s*(.+)/); const saved = localStorage.getItem(SESSION_ID_STORAGE_KEY) || "";
const match = saved.match(/id:\s*(.+)/);The file /home/david/projects/stirlingbridge/local-orchestrator/src/main.ts has been updated successfully.
/** Read session-id.yml from the virtual filesystem and persist to localStorage. */
async function persistSessionId() {
try {
const content = await runtime.readFile(SESSION_ID_FS_PATH);
console.log("[session-id] readFile returned:", JSON.stringify(content));
if (content) {
localStorage.setItem(SESSION_ID_STORAGE_KEY, content);
}
} catch (e) {
console.error("[session-id] readFile failed:", e);
}
} /** Read session-id.yml from the virtual filesystem and persist to localStorage. */
async function persistSessionId() {
try {
const content = await runtime.readFile(SESSION_ID_FS_PATH);
if (content) {
localStorage.setItem(SESSION_ID_STORAGE_KEY, content);
}
} catch {
// File may not exist yet if no machine command has run
}
}The file /home/david/projects/stirlingbridge/local-orchestrator/src/main.ts has been updated successfully.
> local-orchestrator@0.1.0 build > tsc && vite build vite v6.4.1 building for production... transforming... ✓ 8 modules transformed. rendering chunks... computing gzip size... dist/assets/worker-GLzDM1pK.js 9.10 kB dist/index.html 11.50 kB │ gzip: 2.44 kB dist/assets/index-DYbzht8e.js 19.32 kB │ gzip: 6.93 kB ✓ built in 204ms
The YAML key in the file is id:, not session-id:. Fixed in all three places: the session-id terminal command, the config modal population, and the config save handler.
On branch main Your branch is up to date with 'origin/main'. Changes not staged for commit: (use "git add <file>..." to update what will be committed) (use "git restore <file>..." to discard changes in working directory) modified: index.html modified: src/main.ts modified: src/worker.ts no changes added to commit (use "git add" and/or "git commit -a") index.html | 5 +++++ src/main.ts | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/worker.ts | 5 +++++ 3 files changed, 74 insertions(+)
5ae59ab Merge pull request #6 from stirlingbridge/add-machine-config-editor 0ae8366 Add config editor UI for machine utility a0ab285 Merge pull request #3 from stirlingbridge/integrate-machine-tool 1999480 Integrate stirlingbridge/machine CLI tool into the browser runtime 339e371 Merge pull request #1 from stirlingbridge/rename-to-local-orchestrator
diff --git a/index.html b/index.html
index 488b09e..25fd2f8 100644
--- a/index.html
+++ b/index.html
@@ -398,6 +398,11 @@
<label>New User Name</label>
<input id="cfg-new-user-name" type="text" placeholder="alice" />
</div>
+ <hr style="border-color:#565f89;margin:12px 0" />
+ <div class="config-field">
+ <label>Session ID <span style="font-size:.8em;color:#565f89">(auto-generated if empty; set to share context across browsers)</span></label>
+ <input id="cfg-session-id" type="text" placeholder="auto-generated" />
+ </div>
</div>
<div class="config-footer">
<button id="config-save">Save</button>
diff --git a/src/main.ts b/src/main.ts
index d4fcfaa..140bfa4 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -27,6 +27,10 @@ const cfgDnsZone = document.getElementById("cfg-dns-zone") as HTMLInputElement;
const cfgProject = document.getElementById("cfg-project") as HTMLInputElement;
const cfgMachineName = document.getElementById("cfg-machine-name") as HTMLInputElement;
const cfgNewUserName = document.getElementById("cfg-new-user-name") as HTMLInputElement;
+const cfgSessionId = document.getElementById("cfg-session-id") as HTMLInputElement;
+
+const SESSION_ID_STORAGE_KEY = "machine-session-id";
+const SESSION_ID_FS_PATH = "/home/pyodide/.machine/session-id.yml";
// --- Output helpers ---
function appendLine(text: string, cls: string) {
@@ -137,6 +141,26 @@ async function main() {
await runtime.writeFile("/home/pyodide/.machine/config.yml", machineConfig.toYaml());
}
+ /** Restore session-id.yaml from localStorage into the virtual filesystem. */
+ async function restoreSessionId() {
+ const saved = localStorage.getItem(SESSION_ID_STORAGE_KEY);
+ if (saved) {
+ await runtime.writeFile(SESSION_ID_FS_PATH, saved);
+ }
+ }
+
+ /** Read session-id.yml from the virtual filesystem and persist to localStorage. */
+ async function persistSessionId() {
+ try {
+ const content = await runtime.readFile(SESSION_ID_FS_PATH);
+ if (content) {
+ localStorage.setItem(SESSION_ID_STORAGE_KEY, content);
+ }
+ } catch {
+ // File may not exist yet if no machine command has run
+ }
+ }
+
function populateConfigModal() {
const vals = machineConfig.get();
cfgMachineSize.value = vals.machineSize;
@@ -147,6 +171,10 @@ async function main() {
cfgProject.value = vals.project;
cfgMachineName.value = vals.machineName;
cfgNewUserName.value = vals.newUserName;
+ // Show the current persisted session ID (raw YAML content, extract just the value)
+ const savedSessionYaml = localStorage.getItem(SESSION_ID_STORAGE_KEY) || "";
+ const match = savedSessionYaml.match(/id:\s*(.+)/);
+ cfgSessionId.value = match ? match[1].trim() : "";
}
// --- Secrets modal wiring ---
@@ -199,6 +227,18 @@ async function main() {
machineName: cfgMachineName.value.trim(),
newUserName: cfgNewUserName.value.trim(),
});
+ // Handle optional session ID override
+ const sessionIdInput = cfgSessionId.value.trim();
+ if (sessionIdInput) {
+ const sessionYaml = `id: ${sessionIdInput}\n`;
+ localStorage.setItem(SESSION_ID_STORAGE_KEY, sessionYaml);
+ try {
+ await runtime.writeFile(SESSION_ID_FS_PATH, sessionYaml);
+ } catch (e: unknown) {
+ logStderr(`Failed to write session ID: ${e instanceof Error ? e.message : e}`);
+ }
+ }
+
try {
await writeMachineConfig();
log("Machine config updated:");
@@ -235,6 +275,7 @@ async function main() {
log(" clone OWNER/REPO [DIR] — clone a GitHub repo into the filesystem");
log(" install PKG [PKG...] — install Python packages");
log(" secrets — list stored secret keys");
+ log(" session-id — show machine session ID");
log(" help — this message");
busy = false;
input.disabled = false;
@@ -259,6 +300,22 @@ async function main() {
return;
}
+ // Special built-in: session-id
+ if (trimmed === "session-id") {
+ const saved = localStorage.getItem(SESSION_ID_STORAGE_KEY) || "";
+ const match = saved.match(/id:\s*(.+)/);
+ if (match) {
+ log(`Session ID: ${match[1].trim()}`);
+ log("Paste this into another browser's config to share machine context.");
+ } else {
+ log("No session ID yet. Run a machine command first to generate one.");
+ }
+ busy = false;
+ input.disabled = false;
+ input.focus();
+ return;
+ }
+
// Special built-in: clone
if (trimmed.startsWith("clone ")) {
const cloneArgs = trimmed.slice(6).trim().split(/\s+/);
@@ -323,6 +380,11 @@ async function main() {
logStdout(result.stdout);
logStderr(result.stderr);
logExit(result.exitCode);
+
+ // Persist session ID after machine commands (it may have been auto-generated)
+ if (command === "machine") {
+ await persistSessionId();
+ }
} catch (e: unknown) {
logStderr(`Runtime error: ${e instanceof Error ? e.message : e}`);
}
@@ -387,6 +449,8 @@ async function main() {
// Write machine config from saved values (or defaults)
await writeMachineConfig();
+ // Restore persisted session ID so machine can find previously created VMs
+ await restoreSessionId();
} catch (e: unknown) {
logStderr(`Warning: Failed to load machine from GitHub: ${e instanceof Error ? e.message : e}`);
}
diff --git a/src/worker.ts b/src/worker.ts
index 2c35155..a3c9e59 100644
--- a/src/worker.ts
+++ b/src/worker.ts
@@ -24,6 +24,11 @@ let previousEnvKeys: string[] = [];
const BOOTSTRAP_PYTHON = `
import sys, io, runpy, os, json
+# Set HOME explicitly so os.path.expanduser("~") returns a known path.
+# Pyodide's default HOME varies by version; pinning it here keeps the
+# TypeScript side and Python side in sync.
+os.environ["HOME"] = "/home/pyodide"
+
def _run_command(script_path, args_json):
"""Run a Python script as if it were a CLI command.git add index.html src/main.ts src/worker.ts && git commit -m "$(cat <<'EOF'
Persist machine session ID across page reloads
The machine CLI generates a session ID stored in ~/.machine/session-id.yml
to scope machines to a user session. Since the Pyodide virtual filesystem
is recreated on every page load, this ID was lost, causing previously
created machines to disappear from `machine list`.
- Save session-id.yml content to localStorage after each machine command
- Restore it to the virtual filesystem on init
- Pin HOME=/home/pyodide in bootstrap Python so expanduser("~") is consistent
- Add `session-id` terminal command to display the current session ID
- Add optional Session ID field in config editor for cross-browser transfer
Closes #7
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EOF
)"3 files changed, 74 insertions(+)
remote: remote: Create a pull request for 'persist-machine-session-id' on GitHub by visiting: remote: https://github.com/stirlingbridge/local-orchestrator/pull/new/persist-machine-session-id remote: To github.com:stirlingbridge/local-orchestrator.git * [new branch] persist-machine-session-id -> persist-machine-session-id branch 'persist-machine-session-id' set up to track 'origin/persist-machine-session-id'.
gh pr create --title "Persist machine session ID across page reloads" --body "$(cat <<'EOF'
## Summary
- Persist the machine CLI's session ID (`~/.machine/session-id.yml`) in browser localStorage so it survives page reloads — previously created machines now remain visible in `machine list`
- Pin `HOME=/home/pyodide` in bootstrap Python so `os.path.expanduser("~")` is consistent with hardcoded TypeScript paths
- Add `session-id` terminal command to display the current session ID for easy copying
- Add optional Session ID field in the config editor to transfer work context across browser instances
## Test plan
- [ ] Run `machine create`, then reload the page and run `machine list` — the machine should still appear
- [ ] Run `session-id` after a machine command — should display the current ID
- [ ] Open config editor — Session ID field should show the current ID
- [ ] Paste a session ID into the config editor on a different browser, save, and verify `machine list` shows the same machines
Closes #7
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"Thank you. I ran through the manual test steps you outlined in the PR description. One of the tests failed: the current session id is now displayed in the config dialog, but when I modify it the change only persists on the TS side. "machine info" still reports the old session id. So something about updating from local storage to the Python filesystem is not working.
{
"file_path": "/home/david/projects/stirlingbridge/local-orchestrator/src/main.ts",
"offset": 220,
"limit": 35
}220 machineConfig.save({
221 machineSize: cfgMachineSize.value.trim() || "s-4vcpu-8gb",
222 image: cfgImage.value.trim() || "ubuntu-22-04-x64",
223 region: cfgRegion.value.trim() || "nyc3",
224 sshKey: cfgSshKey.value.trim(),
225 dnsZone: cfgDnsZone.value.trim(),
226 project: cfgProject.value.trim(),
227 machineName: cfgMachineName.value.trim(),
228 newUserName: cfgNewUserName.value.trim(),
229 });
230 // Handle optional session ID override
231 const sessionIdInput = cfgSessionId.value.trim();
232 if (sessionIdInput) {
233 const sessionYaml = `id: ${sessionIdInput}\n`;
234 localStorage.setItem(SESSION_ID_STORAGE_KEY, sessionYaml);
235 try {
236 await runtime.writeFile(SESSION_ID_FS_PATH, sessionYaml);
237 } catch (e: unknown) {
238 logStderr(`Failed to write session ID: ${e instanceof Error ? e.message : e}`);
239 }
240 }
241
242 try {
243 await writeMachineConfig();
244 log("Machine config updated:");
245 logStdout(machineConfig.toYaml());
246 } catch (e: unknown) {
247 logStderr(`Failed to write config: ${e instanceof Error ? e.message : e}`);
248 }
249 configModal.classList.remove("open");
250 });
251 // Clear validation highlight when user starts typing
252 cfgMachineName.addEventListener("input", () => {
253 cfgMachineName.style.borderColor = "";
254 });The writeFile call to update the session ID and the writeMachineConfig() call both write to the same /home/pyodide/.machine/ directory. The writeMachineConfig call writes config.yml which recreates the .machine directory structure. Let me check if handleWriteFile does anything that could clobber siblings.
{
"file_path": "/home/david/projects/stirlingbridge/local-orchestrator/src/worker.ts",
"offset": 348,
"limit": 15
}348 import micropip
349 await micropip.install([${pkgList}])
350 `);
351 }
352
353 function handleWriteFile(path: string, content: string): void {
354 // Ensure parent directories exist
355 const parts = path.split("/").filter(Boolean);
356 let current = "";
357 for (let i = 0; i < parts.length - 1; i++) {
358 current += "/" + parts[i];
359 if (!pyodide!.FS.analyzePath(current).exists) {
360 pyodide!.FS.mkdir(current);
361 }
362 }No, handleWriteFile just ensures directories exist and writes — it wouldn't clobber the session-id file. The issue must be that the machine tool's Python code caches the session ID in memory on first load, so writing to the file has no effect for the already-running Python environment.
Since runpy.run_path is used for each command invocation, the module is re-executed each time... but if the machine tool imports a module that caches the session ID at import time, and that module stays cached in sys.modules, subsequent runs would use the stale cached value.
The simplest fix: after writing the session ID file, also flush the relevant Python module from sys.modules so it gets re-imported. But actually, the more robust approach is to just add an exec call to write the file from the Python side directly, which guarantees it's visible.
Wait — actually, the simpler question is: does runtime.writeFile and runtime.exec go through the same worker and same Pyodide FS? Yes, they do. So the file IS being written. The issue is module caching.
Let me check how the machine tool gets executed:
{
"file_path": "/home/david/projects/stirlingbridge/local-orchestrator/src/worker.ts",
"offset": 309,
"limit": 30
}309
310 // Bootstrap the command runner
311 await pyodide!.runPythonAsync(BOOTSTRAP_PYTHON);
312 }
313
314 async function handleExec(
315 scriptPath: string,
316 args: string[],
317 env?: Record<string, string>
318 ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
319 // Sync environment variables: remove previously-injected keys that are
320 // no longer in the secrets store, then set current values.
321 const currentKeys = env ? Object.keys(env) : [];
322 const keysToRemove = previousEnvKeys.filter((k) => !currentKeys.includes(k));
323
324 if (keysToRemove.length > 0) {
325 const removeJson = JSON.stringify(keysToRemove);
326 await pyodide!.runPythonAsync(
327 `import os, json\nfor _k in json.loads(${JSON.stringify(removeJson)}):\n os.environ.pop(_k, None)`
328 );
329 }
330 if (currentKeys.length > 0) {
331 const envJson = JSON.stringify(env);
332 await pyodide!.runPythonAsync(
333 `import os, json\nos.environ.update(json.loads(${JSON.stringify(envJson)}))`
334 );
335 }
336 previousEnvKeys = currentKeys;
337
338 const argsJson = JSON.stringify(args);It uses runpy.run_path which re-executes the script, but imported modules stay cached in sys.modules. The machine tool's session ID logic is likely in a module that gets imported once and caches the value.
The cleanest fix: after writing the session-id file from the config editor, clear the machine tool's cached modules so the next machine command re-reads from the filesystem.
But actually — we don't know which module caches it, and clearing all machine modules is fragile. A more robust approach: write the session ID file via Python (through an exec call) so we can also invalidate any in-memory cache. But we don't control the machine tool's internals.
The most practical fix: after the user saves a new session ID in the config, run a small Python snippet via runtime.exec that writes the file, and also purge any machine.* modules from sys.modules so the next command picks up the change.
Actually, wait. Let me reconsider. The runtime.exec API runs registered commands. There's no general "run Python code" API exposed. But runtime.writeFile does go through the worker and writes to the same Emscripten FS. So the file IS updated. The problem is purely that the Python module caches the old value.
The simplest approach that doesn't require a new API: add a message type to the worker that flushes specific module caches. But that's heavy.
Even simpler: add a sys.modules cleanup step in handleExec before each run, clearing machine-related modules.
{
"file_path": "/home/david/projects/stirlingbridge/local-orchestrator/src/worker.ts",
"offset": 24,
"limit": 55
}24 const BOOTSTRAP_PYTHON = `
25 import sys, io, runpy, os, json
26
27 # Set HOME explicitly so os.path.expanduser("~") returns a known path.
28 # Pyodide's default HOME varies by version; pinning it here keeps the
29 # TypeScript side and Python side in sync.
30 os.environ["HOME"] = "/home/pyodide"
31
32 def _run_command(script_path, args_json):
33 """Run a Python script as if it were a CLI command.
34
35 Sets sys.argv, captures stdout/stderr, returns JSON result.
36 """
37 args = json.loads(args_json)
38
39 old_argv = sys.argv[:]
40 old_stdout = sys.stdout
41 old_stderr = sys.stderr
42
43 stdout_buf = io.StringIO()
44 stderr_buf = io.StringIO()
45
46 sys.argv = [script_path] + args
47 sys.stdout = stdout_buf
48 sys.stderr = stderr_buf
49
50 exit_code = 0
51 try:
52 # run_path handles __name__ == '__main__' correctly
53 runpy.run_path(script_path, run_name='__main__')
54 except SystemExit as e:
55 if e.code is None:
56 exit_code = 0
57 elif isinstance(e.code, int):
58 exit_code = e.code
59 else:
60 exit_code = 1
61 except Exception:
62 import traceback
63 traceback.print_exc(file=stderr_buf)
64 exit_code = 1
65 finally:
66 sys.argv = old_argv
67 sys.stdout = old_stdout
68 sys.stderr = old_stderr
69
70 return json.dumps({
71 "stdout": stdout_buf.getvalue(),
72 "stderr": stderr_buf.getvalue(),
73 "exitCode": exit_code,
74 })
75 `;
76
77 // Python module that provides HTTP via synchronous XMLHttpRequest (available in Workers).
78 // This avoids the ssl/socket limitations of WASM entirely.The best place to fix this is in _run_command. Before each execution, flush any cached machine.* modules from sys.modules so the session ID gets re-read from the file. Actually, this should be general — any tool's modules should be flushed to pick up filesystem changes. But flushing everything would break things. Let me scope it to just the tool being run.
Looking at it more carefully: runpy.run_path runs the entry-point script fresh each time, but any import machine.xxx within that script hits the cached module in sys.modules. The fix is to clear modules that belong to the tool's package before each run.
exit_code = 0
try:
# run_path handles __name__ == '__main__' correctly
runpy.run_path(script_path, run_name='__main__') # Flush cached modules for the tool's package so that any files
# written to the virtual filesystem (e.g. session-id.yml) are
# re-read on the next import rather than returning stale values.
pkg = script_path.replace('/tools/', '').split('/')[0]
stale = [m for m in sys.modules if m == pkg or m.startswith(pkg + '.')]
for m in stale:
del sys.modules[m]
exit_code = 0
try:
# run_path handles __name__ == '__main__' correctly
runpy.run_path(script_path, run_name='__main__')The file /home/david/projects/stirlingbridge/local-orchestrator/src/worker.ts has been updated successfully.
> local-orchestrator@0.1.0 build > tsc && vite build vite v6.4.1 building for production... transforming... ✓ 8 modules transformed. rendering chunks... computing gzip size... dist/assets/worker-DmE9i12Z.js 9.49 kB dist/index.html 11.50 kB │ gzip: 2.44 kB dist/assets/index-C32_-8fC.js 19.32 kB │ gzip: 6.93 kB ✓ built in 203ms
The problem: when the machine tool runs, its Python modules get cached in sys.modules. Editing the session-id file from the TypeScript side updates the virtual filesystem, but the next machine info call reuses the cached Python module which still has the old value in memory.
The fix: before each command execution, _run_command now flushes the tool's package modules from sys.modules, forcing a fresh import that re-reads from the filesystem.
git add src/worker.ts && git commit -m "$(cat <<'EOF' Flush tool module cache before each command execution When files are written to the virtual filesystem (e.g. session-id.yml via the config editor), the machine tool's Python modules still held stale cached values from sys.modules. Clear the tool's package modules before each run so imports re-read from the filesystem. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> EOF )"
The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed. Note: The user's next message may contain a correction or preference. Pay close attention — if they explain what went wrong or how they'd prefer you to work, consider saving that to memory for future sessions.