@jefferylau/euphony-skills 0.1.0
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/LICENSE +21 -0
- package/README.md +231 -0
- package/README_CN.md +231 -0
- package/bin/euphony-skills.mjs +221 -0
- package/package.json +25 -0
- package/scripts/install-codebuddy.sh +4 -0
- package/scripts/install-codex.sh +4 -0
- package/skills/codebuddy-euphony/SKILL.md +81 -0
- package/skills/codebuddy-euphony/scripts/codebuddy-euphony.mjs +646 -0
- package/skills/codex-euphony/SKILL.md +146 -0
- package/skills/codex-euphony/agents/openai.yaml +4 -0
- package/skills/codex-euphony/scripts/codex-euphony.mjs +468 -0
- package/skills/codex-euphony/scripts/codex-euphony.sh +5 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: codex-euphony
|
|
3
|
+
description: View local Codex session JSONL logs in OpenAI Euphony. Use when the user asks to inspect, visualize, open, browse, debug, or share their local Codex conversations/sessions/logs/history with Euphony, including finding the latest ~/.codex/sessions rollout file, starting the local Euphony dev server, staging a session for browser loading, or explaining how to load a Codex JSONL file into Euphony.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Codex Euphony
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Use this skill to inspect local Codex session JSONL files with Euphony.
|
|
11
|
+
The skill manages Euphony as a disposable runtime checkout under `${CODEX_HOME:-$HOME/.codex}/cache/euphony` by default.
|
|
12
|
+
Local Codex rollouts live under `${CODEX_HOME:-$HOME/.codex}/sessions` by default.
|
|
13
|
+
The primary script is `scripts/codex-euphony.mjs` and works on macOS, Linux, and Windows with Node.js 18+.
|
|
14
|
+
`scripts/codex-euphony.sh` is only a Unix compatibility wrapper around the Node script.
|
|
15
|
+
|
|
16
|
+
If the Euphony cache is deleted, the script recreates it on the next command that needs Euphony.
|
|
17
|
+
If `node_modules` is deleted, the script reruns `pnpm install`.
|
|
18
|
+
The script prefers a directly installed `pnpm`; if only Corepack is available, it runs `corepack pnpm` with `COREPACK_INTEGRITY_KEYS=0` for that subprocess to avoid known Corepack pnpm signature bootstrap failures during first startup.
|
|
19
|
+
|
|
20
|
+
## Workflow
|
|
21
|
+
|
|
22
|
+
1. Find the latest Codex session without starting Euphony:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs latest
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
2. For normal user requests, open the latest session directly in the default browser:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs open
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
This is the default path for requests like "open Euphony", "show my latest Codex session", or "view this session in Euphony".
|
|
35
|
+
It ensures Euphony is installed and running, stages the latest session, prints the load URL, and opens it in the system browser.
|
|
36
|
+
|
|
37
|
+
3. If the user only wants a URL, or GUI opening is unavailable, print a browser URL instead:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs url
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
This ensures Euphony is installed and running, stages the latest session, and prints a URL like:
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
http://127.0.0.1:3000/?path=http://127.0.0.1:3000/local-codex/latest.jsonl&no-cache=true
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
4. If Euphony is already running and you only need to refresh the staged JSONL, use the lightweight path:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs stage
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
This avoids restarting Euphony and normally avoids escalation.
|
|
56
|
+
|
|
57
|
+
5. Check whether Euphony is already running:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs status
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
6. Use staging without starting or opening Euphony only when Euphony is already running:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs stage
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
This stages the latest session at `$EUPHONY_DIR/public/local-codex/latest.jsonl`.
|
|
70
|
+
By default, staging uses a symlink to the live rollout file on macOS/Linux so browser refreshes can read newly appended turns.
|
|
71
|
+
On Windows, staging defaults to copying because symlink creation often requires extra privileges.
|
|
72
|
+
Set `EUPHONY_STAGE_MODE=copy` to force snapshot copying.
|
|
73
|
+
The skill starts Euphony with `VITE_EUPHONY_FRONTEND_ONLY_MAX_LINES=100000` by default because Codex rollout JSONL files often exceed Euphony's older frontend-only 100-line default.
|
|
74
|
+
Staging is intended for a local Euphony server bound to `127.0.0.1`.
|
|
75
|
+
Codex session files can contain private prompts, file paths, tool outputs, and secrets.
|
|
76
|
+
|
|
77
|
+
The staged local-codex path is needed for URL loading because browser pages cannot fetch arbitrary local files.
|
|
78
|
+
To avoid the copy, use Euphony's `Load local file` button manually.
|
|
79
|
+
|
|
80
|
+
7. Start Euphony when it is not running:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs up
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Use foreground startup only when explicitly useful for debugging:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs start
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
8. Verify with:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs status
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Report the printed load URL to the user after `HTTP/1.1 200 OK`, or after the script reports that Euphony is listening.
|
|
99
|
+
|
|
100
|
+
9. Stop Euphony when the user is done:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs stop
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The stop command kills the Euphony process tracked by this script's pid file.
|
|
107
|
+
|
|
108
|
+
## Sandbox And Escalation
|
|
109
|
+
|
|
110
|
+
Do not run heavy commands once in the sandbox just to discover that they need escalation.
|
|
111
|
+
|
|
112
|
+
- `open` is the default user-facing command. It may need escalation in Codex sandbox because it can clone from GitHub, install dependencies, bind a local TCP port, and open a GUI browser.
|
|
113
|
+
- `list`, `latest`, `status`, and `stage` are lightweight local commands and normally do not need escalation.
|
|
114
|
+
- `url`, `up`, `start`, `restart`, and first-time `ensure` may need escalation in Codex sandbox because they can clone from GitHub, install dependencies, or bind a local TCP port.
|
|
115
|
+
- If GUI opening is blocked or not desired, use `url` and report the printed link.
|
|
116
|
+
|
|
117
|
+
If startup fails with `listen EPERM`, rerun the same startup command with escalated permissions because this environment may block local TCP listeners inside the sandbox.
|
|
118
|
+
|
|
119
|
+
## Manual Loading
|
|
120
|
+
|
|
121
|
+
If staging is not desired, tell the user to open `http://127.0.0.1:3000/`, click `Load local file`, and choose the JSONL path from `latest`.
|
|
122
|
+
On macOS, `Cmd+Shift+G` in the file picker allows pasting the path directly.
|
|
123
|
+
|
|
124
|
+
Do not tell the user to paste a local absolute path into Euphony's top URL box. Browser pages cannot fetch arbitrary `file://` or `/Users/...` paths directly.
|
|
125
|
+
|
|
126
|
+
## Script Commands
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs list
|
|
130
|
+
node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs latest
|
|
131
|
+
node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs status
|
|
132
|
+
node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs ensure
|
|
133
|
+
node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs stage [session-jsonl]
|
|
134
|
+
node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs url [session-jsonl]
|
|
135
|
+
node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs open [session-jsonl]
|
|
136
|
+
node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs up
|
|
137
|
+
node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs start
|
|
138
|
+
node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs stop
|
|
139
|
+
node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs restart
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Use environment overrides only when needed:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
EUPHONY_DIR=/path/to/euphony CODEX_SESSIONS_DIR=/path/to/sessions node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs latest
|
|
146
|
+
```
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import http from 'node:http';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const isWindows = process.platform === 'win32';
|
|
9
|
+
const home = os.homedir();
|
|
10
|
+
const codexHome = process.env.CODEX_HOME || path.join(home, '.codex');
|
|
11
|
+
const sessionsDir = process.env.CODEX_SESSIONS_DIR || path.join(codexHome, 'sessions');
|
|
12
|
+
const euphonyDir = process.env.EUPHONY_DIR || path.join(codexHome, 'cache', 'euphony');
|
|
13
|
+
const euphonyRepo = process.env.EUPHONY_REPO || 'https://github.com/openai/euphony.git';
|
|
14
|
+
const host = process.env.EUPHONY_HOST || '127.0.0.1';
|
|
15
|
+
const port = Number(process.env.EUPHONY_PORT || '3000');
|
|
16
|
+
const baseUrl = `http://${host}:${port}/`;
|
|
17
|
+
const runDir = process.env.EUPHONY_RUN_DIR || path.join(euphonyDir, '.codex-euphony');
|
|
18
|
+
const pidFile = path.join(runDir, 'vite.pid');
|
|
19
|
+
const logFile = path.join(runDir, 'vite.log');
|
|
20
|
+
const maxLines = process.env.EUPHONY_FRONTEND_ONLY_MAX_LINES || '100000';
|
|
21
|
+
const stageMode = process.env.EUPHONY_STAGE_MODE || (isWindows ? 'copy' : 'symlink');
|
|
22
|
+
const stagedDir = path.join(euphonyDir, 'public', 'local-codex');
|
|
23
|
+
const stagedJsonl = path.join(stagedDir, 'latest.jsonl');
|
|
24
|
+
const stagedSource = path.join(stagedDir, 'latest-source.txt');
|
|
25
|
+
|
|
26
|
+
function usage() {
|
|
27
|
+
console.log(`Usage: ${path.basename(process.argv[1])} <command> [session-jsonl]
|
|
28
|
+
|
|
29
|
+
Commands:
|
|
30
|
+
list List recent Codex session JSONL files.
|
|
31
|
+
latest Print the newest Codex session JSONL path.
|
|
32
|
+
status Check whether Euphony responds.
|
|
33
|
+
ensure Ensure the Euphony runtime checkout and dependencies exist.
|
|
34
|
+
stage [file] Stage a session JSONL into Euphony public/local-codex/latest.jsonl and print a load URL.
|
|
35
|
+
url [file] Ensure Euphony is running, stage a session, and print the load URL.
|
|
36
|
+
open [file] Ensure Euphony is running, stage a session, and open the load URL in the browser.
|
|
37
|
+
up Start Euphony in the background if it is not already running.
|
|
38
|
+
start Start Euphony Vite dev server in the foreground.
|
|
39
|
+
stop Stop the Euphony Vite server started by this script.
|
|
40
|
+
restart Stop then start Euphony in the background.
|
|
41
|
+
|
|
42
|
+
Environment:
|
|
43
|
+
CODEX_HOME Default: ~/.codex
|
|
44
|
+
CODEX_SESSIONS_DIR Default: $CODEX_HOME/sessions
|
|
45
|
+
EUPHONY_DIR Default: $CODEX_HOME/cache/euphony
|
|
46
|
+
EUPHONY_HOST Default: 127.0.0.1
|
|
47
|
+
EUPHONY_PORT Default: 3000
|
|
48
|
+
EUPHONY_RUN_DIR Default: $EUPHONY_DIR/.codex-euphony
|
|
49
|
+
EUPHONY_REPO Default: https://github.com/openai/euphony.git
|
|
50
|
+
EUPHONY_STAGE_MODE Default: ${isWindows ? 'copy on Windows, symlink elsewhere' : 'symlink'}
|
|
51
|
+
EUPHONY_FRONTEND_ONLY_MAX_LINES
|
|
52
|
+
Default: 100000`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function fail(message) {
|
|
56
|
+
console.error(message);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function commandExists(command) {
|
|
61
|
+
try {
|
|
62
|
+
if (isWindows) {
|
|
63
|
+
execFileSync('where', [command], { stdio: 'ignore', shell: true });
|
|
64
|
+
} else {
|
|
65
|
+
execFileSync('which', [command], { stdio: 'ignore' });
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function requireCommand(command) {
|
|
74
|
+
if (!commandExists(command)) fail(`${command} is required for this command.`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function run(command, args, options = {}) {
|
|
78
|
+
return execFileSync(command, args, {
|
|
79
|
+
...options,
|
|
80
|
+
shell: isWindows && command === 'corepack',
|
|
81
|
+
stdio: options.stdio || 'inherit'
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function packageRunner() {
|
|
86
|
+
if (commandExists('pnpm')) {
|
|
87
|
+
return { command: 'pnpm', argsPrefix: [], env: process.env, shell: isWindows };
|
|
88
|
+
}
|
|
89
|
+
requireCommand('corepack');
|
|
90
|
+
return {
|
|
91
|
+
command: 'corepack',
|
|
92
|
+
argsPrefix: ['pnpm'],
|
|
93
|
+
env: {
|
|
94
|
+
...process.env,
|
|
95
|
+
COREPACK_INTEGRITY_KEYS: process.env.COREPACK_INTEGRITY_KEYS || '0'
|
|
96
|
+
},
|
|
97
|
+
shell: isWindows
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function runPnpm(args, options = {}) {
|
|
102
|
+
const runner = packageRunner();
|
|
103
|
+
if (runner.command === 'corepack' && !process.env.COREPACK_INTEGRITY_KEYS) {
|
|
104
|
+
console.log('Using Corepack with COREPACK_INTEGRITY_KEYS=0 to avoid known pnpm signature bootstrap failures.');
|
|
105
|
+
}
|
|
106
|
+
return execFileSync(runner.command, [...runner.argsPrefix, ...args], {
|
|
107
|
+
...options,
|
|
108
|
+
env: runner.env,
|
|
109
|
+
shell: runner.shell,
|
|
110
|
+
stdio: options.stdio || 'inherit'
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function spawnPnpm(args, options = {}) {
|
|
115
|
+
const runner = packageRunner();
|
|
116
|
+
return spawn(runner.command, [...runner.argsPrefix, ...args], {
|
|
117
|
+
...options,
|
|
118
|
+
env: {
|
|
119
|
+
...runner.env,
|
|
120
|
+
...(options.env || {})
|
|
121
|
+
},
|
|
122
|
+
shell: runner.shell
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function requireSessionsDir() {
|
|
127
|
+
if (!fs.existsSync(sessionsDir) || !fs.statSync(sessionsDir).isDirectory()) {
|
|
128
|
+
fail(`Codex sessions directory not found: ${sessionsDir}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function patchEuphonyFrontendLimit() {
|
|
133
|
+
const apiManager = path.join(euphonyDir, 'src', 'utils', 'api-manager.ts');
|
|
134
|
+
if (!fs.existsSync(apiManager)) return;
|
|
135
|
+
const oldText = fs.readFileSync(apiManager, 'utf8');
|
|
136
|
+
if (oldText.includes('VITE_EUPHONY_FRONTEND_ONLY_MAX_LINES')) return;
|
|
137
|
+
const needle =
|
|
138
|
+
'// The maximum number of lines in a JSONL file to read in frontend-only mode\nconst FRONTEND_ONLY_MODE_MAX_LINES = 100;';
|
|
139
|
+
if (!oldText.includes(needle)) return;
|
|
140
|
+
const replacement =
|
|
141
|
+
"// The maximum number of lines in a JSONL file to read in frontend-only mode.\n" +
|
|
142
|
+
'// Codex rollout files are event streams and routinely exceed 100 lines, so keep\n' +
|
|
143
|
+
'// the default high while allowing local deployments to lower it.\n' +
|
|
144
|
+
'const FRONTEND_ONLY_MODE_MAX_LINES = Number.parseInt(\n' +
|
|
145
|
+
" (import.meta.env.VITE_EUPHONY_FRONTEND_ONLY_MAX_LINES as string) || '100000',\n" +
|
|
146
|
+
' 10\n' +
|
|
147
|
+
');';
|
|
148
|
+
fs.writeFileSync(apiManager, oldText.replace(needle, replacement));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function ensureEuphonyDir() {
|
|
152
|
+
const packageJson = path.join(euphonyDir, 'package.json');
|
|
153
|
+
if (fs.existsSync(packageJson)) {
|
|
154
|
+
patchEuphonyFrontendLimit();
|
|
155
|
+
if (!fs.existsSync(path.join(euphonyDir, 'node_modules'))) {
|
|
156
|
+
console.log(`Installing Euphony dependencies in ${euphonyDir}...`);
|
|
157
|
+
runPnpm(['install'], { cwd: euphonyDir });
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (fs.existsSync(euphonyDir)) {
|
|
163
|
+
fail(`EUPHONY_DIR exists but is not an Euphony checkout: ${euphonyDir}
|
|
164
|
+
Remove it or set EUPHONY_DIR to another path.`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
requireCommand('git');
|
|
168
|
+
fs.mkdirSync(path.dirname(euphonyDir), { recursive: true });
|
|
169
|
+
console.log(`Cloning Euphony into ${euphonyDir}...`);
|
|
170
|
+
run('git', ['clone', euphonyRepo, euphonyDir]);
|
|
171
|
+
patchEuphonyFrontendLimit();
|
|
172
|
+
console.log(`Installing Euphony dependencies in ${euphonyDir}...`);
|
|
173
|
+
runPnpm(['install'], { cwd: euphonyDir });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function walkJsonlFiles(dir, out = []) {
|
|
177
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
178
|
+
if (entry.name === '.DS_Store') continue;
|
|
179
|
+
const fullPath = path.join(dir, entry.name);
|
|
180
|
+
if (entry.isDirectory()) {
|
|
181
|
+
walkJsonlFiles(fullPath, out);
|
|
182
|
+
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
183
|
+
out.push(fullPath);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function recentSessions() {
|
|
190
|
+
requireSessionsDir();
|
|
191
|
+
return walkJsonlFiles(sessionsDir)
|
|
192
|
+
.map(file => ({ file, mtimeMs: fs.statSync(file).mtimeMs }))
|
|
193
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function latestSession() {
|
|
197
|
+
const [latest] = recentSessions();
|
|
198
|
+
if (!latest) fail(`No Codex session JSONL files found under: ${sessionsDir}`);
|
|
199
|
+
return latest.file;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function stageSession(input = latestSession()) {
|
|
203
|
+
ensureEuphonyDir();
|
|
204
|
+
const source = path.resolve(input);
|
|
205
|
+
if (!fs.existsSync(source)) fail(`Session file not found: ${source}`);
|
|
206
|
+
fs.mkdirSync(stagedDir, { recursive: true });
|
|
207
|
+
if (stageMode === 'symlink') {
|
|
208
|
+
try {
|
|
209
|
+
fs.rmSync(stagedJsonl, { force: true });
|
|
210
|
+
fs.symlinkSync(source, stagedJsonl, 'file');
|
|
211
|
+
} catch {
|
|
212
|
+
fs.copyFileSync(source, stagedJsonl);
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
fs.copyFileSync(source, stagedJsonl);
|
|
216
|
+
}
|
|
217
|
+
fs.writeFileSync(stagedSource, `${source}\n`);
|
|
218
|
+
const url = `${baseUrl}?path=${baseUrl}local-codex/latest.jsonl&no-cache=true`;
|
|
219
|
+
console.log(`Staged: ${source}`);
|
|
220
|
+
console.log(`Open: ${url}`);
|
|
221
|
+
return url;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function requestHead(url, timeoutMs = 2000) {
|
|
225
|
+
return new Promise(resolve => {
|
|
226
|
+
const request = http.request(url, { method: 'HEAD', timeout: timeoutMs }, response => {
|
|
227
|
+
response.resume();
|
|
228
|
+
resolve(response.statusCode >= 200 && response.statusCode < 500);
|
|
229
|
+
});
|
|
230
|
+
request.on('timeout', () => {
|
|
231
|
+
request.destroy();
|
|
232
|
+
resolve(false);
|
|
233
|
+
});
|
|
234
|
+
request.on('error', () => resolve(false));
|
|
235
|
+
request.end();
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function readTrackedPid() {
|
|
240
|
+
if (!fs.existsSync(pidFile)) return null;
|
|
241
|
+
const text = fs.readFileSync(pidFile, 'utf8').trim();
|
|
242
|
+
if (!/^\d+$/.test(text)) return null;
|
|
243
|
+
return Number(text);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function pidExists(pid) {
|
|
247
|
+
if (!pid) return false;
|
|
248
|
+
try {
|
|
249
|
+
process.kill(pid, 0);
|
|
250
|
+
return true;
|
|
251
|
+
} catch {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function trackedPid() {
|
|
257
|
+
const pid = readTrackedPid();
|
|
258
|
+
return pidExists(pid) ? pid : null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function legacyCheckoutPids() {
|
|
262
|
+
if (isWindows || !commandExists('lsof')) return [];
|
|
263
|
+
try {
|
|
264
|
+
const output = execFileSync('lsof', ['-nP', `-tiTCP:${port}`, '-sTCP:LISTEN'], {
|
|
265
|
+
encoding: 'utf8',
|
|
266
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
267
|
+
});
|
|
268
|
+
return output
|
|
269
|
+
.split(/\r?\n/)
|
|
270
|
+
.filter(Boolean)
|
|
271
|
+
.filter(pid => {
|
|
272
|
+
try {
|
|
273
|
+
const cwdOutput = execFileSync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], {
|
|
274
|
+
encoding: 'utf8',
|
|
275
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
276
|
+
});
|
|
277
|
+
const cwd = cwdOutput
|
|
278
|
+
.split(/\r?\n/)
|
|
279
|
+
.find(line => line.startsWith('n'))
|
|
280
|
+
?.slice(1);
|
|
281
|
+
return cwd === euphonyDir;
|
|
282
|
+
} catch {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
.map(Number);
|
|
287
|
+
} catch {
|
|
288
|
+
return [];
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function trackedOrAdoptedPid() {
|
|
293
|
+
const pid = trackedPid();
|
|
294
|
+
if (pid) return pid;
|
|
295
|
+
const [legacyPid] = legacyCheckoutPids();
|
|
296
|
+
if (!legacyPid) return null;
|
|
297
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
298
|
+
fs.writeFileSync(pidFile, `${legacyPid}\n`);
|
|
299
|
+
return legacyPid;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function startViteBackground() {
|
|
303
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
304
|
+
const logFd = fs.openSync(logFile, 'a');
|
|
305
|
+
const child = spawnPnpm(['exec', 'vite', '--host', host, '--port', String(port)], {
|
|
306
|
+
cwd: euphonyDir,
|
|
307
|
+
detached: true,
|
|
308
|
+
env: {
|
|
309
|
+
VITE_EUPHONY_FRONTEND_ONLY: 'true',
|
|
310
|
+
VITE_EUPHONY_FRONTEND_ONLY_MAX_LINES: maxLines
|
|
311
|
+
},
|
|
312
|
+
stdio: ['ignore', logFd, logFd]
|
|
313
|
+
});
|
|
314
|
+
child.unref();
|
|
315
|
+
fs.writeFileSync(pidFile, `${child.pid}\n`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function up() {
|
|
319
|
+
const existingPid = trackedOrAdoptedPid();
|
|
320
|
+
if (await requestHead(baseUrl)) {
|
|
321
|
+
if (existingPid) {
|
|
322
|
+
ensureEuphonyDir();
|
|
323
|
+
console.log(`Euphony responds at ${baseUrl}`);
|
|
324
|
+
console.log(`PID: ${existingPid}`);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
fail(`Port ${port} responds at ${baseUrl}, but this script has no live pid file for it.
|
|
328
|
+
Stop the other server or set EUPHONY_PORT to another value.`);
|
|
329
|
+
}
|
|
330
|
+
ensureEuphonyDir();
|
|
331
|
+
if (!existingPid) startViteBackground();
|
|
332
|
+
console.log(`Starting Euphony at ${baseUrl}`);
|
|
333
|
+
console.log(`Log: ${logFile}`);
|
|
334
|
+
for (let i = 0; i < 300; i += 1) {
|
|
335
|
+
if (await requestHead(baseUrl)) {
|
|
336
|
+
console.log(`Euphony is ready at ${baseUrl}`);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
340
|
+
}
|
|
341
|
+
fail(`Euphony did not respond at ${baseUrl}. Check ${logFile}`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function startForeground() {
|
|
345
|
+
ensureEuphonyDir();
|
|
346
|
+
const child = spawnPnpm(['exec', 'vite', '--host', host, '--port', String(port)], {
|
|
347
|
+
cwd: euphonyDir,
|
|
348
|
+
env: {
|
|
349
|
+
VITE_EUPHONY_FRONTEND_ONLY: 'true',
|
|
350
|
+
VITE_EUPHONY_FRONTEND_ONLY_MAX_LINES: maxLines
|
|
351
|
+
},
|
|
352
|
+
stdio: 'inherit'
|
|
353
|
+
});
|
|
354
|
+
child.on('exit', code => process.exit(code ?? 0));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function killProcessTree(pid) {
|
|
358
|
+
if (isWindows) {
|
|
359
|
+
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', shell: true });
|
|
360
|
+
} else {
|
|
361
|
+
process.kill(pid, 'SIGTERM');
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function stop() {
|
|
366
|
+
const pid = trackedOrAdoptedPid();
|
|
367
|
+
if (!pid) {
|
|
368
|
+
if (fs.existsSync(pidFile)) fs.rmSync(pidFile, { force: true });
|
|
369
|
+
console.log('No tracked Euphony process found.');
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
killProcessTree(pid);
|
|
374
|
+
console.log(`Stopped PID ${pid}`);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
console.log(`Could not stop PID ${pid}: ${error.message}`);
|
|
377
|
+
}
|
|
378
|
+
fs.rmSync(pidFile, { force: true });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function status() {
|
|
382
|
+
const pid = trackedOrAdoptedPid();
|
|
383
|
+
if (await requestHead(baseUrl)) {
|
|
384
|
+
if (pid) {
|
|
385
|
+
console.log(`Euphony responds at ${baseUrl}`);
|
|
386
|
+
console.log(`PID: ${pid}`);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
console.log(`Port ${port} responds at ${baseUrl}, but this script has no live pid file for it.`);
|
|
390
|
+
process.exitCode = 1;
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
console.log(`Euphony is not responding at ${baseUrl}`);
|
|
394
|
+
if (pid) console.log(`Tracked PID exists but HTTP is not ready: ${pid}`);
|
|
395
|
+
process.exitCode = 1;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function openBrowser(url) {
|
|
399
|
+
const command = process.platform === 'darwin' ? 'open' : isWindows ? 'cmd' : 'xdg-open';
|
|
400
|
+
const args = isWindows ? ['/c', 'start', '', url] : [url];
|
|
401
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore', shell: false });
|
|
402
|
+
child.on('error', () => {
|
|
403
|
+
console.log(`Open this URL in a browser: ${url}`);
|
|
404
|
+
});
|
|
405
|
+
child.unref();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function openCommand(input) {
|
|
409
|
+
await up();
|
|
410
|
+
const url = stageSession(input || latestSession());
|
|
411
|
+
openBrowser(url);
|
|
412
|
+
console.log(`Opened: ${url}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function main() {
|
|
416
|
+
const command = process.argv[2] || 'help';
|
|
417
|
+
const arg1 = process.argv[3];
|
|
418
|
+
switch (command) {
|
|
419
|
+
case 'list':
|
|
420
|
+
for (const item of recentSessions().slice(0, 20)) {
|
|
421
|
+
console.log(`${new Date(item.mtimeMs).toISOString()} ${item.file}`);
|
|
422
|
+
}
|
|
423
|
+
break;
|
|
424
|
+
case 'latest':
|
|
425
|
+
console.log(latestSession());
|
|
426
|
+
break;
|
|
427
|
+
case 'status':
|
|
428
|
+
await status();
|
|
429
|
+
break;
|
|
430
|
+
case 'ensure':
|
|
431
|
+
ensureEuphonyDir();
|
|
432
|
+
console.log(`Euphony is ready at ${euphonyDir}`);
|
|
433
|
+
break;
|
|
434
|
+
case 'stage':
|
|
435
|
+
stageSession(arg1 || latestSession());
|
|
436
|
+
break;
|
|
437
|
+
case 'url':
|
|
438
|
+
await up();
|
|
439
|
+
stageSession(arg1 || latestSession());
|
|
440
|
+
break;
|
|
441
|
+
case 'open':
|
|
442
|
+
await openCommand(arg1);
|
|
443
|
+
break;
|
|
444
|
+
case 'up':
|
|
445
|
+
await up();
|
|
446
|
+
break;
|
|
447
|
+
case 'start':
|
|
448
|
+
await startForeground();
|
|
449
|
+
break;
|
|
450
|
+
case 'stop':
|
|
451
|
+
stop();
|
|
452
|
+
break;
|
|
453
|
+
case 'restart':
|
|
454
|
+
stop();
|
|
455
|
+
await up();
|
|
456
|
+
break;
|
|
457
|
+
case 'help':
|
|
458
|
+
case '--help':
|
|
459
|
+
case '-h':
|
|
460
|
+
usage();
|
|
461
|
+
break;
|
|
462
|
+
default:
|
|
463
|
+
usage();
|
|
464
|
+
process.exitCode = 1;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
main().catch(error => fail(error.stack || error.message));
|