@wipcomputer/wip-ldm-os 0.4.84 → 0.4.85-alpha.2
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 +178 -4
- package/lib/deploy.mjs +4 -5
- package/package.json +2 -1
- package/scripts/test-installer-hook-toolname.mjs +80 -0
- package/src/hosted-mcp/nginx/codex-relay.conf +109 -0
- package/src/hosted-mcp/nginx/wip.computer.conf +50 -76
- package/src/hosted-mcp/app/codex-remote-control/index.html +0 -254
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 ${
|
|
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 ${
|
|
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
|
-
|
|
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/lib/deploy.mjs
CHANGED
|
@@ -1097,18 +1097,18 @@ function registerMCP(repoPath, door, toolName) {
|
|
|
1097
1097
|
*
|
|
1098
1098
|
* Returns true if at least one door installed successfully.
|
|
1099
1099
|
*/
|
|
1100
|
-
function installClaudeCodeHook(repoPath, doorOrDoors) {
|
|
1100
|
+
function installClaudeCodeHook(repoPath, doorOrDoors, toolName = basename(repoPath)) {
|
|
1101
1101
|
const doors = Array.isArray(doorOrDoors) ? doorOrDoors : [doorOrDoors];
|
|
1102
1102
|
let anyOk = false;
|
|
1103
1103
|
for (const door of doors) {
|
|
1104
|
-
if (installClaudeCodeHookEvent(repoPath, door)) {
|
|
1104
|
+
if (installClaudeCodeHookEvent(repoPath, door, toolName)) {
|
|
1105
1105
|
anyOk = true;
|
|
1106
1106
|
}
|
|
1107
1107
|
}
|
|
1108
1108
|
return anyOk;
|
|
1109
1109
|
}
|
|
1110
1110
|
|
|
1111
|
-
function installClaudeCodeHookEvent(repoPath, door) {
|
|
1111
|
+
function installClaudeCodeHookEvent(repoPath, door, toolName = basename(repoPath)) {
|
|
1112
1112
|
const settingsPath = join(HOME, '.claude', 'settings.json');
|
|
1113
1113
|
let settings = readJSON(settingsPath);
|
|
1114
1114
|
|
|
@@ -1117,7 +1117,6 @@ function installClaudeCodeHookEvent(repoPath, door) {
|
|
|
1117
1117
|
return false;
|
|
1118
1118
|
}
|
|
1119
1119
|
|
|
1120
|
-
const toolName = basename(repoPath);
|
|
1121
1120
|
const extDir = join(LDM_EXTENSIONS, toolName);
|
|
1122
1121
|
const installedGuard = join(extDir, 'guard.mjs');
|
|
1123
1122
|
|
|
@@ -1422,7 +1421,7 @@ export function installSingleTool(toolPath) {
|
|
|
1422
1421
|
|
|
1423
1422
|
if (interfaces.claudeCodeHook) {
|
|
1424
1423
|
if (isEnabled || isAlreadyDeployed) {
|
|
1425
|
-
if (installClaudeCodeHook(toolPath, interfaces.claudeCodeHook)) installed++;
|
|
1424
|
+
if (installClaudeCodeHook(toolPath, interfaces.claudeCodeHook, toolName)) installed++;
|
|
1426
1425
|
} else {
|
|
1427
1426
|
skip(`Hook: ${toolName} not enabled`);
|
|
1428
1427
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wipcomputer/wip-ldm-os",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.85-alpha.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
|
|
6
6
|
"engines": {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"validate:bin-manifest": "node scripts/validate-bin-manifest.mjs",
|
|
23
23
|
"test:skill-frontmatter": "node scripts/test-skill-frontmatter.mjs",
|
|
24
24
|
"test:installer-update-tracks": "node scripts/test-installer-update-tracks.mjs",
|
|
25
|
+
"test:installer-hook-toolname": "node scripts/test-installer-hook-toolname.mjs",
|
|
25
26
|
"test:ldm-install-bin-shim": "node scripts/test-ldm-install-preserves-foreign-bin.mjs",
|
|
26
27
|
"test:doctor-cron-target": "node scripts/test-doctor-cron-target.mjs",
|
|
27
28
|
"test:bin-manifest": "node scripts/test-bin-manifest.mjs",
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
|
|
6
|
+
const tempHome = mkdtempSync(join(tmpdir(), 'ldm-hook-toolname-home-'));
|
|
7
|
+
const tempPkg = mkdtempSync(join(tmpdir(), 'ldm-npm-pack-'));
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
process.env.HOME = tempHome;
|
|
11
|
+
|
|
12
|
+
mkdirSync(join(tempHome, '.claude'), { recursive: true });
|
|
13
|
+
writeFileSync(join(tempHome, '.claude', 'settings.json'), JSON.stringify({ hooks: {} }, null, 2) + '\n');
|
|
14
|
+
const staleExtDir = join(tempHome, '.ldm', 'extensions', 'wip-branch-guard');
|
|
15
|
+
mkdirSync(staleExtDir, { recursive: true });
|
|
16
|
+
writeFileSync(join(staleExtDir, 'guard.mjs'), 'console.log("stale guard");\n');
|
|
17
|
+
writeFileSync(join(staleExtDir, 'package.json'), JSON.stringify({
|
|
18
|
+
name: '@wipcomputer/wip-branch-guard',
|
|
19
|
+
version: '1.9.89',
|
|
20
|
+
}, null, 2) + '\n');
|
|
21
|
+
writeFileSync(join(tempHome, '.ldm', 'extensions', 'registry.json'), JSON.stringify({
|
|
22
|
+
_format: 'v2',
|
|
23
|
+
extensions: {
|
|
24
|
+
'wip-branch-guard': {
|
|
25
|
+
version: '1.9.89',
|
|
26
|
+
ldmPath: staleExtDir,
|
|
27
|
+
paths: { ldm: staleExtDir },
|
|
28
|
+
interfaces: ['module', 'skill', 'claudeCodeHook'],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
}, null, 2) + '\n');
|
|
32
|
+
|
|
33
|
+
const extractedPackageDir = join(tempPkg, 'package');
|
|
34
|
+
mkdirSync(extractedPackageDir, { recursive: true });
|
|
35
|
+
writeFileSync(join(extractedPackageDir, 'package.json'), JSON.stringify({
|
|
36
|
+
name: '@wipcomputer/wip-branch-guard',
|
|
37
|
+
version: '1.9.90',
|
|
38
|
+
type: 'module',
|
|
39
|
+
main: 'guard.mjs',
|
|
40
|
+
claudeCode: {
|
|
41
|
+
hooks: [
|
|
42
|
+
{ event: 'PreToolUse', matcher: 'Write|Edit|Bash', command: 'node guard.mjs', timeout: 5 },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
}, null, 2) + '\n');
|
|
46
|
+
writeFileSync(join(extractedPackageDir, 'guard.mjs'), 'console.log("guard 1.9.90");\n');
|
|
47
|
+
writeFileSync(join(extractedPackageDir, 'SKILL.md'), '---\nname: wip-branch-guard\ndescription: "test skill"\n---\n');
|
|
48
|
+
|
|
49
|
+
const { installSingleTool } = await import('../lib/deploy.mjs');
|
|
50
|
+
const installed = installSingleTool(extractedPackageDir);
|
|
51
|
+
if (installed === 0) throw new Error('installer did not process the test package');
|
|
52
|
+
|
|
53
|
+
const expectedDir = join(tempHome, '.ldm', 'extensions', 'wip-branch-guard');
|
|
54
|
+
const wrongDir = join(tempHome, '.ldm', 'extensions', 'package');
|
|
55
|
+
if (!existsSync(join(expectedDir, 'guard.mjs'))) {
|
|
56
|
+
throw new Error('guard.mjs was not deployed under the package-derived tool name');
|
|
57
|
+
}
|
|
58
|
+
if (!existsSync(join(expectedDir, 'package.json'))) {
|
|
59
|
+
throw new Error('package.json was not deployed under the package-derived tool name');
|
|
60
|
+
}
|
|
61
|
+
if (existsSync(wrongDir)) {
|
|
62
|
+
throw new Error('hook deployment used basename(repoPath) instead of package-derived tool name');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const settings = JSON.parse(readFileSync(join(tempHome, '.claude', 'settings.json'), 'utf8'));
|
|
66
|
+
const command = settings.hooks?.PreToolUse?.[0]?.hooks?.[0]?.command || '';
|
|
67
|
+
if (!command.includes('/wip-branch-guard/guard.mjs')) {
|
|
68
|
+
throw new Error(`hook command points at the wrong extension path: ${command}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const deployedPkg = JSON.parse(readFileSync(join(expectedDir, 'package.json'), 'utf8'));
|
|
72
|
+
if (deployedPkg.version !== '1.9.90') {
|
|
73
|
+
throw new Error(`deployed package version mismatch: ${deployedPkg.version}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.log('installer hook tool-name regression check passed');
|
|
77
|
+
} finally {
|
|
78
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
79
|
+
rmSync(tempPkg, { recursive: true, force: true });
|
|
80
|
+
}
|
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
# Docs redirect to docs.wip.computer
|
|
11
|
+
location = /doc {
|
|
12
|
+
return 301 https://docs.wip.computer;
|
|
13
13
|
}
|
|
14
|
-
|
|
15
|
-
|
|
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 /
|
|
37
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
#
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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) => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[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>
|