@wipcomputer/wip-ldm-os 0.4.84 → 0.4.85-alpha.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.
package/bin/ldm.js CHANGED
@@ -1537,13 +1537,21 @@ async function cmdInstall() {
1537
1537
  // npm install --prefix silently fails for scoped packages in temp directories...
1538
1538
  // it creates the lock file but doesn't extract files. npm pack is reliable.
1539
1539
  const npmName = resolvedTarget;
1540
+ // --alpha and --beta select the corresponding npm dist-tag instead of @latest.
1541
+ // Without this, `ldm install --alpha <pkg>` was pulling the @latest version
1542
+ // from npm pack and an existing global install would never advance to the
1543
+ // current alpha. Now the tag flows through pack + the downstream
1544
+ // installSingleTool's `npm install -g <pkg>@<version>` step uses the
1545
+ // version baked into the alpha tarball.
1546
+ const npmTag = ALPHA_FLAG ? 'alpha' : (BETA_FLAG ? 'beta' : '');
1547
+ const packTarget = npmTag ? `${npmName}@${npmTag}` : npmName;
1540
1548
  const tempDir = join(LDM_TMP, `npm-${Date.now()}`);
1541
1549
  console.log('');
1542
- console.log(` Installing ${npmName} from npm...`);
1550
+ console.log(` Installing ${packTarget} from npm...`);
1543
1551
  try {
1544
1552
  mkdirSync(tempDir, { recursive: true });
1545
1553
  // Use npm pack + tar instead of npm install --prefix
1546
- const tarball = execSync(`npm pack ${npmName} --pack-destination "${tempDir}" 2>/dev/null`, {
1554
+ const tarball = execSync(`npm pack ${packTarget} --pack-destination "${tempDir}" 2>/dev/null`, {
1547
1555
  encoding: 'utf8', timeout: 60000, cwd: tempDir,
1548
1556
  }).trim();
1549
1557
  const tarPath = join(tempDir, tarball);
@@ -4289,6 +4297,164 @@ async function main() {
4289
4297
  console.log('');
4290
4298
  }
4291
4299
 
4300
+ // ── ldm uninstall <pkg> ──
4301
+ //
4302
+ // Removes a single LDM-installed package. Used to reset a single
4303
+ // extension between dogfood cycles without taking down the rest of
4304
+ // LDM OS. Pairs with the per-package cleanup hook each package may
4305
+ // ship (e.g. `codex-daemon uninstall --purge` for Codex Remote
4306
+ // Control), which the user runs first to clean up product-specific
4307
+ // state. This command then removes the LDM-side install record + the
4308
+ // global npm package + the LDM extension dir.
4309
+ //
4310
+ // Safety:
4311
+ // - Never touches ~/.codex/ or other unrelated user state.
4312
+ // - Never removes ~/.ldm/memory/ or ~/.ldm/agents/.
4313
+ // - Never removes other extensions.
4314
+ // - Idempotent: running twice exits cleanly.
4315
+ // - Refuses to uninstall LDM OS itself (use `ldm uninstall` for that).
4316
+
4317
+ async function cmdUninstallPackage(pkgName) {
4318
+ const isDryRun = args.includes('--dry-run');
4319
+
4320
+ if (pkgName === 'wip-ldm-os' || pkgName === '@wipcomputer/wip-ldm-os') {
4321
+ console.error(' Refusing to uninstall LDM OS itself with `ldm uninstall <pkg>`.');
4322
+ console.error(' To remove all of LDM OS: ldm uninstall');
4323
+ process.exit(1);
4324
+ }
4325
+
4326
+ console.log('');
4327
+ console.log(` ldm uninstall ${pkgName}`);
4328
+ console.log(' ────────────────────────────────────');
4329
+
4330
+ // 1. Look up the package in the registry.
4331
+ const registryPath = join(LDM_EXTENSIONS, 'registry.json');
4332
+ let registry = { _format: 'v1', extensions: {} };
4333
+ try {
4334
+ if (existsSync(registryPath)) {
4335
+ registry = JSON.parse(readFileSync(registryPath, 'utf8'));
4336
+ }
4337
+ } catch (e) {
4338
+ console.error(` ! could not read registry at ${registryPath}: ${e.message}`);
4339
+ }
4340
+ const entry = registry.extensions?.[pkgName] || null;
4341
+
4342
+ // 2. Resolve npm package name.
4343
+ // Registry entries from npm installs put the npm name in `source.npm`
4344
+ // or in the top-level `name` field. Fall back to the user-supplied
4345
+ // pkgName (works for unscoped packages).
4346
+ const npmPkg = entry?.source?.npm || entry?.name || pkgName;
4347
+
4348
+ // 3. Resolve LDM extension dir(s).
4349
+ const ldmExtPath = entry?.paths?.ldm
4350
+ || entry?.ldmPath
4351
+ || join(LDM_EXTENSIONS, pkgName);
4352
+ const ocExtPath = entry?.paths?.openclaw
4353
+ || entry?.ocPath
4354
+ || null;
4355
+
4356
+ // 4. Build the action plan.
4357
+ const actions = [];
4358
+ let pkgInstalledGlobally = false;
4359
+ try {
4360
+ const npmList = execSync(`npm list -g --depth=0 --json 2>/dev/null`, { encoding: 'utf8' });
4361
+ const deps = JSON.parse(npmList).dependencies || {};
4362
+ pkgInstalledGlobally = !!deps[npmPkg];
4363
+ } catch {}
4364
+
4365
+ if (pkgInstalledGlobally) {
4366
+ actions.push({ kind: 'npm-uninstall', npmPkg });
4367
+ } else {
4368
+ actions.push({ kind: 'skip', label: `npm: ${npmPkg} not installed globally` });
4369
+ }
4370
+ if (existsSync(ldmExtPath)) {
4371
+ actions.push({ kind: 'rm-dir', label: 'LDM extension dir', path: ldmExtPath });
4372
+ } else {
4373
+ actions.push({ kind: 'skip', label: `LDM extension dir: ${ldmExtPath} already gone` });
4374
+ }
4375
+ if (ocExtPath && existsSync(ocExtPath)) {
4376
+ actions.push({ kind: 'rm-dir', label: 'OpenClaw extension dir', path: ocExtPath });
4377
+ }
4378
+ if (entry) {
4379
+ actions.push({ kind: 'registry-remove', name: pkgName });
4380
+ } else {
4381
+ actions.push({ kind: 'skip', label: `registry: no entry for ${pkgName}` });
4382
+ }
4383
+
4384
+ const realActions = actions.filter(a => a.kind !== 'skip');
4385
+ const skips = actions.filter(a => a.kind === 'skip');
4386
+
4387
+ console.log('');
4388
+ if (realActions.length === 0) {
4389
+ for (const s of skips) console.log(` - ${s.label}`);
4390
+ console.log('');
4391
+ console.log(' Nothing to do.');
4392
+ console.log('');
4393
+ console.log(' Will NOT touch ~/.codex/ or ~/.ldm/memory/ or other extensions.');
4394
+ return;
4395
+ }
4396
+ console.log(' Will:');
4397
+ for (const a of realActions) {
4398
+ switch (a.kind) {
4399
+ case 'npm-uninstall':
4400
+ console.log(` - npm uninstall -g ${a.npmPkg}`);
4401
+ break;
4402
+ case 'rm-dir':
4403
+ console.log(` - remove ${a.label}: ${a.path}`);
4404
+ break;
4405
+ case 'registry-remove':
4406
+ console.log(` - remove registry entry for ${a.name}`);
4407
+ break;
4408
+ }
4409
+ }
4410
+ for (const s of skips) console.log(` - skipped: ${s.label}`);
4411
+ console.log('');
4412
+ console.log(' Will NOT touch ~/.codex/ or ~/.ldm/memory/ or other extensions.');
4413
+
4414
+ if (isDryRun) {
4415
+ console.log('');
4416
+ console.log(' Dry run. Nothing removed.');
4417
+ console.log('');
4418
+ console.log(' Re-run without --dry-run to apply.');
4419
+ return;
4420
+ }
4421
+
4422
+ console.log('');
4423
+ for (const a of realActions) {
4424
+ switch (a.kind) {
4425
+ case 'npm-uninstall':
4426
+ try {
4427
+ execSync(`npm uninstall -g ${a.npmPkg}`, { stdio: 'pipe', timeout: 60000 });
4428
+ console.log(` + npm uninstall -g ${a.npmPkg}`);
4429
+ } catch (e) {
4430
+ console.error(` ! npm uninstall -g ${a.npmPkg} failed: ${e.message}`);
4431
+ }
4432
+ break;
4433
+ case 'rm-dir':
4434
+ try {
4435
+ execSync(`rm -rf "${a.path}"`, { stdio: 'pipe' });
4436
+ console.log(` + removed ${a.label}: ${a.path}`);
4437
+ } catch (e) {
4438
+ console.error(` ! could not remove ${a.path}: ${e.message}`);
4439
+ }
4440
+ break;
4441
+ case 'registry-remove':
4442
+ try {
4443
+ delete registry.extensions[a.name];
4444
+ writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n');
4445
+ console.log(` + removed registry entry for ${a.name}`);
4446
+ } catch (e) {
4447
+ console.error(` ! could not update registry: ${e.message}`);
4448
+ }
4449
+ break;
4450
+ }
4451
+ }
4452
+
4453
+ console.log('');
4454
+ console.log(' Uninstalled.');
4455
+ console.log('');
4456
+ }
4457
+
4292
4458
  // ── ldm worktree ──
4293
4459
 
4294
4460
  async function cmdWorktree() {
@@ -4619,9 +4785,17 @@ async function main() {
4619
4785
  case 'disable':
4620
4786
  await cmdDisable();
4621
4787
  break;
4622
- case 'uninstall':
4623
- await cmdUninstall();
4788
+ case 'uninstall': {
4789
+ // ldm uninstall <pkg> [--dry-run] removes one package
4790
+ // ldm uninstall removes the whole LDM OS install
4791
+ const target = args.slice(1).find(a => !a.startsWith('--'));
4792
+ if (target) {
4793
+ await cmdUninstallPackage(target);
4794
+ } else {
4795
+ await cmdUninstall();
4796
+ }
4624
4797
  break;
4798
+ }
4625
4799
  case 'worktree':
4626
4800
  await cmdWorktree();
4627
4801
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ldm-os",
3
- "version": "0.4.84",
3
+ "version": "0.4.85-alpha.1",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "engines": {
@@ -0,0 +1,109 @@
1
+ # Codex Remote Control relay
2
+ #
3
+ # Routes /api/codex-relay/* to the wip-mcp Node app at 127.0.0.1:18800.
4
+ # Without these blocks nginx falls back to /index.html and the phone-side
5
+ # bootstrap + ws-ticket calls receive HTML, which breaks the E2EE handshake
6
+ # and the relay attach flow.
7
+ #
8
+ # Owners: this snippet pairs with src/hosted-mcp/server.mjs codex-relay
9
+ # routes and the kaleidoscope-private phone surface. See
10
+ # wip-ldm-os-private/ai/product/plans-prds/codex-remote-control/
11
+ # for the full architecture.
12
+ #
13
+ # Include from inside the wip.computer server block.
14
+
15
+ # ── HTTP routes ──────────────────────────────────────────────────────
16
+ # bootstrap (GET) ... phone reads daemon E2EE pubkey + presence
17
+ location /api/codex-relay/bootstrap/ {
18
+ proxy_pass http://127.0.0.1:18800;
19
+ proxy_http_version 1.1;
20
+ proxy_set_header Host $host;
21
+ proxy_set_header X-Real-IP $remote_addr;
22
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
23
+ proxy_set_header X-Forwarded-Proto $scheme;
24
+ }
25
+
26
+ # ws-ticket (POST) ... single-use, route-bound, 60s TTL
27
+ location /api/codex-relay/ws-ticket {
28
+ proxy_pass http://127.0.0.1:18800;
29
+ proxy_http_version 1.1;
30
+ proxy_set_header Host $host;
31
+ proxy_set_header X-Real-IP $remote_addr;
32
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
33
+ proxy_set_header X-Forwarded-Proto $scheme;
34
+ }
35
+
36
+ # state (GET) ... daemon presence diagnostic
37
+ location /api/codex-relay/state {
38
+ proxy_pass http://127.0.0.1:18800;
39
+ proxy_http_version 1.1;
40
+ proxy_set_header Host $host;
41
+ proxy_set_header X-Real-IP $remote_addr;
42
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
43
+ proxy_set_header X-Forwarded-Proto $scheme;
44
+ }
45
+
46
+ # pair-init (POST) ... codex-daemon link starts here
47
+ location /api/codex-relay/pair-init {
48
+ proxy_pass http://127.0.0.1:18800;
49
+ proxy_http_version 1.1;
50
+ proxy_set_header Host $host;
51
+ proxy_set_header X-Real-IP $remote_addr;
52
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
53
+ proxy_set_header X-Forwarded-Proto $scheme;
54
+ }
55
+
56
+ # pair-status (GET) ... daemon polls during pair flow
57
+ location /api/codex-relay/pair-status/ {
58
+ proxy_pass http://127.0.0.1:18800;
59
+ proxy_http_version 1.1;
60
+ proxy_set_header Host $host;
61
+ proxy_set_header X-Real-IP $remote_addr;
62
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
63
+ proxy_set_header X-Forwarded-Proto $scheme;
64
+ }
65
+
66
+ # pair-complete (POST) ... phone -> server after passkey + code
67
+ location /api/codex-relay/pair-complete {
68
+ proxy_pass http://127.0.0.1:18800;
69
+ proxy_http_version 1.1;
70
+ proxy_set_header Host $host;
71
+ proxy_set_header X-Real-IP $remote_addr;
72
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
73
+ proxy_set_header X-Forwarded-Proto $scheme;
74
+ }
75
+
76
+ # ── WebSocket routes ────────────────────────────────────────────────
77
+ # Standard nginx WebSocket pattern: Upgrade + Connection "upgrade", long
78
+ # read/send timeout for daemon's persistent socket, buffering off so
79
+ # streamed Codex events don't pool.
80
+
81
+ # Phone side ... attached with ?ticket=<single-use>
82
+ location /api/codex-relay/web/ {
83
+ proxy_pass http://127.0.0.1:18800;
84
+ proxy_http_version 1.1;
85
+ proxy_set_header Upgrade $http_upgrade;
86
+ proxy_set_header Connection "upgrade";
87
+ proxy_set_header Host $host;
88
+ proxy_set_header X-Real-IP $remote_addr;
89
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
90
+ proxy_set_header X-Forwarded-Proto $scheme;
91
+ proxy_read_timeout 86400;
92
+ proxy_send_timeout 86400;
93
+ proxy_buffering off;
94
+ }
95
+
96
+ # Daemon side ... long-lived presence socket from the user's Mac
97
+ location /api/codex-relay/daemon {
98
+ proxy_pass http://127.0.0.1:18800;
99
+ proxy_http_version 1.1;
100
+ proxy_set_header Upgrade $http_upgrade;
101
+ proxy_set_header Connection "upgrade";
102
+ proxy_set_header Host $host;
103
+ proxy_set_header X-Real-IP $remote_addr;
104
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
105
+ proxy_set_header X-Forwarded-Proto $scheme;
106
+ proxy_read_timeout 86400;
107
+ proxy_send_timeout 86400;
108
+ proxy_buffering off;
109
+ }
@@ -7,103 +7,77 @@ server {
7
7
  access_log /var/log/nginx/wip.computer.access.log;
8
8
  error_log /var/log/nginx/wip.computer.error.log;
9
9
 
10
- location / {
11
- autoindex off;
12
- try_files $uri $uri/ /index.html;
10
+ # Docs redirect to docs.wip.computer
11
+ location = /doc {
12
+ return 301 https://docs.wip.computer;
13
13
  }
14
-
15
- add_header X-Frame-Options "SAMEORIGIN" always;
16
- add_header X-Content-Type-Options "nosniff" always;
17
- add_header X-XSS-Protection "1; mode=block" always;
18
-
19
- # ── Codex Remote Control relay ──
20
- # The Node app at 127.0.0.1:18800 (wip-mcp/server.mjs) owns these.
21
- # Without these blocks, nginx falls back to /index.html and the
22
- # phone-side bootstrap + ws-ticket calls receive HTML, which breaks
23
- # E2EE handshake and relay attach. See:
24
- # wip-ldm-os-private/ai/product/plans-prds/codex-remote-control/
25
-
26
- # HTTP routes: bootstrap (GET), ws-ticket (POST), state (GET),
27
- # pair-init (POST), pair-status (GET), pair-complete (POST).
28
- location /api/codex-relay/bootstrap/ {
29
- proxy_pass http://127.0.0.1:18800;
30
- proxy_http_version 1.1;
31
- proxy_set_header Host $host;
32
- proxy_set_header X-Real-IP $remote_addr;
33
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
34
- proxy_set_header X-Forwarded-Proto $scheme;
14
+ location = /docs {
15
+ return 301 https://docs.wip.computer;
35
16
  }
36
- location /api/codex-relay/ws-ticket {
37
- proxy_pass http://127.0.0.1:18800;
38
- proxy_http_version 1.1;
39
- proxy_set_header Host $host;
40
- proxy_set_header X-Real-IP $remote_addr;
41
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
42
- proxy_set_header X-Forwarded-Proto $scheme;
43
- }
44
- location /api/codex-relay/state {
45
- proxy_pass http://127.0.0.1:18800;
46
- proxy_http_version 1.1;
47
- proxy_set_header Host $host;
48
- proxy_set_header X-Real-IP $remote_addr;
49
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
50
- proxy_set_header X-Forwarded-Proto $scheme;
17
+ location /docs/ {
18
+ return 301 https://docs.wip.computer;
51
19
  }
52
- location /api/codex-relay/pair-init {
53
- proxy_pass http://127.0.0.1:18800;
20
+
21
+ # MCP server
22
+ # OAuth 2.0 for Claude iOS connector
23
+ include snippets/mcp-oauth.conf;
24
+ include snippets/mcp-server.conf;
25
+
26
+ # Codex Remote Control relay (HTTP + WSS proxy_pass to wip-mcp Node app
27
+ # at 127.0.0.1:18800; covers /api/codex-relay/bootstrap, ws-ticket, state,
28
+ # pair-init/status/complete, web/<tid>, daemon).
29
+ include snippets/codex-relay.conf;
30
+
31
+ # Codex Remote Control phone surface
32
+ # The Next.js app at kaleidoscope.wip.computer (port 3001) renders
33
+ # /codex-remote-control/[threadId]. The MCP tool's auth URL uses
34
+ # wip.computer as the origin, so wip.computer/codex-remote-control/<tid>
35
+ # must reach the same Next.js app. WebSocket Upgrade headers included
36
+ # so live-reload / RSC streams work cleanly.
37
+ location /codex-remote-control/ {
38
+ proxy_pass http://127.0.0.1:3001;
54
39
  proxy_http_version 1.1;
40
+ proxy_set_header Upgrade $http_upgrade;
41
+ proxy_set_header Connection "upgrade";
55
42
  proxy_set_header Host $host;
56
43
  proxy_set_header X-Real-IP $remote_addr;
57
44
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
58
45
  proxy_set_header X-Forwarded-Proto $scheme;
59
46
  }
60
- location /api/codex-relay/pair-status/ {
61
- proxy_pass http://127.0.0.1:18800;
47
+
48
+ # Health check
49
+ location /health {
50
+ proxy_pass http://127.0.0.1:18800/health;
62
51
  proxy_http_version 1.1;
63
- proxy_set_header Host $host;
64
- proxy_set_header X-Real-IP $remote_addr;
65
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
66
- proxy_set_header X-Forwarded-Proto $scheme;
67
52
  }
68
- location /api/codex-relay/pair-complete {
69
- proxy_pass http://127.0.0.1:18800;
70
- proxy_http_version 1.1;
71
- proxy_set_header Host $host;
72
- proxy_set_header X-Real-IP $remote_addr;
73
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
74
- proxy_set_header X-Forwarded-Proto $scheme;
53
+
54
+
55
+ # Demo pages (static files)
56
+ location /demo {
57
+ alias /var/www/wip.computer/app/mcp-server/demo;
58
+ index index.html;
59
+ try_files $uri $uri/ $uri/index.html =404;
75
60
  }
76
61
 
77
- # WebSocket routes: phone (web) and daemon long-lived sockets.
78
- # Upgrade headers are the standard WebSocket-via-nginx pattern.
79
- # Long read timeout because daemon sockets stay open indefinitely.
80
- location /api/codex-relay/web/ {
81
- proxy_pass http://127.0.0.1:18800;
62
+ # Demo API (proxied to MCP server)
63
+ location /demo/api/ {
64
+ proxy_pass http://127.0.0.1:18800/demo/api/;
82
65
  proxy_http_version 1.1;
83
- proxy_set_header Upgrade $http_upgrade;
84
- proxy_set_header Connection "upgrade";
85
66
  proxy_set_header Host $host;
86
67
  proxy_set_header X-Real-IP $remote_addr;
87
68
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
88
69
  proxy_set_header X-Forwarded-Proto $scheme;
89
- proxy_read_timeout 86400;
90
- proxy_send_timeout 86400;
91
- proxy_buffering off;
92
70
  }
93
- location /api/codex-relay/daemon {
94
- proxy_pass http://127.0.0.1:18800;
95
- proxy_http_version 1.1;
96
- proxy_set_header Upgrade $http_upgrade;
97
- proxy_set_header Connection "upgrade";
98
- proxy_set_header Host $host;
99
- proxy_set_header X-Real-IP $remote_addr;
100
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
101
- proxy_set_header X-Forwarded-Proto $scheme;
102
- proxy_read_timeout 86400;
103
- proxy_send_timeout 86400;
104
- proxy_buffering off;
71
+
72
+ location / {
73
+ autoindex off;
74
+ try_files $uri $uri/ /index.html;
105
75
  }
106
76
 
77
+ add_header X-Frame-Options "SAMEORIGIN" always;
78
+ add_header X-Content-Type-Options "nosniff" always;
79
+ add_header X-XSS-Protection "1; mode=block" always;
80
+
107
81
  location ~ /\. {
108
82
  deny all;
109
83
  }
@@ -1,254 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
- <title>Codex remote control</title>
7
- <style>
8
- *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
9
- :root {
10
- --bg: #FFFDF5;
11
- --bg-event: #F5F3ED;
12
- --bg-tool: #F0EDE6;
13
- --text: #1a1a1a;
14
- --text-muted: #8a8580;
15
- --accent: #0033FF;
16
- --danger: #b00020;
17
- --border: #E0DDD6;
18
- --user-bubble: #E8F0FE;
19
- --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
20
- --mono: ui-monospace, "SF Mono", Menlo, monospace;
21
- }
22
- html, body { height: 100%; font-family: var(--font); background: var(--bg); color: var(--text); }
23
- body { display: flex; flex-direction: column; }
24
- header { padding: 12px 16px; padding-top: calc(12px + env(safe-area-inset-top, 0px)); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; }
25
- header .id { flex: 1; font-size: 13px; color: var(--text-muted); font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
26
- header .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); }
27
- header .dot.online { background: #2ea44f; }
28
- header .dot.offline { background: var(--danger); }
29
- main { flex: 1; overflow-y: auto; padding: 16px; padding-bottom: 0; -webkit-overflow-scrolling: touch; }
30
- .event { margin-bottom: 12px; padding: 12px 14px; border-radius: 10px; background: var(--bg-event); font-size: 14px; line-height: 1.45; }
31
- .event .meta { font-size: 11px; color: var(--text-muted); font-family: var(--mono); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.05em; }
32
- .event.user { background: var(--user-bubble); }
33
- .event.agent_message { background: var(--bg); border: 1px solid var(--border); }
34
- .event.command_execution { background: var(--bg-tool); font-family: var(--mono); white-space: pre-wrap; word-break: break-all; }
35
- .event.command_execution.failed { border-left: 3px solid var(--danger); }
36
- .event.error { background: #fff0f0; border: 1px solid #f0c0c0; color: var(--danger); }
37
- .event.system { background: transparent; color: var(--text-muted); font-size: 12px; padding: 6px 0; text-align: center; }
38
- .event pre { font-family: var(--mono); white-space: pre-wrap; word-break: break-word; font-size: 13px; }
39
- footer { padding: 12px; padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px)); border-top: 1px solid var(--border); background: var(--bg); }
40
- .composer { display: flex; gap: 8px; align-items: flex-end; }
41
- textarea {
42
- flex: 1; min-height: 44px; max-height: 120px; padding: 12px;
43
- border: 1px solid var(--border); border-radius: 10px;
44
- background: var(--bg); color: var(--text); font-family: var(--font); font-size: 16px;
45
- resize: none;
46
- }
47
- textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
48
- button { padding: 12px 16px; border: none; border-radius: 10px; font-family: var(--font); font-size: 14px; font-weight: 600; cursor: pointer; -webkit-tap-highlight-color: transparent; }
49
- button:active { transform: scale(0.97); }
50
- button:disabled { opacity: 0.4; cursor: not-allowed; }
51
- .btn-send { background: var(--accent); color: white; }
52
- .btn-stop { background: var(--danger); color: white; }
53
- </style>
54
- </head>
55
- <body>
56
- <header>
57
- <div class="dot" id="presence" title="connecting"></div>
58
- <div class="id" id="threadId">...</div>
59
- <button id="stopBtn" class="btn-stop" type="button" disabled>Stop</button>
60
- </header>
61
- <main id="log"></main>
62
- <footer>
63
- <form class="composer" id="composer">
64
- <textarea id="prompt" rows="1" placeholder="Tell Codex what to do..." autocomplete="off"></textarea>
65
- <button type="submit" class="btn-send" id="sendBtn">Send</button>
66
- </form>
67
- </footer>
68
- <script>
69
- function getApiKey() { return sessionStorage.getItem("wip_api_key"); }
70
- function getHandle() { return sessionStorage.getItem("wip_handle") || ""; }
71
-
72
- function ensureSignedIn() {
73
- if (!getApiKey()) {
74
- location.href = "/app/login.html?next=" + encodeURIComponent(location.pathname);
75
- return false;
76
- }
77
- return true;
78
- }
79
-
80
- function parsePath() {
81
- // /:handle/codex-remote-control/:threadId
82
- const m = location.pathname.match(/^\/([^/]+)\/codex-remote-control\/([^/]+)\/?$/);
83
- if (!m) return null;
84
- return { handle: decodeURIComponent(m[1]), threadId: decodeURIComponent(m[2]) };
85
- }
86
-
87
- function setPresence(state) {
88
- const dot = document.getElementById("presence");
89
- dot.classList.remove("online", "offline");
90
- if (state === "online") dot.classList.add("online");
91
- if (state === "offline") dot.classList.add("offline");
92
- dot.title = state;
93
- }
94
-
95
- function appendEvent(html, kind) {
96
- const log = document.getElementById("log");
97
- const div = document.createElement("div");
98
- div.className = "event " + (kind || "");
99
- div.innerHTML = html;
100
- log.appendChild(div);
101
- log.scrollTop = log.scrollHeight;
102
- return div;
103
- }
104
-
105
- function escapeHtml(s) {
106
- return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[c]));
107
- }
108
-
109
- function renderItem(item) {
110
- if (item.type === "agent_message") {
111
- return appendEvent('<div class="meta">codex</div>' + escapeHtml(item.text || "").replace(/\n/g, "<br>"), "agent_message");
112
- }
113
- if (item.type === "command_execution") {
114
- const status = (item.status || "").toString();
115
- const out = item.aggregated_output ? '\n\n' + item.aggregated_output : "";
116
- return appendEvent(
117
- '<div class="meta">$ ' + escapeHtml(status) + (item.exit_code != null ? " (exit " + item.exit_code + ")" : "") + '</div>' +
118
- '<pre>' + escapeHtml(item.command || "") + escapeHtml(out) + '</pre>',
119
- "command_execution" + (status === "failed" ? " failed" : ""),
120
- );
121
- }
122
- if (item.type === "reasoning") {
123
- return appendEvent('<div class="meta">reasoning</div>' + escapeHtml(item.text || ""), "reasoning");
124
- }
125
- return appendEvent('<div class="meta">' + escapeHtml(item.type || "item") + '</div><pre>' + escapeHtml(JSON.stringify(item, null, 2)) + '</pre>', "item");
126
- }
127
-
128
- let ws = null;
129
- let pendingId = 1;
130
-
131
- function send(req) {
132
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
133
- ws.send(JSON.stringify(req));
134
- }
135
-
136
- function connect(threadId) {
137
- const apiKey = getApiKey();
138
- const proto = location.protocol === "https:" ? "wss:" : "ws:";
139
- const url = proto + "//" + location.host + "/api/codex-relay/web/" + encodeURIComponent(threadId) + "?token=" + encodeURIComponent(apiKey);
140
- ws = new WebSocket(url);
141
-
142
- ws.addEventListener("open", () => {
143
- setPresence("online");
144
- appendEvent("connected. open this thread in Codex on your Mac if it's not already.", "system");
145
- });
146
-
147
- ws.addEventListener("close", (ev) => {
148
- setPresence("offline");
149
- appendEvent("disconnected (code " + ev.code + ")", "system");
150
- });
151
-
152
- ws.addEventListener("error", () => {
153
- setPresence("offline");
154
- });
155
-
156
- ws.addEventListener("message", (ev) => {
157
- let msg;
158
- try { msg = JSON.parse(ev.data); } catch { return; }
159
- if (msg.type === "session.started") {
160
- // Daemon assigned a temp id; the real thread id will arrive in thread.started.
161
- return;
162
- }
163
- if (msg.type === "session.event") {
164
- const evt = msg.event || {};
165
- if (evt.type === "thread.started") {
166
- // ok
167
- return;
168
- }
169
- if (evt.type === "turn.started") {
170
- document.getElementById("stopBtn").disabled = false;
171
- return;
172
- }
173
- if (evt.type === "item.completed" && evt.item) {
174
- renderItem(evt.item);
175
- return;
176
- }
177
- if (evt.type === "item.started") {
178
- return; // skip; we render on completed for now
179
- }
180
- if (evt.type === "turn.completed") {
181
- document.getElementById("stopBtn").disabled = true;
182
- const u = evt.usage;
183
- if (u) appendEvent("turn complete (" + (u.input_tokens || 0) + " in / " + (u.output_tokens || 0) + " out)", "system");
184
- else appendEvent("turn complete", "system");
185
- return;
186
- }
187
- if (evt.type === "turn.failed") {
188
- document.getElementById("stopBtn").disabled = true;
189
- appendEvent("turn failed: " + (evt.error && evt.error.message ? evt.error.message : "unknown"), "error");
190
- return;
191
- }
192
- return;
193
- }
194
- if (msg.type === "ack") return;
195
- if (msg.type === "error") {
196
- appendEvent("error: " + (msg.message || ""), "error");
197
- return;
198
- }
199
- });
200
- }
201
-
202
- function init() {
203
- if (!ensureSignedIn()) return;
204
- const parsed = parsePath();
205
- if (!parsed) {
206
- appendEvent("Invalid URL. Expected /<handle>/codex-remote-control/<thread-id>.", "error");
207
- return;
208
- }
209
- document.getElementById("threadId").textContent = parsed.threadId;
210
- connect(parsed.threadId);
211
-
212
- // Open or attach to the session on the daemon. session.start returns a temp
213
- // sessionId; the actual thread.id arrives via thread.started in the stream.
214
- setTimeout(() => {
215
- send({ type: "session.start", id: "open-" + (pendingId += 1) });
216
- }, 250);
217
-
218
- document.getElementById("composer").addEventListener("submit", (ev) => {
219
- ev.preventDefault();
220
- const input = document.getElementById("prompt");
221
- const text = input.value.trim();
222
- if (!text) return;
223
- input.value = "";
224
- appendEvent('<div class="meta">you</div>' + escapeHtml(text), "user");
225
- send({
226
- type: "session.send",
227
- id: "send-" + (pendingId += 1),
228
- sessionId: parsed.threadId,
229
- prompt: text,
230
- });
231
- });
232
-
233
- document.getElementById("stopBtn").addEventListener("click", () => {
234
- send({ type: "session.interrupt", id: "stop-" + (pendingId += 1), sessionId: parsed.threadId });
235
- });
236
-
237
- // Submit on Cmd+Enter / Ctrl+Enter; auto-resize.
238
- const ta = document.getElementById("prompt");
239
- ta.addEventListener("input", () => {
240
- ta.style.height = "auto";
241
- ta.style.height = Math.min(120, ta.scrollHeight) + "px";
242
- });
243
- ta.addEventListener("keydown", (ev) => {
244
- if ((ev.metaKey || ev.ctrlKey) && ev.key === "Enter") {
245
- ev.preventDefault();
246
- document.getElementById("composer").requestSubmit();
247
- }
248
- });
249
- }
250
-
251
- init();
252
- </script>
253
- </body>
254
- </html>